C++20 - 革命性更新
📑 C++ 演进系列:总览 | C++11 | C++14 | C++17 | C++20 | C++23 | C++26
C++20 是继 C++11 之后最大的更新,引入了模块、协程、概念和 ranges 等重大特性。它从四个方向重塑了 C++: Concepts 让模板约束成为一等公民、Ranges 让算法与数据以管道组合、协程把异步/生成器纳入语言、模块从根本上改善编译模型。此外还有三路比较、std::format、std::span、日历/时区、同步原语等大量实用设施。C++20 是现代 C++ 的新基线。
Concepts(概念)
Concepts 是对模板参数的命名约束——用 template<Concept T> 或 requires 子句表达"T 必须满足什么",违反约束时编译器给出人类可读的错误(“int 不满足 Sortable”),而非 SFINAE 那种模板展开几百行的天书。
Concepts 取代了 enable_if/SFINAE 的曲折写法,让泛型代码的意图直接体现在签名里,并能用于重载决议(不同约束的模板可重载)、简化迭代器/算法的分层设计。
1 | |
requires 表达式(requires(T a, T b) { a + b; })检查表达式是否合法,合法则概念满足。标准库提供一组概念:std::integral、std::floating_point、std::same_as、std::convertible_to、std::invocable、std::ranges::range/iterator/sentinel 等,以及算法相关的 sortable/movable/copyable。优先复用标准概念而非自造。
Ranges(范围库)
Ranges 把"算法 + 迭代器对"重构成"算法 + 范围",并引入视图(views)——惰性、可组合、零拷贝的范围适配器。你不再需要把中间结果存进临时容器,而是用管道 | 把数据流串起来:vec | filter | transform | take,用到才求值。
视图是惰性的——views::iota(1) 生成无限序列也不耗内存,take(10) 才取前 10 个。这让函数式风格的链式处理在 C++ 中零开销可用。
1 | |
视图不拥有数据、不拷贝,依赖底层范围的生命周期——对临时容器的视图要小心悬挂。视图组合的求值是惰性拉取的,遍历时才逐元素经过整条管道。C++23 进一步补了 enumerate/zip/chunk 等视图和 ranges::to 物化。
协程(Coroutines)
协程是可暂停/恢复的函数:用 co_await/co_yield/co_return 在某点挂起、保留局部状态、稍后恢复。它让异步代码能像同步代码一样线性写——回调地狱变成顺序流程,生成器不再需要手写迭代器。
C++20 提供的是协程机制(语言特性 + <coroutine> 工具),不提供现成的任务类型——你必须自己实现 promise_type,或用第三方库(如 cppcoro、std::execution)。这门槛较高,标准库的 std::generator 直到 C++23 才补上。
1 | |
promise_type 定义协程的行为:initial_suspend 控制是否立即挂起、yield_value 处理 co_yield、final_suspend 控制结束时是否保持帧、unhandled_exception 处理异常。coroutine_handle 是协程帧的句柄,resume() 恢复、destroy() 销毁。协程帧通常堆分配(可由 operator new 自定义或用对称转移避免)。日常用 C++23 的 std::generator 即可,无需手写这些。
模块(Modules)
模块是替代头文件的编译单元组织方式:export module math; 声明模块,import math; 导入。模块是预编译的语义实体——导入不重复解析、不泄漏宏、导入顺序无关、接口与实现可分离。它从根本上解决头文件重复解析导致的编译慢、宏污染、循环包含等问题。
对大型项目,模块可显著缩短编译时间(头文件解析是 C++ 编译耗时大头)。代价是构建系统需支持模块扫描依赖(CMake 有 FILE_SET CXX_MODULES 支持),工具链集成仍在完善。
1 | |
模块可分区(export module math.core;)、私有片段(module :private;)。宏无法跨模块边界——这是特性,强制封装。模块与头文件可混用(import <iostream>; 是头文件单元)。注意某些项目规约(如本 skill)出于工具链成熟度暂不用模块,统一用头文件 + #pragma once。
三路比较运算符(<=>)
<=>(飞船运算符)一次定义比较,自动生成 </<=/>/>=/==/!= 全部六个运算符。= default 时编译器按成员逐一比较,省去手写一堆重载。
它返回一个比较类别(strong_ordering/weak_ordering/partial_ordering),表达"小于/等于/大于"以及等价的强度(strong:等价即相等;weak:等价但可能不相等,如大小写不敏感字符串;partial:可有不可比,如浮点 NaN)。
1 | |
注意 = default 的 <=> 默认不生成 ==/!=(除非也 = default operator==,但实际默认 <=> 会同时默认 ==)。自定义 <=> 时,若想要 == 走自定义逻辑需单独写 ==。浮点用 partial_ordering(因 NaN)。Version 这种逐字段比较用 if (auto cmp = a <=> b; cmp != 0) return cmp; 惯用法,短路在首个不同字段。
Designated Initializers(指定初始化器)
指定初始化器允许按字段名初始化结构体成员:Point{.x = 1, .z = 3},未指定的成员值初始化(零)。这让初始化的含义自文档化——{1, 0, 3} 要数位置,{.x = 1, .z = 3} 一目了然,也防止参数顺序写反。
源自 C99,C++20 纳入但限制更严:字段必须按声明顺序指定、不能嵌套指定数组、每个成员只能指定一次。
1 | |
C++ 中指定顺序必须与声明顺序一致(C99 允许乱序,C++ 不允许)。适合配置结构体、有大量字段且只想设几个的场合——可读性和防错性都优于位置初始化。
consteval 和 constinit
C++20 引入两个常量相关关键字,与 constexpr 互补:
consteval:函数必须在编译期求值(即时函数)。调用点若无法常量求值则编译错误。适合强制编译期计算(如格式串编译期校验、查表生成)。constinit:变量必须在编译期初始化(但运行期可改)。解决静态/全局变量的"初始化顺序未定义"(static initialization order fiasco)问题——保证不依赖动态初始化。
1 | |
三者对比:constexpr(可编译期可运行期)、consteval(仅编译期,强制)、constinit(仅约束初始化时机,不约束 const 性)。constinit 不隐含 const——变量运行期仍可修改,区别于 constexpr 变量(只读)。constinit 常用于全局/静态变量避免静态初始化竞态。
范围 for 循环支持初始化器
像 if 初始化语句一样,范围 for 现在允许在循环前加一条初始化语句:for (init; decl : range)。用于在循环前准备一个与循环相关的变量,且作用域限定在循环内。
1 | |
典型用途:循环前拷贝一份范围(避免遍历时修改原范围)、锁定互斥量(for (std::lock_guard lk(mtx); auto& e : data))、准备循环相关状态。初始化语句的作用域仅限循环,出循环即销毁——锁会随循环结束释放。
Lambda 模板参数列表
C++14 的泛型 Lambda 用 auto 参数,但无法显式命名模板参数、无法约束。C++20 允许 Lambda 显式写模板参数列表 []<typename T>(T x){},能命名参数类型、做约束、处理数组/函数类型退化等 auto 做不到的事。
1 | |
显式模板参数让 Lambda 能写 []<typename T>(const std::vector<T>& v)(保留容器类型,auto 会退化)、按类型分派、用 Concepts 约束。实际中 auto 已够用时不必上模板参数,需要精细控制时才用。
[[no_unique_address]] 属性
[[no_unique_address]] 告诉编译器:某成员可以与其他成员共享地址,不必独占存储。主要用途是空类型成员(Empty)不占用多余空间——空类理论上 0 字节但 C++ 要求对象非零大小(通常 1 字节),该属性允许把它塞进其他成员的 padding。
这对"策略类/特征类作为成员"的模板设计(如分配器、比较器嵌入容器)很有用——无状态策略不增加对象大小。
1 | |
能否真正优化取决于实现和 ABI——MSVC 行为与 GCC/Clang 不同,static_assert 不一定所有平台都成立。空基类优化(EBO)是类似机制(针对基类),[[no_unique_address]] 是其成员版。多个 [[no_unique_address]] 同类型成员不能共享同一地址(需可区分)。
[[likely]] 和 [[unlikely]] 属性
[[likely]]/[[unlikely]] 向编译器提示分支的走向概率,指导分支预测与代码布局(把热路径放在一起、冷路径移到函数末尾)。在性能敏感代码中,正确提示能改善分支预测命中率、指令缓存局部性。
1 | |
属性放在语句前或 case 标签后。注意现代 CPU 分支预测器很强,多数情况下无需手写提示——只有在 profile 确认某分支明显倾斜、且预测器表现差时才用。滥用可能适得其反。优先用 profile 指导而非直觉。
std::format
std::format 是 {fmt} 库的标准化,提供 Python 风格的类型安全格式化。格式串在编译期校验(参数数量/类型不匹配编译报错),性能优于 iostream(无 locale 切换、无虚调用),语法简洁。
它取代了既啰嗦又易错的 << 链和 printf 的无类型检查。C++23 的 std::print/println 在其基础上加了直接输出。
1 | |
格式说明符:{} 默认、{:.2f} 定点、{:x}/{:b}/{:o} 进制、{:>10} 右对齐、{:<10} 左对齐、{:^10} 居中、{:#x} 带前缀、{:0>5} 零填充、{1} 按索引。自定义类型特化 std::formatter<T> 接入。编译期校验要求格式串是字面量常量。
std::span
std::span<T> 是连续内存的非拥有视图——只持指针+长度,不分配、不拷贝。它统一了 std::array、std::vector、std::string 的 data()、C 数组的访问接口。函数参数接受 span 时所有连续容器都能传,且无拷贝。
它取代了"传 const vector& 还是 T* + size"的两难——前者绑定具体容器类型、后者丢边界信息。span 既类型擦除容器类型又保留长度。
1 | |
span 有固定大小版 span<T, N>(编译期已知大小)和动态版 span<T>(默认,dynamic_extent)。只读用 std::span<const T>。span 不拥有数据,生命周期依赖底层——同 string_view 的注意事项。子视图 first(n)/last(n)/subspan(off, count) 是 O(1)。
std::source_location
std::source_location 捕获调用点的源码位置(文件名、行号、列号、函数名),取代 __FILE__/__LINE__ 宏。作为默认参数 std::source_location::current(),它能在调用处求值,自动记录"谁调用了日志函数"。
相比宏,它是类型安全的对象,可传递、可存储、可格式化。适合日志、断言、错误报告。
1 | |
source_location::current() 作为默认参数在调用点展开,所以 loc 记录的是调用 log 的位置而非 log 定义处。注意它取代了 C++20 提案中的 std::experimental::source_location。stacktrace(C++23)提供完整调用栈,是更进一步的诊断工具。
std::bit_cast
std::bit_cast<To>(from) 把一个对象的位表示重新解释为另一类型——安全、constexpr、类型安全的"按位重解释"。它取代了违反严格别名规则的 reinterpret_cast/memcpy 模式,避免 UB。
要求源和目标大小相同、可平凡拷贝。典型用途:浮点与整型的位级查看(不转换值,看其二进制表示)、压缩位域的打包/解包。
1 | |
与 reinterpret_cast 的关键区别:bit_cast 不违反严格别名(reinterpret_cast 把 float* 当 uint32_t* 读是 UB),且 constexpr 可在编译期用。要求 sizeof(From) == sizeof(To) 且两者皆可平凡拷贝——不满足则编译错误。是处理二进制协议、序列化的安全工具。
std::endian
std::endian 是编译期可查询的本机字节序枚举:std::endian::native 等于 little/big/或两者(PDP 等混合序)。它取代了运行期探测字节序的技巧(把 int 写入再按字节读)。
知道本机端序后,跨平台数据交换(网络、文件、协议)可据此决定是否转换。C++23 的 std::byteswap 提供实际的字节翻转。
1 | |
std::endian::native 是编译期常量,可用 if constexpr 分支,无运行期开销。注意 C++20 只有 endian 查询,没有 byteswap——实际翻转在 C++20 要手写移位或用平台 htonl。byteswap 是 C++23 补充。(原文此处标注有误,已修正。)
std::make_shared 支持数组
C++17 起 std::shared_ptr 支持数组(shared_ptr<T[]>),std::make_shared<T[]>(n) 可分配并共享拥有数组。这取代了此前用 shared_ptr<vector<T>> 或自定义删除器包裹 new[] 的别扭写法。
说明:
shared_ptr数组支持与make_shared<T[]>实际是 C++17 引入(P0674),原文标注为 C++20 有误,此处保留示例并更正。
1 | |
shared_ptr<T[]> 的 operator[] 提供数组访问,析构调 delete[](默认删除器自动适配)。多维用 T[][N](首维动态、其余静态)。注意 make_shared<T[]> 一次分配对象+控制块(同 make_shared 优势),但数组元素零初始化(int[] 全 0)。
contains 成员函数
C++20 给所有关联容器(set/map/unordered_set/unordered_map 及其 multi 版)加了 contains 成员函数,返回 bool 表示键是否存在。它取代了 find(key) != end() 的冗长写法,直接表达"是否包含"的意图。
1 | |
contains 比 find 更清晰、不易写错(忘记 != end 比较是常见 bug)。注意 contains 只返回 bool,需要迭代器时仍用 find。C++23 把 contains 扩展到了 std::string/string_view(子串查找)。
starts_with 和 ends_with
C++20 给 std::string/std::string_view 加了 starts_with/ends_with,返回 bool 判断前缀/后缀。取代了 str.compare(0, n, pre) == 0 或 str.substr(...) 的繁琐写法。
1 | |
接受子串(string/string_view/const char*)或单字符。复杂度 O(n)。文件扩展名判断、协议前缀匹配、URL 路由等场景的样板代码大幅简化。C++23 的 contains 补齐了"子串包含"这最后一块。
std::jthread
std::jthread(joining thread)是 std::thread 的改进版:析构时自动 join()(而非 thread 的 terminate),并内置 stop_token 协作取消机制。它解决了 std::thread 的两大坑——忘记 join/detach 导致 std::terminate、无法干净地取消线程。
jthread 析构时先 request_stop()(请求停止)再 join()(等待退出),让线程有机会清理后退出。这是 C++20 线程管理的默认选择。
1 | |
jthread 接受一个可选的首参 std::stop_token,框架自动传入。线程内轮询 st.stop_requested() 决定何时退出。jthread 不可默认构造后赋值移动的语义与 thread 类似,但析构语义更安全。新代码用 jthread,不用 std::thread。
std::stop_token
stop_token 是协作取消的载体:线程通过它查询"是否被请求停止"、注册停止回调。jthread 内部维护一个 stop_source,request_stop() 触发停止,线程内的 stop_token 查询到停止请求后优雅退出。
协作式取消避免了强制终止线程(pthread_cancel 的资源泄漏、未调析构等问题)——线程自己决定何时、如何退出,保证资源释放。
1 | |
stop_callback 可注册"停止时执行的回调"(如唤醒阻塞操作)。stop_source 可独立于 jthread 使用(手动管理停止)。stop_token 本身只读,可安全传递。这是 std::thread 时代做不到的干净取消机制。
信号量(Semaphores)
C++20 引入信号量同步原语:counting_semaphore<N>(计数信号量)和 binary_semaphore(二值,等同互斥但可从不同线程释放)。信号量维护一个计数,acquire 减一(为零则阻塞)、release 加一(唤醒等待者)。
适用"限制并发数"的场景:连接池限流、资源配额、生产者-消费者计数。与互斥的区别:互斥强调"独占访问",信号量强调"许可计数"——同一时刻可有 N 个持有者。
1 | |
模板参数是"最大计数"(编译期上限),构造参数是初始计数。binary_semaphore 即 counting_semaphore<1>。与 mutex 的关键区别:信号量可由非持有者释放(任何线程都能 release),而互斥只能由持有者解锁——这让信号量适合"通知"语义而非"互斥"语义。
Barriers
std::barrier 是阶段同步原语:N 个线程到达屏障后全部放行进入下一阶段,可配一个"阶段完成回调"(最后一个到达的线程执行)。适合分阶段并行计算——各线程完成一阶段后同步,再开始下一阶段。
与 latch 的区别:barrier 可重用(每阶段重置计数),latch 一次性。barrier 的阶段回调常用于交换/归约各线程的中间结果。
1 | |
arrive_and_wait() 等价于 arrive() + wait()——递减计数并阻塞直到归零。arrive_and_drop() 递减并永久退出(用于线程数随阶段减少)。阶段回调在每阶段结束时被最后一个到达的线程调用一次,适合做阶段间的归约/广播。
Latches
std::latch 是一次性倒计数同步原语:初始化为 N,线程完成时 count_down,主线程 wait 直到计数归零。适合"等待一组一次性任务完成"——如初始化阶段等待所有工作线程就绪。
与 barrier 的区别:latch 一次性、不可重用、任意线程可 count_down(不必 wait)。barrier 可重用、要求线程配对。
1 | |
count_down(n) 一次减 n,wait() 阻塞到归零,try_wait() 非阻塞查询,arrive_and_wait() 减一后等归零。典型用途:启动期等待所有工作线程初始化完成、并行计算的汇合点。一次性——归零后无法重置。
Atomic wait/notify
C++20 给原子变量加了 wait/notify_one/notify_all,实现高效的"等待值变化"——线程阻塞直到原子变量值改变且被通知。这取代了用条件变量+互斥量轮询的开销模式,原子变量的等待无需互斥量。
wait(old) 阻塞直到当前值不等于 old 且被 notify 唤醒;notify_one/notify_all 唤醒等待者。注意 wait 内部可能有伪唤醒,但标准保证语义正确(值未变时会重新阻塞)。
1 | |
wait(old) 的语义:若当前值等于 old 则阻塞,否则立即返回。返回后需重新检查值(处理伪唤醒与已变)。notify_one 唤醒一个、notify_all 唤醒全部。这种"原子 + 等待/通知"比条件变量轻量(无互斥量、无锁),适合无锁数据结构的同步(如 atomic<shared_ptr> 链表)。(原文 wait 返回值比较的写法有误,wait 返回 void,已改为循环检查。)
std::atomic<std::shared_ptr>
C++20 特化了 std::atomic 对 shared_ptr 的支持,让共享指针的原子加载/存储/CAS 成为类型安全的一等操作。此前要用 std::atomic_load(&sharedPtr) 自由函数(已废弃),易错且不封装。
典型用途:无锁并发数据结构,如无锁栈/链表头指针的原子更新——多个线程 CAS 修改 head,shared_ptr 自动管理节点生命周期。
1 | |
compare_exchange_weak/strong 在失败时把 expected 更新为当前值,便于循环重试(weak 可伪失败,适合循环)。atomic<shared_ptr> 内部通常用双重 CAS(指针+控制块)保证原子性。注意 ABA 问题——shared_ptr 的控制块计数能在一定程度上缓解 ABA,但复杂场景仍需谨慎。
常用特性总结
最常用的 C++20 特性:
- Concepts(概念)
- Ranges(范围库)
- 三路比较运算符(
<=>) std::formatstd::spancontains成员函数starts_with/ends_withstd::jthread








