1. 为什么“懒惰”的程序员更需要C20干了这么多年C我越来越觉得一个优秀的程序员尤其是C程序员其核心特质不是“勤奋”而是“懒惰”。这里的“懒惰”不是指消极怠工而是一种追求高效、厌恶重复、希望用最少的代码解决最复杂问题的“战略性懒惰”。我们懒得去记那些冗长的模板错误信息懒得一遍遍编译庞大的头文件懒得为了一个简单的数据过滤和转换写十几行循环嵌套。而C20在我看来就是为这种“懒惰”程序员量身定做的一次解放。如果你还在用C98或者C11的思维写代码每天和令人头皮发麻的模板报错、缓慢的编译速度、以及为了一个简单算法而不得不写的样板代码作斗争那你真的太“勤奋”了勤奋得有点“冤”。C20引入的几大核心特性——概念Concepts、模块Modules、范围Ranges和协程Coroutines——其根本目的就是让我们写得更少、想得更少、错得更少同时让编译器帮我们做得更多、查得更早、跑得更快。这就像是从手动挡换成了自动挡还附带了车道保持和自适应巡航让你能把精力集中在“往哪开”而不是“怎么开”上。所以这个教程系列就是给那些不想再“辛苦”地写C的同行们指一条更轻松、更现代的路。我们不需要从零开始啃标准文档而是直接瞄准那些能立刻提升我们开发幸福感和效率的特性用“懒惰”的方式学会它们。2. 核心思路用“约束”代替“猜错”概念Concepts入门2.1 模板编程的“痛”与概念的“药”在C20之前使用模板就像在黑暗中摸索。你写了一个函数模板期望用户传入一个支持操作的类型。但当用户不小心传了一个std::string时编译器不会在函数接口处就报错说“嘿这类型不行”。它会忠实地开始实例化模板深入到operator的实现细节中然后抛出一堆几十行甚至上百行的、充斥着std::basic_string内部实现的错误信息。你不得不从错误堆栈的底部开始像侦探一样逆向推理最终才发现“哦原来是类型不匹配”。这种体验极其糟糕错误信息对使用者不友好也极大地增加了调试的认知负担。概念Concepts就是为了根治这个问题而生的。它的核心思想是“契约编程”或“约束编程”。我们在定义模板时就明确地告诉编译器“我这个模板参数T必须满足某某条件概念”。如果调用时传入的类型不满足这个条件编译器会在第一时间、在接口层面就给出清晰明了的错误比如“std::string不满足Incrementable概念”直接命中要害。这带来的好处是双重的。第一对于库的作者你可以明确地表达你的接口要求代码即文档。第二对于库的使用者你可以在编译期就获得清晰的错误指引而不是在模板实例化的深渊里挣扎。这本质上是一种“将错误检查前置”的懒惰哲学——把问题消灭在萌芽状态避免后续更复杂的调试。2.2 如何定义和使用一个概念概念本质上是一个编译期的布尔常量。C20在concepts头文件中提供了一系列标准概念比如std::integral、std::copyable等。但我们更强大的是可以自定义概念。定义一个概念主要使用concept关键字和requires表达式。requires表达式用于指定一组约束条件。我们来看一个比简单加法更贴近实际需求的例子一个可以“展示”自己内容的类型。假设我们正在写一个调试日志库我们希望有一个函数debugPrint它能打印任何提供了to_string()方法或支持流输出的类型。#include iostream #include concepts #include string #include sstream // 1. 定义一个概念拥有 to_string() 成员函数 templatetypename T concept HasToString requires(T obj) { { obj.to_string() } - std::convertible_tostd::string; }; // 2. 定义一个概念支持通过 输出到 ostream templatetypename T concept Streamable requires(T obj, std::ostream os) { { os obj } - std::same_asstd::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::vectorint vec{1,2,3}; // debugPrint(vec); // 编译错误没有匹配的 debugPrint 重载 // 错误信息清晰const std::vectorint 不满足 HasToString 或 Streamable 约束 }这段代码完美展示了概念的威力。我们定义了两个清晰的概念HasToString和Streamable。debugPrint函数利用auto占位符和概念名形成了两个约束模板。编译器会根据传入参数的类型选择最匹配的、约束满足的重载。对于不满足任何概念的类型编译会直接失败并明确指出是哪个约束没通过。实操心得requires子句的两种写法你可能会看到概念约束的两种写法它们本质等价但适用场景略有不同简写形式Abbreviated Function Templatevoid func(const Streamable auto obj)。这非常简洁适用于函数参数直接使用概念约束的情况是我们“懒惰程序员”的首选。requires子句形式template typename T requires StreamableT void func(const T obj)。这种形式更灵活可以表达更复杂的组合约束例如requires StreamableT HasToStringT同时满足两个概念或者requires (sizeof(T) 4)自定义编译期布尔表达式。当约束条件比较复杂时就需要用到这种形式。注意概念检查发生在编译的早期阶段在模板实例化之前。这意味着它不会增加运行时开销是纯粹的“零成本抽象”。3. 告别头文件地狱模块Modules实战3.1 为什么需要模块头文件的“原罪”传统的#include机制是一种简单的文本替换。当你在a.cpp和b.cpp中都#include “utils.h”时utils.h的内容会被完整地复制粘贴到这两个编译单元中并被分别解析多次。这导致了著名的“头文件地狱”编译速度慢同样的代码被反复解析项目越大越明显。宏污染头文件中的宏定义会不受控制地扩散到所有包含它的地方可能引发难以察觉的命名冲突。脆弱的包含顺序如果头文件之间有依赖关系包含顺序错了就可能编译失败。封装性差#include会将头文件里所有内容包括私有声明都暴露出来。模块旨在从根本上解决这些问题。它不再是文本替换而是一种逻辑上的依赖声明。模块会被编译一次生成一个二进制接口文件如.pcm后续其他模块导入import时直接使用这个接口文件无需重新解析源代码。这带来了革命性的变化编译速度大幅提升宏不再跨模块传播依赖关系清晰明确。3.2 创建和使用你的第一个模块让我们一步步创建一个数学工具模块。首先你需要一个支持C20模块的编译器如MSVCVisual Studio 2019 16.8、GCC 11或Clang 16并确保开启对应标准如/std:c20-stdc20-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 -stdc20 -fmodules-ts -c math_utils.cppm -o math_utils.o # 2. 编译主程序并指明模块依赖 g -stdc20 -fmodules-ts -c main.cpp -o main.o -fmodule-filemath_utilsmath_utils.gcm # 3. 链接所有目标文件 g math_utils.o main.o -o main_program对于MSVC在Visual Studio IDE中创建正确的项目配置设置C语言标准为/std:clatest或/std:c20并将.ixx文件的“项类型”设置为“C/C编译器”IDE通常会帮你处理好编译顺序。重要提示模块的编译支持仍在不断完善中不同编译器的命令和细节差异很大。上述GCC命令仅作示例实际使用时请务必查阅你所使用编译器版本的最新文档。这是目前使用模块最大的“不懒惰”之处但一旦构建系统配置好后续开发的体验提升是巨大的。4. 声明式编程利器范围Ranges与视图Views4.1 从命令式循环到声明式管道在传统STL算法中我们经常需要写这样的代码std::vectorint vec {...}; std::vectorint 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_squares2) 代码是命令式的描述了“怎么做”先拷贝再转换而不是“做什么”3) 需要处理繁琐的迭代器。C20的范围库和视图Views彻底改变了这一点。视图是对范围可以简单理解为容器的惰性投影或变换它们不复制数据也不立即执行计算。只有在你真正遍历视图时操作才会按需发生。结合管道运算符|我们可以写出声明式的代码#include iostream #include vector #include ranges // C20 范围库头文件 namespace vw std::views; // 常用的命名空间别名 int main() { std::vectorint 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::reversekeys,values取pair-like元素的键或值views::keys(my_map)iota生成一个整数序列views::iota(1, 10)join展平嵌套范围views::join(vector_of_vectors)组合技巧解决实际问题假设我们有一个std::mapstd::string, int我们想找出所有值大于100的键并按字母顺序排序。#include iostream #include vector #include map #include ranges #include algorithm namespace vw std::views; int main() { std::mapstd::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 (C23) // C20 中可以先收集到 vector 再排序 std::vectorstd::string sorted_names(high_scorers.begin(), high_scorers.end()); std::ranges::sort(sorted_names); // C20 的范围算法 // 或者直接使用 ranges::to (如果编译器支持C23) // auto sorted_names high_scorers | std::ranges::tostd::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或管理繁琐的状态变量。你可以用近乎同步的代码风格去写异步的逻辑。需要明确的是C20只提供了协程的底层语言机制关键字co_await,co_yield,co_return和标准库头文件coroutine中的一些基础类型。它并没有提供像Python的asyncio或C#的async/await那样开箱即用的高级框架。这意味着要实际使用协程我们通常需要自己定义协程的返回类型即promise_type或者使用第三方库如cppcoro。C23引入了std::generator才让生成器这类简单协程变得容易。5.2 手搓一个简单的生成器Generator虽然有点“不懒惰”但通过自己实现一个简单的生成器能最深刻地理解协程是如何工作的。下面我们实现一个用于遍历二叉树的生成器。#include coroutine #include iostream #include optional #include memory // 二叉树节点 struct TreeNode { int value; std::unique_ptrTreeNode left; std::unique_ptrTreeNode right; TreeNode(int v) : value(v), left(nullptr), right(nullptr) {} }; // 1. 定义生成器模板 templatetypename 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_handlepromise_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::optionalT 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. 使用协程的生成器函数中序遍历二叉树 Generatorint 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; } } // 更简单的例子生成一个数字序列 Generatorint 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_uniqueTreeNode(1); tree-left std::make_uniqueTreeNode(2); tree-right std::make_uniqueTreeNode(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才初步支持。确保编译标志包含-stdc20GCC/Clang或/std:c20//std:clatestMSVC。链接错误未定义的符号如operator new等。排查协程底层会调用一些特殊的operator new和operator delete来分配协程帧。确保你的编译环境链接了完整的C标准库。在某些平台上可能需要特定的链接选项。协程帧的生命周期管理。注意协程的局部变量在其挂起时仍然存活存储在堆分配的“协程帧”中。确保Generator对象持有协程句柄的生命周期长于你使用它的时间。在移动Generator后原来的对象将变为空不能再使用。析构函数中调用destroy()是必须的否则会内存泄漏。性能考量。提示协程的挂起和恢复、协程帧的分配都有开销。对于非常简单的循环传统的迭代器或循环可能更快。协程的优势在于简化复杂的异步状态管理而不是微秒级的性能优化。6. 避坑指南与“懒惰”开发环境搭建6.1 编译器支持与特性检测C20特性并非所有编译器都完全支持。在开始一个项目前最好先确认你的工具链状态。以下是一个简明的支持情况参考截至2024年初特性GCC ()Clang ()MSVC ()备注概念Concepts10102019 16.3核心特性支持良好模块Modules11 (部分)16 (部分)2019 16.8支持仍在完善构建系统是关键范围Ranges10132019 16.10ranges库支持良好协程Coroutines10132019 16.8语言核心支持库支持需等待C23你可以使用预定义宏来检测编译器对某个特性的支持程度但更“懒惰”也更推荐的做法是直接查阅编译器官方文档的“C Standards Support”页面以及使用如https://en.cppreference.com/上的编译器支持表格。6.2 构建系统集成让模块“跑起来”使用模块最大的挑战在于构建。CMake从3.26版本开始提供了对C20模块的实验性支持。以下是一个简单的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我们使用C20模块 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 渐进式采用策略别想一口吃成胖子对于已有的大型项目全面迁移到C20是不现实的。建议采用渐进式策略从概念开始概念可以立即在头文件模板库中使用对现有代码影响最小且能立刻提升错误信息质量。为新写的模板代码加上概念约束。在新模块或工具库中试用范围视图在编写新的数据处理或算法代码时尝试用ranges和视图来替代传统的for循环和STL算法组合。你会发现代码更简洁。隔离使用协程在需要异步操作或生成器的新组件中尝试协程。由于其需要特定的返回类型最好将其封装在独立的库或模块中避免污染核心业务逻辑。最后考虑模块模块的引入涉及编译流程和项目结构的改变成本最高。可以从小的、相对独立的工具库开始尝试积累经验后再逐步推广。记住“懒惰”的精髓是用更少的努力获得更好的结果。C20的这些特性正是为了帮助我们达成这个目标。它们初看可能有些复杂但一旦掌握将成为你代码库中提升开发效率、增强代码安全性和表现力的强大武器。先从一两个小特性用起来感受它们带来的“懒惰”快感吧。