简介:《C++20 完全指南》(C++20 – The Complete Guide)是Nicolai M. Josuttis撰写的最新C++标准参考书籍,面向已有一定C++基础、希望系统掌握C++20新特性的应用开发者与基础库程序员。全书聚焦C++20带来的里程碑式更新,涵盖模块(Modules)、协程(Coroutines)、概念(Concepts)、三路比较运算符(<=>)、范围库(std::ranges)、格式化输出(std::format)等核心特性,并结合示例代码详解语法、典型用法与底层语义。资源包为1个PDF文件,体积3.44MB,内容为该书完整电子版,目录结构清晰,便于按章节检索阅读。已有353人学习下载。书中从比较运算符与<=>的定义与使用讲起,逐步深入各新特性,既解释了新特性为何产生、如何解决旧写法痛点,也标注了编译器支持情况与常见误用。对于需要维护基础库的程序员,还能从中获得新标准在库设计上的指导;对于应用开发者,则是一份从C++17向C++20平稳过渡的系统学习资料与日常参考手册。
1. C++20 完整指南(2022):这份指南要解决的问题
C++20 完整指南(2022)并不是一本从头教你写变量的入门书,它更像是给已经会写 C++11/14/17 的工程师,在标准大版本切换时的一份“差异地图”。C++20 是 C++11 之后改动最猛的一版,概念、范围、协程、模块、三向比较、格式化输出这些特性叠加在一起,几乎重塑了泛型编程的写法。很多团队在 2022 年评估 C++20 是否值得升级时,缺的正是一个能照着验证的完整参考。
这份指南的定位就是“完整”:把语言特性、标准库能力和工程迁移路径放在一起讲。它解决的不是“某个关键字怎么用”,而是“我现有的 C++17 代码在 C++20 下应该怎么调整”“编译器都声明支持了,为什么我写出来还是编译不过”“并行化、缓存、模板可读性到底能提升多少”。适合已写了两三年 C++、打算把项目切到 C++20 的开发者,也适合正在做技术选型、需要给团队一个可靠评估结论的人。新手也能读,但至少要先把 C++11 的移动语义和 lambda 弄熟,否则很容易被后面的概念和视图绕进去。
2. C++20 的底层认知变化:三个方向重新定义“现代 C++”
2.1 概念:把泛型约束从“SFINAE 玄学”变成编译期契约
C++20 最基础的变化是引入概念。这里给没有接触过的人一个定位:概念是把“模板参数必须满足什么约束”这件事,从一堆模板元编程技巧里拎出来,成为类型系统里可命名、可复用的一等实体。以前做模板约束,参与过维护的同学都知道,一个 std::enable_if 牵扯出来的重载和偏特化能绕很久,报错时输出的几屏 STL 内部模板实例信息,对定位问题帮助很小。概念则在调用点先做一次约束检查,失败信息是“你传入的类型不满足某某概念”,直观得多。
完整指南在概念部分一般会分四层介绍:概念声明、requires 表达式、requires 子句、约束偏序。我自己的经验是,先花十分钟把 requires 表达式的三个可选块弄明白,比什么都重要。requires 表达式有三个部分:参数列表、约束序列、类型/异常条件。初学者最容易漏掉的是{ expr } -> std::same_as<T>这类 trailing return 约束,它检查的是表达式结果类型是否匹配。另一个常见误解是 concept 本身是编译期布尔值,不是函数,不能“在运行时调用”。
如果你手上有一套 C++17 的老泛型代码,最常见的做法是先把内部的 enable_if 拆成一个小概念。比如一个“可以安全相加且有符号整型”的约束,在 C++17 里可能是这样:
template<typename T, typename = std::enable_if_t< std::is_integral_v<T> && std::is_signed_v<T>>> T add(T a, T b) { return a + b; }C++20 里可以改成:
template<typename T> concept SignedNumber = std::integral<T> && std::signed_integral<T>; template<SignedNumber T> T add(T a, T b) { return a + b; }注意第二段代码里我们把“类型特征 + 语法检查”合并为一个概念,但这里没有检查operator+是否存在。如果需要检查运算可用,就把&& requires(T a, T b) { a + b; }加进概念定义。这里有一个参数「顺序」的说法:概念定义中 requires 子句是编译期条件,建议只放类型特征;requires 表达式里的语法检查独立成行。这样当约束失败时,编译器能直接告诉你“是类型不对,还是运算不存在”,而不是混在一起。
2.2 范围与视图:让算法链从“跑数据”变成“描述数据”
范围库很容易被认为是“又一组 STL 算法包装”,但如果只这么理解,就不会用懒惰视图。范围库真正的价值是:把“数据从哪里来、怎么变换、什么时候消费”分开。传统的 vector 处理链要多次分配中间容器,而视图链在每次遍历时按需计算。举例来说,从一个整数列表里选出偶数并平方,取前三个,C++17 的写法可能用到 std::copy_if、std::transform 和多次容器 resize;C++20 的写法像下面这样:
#include <ranges> #include <vector> #include <print> int main() { std::vector<int> data{1, 2, 3, 4, 5, 6, 7, 8}; auto result = data | std::views::filter([](int x) { return x % 2 == 0; }) | std::views::transform([](int x) { return x * x; }) | std::views::take(3); for (auto v : result) std::println("{}", v); }这段代码里没有出现“循环 + 临时变量”,而是用|把三个视图串起来。注意result的类型是个视图对象,它没有拷贝数据,也没有真正执行任何变换。直到最后范围 for 遍历时,每个元素才会经过 filter、transform、take 三层计算。这就是“描述数据”的含义,你还只描述了规则,消费动作发生在 for 语句里。
选型时要注意视图和容器的生命周期。如果被过滤的 vector 在视图创建后就被销毁,那么这个视图的迭代器行为就是未定义的。指南通常会警告“视图不拥有数据”,这句话是要背下来的。另一个容易踩的坑是把auto&&用在范围 for 里,然后试图把迭代器存到容器里复用,结果因为懒惰求值,迭代器在每次前置++时重复计算。我们后面单独开一节讲。
2.3 模块与协程:两个改变工程结构的机制
模块的最大卖点是“编译时隔离”。传统头文件会在每个翻译单元里重复展开,模板库尤其明显,改一个内部头文件可能触发整个项目重编译。模块把接口和实现分离到一个带语义的中间表示中,重复 import 时解析成本更可控。但 2022 年它的落地水平差异很大,三大编译链在模块实现上有各自的约定,标准库头是否可作为模块导入也并不统一。完整指南在模块这一章通常会花大量篇幅讲构建系统配置,而不是语言语法本身。
协程则完全不同。标准定义了 co_await、co_yield、co_return 和 promise_type 等机制,但没有规定调度器、事件循环或线程模型。所以你在代码里写一个 co_await 表达式时,必须有一个返回可等待对象的函数,且这个可等待对象的 await_transform 和 promise_type 都属于你自己的设计。用第三方协程库更省事,但如果你想看懂它内部如何挂钩子,还是得知道语言层面的机制。
我见过不少团队急着上协程,结果在无操作系统环境里发现标准库不提供任何调度器,只能自己包装系统调用的结束回调。这里有一个比较务实的选型建议:如果你只是想做数据流变换,优先用范围和概念,它们只在编译期和语法层面改动,风险小;如果你要做异步网络或 I/O,协程可以带来大幅简化,但必须接受需要额外引入执行器或自己写的接口,这会直接改变项目依赖结构,不适合在升级首轮就铺开。可以把不同特性的改动范围和风险放在一张表里看:
| 特性 | 修改范围 | 风险等级 | 建议引入时机 |
|---|---|---|---|
| 概念 | 泛型接口签名 | 低 | 替换新增代码里的 enable_if 即可 |
| 范围视图 | 算法链写法 | 中 | 同步改造老代码,需要先统一容器所有权 |
| 模块 | 构建系统与预处理 | 高 | 只在新建模块中试点 |
| 协程 | 运行期控制流 | 高 | 配合成熟执行器,先做独立组件 |
3. 用完整指南搭一个能跑特性的环境:编译链支持、CMake 与最小验证
3.1 编译链支持:2022 年怎么看支持表
在开始写 C++20 代码之前,先要确认环境。2022 年这个时间点,三大编译链对 C++20 核心语言特性的支持已经比较整齐,但库功能仍有明显差异。看支持表的顺序应该是先看“语言核心”,再看“标准库”。比如概念和范围属于前者,绝大多数 C++20 编译器都能编译;但std::format属于后者,只有较新版本的标准库才提供完整实现,所以你写的代码即使语法完全正确,也可能因为库无法链接而失败。完整指南的好处在于它会明确把“语言特性”和“标准库”分开列出,这能省掉大量排查时间。
我当时的做法是先建一个表格,记录项目依赖的每个编译器和它的 C++20 支持档位。这里给一个参考快照:老牌的 GCC 系从 8.4 开始支持部分概念,到 10.1 支持范围;Clang 系从 10 开始有概念,到 13 支持范围;MSVC 系从 VS2019 16.10 起可以稳定使用大部分核心特性。这三个系的具体版本我会以“项目文档为准”,不盲信沙箱测试。关键是要记录“你的最低编译版本能否编译某个特性”,而不是“最高版本有没有特性”。
下面是一个最小支持矩阵(行是特性,列是编译链,数字表示“大致可用的起始大版本”):
| C++20 特性 | GCC 系 | Clang 系 | MSVC 系 |
|---|---|---|---|
| 概念 | 8.4 | 10 | 16.10 |
| 范围 | 10.1 | 13 | 16.10 |
| 协程 | 11 | 11 | 16.10 |
| 模块 | 11 | 17(实验性较强) | 16.11 |
| std::format | 13(libstdc++) | 14(libc++) | 19.26(VS 2022) |
这种表只表达“大致可用”,具体到项目里你还要看标准库宏和文档。完整指南在 2022 年的版本里也会给你一张类似的表,重点不是背数字,而是学会看“从哪个版本开始,我可以把这条特性用于生产代码”。实际上很多库功能在表上写“可用”前还需要额外导入或配置,例如std::format在 GCC 13 之前有时需要打开实验命名空间。更实用的一条经验是:与其看支持表,不如看两份本地信息,其一是__cplusplus宏的值是否为 202002L,其二是几个__cpp_lib_xxx特性宏是否被定义。前者代表语言标准模式,后者代表标准库给不给你这个功能。
3.2 CMake 里打开 C++20:最低要求的构建配置
有了支持矩阵,下一步是把构建系统打开。项目若使用 CMake,最稳妥的做法是通过 target feature 指定标准,并允许编译器自己处理参数差异。下面是我常规使用的 CMakeLists.txt 片段:
cmake_minimum_required(VERSION 3.16) project(example LANGUAGES CXX) add_library(example_lib src/example.cpp) target_compile_features(example_lib PUBLIC cxx_std_20) if(MSVC) target_compile_options(example_lib PRIVATE /Zc:__cplusplus) endif()这段配置里,target_compile_features比全局set(CMAKE_CXX_STANDARD 20)更精确,它会告诉编译器把需要 C++20 的翻译单元按此标准编译。/Zc:__cplusplus是 MSVC 下让__cplusplus宏报告真实标准值的选择,只在 MSVC 环境需要设置。如果你同时用多个编译器,不要在这里手写-std=c++20之类的标志,因为 MSVC 使用的/std:c++20是另一套语法,让 CMake 自己映射即可。
参数说明:cxx_std_20是 CMake 从 3.12 开始引入的 feature 名,若你的 CMake 版本低于 3.12,需要写CMAKE_CXX_STANDARD老办法。建议把cmake_minimum_required设成 3.16 以上,因为你可能还要配合FetchContent或CTest。在实际项目中,我一般还会加上set(CMAKE_CXX_EXTENSIONS OFF)来关闭 GNU 扩展,确保代码可移植;但如果你要使用__int128等编译器扩展,则需保持打开。如果不使用 CMake,直接在命令行编译,GCC 和 Clang 系用-std=c++20,MSVC 用/std:c++20;MSVC 还需要补一个/Zc:__cplusplus,否则__cplusplus宏不会报告 202002L,很多开源库会在预检阶段误判标准版本。
3.3 快速验证四个特性的最小代码片段
配置完环境,写一个所有特性打包的“烟雾测试”文件,是最快的确认方法。下面这段代码覆盖概念、范围、三向比较和格式化输出:
#include <compare> #include <concepts> #include <ranges> #include <vector> #include <cstdio> template<std::integral T> T triple(T v) { return v * 3; } struct Order { int id; int amount; auto operator<=>(const Order&) const = default; }; int main() { std::vector<int> vals{1,2,3,4,5}; for (int v : vals | std::views::take(3)) std::printf("%d\n", triple(v)); Order a{1, 100}, b{2, 200}; std::printf("%d\n", a < b ? 1 : 0); }这段代码里的概念约束用了标准库内置的std::integral,你不需要自己写;operator<=>默认生成三向比较后,<运算符也会自动派生出来。编译命令示例(在不同平台略有差异):
g++ -std=c++20 smokes.cpp -o smokes clang++ -std=c++20 -stdlib=libc++ smokes.cpp -o smokes如果编译通过并正常输出1 2 3 0,说明你机器上的编译链和标准库已经具备学习完整指南所需的 C++20 主体功能。这时候再去翻后面章节,遇到失败案例就有自己的实验环境可复现了。注意验证程序编译失败时,先按编译器输出去查三件事:第一,__cplusplus值是否达标;第二,标准库头文件是否存在;第三,是不是因为某个特性在你的编译链上属于“实验性”,需要额外宏开关。有的编译器会把不支持的特性静默当作扩展,编译过不代表运行时行为符合标准,所以烟雾测试不仅要编译,还要运行并检查输出。
4. 从指南到代码:把一个 C++17 订单计算改写成 C++20
4.1 原始场景:一条老式流水线
假设有一个模拟订单系统,orders是订单集合,每个订单有 id、金额 amount、状态 status。需求:筛选出已支付订单、金额大于 100 的,按金额降序,取前 3 个,打印 id 和金额。用 C++17 常见写法会分段处理,先在中间容器里做 copy,再 sort,再用 resize 截断,最后循环打印。代码如下:
std::vector<Order> tmp; std::copy_if(orders.begin(), orders.end(), std::back_inserter(tmp), [](const Order& o){ return o.status == "paid" && o.amount > 100; }); std::sort(tmp.begin(), tmp.end(), [](const Order& a, const Order& b){ return a.amount > b.amount; }); tmp.resize(3); for (const auto& o : tmp) { std::cout << o.id << " " << o.amount << "\n"; }这段代码本身没问题,但它暴露了一个实际工作中的痛点:中间容器tmp把整条链路拆成了三个阶段,每段都要用迭代器表达“数据在哪”。业务逻辑反复修改时,比如金额阈值变化、取前 5 个,要同时改 copy_if、resize 以及临时变量的类型,容易漏。这是一线代码里最常见的“刺”,不是复杂度有多高,而是改起来不顺手、容易埋 bug。
4.2 用 ranges 和 concepts 重写
C++20 的做法是把“过滤、排序、截断、输出”都描述成一个管道,排序由于需要知道完整序列,只能对容器求值,所以它通常不是视图操作。一个常见写法如下:
template<ranges::range R> void printTopPaid(const R& orders, int threshold, int n) { auto top = orders | views::filter([](const Order& o){ return o.status == "paid" && o.amount > threshold; }) | views::transform(&Order::amount) | views::take(n); std::vector<int> amounts(top.begin(), top.end()); std::ranges::sort(amounts, std::greater<>{}); for (int a : amounts) std::println("{}", a); }注意这里我把成员函数指针&Order::amount直接传给 transform,它会把每个 Order 映射成 amount,比手写 lambda 更短。take(n)是懒惰求值,所以真正拿到 amounts 前不会复制整个视图;但因为排序需要随机访问和稳定存储,最终仍需放入一个 vector。views::filter的顺序是“先过滤再截断”,如果业务希望先排序再截断,那管道顺序会不一样,这也是用 ranges 时需要想清楚的:视图链的顺序就是处理顺序,但像 sort 这种需要全量数据的操作,必须在某个容器上执行,不能嵌在懒惰视图中间。
这段代码还带了概念约束ranges::range R,如果你误传入 int 之类的类型,编译器会在调用点上报“不满足 range”,而不是在模板内部深处报错。这就是概念带来的可读性。先排序再取前 N 的做法,正确姿势是先拷贝一个 vector,用 ranges::sort 排序,再用 views::take 截断。不要试图在 views 里 sort 或 reverse,这是新手常犯的错。reverse 视图确实存在,但 sort 不行,因为它需要全量数据并且要原地修改序列,懒惰视图没有随机访问底层容器。
4.3 三向比较带来的默认行为变化
旧代码如果想自定义排序,往往要写<、==等一整套比较运算符。C++20 三向比较<=>加上= default以后,编译器会帮你生成<、<=、>、>=、==、!=。对于第 4.1 节的 Order,想要天然支持按 id 排序,只需加一行:
struct Order { int id; int amount; std::string status; auto operator<=>(const Order&) const = default; };此时std::less<Order>{}可用,std::ranges::sort也可以直接按成员顺序排序。这里有个坑:默认三向比较按成员声明顺序逐个比较,如果你把 amount 放在 id 前面,排序就会先按金额排,不是你想当然的“按 id”。指南里通常会用一整节讲“成员顺序决定比较语义”,这一点很容易被忽略,尤其类经过重构后成员顺序变化,会静默改变排序结果,而且不会触发任何编译警告。
另一个坑是三向比较的返回类型。auto会让编译器推导出std::strong_ordering或std::partial_ordering,具体取决于成员里有没有浮点数。如果你显式写成std::partial_ordering,那相等性比较的行为和强排序不同,a == b和a <=> b == 0不一定等价。所以默认生成的代码,建议保持auto让编译器自己推导。迁移老代码时,先删掉所有手写的比较运算符,再添加default三向比较,然后让编译器和单测告诉你哪些行为变了。不要依赖直觉,比较语义的变化会影响标准库容器和排序,是真正会产生运行时差异的地方。
5. 照着指南写代码最该避的五个坑:编译失败与语义误解排查
下面这五条基本都是我在迁移和代码审查里真实遇到过的坑,每条按“现象 → 原因 → 解决”说清楚。
5.1 概念定义处报“requires 表达式语法错误”
现象:刚把 enable_if 改成 concept,编译就在requires(T a, T b) { a + b; }这一行报语法错误,提示有一堆看不懂的 token 或需要分号。原因:requires 表达式是编译期构造,不是普通函数体。你既不能把它直接放在某个成员函数内部,也不能在 requires 里使用非模板参数列表。最常见的是漏掉了 concept 声明外层的template<typename T>前缀,或是把原来的template<typename T, typename = enable_if...>直接搬过来,导致 requires 被当成一个普通表达式。解决:把 requires 表达式包进独立的概念定义中,并确认概念定义本身是一个模板;如果是在函数后加约束,用template<typename T> requires Foo<T>这种 requires 子句,不要和概念定义混淆。
5.2 协程在无协程库支持下拒绝编译
现象:包含<coroutine>后编译通过,但链接时报std::coroutine_handle相关符号找不到;或者你写了一个co_yield,编译器直接报unable to find the promise type。原因:C++20 标准只定义了协程的语言骨架,promise_type、awaitable 都是需要库层实现或你自己实现的东西。编译器不知道你说 co_yield 的数据要怎么交给外部。解决:最小实验不要直接co_await随便一个对象,先从一个已经实现的std::generator<T>或第三方的协程任务类型开始;跨平台迁移时,确认每个目标平台的协程栈或堆需求,否则你的协程在 Windows 上能跑,在嵌入式环境可能栈溢出都不提示。
5.3 std::format 在标准库缺失导致链接错误
现象:代码头文件写了#include <format>,编译器也过了,但到链接阶段报std::format未定义,或者头文件本身不存在直接报红。原因:语言特性和标准库特性是两个时间表。编译器版本支持 C++20 语言,不代表标准库已经实现了<format>。尤其在一些嵌入式工具链和老版本标准库组合里,<format>头文件可能整个缺失。解决:用特性宏__cpp_lib_format做预处理分支,未定义就回退到printf或第三方格式化库。不要硬开实验命名空间,因为不同编译器的实验接口不一定一致。这个坑在写跨平台代码时特别常见,我在一个模拟图像处理 Demo 里就遇到过:主链路的编译器很新没暴露,交叉编译到旧板子时直接全体报错。
5.4 模块与 include 混用导致重复定义
现象:一个新写的 .cpp 文件里既#include "some.h"又import some_module;,链接时报 multiple definition 或 redefinition。原因:同一组实体通过两个不同语义通道进入同一个翻译单元,模块的接口单元和传统头文件各自生成一份声明,在链接时冲突。加上早期模块实现并不成熟,CMake 对模块依赖的构建排序时常出错。解决:一个翻译单元内对同一逻辑模块只选一种引入方式;如果拆模块,最好新建目录,不要在老目录里混合 .h 和 .cppm。构建系统上优先用target_sources显式列出模块文件,并关掉某些实验性的-fmodules-ts全局开关。
5.5 范围视图懒惰求值被误以为立即执行
现象:代码里创建了一个views::filter,随后立刻调用.size(),编译报错或结果是 0;或者把 filter 的迭代器保存到容器里,等原来数据容器销毁后再遍历,程序崩溃或打印脏数据。原因:视图不拥有数据,filter 等视图是懒惰求值,.size()在 filter 上不可用;保存的迭代器依赖原始容器生命周期。解决:需要元素个数时用std::distance(result.begin(), result.end()),或者干脆物化到std::vector再计算;保存任何视图迭代器前,必须确保底层的容器在迭代器生命周期内始终存活。这条在重构老代码时很容易漏,因为老代码里的 copy_if 结果是一个独立容器,生命周期自然安全,换成视图后所有权就变了。
6. 把这份指南用起来:用特性测试宏守住升级边界
读完完整指南不等于把 C++20 用起来了。我现在的习惯是在项目里维护一个compat.h头文件,把所有准备使用的特性用宏和编译期断言锁住。C++20 标准库为每个重要功能都提供了一个特性测试宏,例如__cpp_concepts表示概念支持、__cpp_lib_ranges表示范围库支持、__cpp_lib_three_way_comparison表示三向比较支持、__cpp_lib_format表示格式化库支持。这些宏只有当编译器在对应标准模式下实现该功能时才被定义,值是标准采纳年份,例如__cpp_lib_three_way_comparison >= 201907L。
一个最小自测文件可以长这样:
#ifndef COMPAT_H_ #define COMPAT_H_ #if __cpp_concepts < 202002L #error "Need C++20 concepts support" #endif #if __cpp_lib_ranges < 201911L #error "Need C++20 ranges support" #endif #if __cpp_lib_three_way_comparison < 201907L #error "Need C++20 three-way comparison support" #endif #include <concepts> #include <compare> #include <ranges> static_assert(std::integral<int>); static_assert(std::three_way_comparable<int>); #endif把这个头文件放到项目的全局公共头里,只要哪个目标编译链不满足要求,预处理阶段就会直接报出英文错误,而不是让特性代码散落到深处产生难以定位的编译失败。每次切换编译器或升级工具链,先编译一个只包含该头文件的空单元,确认当前环境支持哪些能力,再决定要不要开对应特性。这个思路虽然很简单,却能省掉大量“为什么这段代码在 A 平台能编、在 B 平台不能编”的排查时间。我自己的习惯是每月拉一次新编译器把 self-test 跑一遍,更新支持矩阵,然后才有底气去升级生产构建。希望帮到你。
本文还有配套的精品资源,点击获取