把工程从C++17切到C++20的那几周,我被问得最多的问题一直没变过:“这一版到底改了什么?”这不是一个能一句话回答清楚的问题。C++20不是加了一两个语法糖,而是把模板约束、容器遍历、异步模型和比较语义这几个最底层的房间全部重新装修了一遍。我在一个中大型项目里做了小半年的标准迁移和代码现代化,今天先把影响面最大的四件事拆开讲清楚:概念(concepts)、三向比较(<=>)、范围(ranges)和协程(coroutines)。不管你是在写库、维护老系统,还是刚把C++17用得顺手准备往前看,这篇的落脚点都很实际:怎么用、为什么这么用、以及哪些地方有坑。
另外交代一句,这个主题我按上下两篇来写,上篇集中讲最容易改变你“写法习惯”的四个特性,下篇再聊constexpr增强、格式化输出、lambda改进这些相对独立的部分。如果你已经知道C++20的所有术语,只是缺实操经验,可以直接跳到第2章之后。
1. 先看整体:C++20为什么被当成一道“用法分水岭”
1.1 和前几代标准完全不同的地方
C++11把现代C++带了回来,移动语义、lambda、智能指针,那一波是真真正正把“写C++”的方式扭过来了。C++14和C++17也很重要,但本质上是在C++11的基础上做增量修补:返回类型推导、结构化绑定、if constexpr,每一个都好用,但都不至于让代码的“组织方式”发生质变。
C++20不一样。概念把模板约束从注释和静态断言变成了编译器能理解的契约;范围把迭代器加加减减那套老手艺,往更接近“集合运算”的方向推了一大步;协程又给异步逻辑提供了普通函数一样顺序书写的可能。这三件事同时落地,意味着你写出来的代码结构不再是“类C++11的化合物”,而是一种新的表达顺序。
以前模板报错要靠肉眼在一千行error里找类型,现在概念能直接把错误钉在你的参数上。以前遍历一个容器要写begin()和end(),现在可以用一段流水线一样的视图链。以前异步流程要回调嵌套回调,现在协程可以写成从上到下的顺序代码。这些变化相互独立,但合在一起,会让你被迫重新思考每个函数的接口长什么样。
1.2 这篇为什么只挑这四个特性
C++20总共包含的改动有几十项,但大部分都是“普适性强、迁移成本高”的类型。比如consteval、constinit这类关键字,如果你不写高性能代码,基本碰不到;比如std::jthread,如果没有strictly-before的协作取消需求,它只是一颗糖。真正会在你写每天的业务逻辑时反复改变思路的,其实就是概念、三向比较、范围和协程。
所以我决定上篇只写这四个。它们的共同点是:都改变“一个函数或类怎么被设计”这件事,而不只是“某个语法写法变简洁”。读完这一篇,你至少能对代码产生一种新的直觉:模板参数上应该写约束,自定义类型应该默认给<=>,循环外应该优先用视图组合,异步函数应该尝试用协程表达。
2. 概念:把模板约束从“碰运气”变成“编译期契约”
2.1 没有概念的模板代码是怎么折磨人的
C++17之前写模板函数,最常见的验证方式是static_assert(sizeof...(Args) > 0)或者干脆等实例化之后看编译错误。问题是,模板在实例化之前,编译器只做语法检查,很多错误要到实例化那一步才爆炸。而你收到的错误信息,往往是从模板函数内部某个标准库调用点发出的,不是从你传错类型的那个调用点发出的。
我见过一个特别典型的场景:有人传错了一个容器类型给排序模板,结果编译器报了四十行错误,最显眼的是一个swap实现里的type_traits断言失败。对新手来说,这种错误提示约等于没有。概念要解决的正是这件事:把“必须满足某种要求”写成模板声明的一部分,让报错出现在调用点,并且提示信息里直接说出哪个约束不满足。
2.2 从基础用法到requires表达式的三层递进
定义一个概念,最简单的方式是把它当成一个模板布尔常量:
#include <type_traits> template<typename T> concept Number = std::is_arithmetic_v<T>;有了Number之后,可以用三种方式把它挂到函数上,它们本质等价:
// 1. 模板参数约束 template<Number T> T half(T v) { return v / 2; } // 2. requires子句紧跟在模板参数之后 template<typename T> requires Number<T> T half(T v) { return v / 2; } // 3. 函数尾置requires template<typename T> T half(T v) requires Number<T> { return v / 2; }如果你是写库的人,我建议首选第一种写进模板参数列表,理由不仅是短,而是它和函数重载声明放在一起时最容易被阅读者一眼看见。第三种适合某些模板参数不是制约条件的场景,比如只有特定的两个参数类型组合才满足某个约束。
Number这种带布尔值的概念只能表达“是或不是”。实际项目里你常常会想说:“这个类型必须支持某种操作”,这时候要用requires表达式:
template<typename T> concept Addable = requires(T a, T b) { { a + b } -> std::same_as<T>; };这里有两层意思:a + b必须能编译,而且它的返回类型必须和T完全相同。requires表达式里可以出现四种要求:简单要求检查“表达式是否合法”,类型要求检查“是否有某种嵌套类型”,复合要求检查“表达式的结果类型是否满足某个约束”,嵌套要求则直接在内部再套一个requires。实际开发中最常用的是复合要求,因为它同时检查“能编译”和“结果类型正确”。
再扩展一点,一个概念可以组合约束其它概念:
template<typename T> concept ContainerLike = requires(T t) { typename T::value_type; t.begin(); t.end(); }; template<typename T> concept ReversibleContainer = ContainerLike<T> && requires(T t) { std::ranges::begin(t); std::ranges::end(t); };这种“一个概念建立在下层概念之上”的做法,和普通代码里的接口继承是同一个思路。你的约束层次设计得越清晰,编译器给出的约束检查提示就越容易理解。我在项目里甚至把概念列表直接当接口文档用,因为概念本身就是可被编译器验证的文档。
2.3 概念实操中最容易掉的三个坑
第一个坑是过度约束。我记得有人把一个sort函数模板的T约束成“必须是某种具体容器的模板”,结果用户换了std::vector的分配器模板参数之后直接编译失败,但旧代码其实是完全能跑的。正确的约束方式是约束“行为”而不是“实现”,比如只需要std::ranges::random_access_range<T>,而不是std::vector<T>。行为约束可以灵活接纳更多合法类型,实现约束只会让你每个版本都要追着标准库改。
第二个坑是std::same_as和std::convertible_to的区别。如果你想表达“返回类型可以转换成bool”,用std::convertible_to<bool>;如果你想表达“返回类型严格就是bool”,用std::same_as<bool>。两者在日常代码里差别往往看不见,但在重载候选和约束偏序判断中会有完全不同的效果。我见过一个问题:约束写成了same_as<int>,而实际函数返回long,编译器提醒“约束不满足”,看半天才发现是这里太严格。
第三个坑是概念不能用来约束编译期函数体内的实现细节。比如:
template<typename T> requires (!std::is_pointer_v<T>) void deref(T p) { *p; }这个约束对“合法性”的判定只发生在实例化前,但如果T是一个没有重载operator*的类,你依然会在实例化时看到系统错误。概念不是万能解析机,它不负责实现函数体内部逻辑,只负责调用方类型是否满足契约。所以要配合static_assert(MyConcept<T>)快速自测某个自造类型是否满足概念,这比让编译错误从深层弹出来要省心得多。
3. 三向比较:以后写比较逻辑可以少吃几年苦
3.1 一个自定义类型六个比较运算符的历史包袱
以前给类型加上比较能力,最直接的方式是写六个operator<,<=,>,>=,==,!=,或者在C++11之后写一个operator<和operator==再加上一堆std::rel_ops,但std::rel_ops早就被标志为不推荐。每个自定义类型都重复这套模式,不仅代码冗余,而且稍不留神就会在方向、边界条件上出错。尤其是把类放进std::map、std::sort或者当某个算法的Compare参数时,一旦比较语义不一致,结果就变成了“几乎正确但偶尔诡异”。
C++20的三向比较运算符<=>直接把六个比较运算符的生成规则内置到了语言里。你不再需要手工写六个,只要一个默认的<=>,编译器就能推导出其余运算符。
#include <compare> struct Money { int cents; auto operator<=>(const Money&) const = default; };定义完之后,Money可以直接放进std::sort,可以放到std::set里,甚至可以用==判断相等。最大的价值不是少写几行代码,而是避免六种比较逻辑内部出现不一致。比如有人写了operator<时忘了对负零特殊处理,但operator>又正确处理了,这种细小的分裂正是重灾区的来源。
3.2 返回类型里的三种比较类别是怎么回事
auto看上去自动推导,但背后有深意。三向比较的可能返回类型有三种:std::strong_ordering、std::weak_ordering、std::partial_ordering。
strong_ordering表示严格全序:a和b两者必然有一个小于、等于或大于,并且相等性可以被交换、替换。整数就是这种类型。weak_ordering表示一个可比较的等价关系,但不等价的东西不一定是同一个值的不同表示?可以简单理解为“按某个排序规则相等但内部可能存在不可互换的差异”。partial_ordering允许不可比较的情况,浮点数就是典型:NaN和任何数都不存在<、==或>关系。
<=>采用auto推导时,编译器会自动选择满足条件的最弱类别。比如一个数值类型内部是浮点,那么<=>会把四个量按浮点数比较,结果可能包含不可比较状态,最终推导成partial_ordering。
实际开发中,真正需要手写<=>返回值的情况很少,但搞清楚这一点很重要,因为当你手写一个自定义比较函数时,如果返回std::weak_ordering::less而实际上类型是全序,不会编译错误,但可能在库内部断言中被挡下来。合理的做法是只在类型语义不满足全序时才选择弱序。
3.3 默认比较和自写比较的分寸拿捏
默认比较的关键在于:<=>默认会用成员子对象在声明顺序上逐项比较,==默认会逐项比较成员,且<=>与==会各自独立推导。很多人在这一步踩坑:他们希望<=>按价格比较、==按ID比较,于是只推了<=>默认。这是完全错误的,因为默认的==也会变成按所有成员逐项相等。
如果类型需要“比较规则A,相等规则B”,最稳妥的做法是:
struct OrderKey { int group_id; int version; auto operator<=>(const OrderKey&) const = default; bool operator==(const OrderKey&) const = default; }先让默认版本工作起来,再在需要时单独替换某一个。还有个小技巧:手写operator<=>返回值高概率是bool,这不对,<=>必须返回三向比较类别,不能返回布尔。如果你只是想返回“a < b”,那就是另一个需求,应该提供operator<而不是<=>。
另外,浮点成员是默认<=>的一颗雷。如果类里有一个float成员,而你认为两个float在排序上相等就算这个对象“等价”,那么默认的<=>连同浮点比较风格会给你带回partial_ordering,并不支持将“不同数值但相等”的状态合并为等价。这种情况下不要犹豫,手写比较实现,不要期望用默认规则兜底。
4. Ranges:把容器操作编成一条流水线
4.1 老写法为什么总有一种“中间变量满天飞”的憋屈感
传统STL算法很强大,但组合起来非常笨重。比如你想“取所有过滤后的元素,再做一个变换,然后取前5个”,通常你会写出嵌套调用或者一连串临时变量:
std::vector<int> tmp1; for (auto v : values) if (v % 2 == 0) tmp1.push_back(v); std::vector<int> tmp2; for (auto v : tmp1) tmp2.push_back(v * v); std::vector<int> result(tmp2.begin(), tmp2.begin() + std::min(5, (int)tmp2.size()));这种写法不但长,而且每一步都会分配内存、复制数据。到了C++20,问题变成了同一个逻辑用views流水线表达:
#include <ranges> #include <vector> auto result = values | std::views::filter([](int v) { return v % 2 == 0; }) | std::views::transform([](int v) { return v * v; }) | std::views::take(5);这个result并不是一个盛满结果的容器,而是一个惰性视图。你把它放进for循环或调用算法前,它不会真的执行任何转换。只有当你遍历它时,过滤器和转换器才顺着元素逐个触发。这个性质让组合过程不再产生中间容器,也避免了临时变量的生命期管理问题。
4.2 视图、适配器和惰性求值三个关键概念要分开看
范围(range)是对“可遍历序列”的抽象,标准容器、原生数组、字符串都满足。视图(view)是在不拥有元素的情况下对某个范围的变换封装。视图适配器(view adaptor)就是filter、transform、take这些能把一个范围变成另一个范围的东西。常用来合并它们的|操作符,可以把一个范围从左往右依次喂给各个适配器。
这里最容易被忽略的是“惰性求值”。filter和transform的循环体不会在你构建视图时执行,而是推迟到遍历时。好处很明显:如果你只要前5个结果,后十万个元素根本不会被转换。坏处也值得注意:如果你的转换函数有副作用,比如计数或打印,那么这个副作用不会在流水线构建时发生,而是在遍历时发生,而且遍历几次就执行几次。
int call_count = 0; auto view = values | std::views::transform([&](int x) { ++call_count; return x * 2; }); int first = *view.begin(); // call_count变成多少?取决于实现,但肯定发生 int second = *std::next(view.begin()); // call_count又增加了这完全是合法的行为,但如果你写的是“每个元素只该处理一次”的转换函数,第二次遍历就会破坏预期。所以必须把视图理解为“描述一次计算的轻量对象”,而不是“已经完成计算的结果”。
4.3 陷阱集中在生命期和“视图不能拥有元素”这件事上
最容易出事故的是从函数返回视图。视图不拥有元素,只持有对底层范围的引用或迭代器。如果底层范围是一份函数内局部变量,函数返回后视图就悬挂了:
auto bad() -> std::views::filter_view<???> { ··· } // 不要在返回时让视图引用局部容器我见过有人写了一个解析函数,返回一个指向局部std::vector的过滤视图,然后在调用方遍历,结果要么崩溃要么出现未定义行为。安全的做法是:如果函数需要返回一个可遍历的序列,并且数据由函数内部生成,可以直接返回容器;如果一定要返回视图,那么底层范围必须具有更长的生命期,比如从线程池持有的共享数据或者static全局表。
另一个和元素所有权相关的坑,是把视图和std::ranges::to混用。C++23才补齐的ranges::to可以把视图物化成容器,但在C++20标准下,你需要老老实实遍历视图推入容器,或者自己写一个类似的小工具。别把std::vector直接拿视图来构造,旧标准里它不是range构造接口的直接配套实现。
4.4 编译期性能:为什么组合听起来花哨但通常不吃亏
虽然每个适配器都提供逻辑上的包装,但合格的实现会把循环体层层内联,让最终生成的机器码看起来几乎和一个手写循环差不多。至少在我见过的项目里,用ranges::views::filter加transform的版本不会比手写循环慢,很多时候可读性远比可测性重要。
不过有一个例外:如果某个适配器让迭代器额外跨越一层std::function,性能就会掉下去。比如你把一个带有std::function参数的算法套在视图链里,编译器很难跨过类型擦除层做内联。所以生产环境里尽量给视图的谓词直接传lambda,不要先保存成std::function再塞进去。虽然标准里没有强制禁止,但这个习惯可以帮你避掉一个常见性能陷阱。
5. 协程:让异步逻辑看起来像同步代码
5.1 协程想要解决的,是异步回调里那本“翻不完的账”
以前写异步逻辑,最常见的是回调链。拿到结果→解析→再请求→再解析→再写缓存,每多一个异步环节,回调嵌套就深一层。代码读起来是从外往内跳的,和你脑子里“从上往下”的处理流程完全相反。协程通过co_await把“等一下结果”的动作变成挂起点:协程执行到这里,就把当前函数的状态保存起来,把控制权交出去;等条件满足时,再从挂起点恢复,继续往下跑。这样一来,你能像写同步代码一样写异步逻辑。
C++协程属于无栈协程。它不像线程那样有自己的运行栈,而是把挂起状态压进一个动态分配的帧里。所以协程本质上非常适合I/O密集、等待多、计算少的场景,不适合替代并行计算的线程。
三个新关键字各有分工:co_return结束协程并返回值,co_yield向外生成一个值并挂起,co_await等待某个可等待对象。最朴素的理解方式:
- 一个协程函数是一个可暂停的函数。
co_yield像“生产单个值的迭代器”。co_await像“等异步结果回来再继续”。
5.2 从顶层看一个协程需要哪几块积木
直接用裸协程并非开箱即用,你需要一个promise_type、一个句柄、以及对应在协程挂起/恢复时被调用的几个接口。标准库直到C++20都没给现成的generator或task类型,很多项目需要自己三层封装。这导致很多团队“看了一晚上协程PPT,第二天项目里一行协程都没写”。
实际项目中比较好的做法,是先把一个最小可用的生成器类型垫在项目内部。这里给一个极简骨架:
template<typename T> struct generator { struct promise_type { T current_value; generator get_return_object() { return generator{std::coroutine_handle<promise_type>::from_promise(*this)}; } std::suspend_always initial_suspend() noexcept { return {}; } std::suspend_always final_suspend() noexcept { return {}; } void return_void() {} std::suspend_always yield_value(T value) { current_value = std::move(value); return {}; } void unhandled_exception() { std::terminate(); } }; using handle_type = std::coroutine_handle<promise_type>; explicit generator(handle_type h) : handle(h) {} ~generator() { if (handle) handle.destroy(); } bool move_next() { if (!handle) return false; handle.resume(); return !handle.done(); } T current_value() const { return handle.promise().current_value; } private: handle_type handle; };有了这个骨架就能写:
generator<int> counter(int limit) { for (int i = 0; i < limit; ++i) { co_yield i; } }虽然这个版本很简略,没有关注异常处理、协程销毁所有权、co_await等,但底层的执行节奏已经出来了。把一个大型任务往下拆,封装协程框架的工作量和难度都要认真评估。
5.3 协程调试与生命期管理的几条经验
最容易出bug的是“协程挂起后,函数返回了,但局部变量的引用还在”。老调重弹但必须提醒:协程挂起时,整个栈帧仍然活着,如果它的局部变量是一个指向外部数组的指针,而外部数组在其他线程里被缩减了,恢复后你访问到悬空内存的概率极高。
第二个经验是,不要在协程里默认依赖线程局部状态。协程挂起和恢复可能发生在不同线程上,特别是当异步执行库使用线程池时,thread_local变量在协程恢复后往往会回到一个和你预期不同的值。避免把“线程身份”作为可靠状态,除非明确恢复策略。
第三个经验是内存消耗。每个协程函数被调用时,编译器会生成一个动态分配的协程帧。如果函数里有一个大数组或者大对象,内存占用会被放大。大量高频调用的协程应该评估,能不能把协程帧的分配定制为池化,或者是否应该把大对象放到协程外部。标准允许在operator new上做优化,但很多团队先选择不用协程处理“每秒百万级别的短小请求”任务。
6. 把新特性塞进旧工程:编译器适配与排雷手册
6.1 编译器支持不是一张平滑的进度表
C++20的特性从编译器第一天支持到今天,经历了非常漫长的过程。甚至到了今天,不同CRT、不同平台下的细节行为还不完全一致。有一个硬道理:不要相信“某个特性在某编译器上能编译,就代表在所有地方都安全”。在项目里切标准位之前,我建议先列一张内部矩阵,至少覆盖三件常用事:GCC和Clang都打开-std=c++20后能不能编过、MSVC 2022当前供应链上的标准库头文件完整度、以及你们的嵌入式交叉编译工具链是否支持。
如果你必须同时支持C++17和C++20,可以用特性测试宏来做条件编译:
#if defined(__cpp_concepts) && __cpp_concepts >= 202002L template<typename T> requires NoThrowCopyable<T> void foo(T v) { ... } #else template<typename T> void foo(T v) { static_assert(NoThrowCopyable<T>, "NoThrowCopyable required in C++20 mode"); ... } #endif__cpp_concepts这类宏比你自己判断编译器版本可靠得多。宏的值代表编译器提供的能力版本,不同编译器即使都支持某个特性,具体宏值也可能不同。写兼容代码时,宏是你的好助手;不需要兼容时,能直接编译就能验证。
6.2 旧代码迁移时的三类高频问题
第一类,是概念相关代码在旧编译器下的“类SFINAE痕迹”。有些旧的enable_if包装和概念混在一起会引发约束冲突。迁移的时候不要把每个enable_if都强行替换成概念,优先替换那些之前难以阅读的模板约束,其余保持原样没有坏处。
第二类,是范围视图的悬挂问题。凡是写了auto range = container | filter(...)之后又引用container的地方,迁移时要重点检查。因为旧代码里的container生命周期通常一直延伸到range使用完之后,问题不大;但如果container是临时对象,比如从一个函数返回的字符串直接接views::split,那隐患就很明确。
第三类,是三向比较的默认成员比较对“成员声明顺序”极敏感。如果你把类成员声明顺序打乱,<=>的默认比较顺序也会变。这种变更一般不会破坏编译,但可能改变排序结果,属于最阴的一类bug。迁移时先跑一遍全部排序测试,别只测编译期。
6.3 一个便于快速验证新特性的“最小单元模板”
我个人习惯在每个项目里留一个language_features_test.cpp,里面专门放三个最小片段:
- 一个定义了
operator<=>并检查std::sort是否能用的类型; - 一个使用了
std::views::filter和std::views::transform的最小集合; - 一个自定义
generator<int>的协程样例并执行三次。
这样每次换新工具链版本,我只要编译并运行这个单元文件,就能快速判断这轮迁移的环境有几个大版本坑等待处理。比起在一百个业务文件里逐个试错,这个小文件等于提前把风险信号集中暴露。如果你也准备切标准,可以先从这样一个最小测试开始。
7. 上篇没写完的部分,以及我从坑里提炼的一条建议
概念、三向比较、范围、协程这四个特性,我现在已经把它们当成日常编码的基本工具在用。但坦白讲,我一开始高估了自己对它们的适应速度。真正开始给现有代码做迁移时,前两周节奏非常慢,四处踩编译器版本和标准库实现的边缘。后来我把目标降低,只挑几个非核心模块试点,跑完测试再决定是否铺开,这个节奏才稳下来。
如果你也准备升级到C++20,我的个人建议是:先不要急着改全部老代码,选一个新的功能模块,强制要求所有新代码用概念做接口文档、用三向比较定义比较语义、用范围代替手写循环、只在异步等待点上引入协程。跑两个迭代之后,你会真心觉得C++20以前那些写法和这个版本的差距,没能被一句“语法糖多了”概括。下篇我会继续聊constexpr大幅增强、lambda模板参数、格式化输出这类相对独立但很好用的特性,到时候见。