简介:面向具备一定 C++ 基础、希望系统跟进 C++20 标准的开发者与基础库作者,这份《C++20 - The Complete Guide》英文原版电子书由 Nicolai M. Josuttis 撰写,2021 年底发布,用于解决新标准特性分散、官方文档难以串联成体系的问题,既可作为查阅手册,也适合从头通读。压缩包内仅 1 个 PDF 文件,约 3.44MB,便于在电脑、平板上随身翻阅与检索。资源目前已有 361 人学习,属于小众但目标明确的专业读物。内容围绕语言与库两条主线展开:比较运算符 <=> 与比较类别、概念对模板参数的约束、模块化对编译与封装的影响、协程的暂停恢复机制,以及 ranges、format、bit 等新库组件的用法,均配有示例代码与取舍说明;书中还解释了多版本标准库的兼容处理、强类型枚举、原子浮点操作等细节,并提示读者按编译器支持程度选择可实践的部分,适合边读边写代码验证。
1. 三件事改的是同一层:C++20 到底动了什么
拿 C++20 当「功能包」来读,很容易看偏。ranges 能少写几个 for 循环,format 能替掉 printf,这些是表层收益;真正动底层的是三条编译期规则:<=>重写了比较表达式的候选集,requires改写了重载决议的候选筛选顺序,view 把「迭代器必须同类型」这个前提拆掉了。改动全发生在编译期,运行期一条冗余指令都没加。
理解这三条改的到底是什么规则,才能看懂为什么老代码会静默改变行为、为什么模板报错从两百行收敛成一行、为什么一个 view 能跨过临时对象的生命周期而不炸。这不是语法糖的合集,是编译器解析和匹配逻辑的一次重构。
2. operator<=>:一个运算符顶掉六个的编译期机制
2.1 从六个手写函数到一条 default
老写法里,一个可比较的值类型意味着六个成员函数,而且要保证它们互相一致。漏写一个不容易出事,写反一个方向才是血泪经验——operator>里调用了operator<结果参数顺序搞反,测试刚好没覆盖到,等到上线才发现在某个排序路径上结果整个颠倒。
// C++17:六个函数一个不能少,还得保证语义互相自洽 struct Version { int major, minor, patch; bool operator==(const Version& r) const { return major == r.major && minor == r.minor && patch == r.patch; } bool operator!=(const Version& r) const { return !(*this == r); } bool operator<(const Version& r) const { if (major != r.major) return major < r.major; if (minor != r.minor) return minor < r.minor; return patch < r.patch; } bool operator>(const Version& r) const { return r < *this; } bool operator<=(const Version& r) const { return !(r < *this); } bool operator>=(const Version& r) const { return !(*this < r); } };六个函数里真正携带信息的只有==和<,其余四个是纯推导。C++20 把这层推导收进语言规则里:
struct Version { int major = 0; int minor = 0; int patch = 0; // 一条声明同时生成 < <= > >=,并隐式声明一个 defaulted operator== auto operator<=>(const Version&) const = default; };= default的展开规则是固定的:先按声明顺序比较基类子对象,再按声明顺序比较非静态数据成员,数组成员逐元素比较。返回类型由auto推导,推导结果是所有成员比较结果的公共比较类别——这条「公共类别」规则是后面一连串坑的源头。
有个细节容易记错:隐式生成==这件事,只在operator<=>被声明为= default时发生。如果自己写了函数体,编译器不会补==,a != b就找不到候选。这个差异不是疏忽,而是标准刻意的保守设计——手写<=>时,编译器无法确认你心里的「相等」和「等价」是不是一回事,索性不替你决定。
2.2 比较类别不是装饰:strong、weak、partial 的语义边界
<=>的返回值不是int,是三个比较类别类型之一。它们不是三个不同的整数包装,而是三种不同的数学承诺:
| 类别 | 承诺 | 典型场景 |
|---|---|---|
std::strong_ordering | 等价即可互换,任何观察手段都区分不出 | 整数、std::string、逐成员比较的 POD |
std::weak_ordering | 序上等价,但对象本身可区分 | 忽略大小写的字符串、忽略精度的坐标 |
std::partial_ordering | 存在与任何值都不可比的值 | 含double的类型(NaN) |
这三个类型的区别不体现在运算符上,体现在「你能拿它做什么」。strong_ordering可以安全地喂给std::map的键比较;partial_ordering喂进去就是未定义行为的入场券,因为 NaN 让比较既不满足自反也不满足传递,红黑树的结构会被破坏。标准库没有在类型层面拦住你,这个判断得自己做。
#include <compare> #include <string> struct CaseInsensitive { std::string s; // 忽略大小写比较:内容不同的两个串在序上等价 std::weak_ordering operator<=>(const CaseInsensitive& rhs) const { return to_lower(s) <=> to_lower(rhs.s); // strong_ordering 可隐式转 weak_ordering } // 自己写 <=> 时编译器不会补 ==,必须手工给出,且语义要和 <=> 一致 bool operator==(const CaseInsensitive& rhs) const { return to_lower(s) == to_lower(rhs.s); } };这里的to_lower是示意函数。关键点在返回类型:std::string的<=>返回strong_ordering,但业务语义上「A」和「a」是两个不同的对象,只是排序等价,所以函数签名显式降级成weak_ordering。这个降级是隐式允许的,反过来不行——把weak_ordering当strong_ordering用会编译失败。
一个类型是否「强序」,本质上是对外承诺:如果两个对象比较相等,调用方能否把它们当成同一个东西互换使用。承诺错了,unordered_map的哈希和==就会打架,这种 bug 极难查。
2.3 重写表达式:a < b到底变成了什么
<=>之所以能顶掉六个运算符,靠的是重写表达式(rewritten expression)。当编译器看到a < b,它构造的候选集里不只有operator<(a, b),还有两个重写出来的候选:
(a <=> b) < 0 // 正向重写 0 < (b <=> a) // 反向重写重载决议时,非重写的候选优先于重写候选。所以老代码里显式定义的operator<依然会被优先选中,行为不变——这是 C++20 兼容性设计的核心:新增的重写候选只是补位,不抢占。
==和!=走一套独立的重写规则:a != b重写为!(a == b),a == b也可以重写为(a <=> b) == 0。这套规则的副作用是,一个只定义了operator==的老类型,!=现在也能用了;而一个只定义了operator<的老类型,<=依然不能用,因为<=不参与重写——<=只能由<=>或显式重载提供。
坑就埋在这里。<=>的返回类型必须是比较类别(或者能转成比较类别),但标准没有强制这一点:
struct Legacy { // 灾难写法:<=> 返回 bool bool operator<=>(const Legacy&) const; // 编译期不报错,但 a <= b 被重写成 (a <=> b) <= 0 // bool 转 int 后与 0 比较,语义完全错掉 };bool能隐式转成int,0能转成bool,重写后的表达式语法合法,编译一路绿灯。这种错误不会在编译期暴露,只在某个比较结果反了的瞬间才显现。我的习惯是:写<=>只写= default,需要自定义时明确写出返回类型std::strong_ordering或std::weak_ordering,绝不让auto或bool糊过去。
2.4 默认生成的边界:继承、指针和浮点
= default看起来最省事,但它有三个边界要知道。
第一是继承。默认生成的<=>会先比较基类子对象,基类的<=>必须可访问且没被删除,否则整个默认函数被定义为 deleted。多继承时按基类在声明列表里的顺序依次比较,这个顺序不是「哪个更重要」,纯粹是声明顺序,改一下基类列表的排列,排序结果就变了。
第二是浮点。成员里出现double,auto推导出的返回类型会降级成std::partial_ordering,因为double的<=>本身返回partial_ordering。这条规则对含浮点的配置结构体几乎是必踩:
struct Point { double x = 0, y = 0; auto operator<=>(const Point&) const = default; // 返回 std::partial_ordering:只要有一个分量是 NaN,与任何值都不可比 }; std::map<Point, int> m; // 编译通过,但 NaN 键会让红黑树结构失效解决办法不是删掉<=>,而是在需要严格弱序的场合显式把 NaN 规范化(比如比较前把 NaN 替换成一个哨兵值),或者干脆不给这个类型默认<=>,改用带容差的比较函数。
第三是指针。类里有裸指针成员时,默认<=>比较的是地址。C++20 的内置指针<=>返回strong_ordering听起来很美好,但它的承诺有前提:只有指向同一数组(或数组末尾后一位)的指针之间才有确定结果,其他情况结果是 unspecified。std::map默认用std::less<Key>,它对指针保证严格全序,所以指针做键是安全的;但如果你手写<=>拿指针地址比大小再喂给排序,行为就没有保证。
3. Concepts:约束为什么能把报错压在调用点
3.1 SFINAE 的黑匣子:为什么以前报错那么长
C++17 里做约束只有一条路:把条件塞进返回类型或模板参数列表,让替换失败触发 SFINAE,把候选从重载集里剔除。这套机制能工作,但它把「这个类型不满足条件」这个信息藏在了函数签名的最深处,调用方看到的只有一句「没有匹配的函数」。
// C++17:约束藏在返回类型里,读代码的人得自己在脑子里展开 template<typename T> std::enable_if_t<std::is_integral_v<T>, T> twice(T x) { return x + x; } // C++20:约束作为接口的一部分,写在参数表后面 template<typename T> requires std::integral<T> T twice(T x) { return x + x; } // 简写形式:concept 名 + auto 占位符,等价于上面那个模板 auto twice(std::integral auto x) { return x + x; }后两种写法在语义上等价,都是缩写函数模板,区别在于简写形式的模板参数是隐式的,调用时不能显式指定模板实参(twice<int>(3)在简写版本上不成立)。我在团队里推的是第二种:约束单独一行,一眼能看出这个函数对类型有什么要求。
真正的收益在报错上。约束参与重载决议的候选筛选阶段:不满足的候选直接被剔除,所有候选都被剔除时,编译器报的是「constraints not satisfied」,并且会沿着约束的合取链指出是哪一个原子约束求值为 false。相比之下,SFINAE 版本的错误会出现在函数体实例化到第五层的时候,错误信息里混杂着std::vector<...>::iterator这种完全无关的类型名。
3.2 requires 表达式能问的四类问题
requires关键字有两个完全不同的用法,混淆这两个用法是新手最常见的困惑。一个是 requires 子句(写在模板参数后面),一个是 requires 表达式(一个布尔常量表达式,里面写若干条要求)。后者能问四类问题:
template<typename T> concept Container = requires(T t, const T ct) { // 1. 简单要求:t.size() 这个表达式能编译就通过,不求值 t.size(); // 2. 类型要求:T 里必须存在 value_type 这个嵌套类型 typename T::value_type; // 3. 复合要求:表达式合法,并且返回类型满足右箭头后的 concept { t.begin() } -> std::convertible_to<typename T::value_type*>; // 4. 嵌套要求:把另一个编译期条件也拉进来 requires std::is_nothrow_destructible_v<T>; };四条要求里,简单要求最容易被误用。t.size()只检查语法合法性,不检查返回类型——一个返回char的size()也能通过。如果后面要用这个返回值做算术,就应该升级成复合要求,把返回类型约束住。另外requires表达式整体不求值,里面写t.pop_back()不会真的删元素,它只是一次纯粹的类型层面体检。
参数列表(T t, const T ct)里的变量在检查时是「假装存在」的,编译器不会为它们生成代码,也不会检查它们的初始化是否合法。这一点决定了 requires 表达式只能验证「形状」,验证不了「行为」。
3.3 subsumption:约束也能比较「谁更特殊」
concept 最精妙的一层机制是约束的偏序(subsumption)。当两个重载的约束有包含关系时,编译器能自动判定哪个更特殊,不需要你手写优先级标签。
template<typename T> concept MoveCtor = std::is_move_constructible_v<T>; template<typename T> concept MoveAndCopy = MoveCtor<T> && std::is_copy_constructible_v<T>; template<MoveCtor T> void serialize(T&); // #1 template<MoveAndCopy T> void serialize(T&); // #2传一个既可移动又可拷贝的类型时,#2 被选中。机制是:约束会被规范化成原子约束的合取范式,#1的约束是原子约束 A,#2的约束是A ∧ B。偏序判定规则是「合取式包含其任一合取项」,所以A ∧ B蕴含 A,#2更特殊。
这套判定的前提是「同一个原子约束」——只有当两个约束共享同一个来源、同一段表达式记号时,才被认为是同一个原子。如果你在MoveAndCopy里不写MoveCtor<T>,而是把std::is_move_constructible_v<T>原样抄一遍,字面看起来一样,但不保证被判为同一个原子约束,subsumption 就失效了,两个重载变成二义性。我一般会这么理解:共用条件抽成 concept,组合时只引用 concept 名,不抄表达式。这是让 subsumption 可靠工作的唯一稳妥做法。
还有一层间接 subsumption:通过 concept 定义层层展开后,如果原子约束能对上,偏序依然成立。所以把MoveCtor再包一层template<typename T> concept Serializable = MoveAndCopy<T> && ...也不会破坏关系,只要组合链上引用的都是 concept 名。
3.4 语义约束:编译器管不了的那一半
concept 检查的是语法形状,不是语义正确性。一个要求「可比较」的 concept,遇到一个<=>返回bool的类型照样通过;一个要求「容器」的 concept,遇到一个size()返回元素个数但begin()返回反向迭代器的类型也照样通过。
标准把这个空缺叫语义约束(semantic constraints),靠文档和命名约定来保证。std::regular的文档里写明「相等必须真的是相等」,编译器只验证==存在且返回可转bool的东西。
所以 concept 的命名必须携带语义,不能只描述语法。叫Comparable是不够的,叫TotallyOrdered才能让使用者知道这里要求的是严格弱序而不是随便一个<=>。这是设计 guideline 层面的事,但踩过坑的人都知道它有多值钱——一个名字含糊的 concept,会在半年后变成整个团队的认知负担。
另一个分工问题:if constexpr和 concept 看起来都能做「按类型分支」,但职责完全不同。concept 决定「这个重载要不要被选中」,if constexpr决定「选中之后函数体里走哪条路」。把大量if constexpr塞进一个被 concept 约束的函数里,等于把接口约束退化成了实现细节,调用方完全看不出这个函数对类型有什么要求。我的原则是:跨函数的接口差异用 concept,单函数内部的实现分支用if constexpr。
4. Ranges 与 Views:惰性求值的边界在哪里
4.1 sentinel 解决了什么问题
C++17 之前,所有算法都要求begin和end是同类型迭代器。这条限制看起来无害,直到你遇到 C 风格字符串——end的位置必须扫一遍才知道,等于每次遍历都强制先做一次 O(n) 的strlen。
range 概念把这条限制拆了:std::ranges::range<T>只要求ranges::begin(t)和ranges::end(t)合法,两者类型可以不同,只要能互相比较。这个「不同类型的终点」就叫 sentinel。
// 哨兵类型:不需要知道长度,只表示「到这儿就结束」 struct cstr_sentinel {}; // 迭代器只需要能和哨兵比较相等 bool operator==(const char* p, cstr_sentinel) { return *p == '\0'; } bool operator!=(const char* p, cstr_sentinel s) { return !(p == s); } // 遍历时不需要先求长度,O(n) 的扫描摊进了遍历本身 for (const char* p = str; p != cstr_sentinel{}; ++p) { /* ... */ }这个设计的价值不只是省一次strlen。它让「无限序列」和「按条件终止的序列」变得可表达:iota_view可以产生一个理论无限的整数序列,配合views::take(10)截断,整个链路上没有任何一步需要预知长度。
代价在算法实现侧。所有 range 算法都必须用ranges::begin/ranges::end而不是成员函数,返回类型也必须用ranges::borrowed_iterator_t这类工具类型,因为「迭代器 + 哨兵」是一个二元组,不能再假装它是一个迭代器。
4.2 borrowed_range:悬垂引用是怎么在编译期被抓到的
view 的核心特征是不拥有元素,它只持有底层 range 的引用或迭代器。这个特征带来一个经典问题:底层对象销毁了,view 还活着。
// 底层 vector 是临时对象,filter_view 只保存了对它的引用 auto v = std::vector{1, 2, 3, 4} | std::views::filter([](int x) { return x % 2 == 0; }); auto it = v.begin(); // 此时临时 vector 已经销毁,it 是悬垂的标准没有试图在运行期检测这件事,它在类型系统里做了一道闸:borrowed_range概念。一个 range 是 borrowed 的,意味着它的迭代器不依赖 range 对象本身的生命周期(左值引用天然满足,拥有所有权的容器不满足)。
// find 的返回类型是 borrowed_iterator_t<R> auto it = std::ranges::find(std::vector{1, 2, 3}, 2); // decltype(it) 是 std::ranges::dangling,不是迭代器 // 任何解引用尝试都会编译失败std::ranges::dangling是一个空类型,除了赋值和比较什么都不能做。这个机制把「运行期的段错误」换成了「编译期的类型错误」,是 ranges 设计里我觉得最漂亮的一处:它不试图预测你是不是真的用错了,它只是让「用错」这件事在语法上不成立。
同样的机制也保护了views::all之外的适配器链。一个 view 是否 borrowed,取决于它持有的底层 range 是否 borrowed;ref_view持有引用所以是 borrowed,owning_view持有所有权所以不是。判断链条会自动传播,不需要你手工标注。
4.3 view 为什么切断了 const 传播
这是 ranges 里最反直觉的一条:一个const的 view,不代表它指向的元素是 const 的。
std::vector<int> v{1, 2, 3}; auto r = v | std::views::all; // ref_view,持有对 v 的引用 const auto& cr = r; // cr 是 const ref_view *cr.begin() = 42; // 合法:v[0] 被改成 42原因在 view 的语义定义上:const view只保证 view 对象本身不可修改(不能换绑到别的 range),不保证底层元素不可修改。view 里存的是引用和迭代器,const作用在 view 上时,成员指针本身是 const 的,但它指向的对象完全不受影响。
这条规则的连锁反应很广。泛型代码里如果你用const T&接收一个 range,传进来的filter_view会让begin()调用失败——因为filter_view::begin()不是 const 成员函数(下一节会解释为什么)。所以 range 的泛型参数应该写成T&&转发引用,而不是const T&。这不是风格问题,是编译能不能过的问题。
C++20 没有提供把 const 传播回去的工具。想要「深 const」,只能自己包一层,或者用views::transform把元素转成只读视图。这一点在 C++23 里才有views::as_const补上,写 C++20 代码时只能靠约定。
4.4 filter_view 的缓存与惰性求值的复杂度
惰性求值听起来是纯收益:不遍历就不计算。但它有一个具体代价,藏在filter_view::begin()的实现里。
std::vector<int> data{1, 2, 3, 4, 5, 6}; auto even = data | std::views::filter([](int x) { return x % 2 == 0; }); // 第一次 begin():从头扫描,找到第一个偶数 2,把这个位置缓存进 view 对象 // 之后 begin() 直接返回缓存,O(1) for (int x : even) { /* 2 4 6 */ }begin()要做的事是「找到第一个满足谓词的元素」,这个操作天然是 O(n),而且必须把结果存下来,否则每次调用都要重新扫。存储意味着 view 对象可变,意味着begin()不能是 const 成员函数,这就是上一节那条 const 规则的根源。
这个缓存的副作用不止于此。如果底层数据在 view 创建之后发生了变化,缓存的迭代器可能指向一个已经不满足谓词的位置。标准把这种情况交给使用者——view 不持有数据,也就没有义务感知数据变化。
另一个容易忽略的点是嵌套 filter 的复杂度。三个 filter 串联起来,要找到第一个满足全部条件的元素,最坏情况要扫描三遍(每个 filter 各自扫描到自己的第一个匹配)。惰性求值不等于零成本,它只是把成本从「创建时」挪到了「首次访问时」,总的工作量并没有变少。对数据量大、谓词昂贵的场景,把多个条件合并成一个filter往往比串联更划算。
5. 排查手册:五条从编译错误到未定义行为的现场记录
5.1 编译期就炸的三条
第一条:auto operator<=>推出bool,比较结果静默反转。
现象是某次排序后顺序完全颠倒,单测全绿,因为测试数据恰好只有两个元素且方向没覆盖到。原因是用auto声明了一个函数体手写的<=>,里面写的是return lhs.key < rhs.key;,auto推出bool。重写规则把a < b变成(a <=> b) < 0,bool转int后变成0 < 0或1 < 0,永远返回 false。
解决方式很直接:自定义<=>时显式写出std::strong_ordering/std::weak_ordering返回类型,让编译器帮你检查表达式类型。auto只留给= default。
第二条:concept 里的简单要求没约束返回类型,模板实例化到深处才炸。
现象是调用方报错指向了函数体里某个.size()参与算术的行,错误信息是一大串类型推导失败,完全看不出根因。原因是 concept 里只写了{ t.size() },一个返回std::optional<size_t>的类型也通过了形状检查,直到函数体里拿它去做+才出问题。
解决方式是把简单要求升级成复合要求:{ t.size() } -> std::convertible_to<std::size_t>;。约束写得越具体,报错位置越靠前。
第三条:requires表达式里写了t.pop_back(),以为能检查可变性。
现象是 concept 通过了,但函数体里调用push_back报错。原因是requires表达式的参数列表里声明的T t是一个「假装存在」的对象,requires只检查表达式在语法上是否成立,pop_back存在就通过,至于t是不是真正的左值、能不能被修改,它不管。
解决方式是把可变性单独用类型约束表达,比如加一条requires std::is_same_v<decltype(t), T&> && !std::is_const_v<T>这样的辅助 concept,或者干脆用std::ranges::range这类已经携带了完整语义的标准概念。
5.2 运行期和语义层面的两条
第四条:view 挂在临时容器上,编译器不报错,程序偶发崩溃。
现象是某个函数里auto r = vec | std::views::filter(pred);跑得好好的,改成auto r = std::vector{...} | std::views::filter(pred);之后偶发段错误。原因就是 4.2 节说的悬垂——临时容器在分号结束时就销毁了,view 只保存了引用。
borrowed_range机制能拦住把它传给算法的那条路径(返回dangling),但拦不住你直接在同一个作用域里对 view 调begin()。解决方式是养成习惯:view 的底层 range 必须是左值,用临时对象时立刻消费(写成for (auto x : 临时容器 | filter)),不要落成一个具名变量。编译器不会替你把关这一条,只能靠 review 和静态分析工具。
第五条:const view 意外可写,跨模块改坏了数据。
现象是某个只读接口接收const T&参数,内部包了一层 view 做过滤,下游拿到 view 的迭代器后改写了元素,调用方数据被污染。原因是 view 不传播 const,const ref_view的元素仍然可写。
解决方式是在接口边界上明确所有权和可写性:跨模块传递的 view 要么包一层views::transform把元素降级成只读,要么在文档里写死「这个 view 可写,调用方负责」。C++20 没有标准手段做深 const,这是一个必须靠约定堵的洞。
6. 设计取舍:把复杂度挪到编译期到底值不值
这三块特性有一个共同点:它们都在把成本从运行期挪到编译期。<=>省掉的六个函数体,代价是一次更复杂的重载决议;concept 省掉的 SFINAE 嵌套,代价是编译器的约束规范化计算;ranges 省掉的手写循环,代价是模板实例化层的膨胀。
| 机制 | 省掉的运行期开销 | 付出的编译期代价 |
|---|---|---|
<=>重写 | 没有额外函数调用,内联后完全消失 | 候选集扩大,重载决议多两轮匹配 |
| concept | 无类型擦除、无虚函数、零运行期检查 | 约束规范化与偏序计算,模板实例化数量上升 |
| ranges/view | 惰性求值,未访问的元素不计算 | 每个适配器的组合都会产生独立的模板实例 |
这条路线是 C++ 一贯的选择:运行期开销必须是可解释的,不可解释的复杂度一律推到编译期。它的收益在性能敏感的领域非常明显——一个 view 管道编译完之后,和手写循环生成的汇编几乎一致,而错误在编译期就被拦住,不需要在线上靠日志反推。
代价同样真实。模板实例化数量上升直接拉长编译时间,一个重度使用 ranges 的翻译单元编译时间是 C++17 版本的数倍;约束规范化带来的报错信息在嵌套 concept 时又会膨胀回去;不同编译单元对同一模板给出不同定义还会引发 ODR 问题,而 concept 让这种差异更难肉眼发现。这三点是当前使用这套特性时最痛的三个地方,标准层面暂时没有解法。
我自己的经验是分界线要画在「接口」和「实现」之间:接口层面用 concept 约束形状,用<=>定义可比较性,用 view 组合数据流;实现层面该写循环写循环,该用
本文还有配套的精品资源,点击获取