简介:《C++20 完全指南》是Nicolai M. Josuttis撰写的C++20权威参考书,面向已有C++基础的应用开发者与基础库程序员,目标是帮助读者快速跟上标准演进、掌握新特性设计动机与最佳实践。全书以C++20相较C++11同样里程碑意义的核心变革为主线,系统讲解模块化、协程、概念、三路比较运算符、ranges与format等新库,也涵盖原子浮点数操作、std::string_view增强、强类型枚举与 位运算库,并专章演示std::to_string增强、if constexpr等细节用法,配以大量新旧写法对比和示例代码,便于在真实项目中落地。这份PDF电子书共1个文件,压缩包仅3.44MB,内置完整目录,章首有序、章末有小结,结构清晰,支持按需查阅。全书内容既贴近底层机制,又兼顾工程实践,适合个人研读,也适合作为团队技术分享的参考底本。目前已有353人学习/浏览,对从C++17迁移到C++20、或希望在项目里引入现代C++特性的团队,堪称案头常备的进阶指南。
1. C++20 the Complete Guide:这份指南到底在解决什么问题
如果你手头有一套 C++17 跑得正欢的代码库,2022 年出版的这本 C++20 the Complete Guide 出现在视野里时,最真实的反应不是兴奋,而是犹豫:我又不写编译器,学 C++20 干嘛?但真把书翻到 Concepts 那一章、照着示例把模板报错从三百行降到三行之后,想法会变——C++20 不是又一轮语法膨胀,而是把 C++ 从「模板元编程黑匣子」往「可读、可约束、可验证」的方向拉了一大步。指南解决的问题很具体:C++20 有四大特性、几十个小改进,哪些值得为它承担迁移风险,哪些只是锦上添花。适合读它的人不是语言发烧友,而是手里有存量代码、要评估下一代 C++ 标准到底值不值得投入的从业者。本文就顺着这份指南的路径,把特性、用法、落地顺序和坑一次说清。
2. 从 C++17 到 C++20:四大特性与七个顺手小改进,按需取舍
2.1 C++20 四大件:先分清「必上」和「可缓」
C++20 的完整特性清单超过几十项,但真正影响架构级决策的只有四件:Concepts、Ranges、Coroutines、Modules。指南对它们的定位不是「四个都要立刻用」,而是按场景分优先级。我的判断是这样:Concepts 和 Ranges 属于「改造成本低、收益立竿见影」,适合任何规模的代码库优先尝试;Coroutines 属于「特定场景真香、通用场景别硬上」;Modules 在 2022 年乃至现在,都还受制于构建生态,属于「可缓」。
判断优先级的依据来自一个容易被忽视的事实:C++20 不是 C++11 那种「推翻重来」的版本,它更像是把已有的实践标准化。比如 Concepts 本质上解决的是模板报错可读性问题,Ranges 解决的是算法组合的代码结构问题,这两者都不改变 ABI,不改变对象内存布局,迁移时编译器帮你兜底。而 Coroutines 涉及栈帧分配和编译器生成的调度代码,Modules 涉及编译单元边界的彻底重构。所以选型理由很简单:先做不改变 ABI 的,再做改变代码组织方式的,最后碰改变运行机制的。
2.2 七个顺手小改进:改造成本最低的那批
四大件之外,C++20 有一批小特性,单独看都不起眼,合起来能让日常代码干净一截。最值得优先落地的是 designated initializers(指定初始化器),它允许写{.x = 1, .y = 2}这种带名字的初始化,解决结构体初始化时参数顺序对不齐的问题。其次是 spaceship 运算符<=>,把六个比较运算符压缩成一个,代价是编译器帮你生成其余五个,前提是你了解std::strong_ordering和std::partial_ordering的语义差别。
还有std::span,它是我个人在存量代码里用得最勤的特性。原来函数签名里const std::vector<T>&和const T* + size两种风格并存,现在统一成std::span<T>,既不拷贝数据又保留了边界信息。std::string::starts_with和ends_with这两个成员函数,看着 trivial,实际把大量s.compare(0, prefix.size(), prefix) == 0的写法消灭了。加上constexpr能力的扩展和std::erase系列,这些小改进的迁移成本几乎为零,却能在 code review 时肉眼可见地减少噪音。指南把这类特性安排在前面讲,是有道理的——它们负责让团队先尝到甜头。
| 特性 | 改造成本 | 收益场景 | 推荐优先级 |
|---|---|---|---|
| Concepts | 低 | 模板库接口、泛型约束 | 必上 |
| Ranges | 中 | 算法链、管道式处理 | 优先 |
| std::span | 极低 | 函数参数、避免拷贝 | 必上 |
| Coroutines | 高 | 异步 IO、生成器 | 按需 |
| Modules | 高 | 大型工程构建加速 | 可缓 |
3. Concepts 与 Ranges 落地:让模板报错从玄学变可读
3.1 Concepts:把模板约束写进签名
C++20 之前,模板约束靠static_assert和std::enable_if叠加实现,报错信息在实例化点爆炸是常态。Concepts 的核心理念是把约束变成一等公民,直接出现在函数签名里。最基础且最常用的一个是自己定义 Concept 还是直接用标准库的?我的建议是:先忘掉自定义,把标准库<concepts>里的std::integral、std::floating_point、std::same_as、std::convertible_to用熟,就能覆盖八成需求。
一个能直接抄进项目的写法是这样,注意requires子句和 Concept 名字的配合:
#include <concepts> #include <iostream> template <typename T> concept Number = std::integral<T> || std::floating_point<T>; template <Number T> T add(T a, T b) { return a + b; } // 加法概念版实现 // 参数说明:仅接受整数与浮点类型,编译期拦截字符串、指针等非法类型 int main() { std::cout << add(1, 2) << '\n'; // 正确:int 满足 Number // std::cout << add("a", "b"); // 错误:const char* 不满足 Number return 0; }这段代码的关键在template <Number T>这一行,它把约束从函数体内提到了签名层。当传入非法类型时,编译器直接报「约束未满足」,而不是深入模板展开内部抛出一堆无关上下文。Number这个 Concept 用||组合了两个标准库 Concept,自定义成本很低。如果你硬要在 C++17 里实现同样效果,得写std::enable_if_t<std::is_integral_v<T> || std::is_floating_point_v<T>, int> = 0这种反人类的默认模板参数,这就是 Concepts 最直观的收益——把可读性还给泛型代码。
3.2 Ranges:管道式组合与视图的惰性
Ranges 引入的|管道语法是 C++20 在表达力上的最大改进。但它的底层逻辑不是语法糖,而是 lazy evaluation(惰性求值)。std::views::filter和std::views::transform返回的是视图对象,它们不立即计算结果,而是在你遍历时才逐个求值。这意味着一条管道链可以组合得很长而不会产生中间容器,内存占用基本是常数。
#include <ranges> #include <vector> #include <iostream> std::vector<int> data = {1, 2, 3, 4, 5, 6}; // 管道式处理:过滤偶数 -> 平方 -> 遍历输出 // 注意:filter 和 transform 都是惰性视图,未实际创建新容器 auto result = data | std::views::filter([](int x) { return x % 2 == 0; }) | std::views::transform([](int x) { return x * x; }); for (int v : result) { std::cout << v << ' '; // 输出:4 16 36 }这段代码里最容易踩的坑是把std::views::transform的返回值存成auto后继续传递。视图对象默认持有底层容器的引用,如果data在遍历前被销毁或修改,结果是未定义行为。另一个常见误用是用std::ranges::transform(算法版)替代std::views::transform(视图版),前者是立即求值、需要输出迭代器,后者是惰性求值、返回视图,两者用途完全不同。
Ranges 落地时真正值得留意的是性能语义。惰性求值不是免费的:每个视图元素在遍历时有函数调用开销,编译器开了-O2通常能内联掉,但在调试构建下会明显变慢。所以我的习惯是:管道链用在业务逻辑层,写清楚意图;性能热点循环里还是手写 for 或者用原始的迭代器版本。
3.3 Concepts + Ranges 合用的最小可抄作业
两者合用的经典场景是写一个「只接受可排序容器、并返回排序后视图」的函数。这里 Concepts 负责约束类型,Ranges 负责组合运算,两个特性互为补充。
#include <ranges> #include <vector> #include <concepts> #include <algorithm> template <std::ranges::range R> requires std::sortable<std::ranges::iterator_t<R>> auto sorted_view(R& r) { // 返回排序后的视图(非拷贝) // 注意:std::views::all 把容器包装成视图,sort 将就地排序 return r | std::views::all; }requires std::sortable<...>这一行的价值在于:如果传入的容器元素类型不支持<比较,编译器会在调用点直接给出「约束未满足」的提示,而不是深入std::sort内部爆出段错误或晦涩的静态断言。这个组合在 C++17 里要写大约二十行 SFINAE 代码才能等价。落地时先别追求自定义 Concept 的完备性,用标准库提供的组合就够了。
4. 协程与模块:两个「慎用但真香」特性的实践路线
4.1 协程:IO 密集场景的调度器写法
C++20 的 Coroutines 是三大特性里最容易被误用的。它不提供任何具体的调度器、任务类型或事件循环——标准只定义了语言层面的机制:co_await、co_yield、co_return三个关键字和协程帧的分配规则。这意味着你需要自己实现或者引入一个库,常见的做法是写一个极简的任务类型。
一个能跑通的最小协程示例要包含 Promise 类型和 Awaitable 类型,代码量不小,但它能说明协程的收益:
#include <coroutine> #include <iostream> #include <optional> struct Generator { struct promise_type { int current_value; Generator get_return_object() { return Generator{this}; } std::suspend_always initial_suspend() { return {}; } std::suspend_always final_suspend() noexcept { return {}; } void unhandled_exception() { std::terminate(); } std::suspend_always yield_value(int v) { current_value = v; return {}; } }; // promise_type 是协程的核心,编译器通过它生成状态机 using handle_type = std::coroutine_handle<promise_type>; explicit Generator(promise_type* p) : h(handle_type::from_promise(*p)) {} ~Generator() { if (h) h.destroy(); } bool next() { return h ? (h.resume(), !h.done()) : false; } int value() { return h.promise().current_value; } private: handle_type h; }; Generator count_up(int n) { for (int i = 1; i <= n; ++i) { co_yield i; // 关键:挂起并返回值,下次 resume 继续循环 } } int main() { Generator gen = count_up(5); while (gen.next()) { std::cout << gen.value() << ' '; // 输出 1 2 3 4 5 } }协程的性能特征和普通函数完全不同:每次co_yield都会分配协程帧(除非编译器优化掉),默认分配走operator new。在高频调用场景,比如每微秒创建一个协程,堆分配会吃掉全部收益。指南里强调的落地前提就在这里——协程最适合的是「大量 IO 等待、少量计算」的场景,比如网络服务里同时挂几万个连接请求;不适合的是「循环体内频繁创建协程」的计算密集逻辑。MSVC 和 GCC 对协程的实现细节有差异,代码要跨编译器就必须测试。
4.2 模块:构建加速与生态分裂的两难
Modules 是 C++20 里理念最激进、落地最坎坷的特性。它的目标是替代头文件机制,解决「include 一次就要预处理一次」的构建性能问题。2022 年时,三个主流编译器的实现各不相同:有的要求.cppm扩展名,有的用-fmodules-ts开关,有的在标准库层面还不敢默认启用。指南对模块的写法讲解得很细,但真正动手时你会发现:构建系统的支持比编译器本身落后更多。
一个「看起来正确但难以跨平台」的模块声明是这样的:
// math.cppm export module math; export int add(int a, int b) { return a + b; } // main.cpp import math; int main() { return add(1, 2); }代码只有三行,但构建它需要两步:先把.cppm编译成 BMI(Binary Module Interface),再编译main.cpp时链接该 BMI。CMake 在 2022 年对模块的支持还在实验阶段,你得手动管理编译顺序,否则就会出现「找不到模块」的错误。我的判断是:除非你在做一个全新的、团队规模不超过十人的项目,否则 2022 年(乃至现在)都不建议把存量代码迁到 Modules。它的收益要工程规模够大才明显,而踩坑成本却是每一个成员都要承担的。
5. C++20 踩坑实录:编译器、标准库与构建的三类翻车现场
5.1 编译器支持差异:同一个特性,三个编译器三个坑
现象:一个 C++20 特性在自己的开发机上编译通过,提交 CI 后在其他编译器上直接报错,错误信息还完全不一样。原因:C++20 的特性支持度在 2022 年前后才逐步对齐,各编译器的标准库实现也有先后。解决:先建立一个特性支持矩阵,把项目用到的特性列出来,逐个确认目标编译器的最低版本。比如std::ranges在某个编译器上需要特定版本才完整,另一个编译器则还需要额外的库选项。我的习惯是在 CI 里加一个编译检查任务,专门用最新稳定版编译器编译一个最小特性样例,提前感知差异,而不是等主工程翻车后再定位。
5.2 协程的性能假象与悬挂引用
现象:协程在 Debug 构建下运行正常,Release 开启优化后偶发崩溃或数据错乱。原因:协程帧的生命周期管理出错,最常见的是 „悬挂引用“——协程挂起时,局部变量的地址被外部持有,外部在协程 resume 前销毁了对象。另一个问题是final_suspend返回std::suspend_always时,协程帧不会自动销毁,必须显式调用handle.destroy(),否则内存泄漏。解决:严格规定协程帧内存的所有权归属——谁创建谁销毁;跨协程传递参数一律按值传,不要传引用或指针。调试时先用 AddressSanitizer 跑一遍协程密集的测试用例,它能立刻暴露帧生命周期问题。
5.3 Modules 的构建顺序地狱与宏泄漏
现象:使用模块的工程在增量编译时出现「模块文件已过期」的莫名错误,或者模块里明明没有定义宏,外部却能使用某些宏。原因:模块的 BMI 文件依赖编译顺序,构建系统如果没有正确追踪模块依赖关系,增量编译就会用旧的 BMI;宏泄漏则是模块接口里export了一个包含宏引用的类型,宏在模块外部展开了意外内容。解决:确保构建系统显式声明模块间的依赖,并做全量 clean 后重编;模块接口文件里不要导出任何依赖头文件宏的类型。避不开就用最土的办法——模块接口文件里只放纯类型和纯函数,所有宏相关的逻辑全部用内联函数封装。
6. 验证你的 C++20 环境:一个编译开关自检法
6.1 三个编译选项确认特性完整度
判断一个编译器是否适合承接 C++20 项目,不要看特性列表,直接编译这段最小探测代码:
# 用目标编译器编译,输出成功表示核心特性基本可用 g++ -std=c++20 -fconcepts -Wall -Wextra test.cpp -o /dev/null clang++ -std=c++20 -Wall -Wextra test.cpp -o /dev/null// test.cpp:一次验证 ranges + concepts + coroutine 是否完整 #include <ranges> #include <coroutine> #include <concepts> #include <vector> template <std::ranges::range R> requires std::integral<std::ranges::range_value_t<R>> auto sum_view(R&& r) { int s = 0; for (auto v : r) s += v; return s; } struct task { struct promise_type { task get_return_object() { return {}; } std::suspend_never initial_suspend() { return {}; } std::suspend_never final_suspend() noexcept { return {}; } void return_void() {} void unhandled_exception() {} }; }; task demo() { co_return; } int main() { std::vector<int> v{1, 2, 3}; return sum_view(v) == 6 ? 0 : 1; }这段代码刻意把三大特性揉在一个文件里,任何一个特性不完整都会导致编译失败或运行返回非零值。它不验证模块,因为模块在 2022 年基本无法跨编译器统一启用,需要单独用对应编译器的开关测试。
6.2 用标准库特性反推编译器版本
当我面对一台没文档的编译服务器时,最实用的办法是直接编译一段依赖最新特性的代码,用错误信息判断版本。比如尝试实例化std::views::chunk_by(一个更晚加入的特性),编译器报「不在 std 命名空间」就说明标准库版本不够新;报「未声明标识符」则说明编译器本体版本过低。这个方法比g++ --version更可靠,因为版本号只能说明编译器本体版本,标准库实现往往是单独维护的头的集合。我的收尾习惯是:每次升级编译器后,先跑一次上述探测程序,再把结果记到项目文档里,标注「该版本下哪些特性不可用」。这样新成员加入时不用自己踩一遍坑。希望帮到你。
本文还有配套的精品资源,点击获取