C++20核心特性实战:Concepts、Ranges与Coroutines详解
1. C20 主线三件套从约束到组合再到异步执行C20 特性解析这个话题我在不同场合聊过很多次但每次都觉得没有一个完整的时间把三个最核心的东西串起来讲清楚。这篇先解决上篇的部分聚焦在 Concepts、Ranges、Coroutines 这三套特性上。它们不是三个孤立的关键字而是从编译期约束类型到容器算法组合再到函数执行模型的一条完整链路理解了这条链路再看 C20 的其他改进就顺了。先说个背景判断。C11 解决的是语言现代化的问题移动语义、lambda、auto、可变参数模板把这门语言从老派系统语言拉回了现代语言行列。C14 和 C17 是补丁式的小步迭代真正在语言层面动刀子、在库层面重写范式的地方不多。C20 不一样它一次性把三个酝酿了很久的大特性放进了标准模板约束从事后报错变成事前契约算法调用从迭代器散装操作变成视图流水线函数执行从栈上一次性跑完变成可挂起可恢复。这三个变化都触及了日常代码的写法习惯所以我说它是 C11 之后最大的一次大版本不是宣传话术是实打实的语法面和库面双更新。这篇适合谁看已经会用 C17、但还没系统跟过 C20 的开发者或者正在评估要不要把项目迁到新标准的团队。我不会只贴语法会把每个特性背后的为什么要这么设计也拆开讲因为知道为什么踩坑的时候才找得到原因。2. Concepts把模板警铃变成接口契约2.1 模板报错为什么让人头皮发麻先回忆一下没有 Concepts 的日子。你写了一个泛型函数调用方传错了类型编译器从函数体内部开始吐错误一路回溯到模板头、调用点、甚至标准库头文件里的几十层实例化。一个std::vectorint传成了std::vectorstd::string错误信息能刷几百行本质原因往往藏在最底下那几行。问题出在模板的鸭子类型模型编译器不是在调用边界检查参数而是等到模板实例化之后、真正用到不合法操作时才暴露问题。这就好比快递员不对证件就放你进门等你在屋里砸了东西才喊保安——太迟了。2.2 concept 定义与 requires 子句的基本写法C20 的 Concepts 在语言层面引入了一个新实体concept用来给类型约束命名。最基础的写法是这样#include concepts templatetypename T concept Arithmetic std::is_arithmetic_vT; templatetypename T requires ArithmeticT T add(T a, T b) { return a b; }typename T声明这是一个模板参数然后是requires ArithmeticT说明这个函数只接受满足Arithmetic的类型。调用add(1, 2)没问题add(std::string{a}, std::string{b})在编译期直接被拒而且错误信息会明确告诉你约束ArithmeticT没有被满足。报错位置从深处实例化提前到了调用边界这是质的改变。concept 可以组合也可以和||、做逻辑运算templatetypename T concept IntegralOrFloating std::integralT || std::floating_pointT;标准库在concepts里已经内置了一批常用概念std::integral、std::floating_point、std::same_as、std::convertible_to、std::copyable等等自己写之前先翻一翻避免重复造轮子。2.3 requires 表达式里的四种约束形态requires后面接的如果是一个表达式块那就是requires expression它可以在模板定义内联地检查类型是否支持某些操作。这比单纯用一个布尔常量更强大。一共四种形态templatetypename T concept HasSize requires(T t) { t.size(); // 简单要求表达式合法即可 typename T::value_type; // 类型要求存在这个嵌套类型 { t.front() } - std::convertible_toint; // 复合要求返回值可转换 };第四种是嵌套要求用内层requires表达额外约束templatetypename T concept SortableContainer requires(T c) { typename T::value_type; requires std::totally_orderedtypename T::value_type; };重点是理解复合要求的语法{ 表达式 } - 概念的意思是表达式要合法而且表达式结果必须满足那个概念。这个箭头后面的东西不是返回类型而是一个约束注意别写成具体类型。2.4 约束排序不止是限制还能参与重载决策Concepts 有个经常被忽略的能力带约束的模板可以被排序。当两个函数模板都能匹配时编译器会选择约束更严格的那个。这就是所谓的部分有序重载templatetypename T requires std::integralT T pick(T a) { return a; } templatetypename T requires std::signed_integralT T pick(T a) { return -a; } pick(3); // 调用第二个signed_integral 更严格这相当于把 SFINAE 时代那套enable_if把戏换成了可读性极高的声明式写法。以前要用std::enable_if_t加一堆::type才能实现的按能力选择重载现在一行requires就表达清楚了。实践里我用它解决过一个序列化和反序列化的分发问题聚合类型走通用的成员遍历自定义类型走用户提供的serialize方法两类模板通过约束自然分流代码比if constexpr嵌套清爽得多。3. Ranges用管道把算法串成流水线3.1 迭代器范式为什么难组合C 的算法库std::find、std::sort、std::transform从 C98 起就是迭代器对驱动begin和end两个参数把容器打开给算法。这种设计能工作但组合起来非常痛苦。想对一组数字先过滤偶数、再平方、再取前五个你得写一堆中间变量std::vectorint evens; std::copy_if(v.begin(), v.end(), std::back_inserter(evens), [](int n){ return n % 2 0; }); std::vectorint squares; std::transform(evens.begin(), evens.end(), std::back_inserter(squares), [](int n){ return n * n; }); std::vectorint first5(squares.begin(), squares.begin() 5);三个步骤三个容器中途的内存分配、拷贝、同步都成了隐形成本。本质上是因为算法和数据流耦合在了迭代器对上中间结果必须物化成一个实体对象。3.2 view 与惰性求值中间结果不再落地Ranges 库带来两个核心概念range和view。range是任何能用begin()/end()表示的东西容器、数组、字符串都算。view是一种特殊的 range它不拥有数据只是看数据的窗口拷贝成本低组合起来不会复制底层元素。关键设计是惰性求值。视图链上的每个变换不会立刻执行而是等到真正遍历时才逐个元素计算#include ranges #include vector std::vectorint nums{1, 2, 3, 4, 5, 6, 7, 8, 9}; auto pipeline nums | std::views::filter([](int n){ return n % 2 0; }) | std::views::transform([](int n){ return n * n; }) | std::views::take(3);pipeline在定义时什么都没算唯一的代价是创建几个轻量视图对象。真正开始遍历时take(3)只要三个结果所以filter和transform也最多只处理前六个元素因为偶数在 1~9 里是 2、4、6、8第六个是 6正好凑够三个平方数。整个管线不产生中间容器不提前计算没用的值内存和时间都被省下来了。3.3 算子适配器与哨兵管道语法背后的两处细节管道操作符|左边的数据流接入右边的视图适配器views::filter、views::transform、views::take、views::reverse、views::drop等形成新视图。右边也可以再是另一个视图所以可以一直串联。这个设计借鉴了函数式语言里 data pipeline 的思路读代码的顺序就是数据流动的顺序不用再倒着找。另一个容易忽略的改动是 sentinel哨兵类型。以前end必须和begin同类型Ranges 允许结束位置是另一种类型只要支持比较即可。这带来两个直接好处一是可以表达遇到某个条件就停的范围比如按行读取文件直到空行二是对未定长输入如 I/O 流也能优雅地做算法调用。标准库里std::ranges::sort(v)这种直接传容器名的写法背后就是 sentinel 机制解放出来的简化。3.4 性能真相与连续视图的坑惰性组合听起来无懈可击但要注意几种情况。第一views::reverse这类适配器要求底层是随机访问范围如果底层容器是链表编译期就会因为不满足random_access_range约束而报错——这不是缺陷是设计上的刹车。第二视图不拥有数据生命周期取决于底层容器容器销毁后还持有视图访问就是悬垂引用auto bad() { std::vectorint local{1, 2, 3}; return local | std::views::transform([](int n){ return n * 2; }); // 悬垂 }函数返回时local析构返回的视图成了空指针对着已释放内存。这是 Ranges 最常见的坑标准库为此做了保护某些视图在返回时如果挂着临时容器会直接编译报错dangling range 检测但bad()的例子是局部命名对象编译器不背这个锅得自己控制好生命周期。还有一点视图链上每个transform的 lambda 都是按值拷贝的如果闭包捕获了大对象每个视图都会持有一份拷贝。遇到这种情况用引用捕获或确保 lambda 轻量否则内存翻倍不是开玩笑。4. Coroutines函数也能挂起再续了4.1 回调地狱与手工状态机异步编程一直是大坑。最朴素的做法是异步操作完成后回调一个函数但业务复杂后回调层层嵌套可读性迅速劣化错误传播也要靠回调链一路手动传递。有人会说可以用线程加锁但线程切换有成本锁竞争有风险不是所有场景都适合。另一个传统方案是手写状态机把一次异步流程拆成多个阶段每个阶段用枚举标记事件来了就查状态机决定下一步。这倒是能解决回调嵌套但代码里充斥着 case 分支业务逻辑被状态跳转切得稀碎。协程解决的是同一个问题我能不能把挂起点写进普通函数用同步风格的代码表达异步流程函数执行到某个操作时可以暂停先把用户态上下文存起来等操作完成后再从暂停的地方继续跑。4.2 三个关键字的明确分工C20 协程是语言层面的机制不依赖操作系统线程。它引入三个关键字co_await expr对表达式求值如果结果暂时不可得协程就挂起控制权返回给调用方等条件满足后再恢复执行。co_yield expr挂起并把一个值交给调用方常用于生成器场景。co_return value结束协程并返回值。这三个都不是普通函数里的关键字一个函数只要包含其中任意一个就自动变成协程。编译器会把协程体转换成状态机每次挂起点对应一个状态协程的局部变量和挂起上下文存放在堆上分配的协程帧里而不是普通栈帧里。协程要能工作还依赖两个参与者promise_type协程和调用方之间的约定对象和可等待对象awaitable。co_await后面的东西必须能被co_await运算标准没有给你现成的通用可等待类型而是定义了机制让库作者去实现。这也是 C20 协程上手门槛偏高的根源语言给了标准库给的配套太少你得先自己造一个。4.3 最小生成器从零手写一个可用的协程类型C20 标准库没有直接提供生成器模板std::generator要等 C23。所以日常在使用 C20 编译器时通常要自己封装一个最小生成器。下面这个是我项目里在用的精简版去掉异常处理和安全检查只看骨架#include coroutine #include exception #include utility templatetypename T class Generator { public: struct promise_type { T current; Generator get_return_object() { return Generator(std::coroutine_handlepromise_type::from_promise(*this)); } std::suspend_always initial_suspend() noexcept { return {}; } std::suspend_always final_suspend() noexcept { return {}; } void return_void() {} void unhandled_exception() { std::terminate(); } std::suspend_always yield_value(T value) { current std::move(value); return {}; } }; explicit Generator(std::coroutine_handlepromise_type h) : handle(h) {} ~Generator() { if (handle) handle.destroy(); } bool next() { handle.resume(); return !handle.done(); } T const value() const { return handle.promise().current; } private: std::coroutine_handlepromise_type handle; };用起来就是这样Generatorint fibonacci() { int a 0, b 1; while (true) { co_yield a; auto next a b; a b; b next; } } int main() { auto gen fibonacci(); for (int i 0; i 10; i) { if (gen.next()) { // 打印 gen.value() } } }co_yield a会把值放进promise_type::current然后挂起外部调用next()时handle.resume()恢复协程执行到下一个co_yield又挂起。fibonacci函数里那个while(true)可以无限生成斐波那契数列永远不会真的跑满 CPU因为每次挂起后控制权就交出去了。这个例子的价值在于看出协程的内部结构promise_type是协程和外部世界的信箱std::suspend_always是最简单的可等待对象永远挂起std::coroutine_handle是直接操纵协程帧的句柄。封装好一次后面生成器场景就是白嫖。4.4 关于性能与滥用的一点意见协程不是免费的。每个挂起点都要在堆上分配协程帧除非编译器对生命周期做了优化挂起和恢复涉及状态保存和恢复吞吐量敏感的高频路径上要谨慎。网上经常有人吹协程零开销准确的说法是挂起而不执行任何代码时有接近零的调度开销而不是协程比普通函数快。我个人的意见是I/O 等待型的异步任务网络请求、文件读写、任务流适合协程因为等待时间远大于挂起恢复的开销但纯粹为了显得高级把一个同步计算函数改成协程只会徒增内存分配和复杂度没有任何收益。另外协程不能直接传引用参数的问题是个大坑——协程帧的存活周期可能长于函数调用上下文参数里的引用很容易悬垂建议一律按值传递或拷贝进成员。5. 容易被漏掉的实用特性三路比较、指定初始化与 constexpr 扩展5.1 三路比较运算符一个符号省一堆样板代码是 C20 引入的太空船运算符。它的思路很简洁一次比较得出两个值的大小关系结果类型是std::strong_ordering、std::partial_ordering或std::weak_ordering三者之一通过结果和0比较就能判断小于、等于、大于。更实用的是默认化struct Point { int x; int y; auto operator(const Point) const default; };这一行 default自动生成所有的比较运算符、、、编译器按成员声明顺序做字典序比较。以前需要手写六个运算符的类现在一行搞定而且不会出现某个运算符写反导致排序异常的低级错误。注意一个小细节默认化的只在你需要时才会顺带生成如果你只声明了代码里p1 p2可能不合法。所以需要全量比较时最好把也默认化bool operator(const Point) const default;5.2 指定初始化器结构体初始化终于不再靠顺序瞎猜C 语言老早就有的指定初始化C20 终于也进来了struct Config { int timeout; int retries; std::string host; }; Config c{.host 127.0.0.1, .timeout 30};好处是字段多时不用数位置也不会因为别人在前面插了个字段导致后续位置全部错位。但要记住规则指定初始化必须按声明顺序书写不能跳着来且不能和顺序初始化混用。另外有个限制是匿名联合体成员不能用这是个小坑遇到再说。5.3 constexpr 的持续扩张C 这些年一直在扩大constexpr的可用范围。C20 允许在constexpr函数里做有限制的动态内存分配、try块、虚函数调用和std::vector/std::string的操作。这意味着编译期计算的代码越来越接近普通运行时代码的写法。最直接的应用是编译期构造哈希表、解析常量字符串等。不过要动态分配就绕不开常量求值引擎对分配释放的检查规则使用constexpr std::vector时要注意容量操作可能触发的分配次数限制。实践中我的建议是不要为了追求整个程序全是 constexpr而把代码改得晦涩先找那些确实在运行时会重复执行的高频小计算把它搬进编译期收益最明显。6. 上手顺序、编译器现状与实践中踩过的坑6.1 三大件各自的上手成本评估先给个直观结论如果只想近期在项目里用上 C20优先级排序是 Concepts 最低风险、Ranges 收益最快、Coroutines 投入产出最高但学习曲线最陡。Concepts 的引入对现有代码几乎不构成破坏。你可以在新写的模板函数上慢慢加约束老代码一动不用。唯一要注意的是头文件里定义了 concept 之后重载解析规则会和以前略有不同约束非满足的模板会被静默不参与重载而不是报错——这反而是好事但团队里如果有人对模板匹配不熟可能把它为什么不报错当成 bug 来查。Ranges 适合改造链条式的数据处理代码。老代码里的循环加中间容器可以逐步替换成视图管线可读性提升是肉眼可见的。改动有边界先在内层纯计算部分用视图不要在涉及复杂迭代器失效逻辑的算法上盲目替换。我曾把一个链表遍历改成视图链结果因为底层不是随机访问容器编译不过最后又改回循环——不是视图不好是没选对场景。Coroutines 的上手门槛最高因为标准库配套少自己要先补基础设施。我的建议是先去抄一个封装良好的协程库来用常见的任务类、生成器类等熟悉了挂起恢复的模型再去看promise_type的实现细节。直接裸写promise_type入门大概率会被各种挂起语义细节劝退。6.2 编译器支持情况与 ABI 提醒经过这几年的迭代主流编译器对 Concepts、Ranges、Coroutines 这三块的支持已经相当成熟日常开发基本碰不到特性摆了但用不了的情况。相对而言Modules模块那部分才是各家用各自实现、互相不兼容的重灾区——这也是我把它留在下篇来讲的原因。另外提醒一句 ABI 的问题协程的关键字co_await等在某些老代码里可能作为变量名出现过迁移编译时会报错。升级 C20 前先全局搜一下co_await、co_yield、co_return这三个标识符是否被用作普通标识符否则编译期会收获一堆莫名其妙的解析错误。6.3 我认为值得养成的三个习惯第一concept 定义尽量放在头文件中靠近使用它的模板旁边命名用名词形式Arithmetic、Sortable不要用动词短语这样约束读起来像形容词修饰类型。第二Ranges 视图链尽量封装成带名字的函数或变量不要在表达式中堆十几层管道否则出错时定位具体是哪一段费时。第三协程代码一定要写清楚生命周期注释尤其是协程帧接管了哪些资源、由谁负责释放这个东西靠人记是记不住的。C20 这波更新把模板和异步两大主题都往前推了一大步我实际用了大半年之后最深的感受是写代码时能不能编译过的直觉要比以前强很多因为约束和视图把接口边界暴露得更清楚了。下一篇我会把 Modules、format、span、jthread 以及日历时间库这些剩余部分继续拆完如果你正在犹豫要不要全面启用 C20可以先从这篇里的三件套挑一个最贴合当前项目痛点的入手跑通一个模块再逐步铺开。