1. 为什么“懒惰”的程序员更需要C++20?
干了这么多年C++,我越来越觉得,一个优秀的程序员,尤其是C++程序员,其核心特质不是“勤奋”,而是“懒惰”。这里的“懒惰”不是指消极怠工,而是一种追求高效、厌恶重复、希望用最少的代码解决最复杂问题的“战略性懒惰”。我们懒得去记那些冗长的模板错误信息,懒得一遍遍编译庞大的头文件,懒得为了一个简单的数据过滤和转换写十几行循环嵌套。而C++20,在我看来,就是为这种“懒惰”程序员量身定做的一次解放。
如果你还在用C++98或者C++11的思维写代码,每天和令人头皮发麻的模板报错、缓慢的编译速度、以及为了一个简单算法而不得不写的样板代码作斗争,那你真的太“勤奋”了,勤奋得有点“冤”。C++20引入的几大核心特性——概念(Concepts)、模块(Modules)、范围(Ranges)和协程(Coroutines)——其根本目的,就是让我们写得更少、想得更少、错得更少,同时让编译器帮我们做得更多、查得更早、跑得更快。这就像是从手动挡换成了自动挡,还附带了车道保持和自适应巡航,让你能把精力集中在“往哪开”而不是“怎么开”上。所以,这个教程系列,就是给那些不想再“辛苦”地写C++的同行们,指一条更轻松、更现代的路。我们不需要从零开始啃标准文档,而是直接瞄准那些能立刻提升我们开发幸福感和效率的特性,用“懒惰”的方式学会它们。
2. 核心思路:用“约束”代替“猜错”,概念(Concepts)入门
2.1 模板编程的“痛”与概念的“药”
在C++20之前,使用模板就像在黑暗中摸索。你写了一个函数模板,期望用户传入一个支持++操作的类型。但当用户不小心传了一个std::string时,编译器不会在函数接口处就报错说“嘿,这类型不行”。它会忠实地开始实例化模板,深入到operator++的实现细节中,然后抛出一堆几十行甚至上百行的、充斥着std::basic_string内部实现的错误信息。你不得不从错误堆栈的底部开始,像侦探一样逆向推理,最终才发现:“哦,原来是类型不匹配”。
这种体验极其糟糕,错误信息对使用者不友好,也极大地增加了调试的认知负担。概念(Concepts)就是为了根治这个问题而生的。它的核心思想是“契约编程”或“约束编程”。我们在定义模板时,就明确地告诉编译器:“我这个模板参数T,必须满足某某条件(概念)”。如果调用时传入的类型不满足这个条件,编译器会在第一时间、在接口层面就给出清晰明了的错误,比如“std::string不满足Incrementable概念”,直接命中要害。
这带来的好处是双重的。第一,对于库的作者,你可以明确地表达你的接口要求,代码即文档。第二,对于库的使用者,你可以在编译期就获得清晰的错误指引,而不是在模板实例化的深渊里挣扎。这本质上是一种“将错误检查前置”的懒惰哲学——把问题消灭在萌芽状态,避免后续更复杂的调试。
2.2 如何定义和使用一个概念
概念本质上是一个编译期的布尔常量。C++20在<concepts>头文件中提供了一系列标准概念,比如std::integral、std::copyable等。但我们更强大的是可以自定义概念。
定义一个概念,主要使用concept关键字和requires表达式。requires表达式用于指定一组约束条件。我们来看一个比简单加法更贴近实际需求的例子:一个可以“展示”自己内容的类型。
假设我们正在写一个调试日志库,我们希望有一个函数debugPrint,它能打印任何提供了to_string()方法或支持流输出(<<)的类型。
#include <iostream> #include <concepts> #include <string> #include <sstream> // 1. 定义一个概念:拥有 to_string() 成员函数 template<typename T> concept HasToString = requires(T obj) { { obj.to_string() } -> std::convertible_to<std::string>; }; // 2. 定义一个概念:支持通过 << 输出到 ostream template<typename T> concept Streamable = requires(T obj, std::ostream& os) { { os << obj } -> std::same_as<std::ostream&>; }; // 3. 利用概念重载函数 void debugPrint(const HasToString auto& obj) { std::cout << "[ToString] " << obj.to_string() << std::endl; } void debugPrint(const Streamable auto& obj) { std::cout << "[Stream] " << obj << std::endl; } // 测试类 class Person { public: Person(const std::string& n) : name(n) {} std::string to_string() const { return "Person: " + name; } private: std::string name; }; int main() { Person p("Alice"); debugPrint(p); // 匹配 HasToString 版本,输出: [ToString] Person: Alice int num = 42; debugPrint(num); // 匹配 Streamable 版本,输出: [Stream] 42 // std::vector<int> vec{1,2,3}; // debugPrint(vec); // 编译错误:没有匹配的 debugPrint 重载 // 错误信息清晰:`const std::vector<int>` 不满足 `HasToString` 或 `Streamable` 约束 }这段代码完美展示了概念的威力。我们定义了两个清晰的概念HasToString和Streamable。debugPrint函数利用auto占位符和概念名,形成了两个约束模板。编译器会根据传入参数的类型,选择最匹配的、约束满足的重载。对于不满足任何概念的类型,编译会直接失败,并明确指出是哪个约束没通过。
实操心得:requires子句的两种写法你可能会看到概念约束的两种写法,它们本质等价,但适用场景略有不同:
- 简写形式(Abbreviated Function Template):
void func(const Streamable auto& obj)。这非常简洁,适用于函数参数直接使用概念约束的情况,是我们“懒惰程序员”的首选。 requires子句形式:template <typename T> requires Streamable<T> void func(const T& obj)。这种形式更灵活,可以表达更复杂的组合约束,例如requires Streamable<T> && HasToString<T>(同时满足两个概念),或者requires (sizeof(T) > 4)(自定义编译期布尔表达式)。当约束条件比较复杂时,就需要用到这种形式。
注意:概念检查发生在编译的早期阶段,在模板实例化之前。这意味着它不会增加运行时开销,是纯粹的“零成本抽象”。
3. 告别头文件地狱:模块(Modules)实战
3.1 为什么需要模块?头文件的“原罪”
传统的#include机制是一种简单的文本替换。当你在a.cpp和b.cpp中都#include “utils.h”时,utils.h的内容会被完整地复制粘贴到这两个编译单元中,并被分别解析多次。这导致了著名的“头文件地狱”:
- 编译速度慢:同样的代码被反复解析,项目越大越明显。
- 宏污染:头文件中的宏定义会不受控制地扩散到所有包含它的地方,可能引发难以察觉的命名冲突。
- 脆弱的包含顺序:如果头文件之间有依赖关系,包含顺序错了就可能编译失败。
- 封装性差:
#include会将头文件里所有内容(包括私有声明)都暴露出来。
模块旨在从根本上解决这些问题。它不再是文本替换,而是一种逻辑上的依赖声明。模块会被编译一次,生成一个二进制接口文件(如.pcm),后续其他模块导入(import)时,直接使用这个接口文件,无需重新解析源代码。这带来了革命性的变化:编译速度大幅提升,宏不再跨模块传播,依赖关系清晰明确。
3.2 创建和使用你的第一个模块
让我们一步步创建一个数学工具模块。首先,你需要一个支持C++20模块的编译器,如MSVC(Visual Studio 2019 16.8+)、GCC 11+或Clang 16+,并确保开启对应标准(如/std:c++20,-std=c++20,-fmodules-ts等)。
第一步:创建模块接口单元(.cppm 或 .ixx)模块接口单元声明了模块对外提供的内容。通常使用.cppm或MSVC的.ixx作为扩展名。
// math_utils.cppm - 模块接口单元 export module math_utils; // 声明本文件定义了一个名为 math_utils 的模块 // 导入标准库模块(如果编译器支持)或头单元 import <iostream>; // 注意:是 import,不是 #include // 导出(export)一个命名空间(可选,但推荐用于组织代码) export namespace math { // 导出函数 export int add(int a, int b) { return a + b; } export double multiply(double a, double b) { return a * b; } // 导出类 export class Point { public: Point(double x, double y) : x_(x), y_(y) {} double distance_to_origin() const { return std::sqrt(x_ * x_ + y_ * y_); } // 内联函数定义可以放在类内 void print() const { std::cout << "(" << x_ << ", " << y_ << ")\n"; } private: double x_, y_; }; // 导出变量(常量) export constexpr double pi = 3.141592653589793; }第二步:在主程序中使用模块现在,我们可以在另一个源文件中导入并使用这个模块。
// main.cpp import math_utils; // 导入我们定义的模块 import <iostream>; // 导入标准库头单元 int main() { // 使用模块中导出的内容 std::cout << "5 + 3 = " << math::add(5, 3) << std::endl; std::cout << "2.5 * 4.0 = " << math::multiply(2.5, 4.0) << std::endl; math::Point p(3.0, 4.0); std::cout << "Distance to origin: " << p.distance_to_origin() << std::endl; p.print(); std::cout << "Pi is: " << math::pi << std::endl; // 错误示例:尝试使用未导出的内容 // math::some_internal_function(); // 编译错误!未导出,不可见。 return 0; }第三步:编译(这是关键且容易踩坑的一步)模块的编译通常需要分步进行,不能像以前一样直接用一条命令编译所有.cpp文件。以GCC 13为例:
# 1. 预编译模块接口单元,生成 math_utils.gcm g++ -std=c++20 -fmodules-ts -c math_utils.cppm -o math_utils.o # 2. 编译主程序,并指明模块依赖 g++ -std=c++20 -fmodules-ts -c main.cpp -o main.o -fmodule-file=math_utils=math_utils.gcm # 3. 链接所有目标文件 g++ math_utils.o main.o -o main_program对于MSVC,在Visual Studio IDE中创建正确的项目配置(设置C++语言标准为/std:c++latest或/std:c++20,并将.ixx文件的“项类型”设置为“C/C++编译器”),IDE通常会帮你处理好编译顺序。
重要提示:模块的编译支持仍在不断完善中,不同编译器的命令和细节差异很大。上述GCC命令仅作示例,实际使用时请务必查阅你所使用编译器版本的最新文档。这是目前使用模块最大的“不懒惰”之处,但一旦构建系统配置好,后续开发的体验提升是巨大的。
4. 声明式编程利器:范围(Ranges)与视图(Views)
4.1 从命令式循环到声明式管道
在传统STL算法中,我们经常需要写这样的代码:
std::vector<int> vec = {...}; std::vector<int> even_squares; std::copy_if(vec.begin(), vec.end(), std::back_inserter(even_squares), [](int x){return x % 2 == 0;}); std::transform(even_squares.begin(), even_squares.end(), even_squares.begin(), [](int x){return x * x;});这段代码做了两件事:过滤偶数,然后计算平方。但它有几个问题:1) 创建了一个临时容器even_squares;2) 代码是命令式的,描述了“怎么做”(先拷贝再转换),而不是“做什么”;3) 需要处理繁琐的迭代器。
C++20的范围库和视图(Views)彻底改变了这一点。视图是对范围(可以简单理解为容器)的惰性投影或变换,它们不复制数据,也不立即执行计算。只有在你真正遍历视图时,操作才会按需发生。结合管道运算符|,我们可以写出声明式的代码:
#include <iostream> #include <vector> #include <ranges> // C++20 范围库头文件 namespace vw = std::views; // 常用的命名空间别名 int main() { std::vector<int> vec = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10}; // 声明式管道操作:取偶数 -> 平方 -> 取前3个 auto result_view = vec | vw::filter([](int x) { return x % 2 == 0; }) // 惰性过滤 | vw::transform([](int x) { return x * x; }) // 惰性变换 | vw::take(3); // 惰性取前N个 std::cout << "The first 3 even squares are: "; for (int x : result_view) { // 只有在这里,计算才真正发生 std::cout << x << ' '; } std::cout << std::endl; // 输出: 4 16 36 // 证明它是惰性的:我们甚至可以处理“无限”范围 auto infinite_evens = vw::iota(0) // 从0开始的无限整数序列 | vw::filter([](int x) { return x % 2 == 0; }) | vw::take(5); // 只取前5个 std::cout << "First 5 even numbers: "; for (int x : infinite_evens) { std::cout << x << ' '; } std::cout << std::endl; // 输出: 0 2 4 6 8 }这段代码清晰表达了我们的意图:对vec进行过滤、变换和取前N个操作。result_view只是一个轻量的视图对象,它持有对原始vec的引用以及一系列操作描述,没有发生任何数据拷贝或计算,直到for循环开始。
4.2 常用视图适配器与组合技巧
范围库提供了丰富的视图适配器,下面是一个快速参考表:
| 视图适配器 | 功能描述 | 示例 |
|---|---|---|
filter | 过滤满足条件的元素 | views::filter(is_even) |
transform | 将元素映射为另一种形式 | views::transform(square) |
take | 取前N个元素 | views::take(5) |
drop | 跳过前N个元素 | views::drop(2) |
reverse | 反转范围 | views::reverse |
keys,values | 取pair-like元素的键或值 | views::keys(my_map) |
iota | 生成一个整数序列 | views::iota(1, 10) |
join | 展平嵌套范围 | views::join(vector_of_vectors) |
组合技巧:解决实际问题假设我们有一个std::map<std::string, int>,我们想找出所有值大于100的键,并按字母顺序排序。
#include <iostream> #include <vector> #include <map> #include <ranges> #include <algorithm> namespace vw = std::views; int main() { std::map<std::string, int> scoreboard = { {"Alice", 120}, {"Bob", 85}, {"Charlie", 200}, {"David", 95}, {"Eve", 150} }; // 传统方式:需要中间容器,多步操作 // 范围+视图方式:声明式,无中间容器 auto high_scorers = scoreboard | vw::filter([](const auto& pair) { return pair.second > 100; }) // 过滤 | vw::keys // 取键 | vw::transform([](const std::string& name) { // 可以在这里进行额外的变换,比如转为大写 std::string upper = name; std::transform(upper.begin(), upper.end(), upper.begin(), ::toupper); return upper; }); // 注意:视图本身不保证顺序,但源map是按键排序的,filter后顺序保留 std::cout << "High scorers (uppercased): "; for (const auto& name : high_scorers) { std::cout << name << ' '; } std::cout << std::endl; // 输出: ALICE CHARLIE EVE // 如果我们想要排序,可以转换为容器或使用 ranges::to (C++23) // C++20 中可以先收集到 vector 再排序 std::vector<std::string> sorted_names(high_scorers.begin(), high_scorers.end()); std::ranges::sort(sorted_names); // C++20 的范围算法 // 或者直接使用 ranges::to (如果编译器支持C++23) // auto sorted_names = high_scorers | std::ranges::to<std::vector>() | std::ranges::actions::sort; }实操心得:性能与可读性的平衡视图的惰性求值带来了巨大的性能优势,尤其是处理大型数据或组合多个操作时,它避免了不必要的中间存储。但要注意,过度复杂的管道可能会降低可读性。一个好的经验法则是:如果管道操作超过3-4步,或者逻辑变得复杂,考虑将其拆分成多个有意义的命名视图,或者封装成一个函数。例如,可以将filter的条件和transform的运算分别定义为命名lambda或函数,这样管道看起来就像data | filtered(condition) | transformed(func),意图更清晰。
5. 异步新思维:协程(Coroutines)初探
5.1 协程是什么?为什么它很“懒”?
协程,简单说,就是可以暂停执行和恢复执行的函数。普通函数一旦开始,就必须运行到return才会把控制权交还给调用者。而协程可以在执行到一半时,主动挂起(co_yield一个值,或co_await一个未完成的任务),把控制权和一些状态交出去,等将来某个时刻再被唤醒,从上次挂起的地方继续执行。
这种“懒”的特质,使得协程非常适合处理异步I/O、生成器、状态机等场景。你不需要再写复杂的回调函数(Callback Hell)或管理繁琐的状态变量。你可以用近乎同步的代码风格,去写异步的逻辑。
需要明确的是,C++20只提供了协程的底层语言机制(关键字co_await,co_yield,co_return)和标准库头文件<coroutine>中的一些基础类型。它并没有提供像Python的asyncio或C#的async/await那样开箱即用的高级框架。这意味着,要实际使用协程,我们通常需要自己定义协程的返回类型(即promise_type),或者使用第三方库(如cppcoro)。C++23引入了std::generator,才让生成器这类简单协程变得容易。
5.2 手搓一个简单的生成器(Generator)
虽然有点“不懒惰”,但通过自己实现一个简单的生成器,能最深刻地理解协程是如何工作的。下面我们实现一个用于遍历二叉树的生成器。
#include <coroutine> #include <iostream> #include <optional> #include <memory> // 二叉树节点 struct TreeNode { int value; std::unique_ptr<TreeNode> left; std::unique_ptr<TreeNode> right; TreeNode(int v) : value(v), left(nullptr), right(nullptr) {} }; // 1. 定义生成器模板 template<typename T> struct Generator { // 2. 内部 promise_type,编译器通过它来与协程通信 struct promise_type { T current_value; // 用于保存 co_yield 产出的值 // 协程开始时调用,决定初始是挂起还是立即执行。返回 suspend_always 表示懒启动。 std::suspend_always initial_suspend() noexcept { return {}; } // 协程结束时调用,决定最终是否挂起。返回 suspend_always 防止悬空引用。 std::suspend_always final_suspend() noexcept { return {}; } // 处理未捕获的异常 void unhandled_exception() { std::terminate(); } // 3. 关键:当协程中调用 co_yield value; 时,此函数被调用。 std::suspend_always yield_value(T value) { current_value = std::move(value); return {}; // 挂起协程,将控制权返回给调用者 } // 当协程执行到 co_return; 时调用(我们的生成器无返回值) void return_void() {} // 4. 获取生成器对象 Generator get_return_object() { // 从 promise 对象构造一个协程句柄,并用它构造我们的 Generator return Generator{handle::from_promise(*this)}; } }; // 5. 协程句柄类型别名 using handle = std::coroutine_handle<promise_type>; handle coro_handle; // 6. 构造函数和析构函数 explicit Generator(handle h) : coro_handle(h) {} ~Generator() { if (coro_handle) coro_handle.destroy(); } // 禁止拷贝,允许移动 Generator(const Generator&) = delete; Generator& operator=(const Generator&) = delete; Generator(Generator&& other) noexcept : coro_handle(other.coro_handle) { other.coro_handle = {}; } Generator& operator=(Generator&& other) noexcept { if (this != &other) { if (coro_handle) coro_handle.destroy(); coro_handle = other.coro_handle; other.coro_handle = {}; } return *this; } // 7. 核心接口:获取下一个值 std::optional<T> next() { if (!coro_handle || coro_handle.done()) { return std::nullopt; // 协程已结束 } coro_handle.resume(); // 恢复协程执行,直到下一个 co_yield 或结束 if (coro_handle.done()) { return std::nullopt; // 恢复后发现协程结束了 } return coro_handle.promise().current_value; // 返回 yield 的值 } }; // 8. 使用协程的生成器函数:中序遍历二叉树 Generator<int> inorder_traversal(TreeNode* root) { // 递归的协程需要一点技巧,这里用lambda模拟递归栈(为简化,未实现完整递归) // 实际项目中建议使用栈进行非递归遍历,或使用更复杂的协程调度。 if (!root) co_return; // 模拟遍历左子树(实际应递归调用自身) if (root->left) { // 假设我们有一个遍历左子树的生成器 // auto left_gen = inorder_traversal(root->left.get()); // while (auto val = left_gen.next()) { // co_yield *val; // } // 为了示例简单,我们直接 yield 左值(如果存在) co_yield root->left->value; } co_yield root->value; // 访问当前节点 if (root->right) { // 同理,遍历右子树 co_yield root->right->value; } } // 更简单的例子:生成一个数字序列 Generator<int> range(int start, int end, int step = 1) { for (int i = start; i < end; i += step) { co_yield i; // 每次循环产出一个值并挂起 } } int main() { // 使用简单的 range 生成器 std::cout << "Range generator: "; auto gen = range(1, 6); // 生成 1 到 5 while (auto val = gen.next()) { std::cout << *val << ' '; } std::cout << std::endl; // 输出: 1 2 3 4 5 // 构建一个简单的树: 1 -> (2, 3) auto tree = std::make_unique<TreeNode>(1); tree->left = std::make_unique<TreeNode>(2); tree->right = std::make_unique<TreeNode>(3); std::cout << "Simple tree inorder (simplified): "; auto tree_gen = inorder_traversal(tree.get()); while (auto val = tree_gen.next()) { std::cout << *val << ' '; } std::cout << std::endl; // 输出: 2 1 3 }这个例子揭示了协程的工作流程:
- 当调用
range(1, 6)时,它并不会立即执行函数体,因为promise_type::initial_suspend()返回了suspend_always,所以协程在起点就挂起了。 - 我们通过
Generator::next()恢复协程。协程执行到co_yield i;,yield_value被调用,保存值并再次挂起。 next()返回这个值。- 循环调用
next(),直到协程函数执行完毕(for循环结束),coro_handle.done()变为true。
常见问题与排查技巧实录
- 编译错误:找不到
<coroutine>头文件或相关类型。- 排查:检查编译器版本和标准设置。GCC 10+、Clang 13+、MSVC 2019 16.8+才初步支持。确保编译标志包含
-std=c++20(GCC/Clang)或/std:c++20//std:c++latest(MSVC)。
- 排查:检查编译器版本和标准设置。GCC 10+、Clang 13+、MSVC 2019 16.8+才初步支持。确保编译标志包含
- 链接错误:未定义的符号,如
operator new等。- 排查:协程底层会调用一些特殊的
operator new和operator delete来分配协程帧。确保你的编译环境链接了完整的C++标准库。在某些平台上,可能需要特定的链接选项。
- 排查:协程底层会调用一些特殊的
- 协程帧的生命周期管理。
- 注意:协程的局部变量(在其挂起时仍然存活)存储在堆分配的“协程帧”中。确保
Generator对象(持有协程句柄)的生命周期长于你使用它的时间。在移动Generator后,原来的对象将变为空,不能再使用。析构函数中调用destroy()是必须的,否则会内存泄漏。
- 注意:协程的局部变量(在其挂起时仍然存活)存储在堆分配的“协程帧”中。确保
- 性能考量。
- 提示:协程的挂起和恢复、协程帧的分配都有开销。对于非常简单的循环,传统的迭代器或循环可能更快。协程的优势在于简化复杂的异步状态管理,而不是微秒级的性能优化。
6. 避坑指南与“懒惰”开发环境搭建
6.1 编译器支持与特性检测
C++20特性并非所有编译器都完全支持。在开始一个项目前,最好先确认你的工具链状态。以下是一个简明的支持情况参考(截至2024年初):
| 特性 | GCC (>=) | Clang (>=) | MSVC (>=) | 备注 |
|---|---|---|---|---|
| 概念(Concepts) | 10 | 10 | 2019 16.3 | 核心特性支持良好 |
| 模块(Modules) | 11 (部分) | 16 (部分) | 2019 16.8 | 支持仍在完善,构建系统是关键 |
| 范围(Ranges) | 10 | 13 | 2019 16.10 | <ranges>库支持良好 |
| 协程(Coroutines) | 10 | 13 | 2019 16.8 | 语言核心支持,库支持需等待C++23 |
你可以使用预定义宏来检测编译器对某个特性的支持程度,但更“懒惰”也更推荐的做法是,直接查阅编译器官方文档的“C++ Standards Support”页面,以及使用如https://en.cppreference.com/上的编译器支持表格。
6.2 构建系统集成:让模块“跑起来”
使用模块最大的挑战在于构建。CMake从3.26版本开始提供了对C++20模块的实验性支持。以下是一个简单的CMakeLists.txt示例,用于构建我们之前的math_utils模块:
cmake_minimum_required(VERSION 3.26) # 需要 CMake 3.26+ project(LazyCpp20Demo LANGUAGES CXX) set(CMAKE_CXX_STANDARD 20) set(CMAKE_CXX_STANDARD_REQUIRED ON) set(CMAKE_CXX_EXTENSIONS OFF) # 告诉CMake我们使用C++20模块 set(CMAKE_CXX_SCAN_FOR_MODULES ON) # 添加可执行文件,并指定源文件 add_executable(main_program) target_sources(main_program PUBLIC FILE_SET CXX_MODULES BASE_DIRS ${CMAKE_CURRENT_SOURCE_DIR} FILES math_utils.cppm # 模块接口单元 main.cpp # 主程序 )对于更早版本的CMake或其它构建系统(如Makefile、Bazel),你可能需要手动编写依赖编译规则,这比较繁琐。因此,如果你的项目重度依赖模块,升级到支持模块的构建系统和编译器版本是值得的。
6.3 渐进式采用策略:别想一口吃成胖子
对于已有的大型项目,全面迁移到C++20是不现实的。建议采用渐进式策略:
- 从概念开始:概念可以立即在头文件模板库中使用,对现有代码影响最小,且能立刻提升错误信息质量。为新写的模板代码加上概念约束。
- 在新模块或工具库中试用范围视图:在编写新的数据处理或算法代码时,尝试用
<ranges>和视图来替代传统的for循环和STL算法组合。你会发现代码更简洁。 - 隔离使用协程:在需要异步操作或生成器的新组件中尝试协程。由于其需要特定的返回类型,最好将其封装在独立的库或模块中,避免污染核心业务逻辑。
- 最后考虑模块:模块的引入涉及编译流程和项目结构的改变,成本最高。可以从小的、相对独立的工具库开始尝试,积累经验后再逐步推广。
记住,“懒惰”的精髓是用更少的努力获得更好的结果。C++20的这些特性,正是为了帮助我们达成这个目标。它们初看可能有些复杂,但一旦掌握,将成为你代码库中提升开发效率、增强代码安全性和表现力的强大武器。先从一两个小特性用起来,感受它们带来的“懒惰”快感吧。