☰
C++20迁移:operator<=>、concepts与ranges工程边界
2026/11/3 13:26:51 网站建设 项目流程

简介:这份 PDF 电子书为 Nicolai M. Josuttis 所著《C++20 - The Complete Guide》英文第一版,面向已具备一定 C++ 基础、希望系统跟进 C++20 新标准的应用开发者与基础库作者。全书以模块化、协程、概念(Concepts)、三路比较运算符 <=>、范围库 ranges、格式化库 format 等核心特性为主线,配合大量示例讲解语言与标准库的演进,并专章说明各编译器对特性的支持差异,便于读者在 gcc、clang 与 MSVC 上实践验证。压缩包内仅含 1 个 PDF 文件,体积约 3.44MB,轻量易携带,适合离线阅读与随时检索。目前已有 361 人学习,是较为成熟的 C++20 进阶参考材料。读者可借助书中对比较运算符、强类型枚举、原子浮点操作等细节的剖析,掌握迁移到新标准的写法与易错点,也可将其作为团队内部标准升级时的对照手册。

1. C++20 不是多给你几个关键字:这本 guide 解决的是迁移期的判断问题

上个月一个做回测系统的朋友把这份 PDF 发给我,问值不值得花两周读。我翻了半小时,给他一句话:如果你只想知道 C++20 多了哪些关键字,翻标准文档就够了;但你要回答的是「哪些特性我现在能用到生产环境、编译器是否支持、老代码会不会被 operator<=> 改坏」,那这本《C++20 完全指南》(c++20 guide)是少见的、把语言特性和工程边界一起写的材料。它 2021 年底定稿,主线是 operator<=>、concepts、ranges/views、modules、coroutines、 ,每个特性都配了能编译的片段,也明确写清楚了什么场景下不要使用。适合有 C++11/14 底子、正在做 20 迁移的人,也适合要给团队定规范、必须判断某个特性当前能不能实际采用的技术负责人。

2. 先搭一个能复现的验证工程:编译器选型与三套编译参数

这本书的示例是按特性拆开的,每段代码只保留和当前话题相关的那几行,main、头文件、命名空间经常省略。如果你直接复制到编辑器里,十有八九第一行就报错。我一般的做法是先花半小时建一个 lab 工程,把每章示例按章号保存成独立的 cpp 文件,编译器和标准版本都固定在 CMake 里,读到哪一章就编译到哪一章。这么做还有个好处:书里哪些示例依赖编译器扩展、哪些在当前版本上确实跑不通,编译一遍就全暴露了,比读文字描述快得多。

2.1 先看支持矩阵,再决定从哪一章开始读

这本书是 2021 年底定稿的,那时 GCC 11 刚发布不久。如果你现在拿一台工具链较旧的机器照着目录顺序读,很可能在第 5 章 ranges 上卡住,然后误以为是书的问题。实际情况多半是编译器版本不够。下面这张表是我在自己几台机器上验证过的粗略门槛,只作为选型参考;精确值以本机的特性测试宏为准,第 6 章给了一段自检程序。

特性对应章节GCCClangMSVC
operator<=>与<compare>第 1 章10 起基本可用10 起可用19.25 起
concepts / requires第 3~4 章10 需-fconcepts,11 起默认10 需-fconcepts-ts,16 起默认19.30 起
ranges / views第 5~7 章11 可用,12/13 才稳15 起大体可用19.29 起
<format>格式化相关章节13 起17 起,配合新版标准库19.29 起
modules模块章节11 起实验性,需-fmodules-ts17 起部分19.28 起相对完整
coroutines协程章节10 起14 起较稳19.25 起

ranges 对版本格外敏感,原因在于它是语言特性和库特性耦合最深的一块:concepts、requires、constexpr 改进、结构化绑定、CTAD 全都要配合,libstdc++ 的<ranges>虽然很早就进了仓库,但 views 的迭代器分类、borrowed_range判定、管道运算符的重载决议在不同版本之间改过好几轮。你在 11 上跑通的filter | transform,换到 10 上可能报一屏「no match for operator|」,这不是写法问题,是库实现没跟上。我的建议是:语言特性部分(第 1~4 章)随便什么版本都能开读,ranges 那一块先把编译器升到自己能升的最高版本,再动手。

2.2 用 CMake 把标准和扩展固定住

工程本身不用复杂,一个CMakeLists.txt加每章一个源文件足够。关键是把标准、扩展、警告三板斧固定住,别让编译器自己决定。

cmake_minimum_required(VERSION 3.20) project(cpp20_lab LANGUAGES CXX) # 关掉编译器扩展,用严格标准模式编译 # 这一步很重要:gnu++20 会放行一堆 GNU 扩展,把问题掩盖到运行时 set(CMAKE_CXX_EXTENSIONS OFF) set(CMAKE_CXX_STANDARD 20) set(CMAKE_CXX_STANDARD_REQUIRED ON) # 不支持就直接报错,绝不静默降级 add_executable(ch01_compare ch01_compare.cpp) add_executable(ch03_concepts ch03_concepts.cpp) add_executable(ch05_ranges ch05_ranges.cpp) # 公共编译选项:警告开满,调试信息保留 foreach(tgt ch01_compare ch03_concepts ch05_ranges) target_compile_options(${tgt} PRIVATE $<$<CXX_COMPILER_ID:GNU,Clang>:-Wall -Wextra -Wpedantic> $<$<CXX_COMPILER_ID:MSVC>:/W4 /permissive-> ) endforeach() # GCC 上可以额外打开概念诊断深度,报错信息会详细很多 # target_compile_options(ch03_concepts PRIVATE -fconcepts-diagnostics-depth=4)

逐项说明这几个参数为什么这么设。CMAKE_CXX_EXTENSIONS OFF让编译命令行出现的是-std=c++20而不是-std=gnu++20,后者会放行一堆 GNU 扩展,比如某些 VLA 写法、typeof,这些在书上根本没出现,但你一旦用了就再也回不去可移植的写法。CMAKE_CXX_STANDARD_REQUIRED ON是很容易漏掉的一行:不写它,CMake 发现编译器不支持 C++20 时会安静地退到 C++17,然后你对着一个 concepts 语法错误反复怀疑是不是自己抄错了,实际上编译器压根没开 20。最后那个-fconcepts-diagnostics-depth被注释掉了,因为它是 GCC 特有的,用 Clang 或 MSVC 时加上会直接报 unknown option;等你在第 3 章被「constraints not satisfied」这句没头没尾的报错折磨时再打开。

配置和编译:

# 生成构建目录,同时导出 compile_commands.json 给编辑器用 cmake -S . -B build -DCMAKE_BUILD_TYPE=Debug -DCMAKE_EXPORT_COMPILE_COMMANDS=ON # 并行编译,-j 后面跟核数,不写就吃满 cmake --build build -j # 跑第一个冒烟测试 ./build/ch01_compare

CMAKE_BUILD_TYPE=Debug是我个人的习惯:书里 ranges 和协程的示例有不少涉及生命周期,Debug 下配合 ASan 更容易在越界和悬垂的第一现场就抓到,Release 下优化器会把很多错误「优化掉」,等你上线才发现。CMAKE_EXPORT_COMPILE_COMMANDS=ON则让 clangd 或编辑器能正确跳转和补全,读这种代码片段密集的书,跳转体验直接决定阅读速度。如果你不想用 CMake,手工编译也完全可以,三条命令对应三种工具链:

g++ -std=c++20 -Wall -Wextra -O0 -g ch01_compare.cpp -o ch01_compare clang++ -std=c++20 -Wall -Wextra -O0 -g ch01_compare.cpp -o ch01_compare cl /std:c++20 /EHsc /W4 ch01_compare.cpp

2.3 第一段真正跑通的代码:默认化的 operator<=>

环境搭好之后,别急着往第 5 章冲,先用第 1 章的三路比较做一个冒烟测试。这个例子小到可以贴进聊天窗口,但它同时验证了<compare>头文件、默认化比较、特性宏三件事。

// ch01_compare.cpp #include <compare> #include <string> #include <iostream> struct Version { int major; int minor; int patch; std::string tag; // 默认化:按成员声明顺序做字典序比较 // 同时自动生成 ==、!=、<、<=、>、>= auto operator<=>(const Version&) const = default; }; int main() { Version a{1, 2, 0, "beta"}; Version b{1, 10, 0, "beta"}; std::cout << std::boolalpha; std::cout << "a < b : " << (a < b) << '\n'; // true,先比 major 再比 minor std::cout << "a == b : " << (a == b) << '\n'; // false // 直接看三路比较的返回值,负数表示 a 小于 b auto cmp = (a <=> b); std::cout << "a <=> b < 0 : " << (cmp < 0) << '\n'; #ifdef __cpp_lib_three_way_comparison std::cout << "three-way available: " << __cpp_lib_three_way_comparison << '\n'; #endif }

逻辑说明:= default让编译器按成员声明顺序逐个比较,遇到第一对不等的成员立刻返回结果,所以major都是 1,继续比minor,2 小于 10,整体判定为小于,后面的patch和tag根本不会被比较。返回类型是编译器推导出来的比较类别,这里四个成员全是整型和字符串,得到std::strong_ordering;如果我把int patch换成double patch,返回类型会退化到std::partial_ordering,因为浮点数存在 NaN 这种「既不大于也不小于也不等于」的状态。最后那个#ifdef块是保险丝:如果<compare>或三路比较在目标平台上不存在,这段会被预处理掉,程序照样能编译,只是少一行输出,方便你在老工具链上快速判断是「我的代码错了」还是「环境不支持」。

3. 第 1 到第 4 章:operator<=> 与 concepts 的实施顺序

这四章是全书的语言特性核心,也是迁移时改动面最大的部分。我的读法是先把 operator<=> 弄明白,因为它是唯一一个会悄悄改变老代码行为的特性;然后才是 concepts,因为它是纯增量,不加就不会出问题。

3.1 三路比较:默认化能省多少行,以及它什么时候会失效

在 C++20 之前,给一个结构体补全六个比较运算符,大概是二十行样板代码,而且极容易写错其中一个方向。默认化的operator<=>把这件事压缩成一行。

写法代码量出错概率备注
手写六个运算符约 20 行高,方向写反是经典事故需要额外维护
手写operator<+operator==约 8 行中C++20 前的主流折中
auto operator<=>(const T&) const = default;1 行低同时生成全部六个

但默认化不是万能的,有三种情况会失效。第一,成员里有不可比较的类型,比如裸数组、函数指针、没有定义比较的自定义类型,此时默认化会被定义为 deleted,报错点通常就在你写= default的那一行,反而好定位。第二,成员里有浮点数,比较类别退化为partial_ordering,语义上不再满足严格弱序,把它塞进std::set或std::map能编译通过,但一旦容器里出现 NaN,查找行为就是未定义的,这种 bug 不会在编译期给你任何提示。第三,类有基类时,默认化只会比较本类的成员,基类部分需要自己处理。

// 含浮点成员时,比较类别会退化为 partial_ordering #include <compare> #include <cmath> #include <iostream> struct Sample { double value; int seq; // double 参与比较 -> 返回 std::partial_ordering // NaN 参与比较时结果为 unordered,既不大于也不小于 auto operator<=>(const Sample&) const = default; }; int main() { Sample ok{1.0, 1}; Sample nan{std::nan(""), 2}; auto r = (ok <=> nan); std::cout << "unordered? " << (r == std::partial_ordering::unordered) << '\n'; }

参数说明:std::nan("")构造一个静默 NaN,std::partial_ordering::unordered是三路比较返回的四种状态之一,另外三种是less、equivalent、greater。这段代码的意义在于让你亲眼看到 unordered 长什么样:如果你打算把Sample放进有序容器,必须在比较函数里显式处理 NaN,比如把所有 NaN 排到最后,否则容器的不变式在插入 NaN 那一刻就已经被破坏了。书里在讲 compatibility issues 时专门提过,老代码里手写的operator<通常隐含假设「值都是有限的」,换到默认化之后这个假设悄悄失效,是迁移中最容易被忽略的一类问题。

3.2 requires:把模板报错从两百行压到三行

concepts 最直观的收益不是「能表达更强的约束」,而是报错信息变短了。这在书里用了整整一章来演示,我把对比压缩成两段代码。

// C++17 写法:约束藏在默认模板参数里 template <typename T, typename = std::enable_if_t<std::is_integral_v<T>>> T twice(T v) { return v + v; } // 调用 twice("abc") 时,报错会指向 enable_if 内部, // 中间夹着七八层模板实例化,新手基本看不懂 // C++20 写法:约束直接写在签名上 template <std::integral T> T twice(T v) { return v + v; } // 调用 twice("abc") 时,报错只有一行: // "constraints not satisfied",并明确指出 std::integral<T> 不满足

逻辑说明:std::enable_if_t的版本在约束失败时,编译器是在做重载决议,失败信息藏在候选函数列表里,你需要从最后一层往前找,才知道真正的原因是const char*不是整型。concept 版本的约束是签名的一部分,编译器在检查函数体之前就先验证约束,报错点直接落在调用处。

自己写 concept 时,requires表达式有四种形式,书里第 3.3 节讲得很细,我把它整理成表格,写的时候照着对号入座就行。

形式写法示例检查什么
简单要求a + b;表达式是否合法
类型要求typename T::value_type;嵌套类型是否存在
复合要求{ a + b } noexcept -> std::convertible_to<T>;合法性 + 异常规格 + 返回类型
嵌套要求requires sizeof(T) > 4;额外的编译期布尔条件
#include <concepts> // 复用性强的约束提成 concept,一次性约束直接写 requires 子句 template <typename T> concept Addable = requires(T a, T b) { { a + b } -> std::convertible_to<T>; // 能相加,且结果能转回 T { a += b } -> std::same_as<T&>; // 能自增赋值,且返回引用 }; template <Addable T> T accumulate_pair(T a, T b) { return a + b; } // 只在一个函数里用一次,就别单独立个 concept,直接写 requires template <typename T> requires requires(T t) { t.size(); } void print_size(const T& t) { /* ... */ }

参数说明:std::convertible_to<T>比std::same_as<T>宽松,前者允许隐式转换,后者要求类型完全一致;a += b返回T&是 C++ 里赋值运算符的惯例,用same_as卡住可以挡掉那些返回值类型的实现。第二段那个双requires看着别扭,其实是两个不同位置:第一个requires是约束子句,第二个requires开始的是 requires 表达式,书里管这种叫 ad hoc 约束,只在当前函数用一次,不值得单独提成具名 concept。

3.3 标准 concepts:别重复造轮子,也别乱套

书里第 4 章把标准库里的 concepts 按用途分了组,实际写代码时最常用的其实就那么十来个。我把它们和典型误用整理在一起,查起来比翻目录快。

concept语义典型误用
std::integral整型拿它约束bool,bool也算整型
std::floating_point浮点以为能挡住long double的精度问题
std::same_as<T, U>类型完全一致写成std::same_as<T>后忘了第二个参数
std::convertible_to<From, To>可隐式转换用来检查 explicit 构造,会误判
std::totally_ordered全序只保证运算符存在,不保证语义正确
std::ranges::range可遍历不保证能多次遍历,input_range 也能满足
std::ranges::view轻量视图不保证零开销,别当成性能承诺
std::sortable可排序已经蕴含totally_ordered,别重复写
std::invocable<F, Args...>可调用不检查返回值类型,需要返回值就加std::invoke_result_t

表里最后两行是重点。std::sortable的定义里已经包含了totally_ordered,你再写template <std::sortable T> requires std::totally_ordered<T>就是冗余约束,虽然能编译,但会让重载决议的包含关系变复杂,书里 3.5 节讲的 subsumption 就是这件事:约束之间存在包含关系时,更严格的版本优先。另一个是语义问题,std::totally_ordered只检查<、>、<=、>=、==这些运算符是否存在并且返回可转换为bool的东西,它完全不管你的operator<是不是真的满足传递性。书里 3.6 节把这类叫 semantic constraints,明确说了标准库没法替你验证语义,这部分只能靠单元测试兜底。

4. Ranges 与 Views:书里最厚的一块,也是最容易悬垂的一块

从第 5 章开始,书的内容明显变厚,因为 ranges 涉及的概念多、边界多,而且很多行为是运行时才暴露的。这一块我的建议是每读一个 view,就在 lab 工程里打印一遍求值顺序,别靠脑补。

4.1 管道运算符是惰性的:先搞清楚什么时候真正求值

新手最容易犯的错是把filter | transform当成「先过滤出一个新容器,再转换出一个新容器」。实际上整条管道只是构造了一个视图对象,一个元素都没处理,直到你开始遍历。

#include <ranges> #include <vector> #include <iostream> int main() { std::vector<int> v{1, 2, 3, 4, 5, 6}; // 这里只是构造视图,没有任何元素被处理 auto r = v | std::views::filter([](int x) { std::cout << " filter " << x << '\n'; return x % 2 == 0; }) | std::views::transform([](int x) { std::cout << " transform " << x << '\n'; return x * x; }) | std::views::take(2); // 只取前两个满足条件的 std::cout << "--- 开始遍历 ---\n"; for (int x : r) { std::cout << "value " << x << '\n'; } }

跑出来的顺序是:filter 1(奇数,丢弃)、filter 2(偶数,往下走)、transform 2、value 4,然后filter 3、filter 4、transform 4、value 16,循环结束。注意 5 和 6 从头到尾没被 filter 看过,因为take(2)在拿到第二个元素后就让整个管道停了。

这个行为有两层含义。好的一面是短路:对一个百

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询