Table of contents
Open Table of contents
TL;DR
C++20 是继 C++11 之后最大的一次升级,核心是四个范式级特性同时落地:Concepts 把模板错误从 200 行报错变成一行说明,Ranges 用惰性 view + 管道语法重构了整个 STL 算法接口,Coroutines 让异步代码写得像同步(但标准库没给 Task,需要三方库),Modules 终于告别了头文件——每一个单独拎出来都足以改变一整类编程风格。
1. C++20 的历史定位
| 版本 | 角色 |
|---|---|
| C++11 | 现代化革命(move/lambda/auto/智能指针/线程库) |
| C++14 | 小补丁(generic lambda/返回类型推导) |
| C++17 | 精装修(optional/variant/string_view/if constexpr) |
| C++20 | 四大件重写语言 |
C++20 同时解决了四个存在几十年的长期痛点:
| 痛点 | 存在时长 | C++20 的解法 |
|---|---|---|
| 模板错误信息天书 | 从 C++98(模板诞生那天) | Concepts |
| STL 算法冗长、不能组合 | 从 C++98 STL 定型后 | Ranges |
| 异步编程回调地狱 | 从 C++11 引入线程后 | Coroutines |
| 头文件编译慢 / 宏污染 / ODR | 从 C 诞生那天(1972) | Modules |
最后一条尤其讽刺——C++ 花了 48 年才告别 #include。
2. Concepts:模板错误从 200 行变成 1 行
2.1 问题背景:template error avalanche
C++98 到 C++17 的模板是鸭子类型——约束藏在函数体里。调 std::sort(v.begin(), v.end()) 时若 T 没有 operator<,错误在 std::sort 内部某个比较点抛出,冲出栈几十层:
error: no match for 'operator<'
in instantiation of 'void std::__sort<...>'
required from 'void std::__introsort_loop<...>'
required from 'void std::__sort<...>'
required from 'void std::sort<...>'
... 200 more lines ...
根本原因:约束表达力不足。编译器不知道 sort 要求 T 可比较,只能在实际用到 < 时才报错。
C++11-17 的 workaround 是 SFINAE + enable_if,语法丑陋(约束混杂在模板参数列表里)、错误信息依然烂(显示的是 SFINAE fail)、多重约束的解析规则极复杂。
2.2 C++20 的四种写法
#include <concepts>
// 定义 concept:最简形式
template<typename T>
concept Integral = std::is_integral_v<T>;
// 定义 concept:用 requires 表达式(更强大)
template<typename T>
concept Addable = requires(T a, T b) {
{ a + b } -> std::same_as<T>; // 要求 a+b 存在且返回 T
};
// 用法 1: requires 子句(推荐,最清晰)
template<typename T> requires Integral<T>
T add(T a, T b) { return a + b; }
// 用法 2: 模板参数列表直接约束
template<Integral T>
T add2(T a, T b) { return a + b; }
// 用法 3: 尾部 requires
template<typename T>
T add3(T a, T b) requires Integral<T> { return a + b; }
// 用法 4: 缩写函数模板(最简)
Integral auto add4(Integral auto a, Integral auto b) { return a + b; }
2.3 错误信息的质变(核心卖点)
未满足 concept 时的错误(GCC 13+):
error: no matching function for call to 'add(std::string, std::string)'
note: candidate: 'template<class T> requires Integral<T> T add(T, T)'
note: constraints not satisfied
note: the expression 'is_integral_v<T>' evaluated to false
一行就说清楚,不再有 200 行内部模板栈。本质是 把错误检查前移到约束匹配阶段——编译器从”猜你的意图”变成”读你的约束”。这是 Concepts 最大的实用价值。
2.4 标准库常用 concepts
// 基础类型
std::integral<T> std::floating_point<T>
std::same_as<T, U> std::convertible_to<From, To>
std::derived_from<D, B>
// 对象语义(重要)
std::movable<T> std::copyable<T>
std::semiregular<T> // 可默认构造 + copyable
std::regular<T> // semiregular + equality_comparable
// 可调用
std::invocable<F, Args...> std::predicate<F, Args...>
// Ranges 相关
std::ranges::range<T> std::ranges::view<T> std::ranges::sized_range<T>
std::regular 是”值类型”经典建模——可默认构造、可复制、可赋值、可比较相等。行为像 int 的类型都应该满足 regular。
2.5 陷阱:把实现细节写进约束
反例:
template<typename T>
concept HasReserve = requires(T t, size_t n) { t.reserve(n); };
这把”是否有 reserve 方法”当成契约,但这是实现细节不是概念。正确做法是用语义 concept:sized_range / contiguous_range。把实现细节写进 concept 会让 concept 只能约束特定类型,失去抽象意义。
2.6 Subsumption(包含关系):复杂的规则
当多个函数重载都满足时,编译器选择”约束更强”的:
template<typename T> concept Animal = requires(T t) { t.eat(); };
template<typename T> concept Dog = Animal<T> && requires(T t) { t.bark(); };
void f(Animal auto x); // #1
void f(Dog auto x); // #2
// 传入一个有 eat 和 bark 的类型 → 选 #2(Dog 包含 Animal)
陷阱:subsumption 只对”用同一个 concept 或直接展开相关的约束”生效。如果把两个 concept 写成独立的 is_xxx_v 表达式,编译器不能推断包含关系。始终基于已有 concept 组合,不要自己写等价但拆散的 trait。
3. Ranges:STL 史上最大的重构
3.1 问题背景:STL 算法的三宗罪
// 筛偶数、乘 2、取前 3 个——C++17 写法
std::vector<int> tmp1, tmp2, result;
std::copy_if(v.begin(), v.end(), std::back_inserter(tmp1),
[](int x){ return x % 2 == 0; });
std::transform(tmp1.begin(), tmp1.end(), std::back_inserter(tmp2),
[](int x){ return x * 2; });
std::copy_n(tmp2.begin(), 3, std::back_inserter(result));
三个痛点:
- iterator pair 冗长,每次都要
v.begin(), v.end() - 无法组合,每步都要显式中间容器
- 强制 eager evaluation,每步都实际构造新容器
3.2 核心概念
Range = 任何有 begin() 和 end() 的东西。形式化:begin(r) 返回 iterator,end(r) 返回 sentinel(可以是 iterator,也可以是其他 sentinel 类型)。
Sentinel(哨兵)是 Ranges 的重要设计:结束标记不必与起始 iterator 同类型。例如用”遇到 \0 停止”的 sentinel 遍历 C 字符串,不需要先算长度。
View = 轻量、无拥有、复制成本 O(1) 的 range。View 只是一个适配器对象,不拥有数据,迭代时才真正做计算。
3.3 管道语法
#include <ranges>
#include <vector>
int main() {
std::vector<int> v = {1,2,3,4,5,6,7,8,9,10};
auto result = v
| std::views::filter([](int x){ return x % 2 == 0; })
| std::views::transform([](int x){ return x * 2; })
| std::views::take(3);
for (int x : result) std::cout << x << ' '; // 4 8 12
}
编译:g++ -std=c++20。管道 | 的本质是 operator| 重载:v | views::filter(f) 等价于 views::filter(v, f)。这是 Haskell/Elixir 风格的函数组合。
3.4 常用 views
| View | 作用 |
|---|---|
views::filter(pred) | 保留满足 pred 的元素 |
views::transform(f) | 对每个元素应用 f |
views::take(n) / drop(n) | 前/跳过前 n 个 |
views::take_while(pred) / drop_while(pred) | 直到/从 pred 为假 |
views::reverse | 反向 |
views::iota(start[, end]) | 无限/有限整数序列 |
views::join | 嵌套 range 拍平一层 |
views::split(delim) | 按 delim 分割 |
views::keys / values | map 取键/值 |
views::zip(C++23) | 多 range 并行 |
views::enumerate(C++23) | 带下标 |
3.5 惰性求值原理
每个 view 是”轻量对象 + 自定义 iterator”。operator++ 时才去底层 range 拿下一个元素并应用变换。filter_view 的 operator++ 大致逻辑:
auto& operator++() {
do { ++current_; }
while (current_ != end_ && !pred_(*current_));
return *this;
}
整条管道不产生任何中间容器。对 10M 元素做 filter | transform | take(5),实际只迭代到凑够 5 个匹配元素就停——短路求值 + 零中间容器。
3.6 陷阱 1:view 悬空
auto get_view() {
std::vector<int> v = {1,2,3}; // 局部变量
return v | std::views::filter([](int x){ return x > 1; });
}
// 返回值引用了已销毁的 v ——悬空
auto bad = std::vector{1,2,3} | std::views::filter(pred);
// 引用了临时 vector,下一行 vector 就析构了
View 不拥有数据,底层 range 必须比 view 活得长。C++20 引入 std::ranges::dangling 作为部分算法的返回类型提示这种情况,但并非所有接口都能检测到。
3.7 陷阱 2:filter_view 不是 const-iterable
filter_view::begin() 是非 const 且有缓存效应——第一次调用会遍历找到第一个满足的元素并记住位置。这意味着:
- 不能对 const filter_view 调用 begin
begin()不是 O(1)(是 O(n) 到第一个匹配元素)- 多次调用返回缓存值(有 side effect)
会让一些泛型代码编译失败。C++23 做了一些改进但没根本解决。
3.8 陷阱 3:新旧两套 algorithm
C++20 同时保留 std::sort(v.begin(), v.end()) 和 std::ranges::sort(v)。新版本有 projection 参数:std::ranges::sort(people, {}, &Person::age) 直接按成员排序,有 concept 约束,错误友好。但老代码/老库还在用老版本,混用时注意命名空间。
4. Coroutines:无栈协程的编译期状态机
4.1 问题背景
异步编程两种老路:回调地狱 和 手写状态机。开发者想要”写起来像同步、跑起来是异步”。Python async/await、JavaScript Promise、C# async/await、Rust async fn 都是这个方向。C++ 在 C++20 终于加入。
4.2 三个关键字
Task<int> compute() {
int a = co_await read_from_db(); // 暂停,等待
int b = co_await read_from_net(); // 暂停,等待
co_return a + b; // 协程"返回"
}
Generator<int> fib() {
int a = 0, b = 1;
while (true) {
co_yield a; // 产出值,暂停
auto t = a + b; a = b; b = t;
}
}
函数体中出现任何 co_await / co_yield / co_return,这个函数就是协程。
4.3 编译器如何转换协程
核心:每个协程函数被编译器改写成一个状态机。概念层面:
- 创建 coroutine frame:堆分配的结构体,含
promise_type实例、跨co_await存活的局部变量、状态字段、参数的拷贝 - 函数体重写为 resume 函数:顺序代码变成大 switch,case 对应 suspend point
- 每个
co_await expr插入挂起点:计算 expr 得 awaitable → 获取 awaiter →await_ready()→ 若挂起调await_suspend(handle)返回给调用者 → resume 时调await_resume()
概念伪代码:
// 原协程
Task f() {
int a = 1;
co_await x;
int b = 2;
co_await y;
co_return a + b;
}
// 改写后
struct f_frame {
promise_type promise;
int state;
int a, b; // 跨 suspend 的变量进 frame
void resume() {
switch (state) {
case 0: a = 1; state = 1; suspend_on(x); return;
case 1: b = 2; state = 2; suspend_on(y); return;
case 2: promise.return_value(a + b); return;
}
}
};
4.4 无栈 vs 有栈:C++ 为什么选无栈
| 维度 | 有栈协程(Go、Lua、Boost.Fiber) | 无栈协程(C++20、Rust、Python async) |
|---|---|---|
| 栈 | 独立栈(几 KB~几 MB) | 没独立栈,frame 存堆 |
| 切换 | 保存/恢复寄存器和 SP | 状态字段 switch |
| 代价 | 栈空间固定开销 | 堆分配 frame |
| 调用任意函数 | 可以在任何地方 suspend | 只能在协程边界 |
C++ 选无栈的理由:
- 零开销原则:不用时不付代价,有栈协程必须分配固定栈
- 与 ABI 兼容:不需要引入新的调用约定
- HALO 优化空间:无栈 frame 有机会被内联掉
- 可组合性:无栈协程可以在任何调用上下文恢复
代价:async 传染性(只能在协程边界 suspend)、写起来比有栈复杂得多。
4.5 “C++20 协程是库特性”的真正含义
C++20 只给了语言机制(关键字 + 状态机转换 + promise_type/awaiter 接口约定),标准库没有任何现成协程类型:
- 没有
std::task<T> - 没有
std::generator<T>(C++23 才加入) - 没有调度器 / 执行器
- 没有协程版 IO 库
想用协程写点东西?自己写 promise_type。这是 C++20 协程被诟病”没法直接用”的核心原因。2026 年依赖三方库:cppcoro、folly::coro、libunifex、asio::awaitable。
4.6 必须实现的接口
promise_type 必须提供:
struct promise_type {
Task get_return_object();
std::suspend_always initial_suspend();
std::suspend_always final_suspend() noexcept;
void return_value(int v); // 或 return_void()
void yield_value(int v); // 生成器需要
void unhandled_exception();
};
Awaiter 必须提供:
struct Awaiter {
bool await_ready();
void await_suspend(std::coroutine_handle<> h);
T await_resume();
};
4.7 最小生成器示例
#include <coroutine>
#include <optional>
// g++ -std=c++20 -fcoroutines
template<typename T>
struct Generator {
struct promise_type {
std::optional<T> current;
Generator get_return_object() {
return Generator{std::coroutine_handle<promise_type>::from_promise(*this)};
}
std::suspend_always initial_suspend() { return {}; }
std::suspend_always final_suspend() noexcept { return {}; }
std::suspend_always yield_value(T v) { current = v; return {}; }
void return_void() {}
void unhandled_exception() { std::terminate(); }
};
std::coroutine_handle<promise_type> h;
explicit Generator(std::coroutine_handle<promise_type> h) : h(h) {}
~Generator() { if (h) h.destroy(); }
bool next() { h.resume(); return !h.done(); }
T value() { return *h.promise().current; }
};
Generator<int> range(int a, int b) {
for (int i = a; i < b; ++i) co_yield i;
}
// 使用:auto g = range(0, 5); while (g.next()) std::cout << g.value();
4.8 陷阱
Frame 堆分配性能:每次调用协程分配一块堆内存。HALO(Heap Allocation eLision Optimization) 编译器如果能证明协程生命周期完全包含在调用者里可以省掉分配,条件苛刻(需内联、frame 大小已知)。Clang 做得比 GCC 好。实际项目中不要假设 HALO 一定发生。
悬空参数(经典陷阱):
Task<int> use_ref(const std::string& s) {
co_await something();
std::cout << s; // 危险!
}
use_ref(std::string("hello")); // 临时对象在第一次 suspend 后析构
协程参数应传值,让编译器把副本存到 frame 里。这与”引用传递更高效”的直觉相反,是协程核心坑之一。
5. Modules:告别头文件
5.1 问题:头文件的所有原罪
- 编译慢:每个
.cpp都重新预处理、重新解析所有#include - ODR 违反:同一符号在不同翻译单元被不同定义
- 宏污染:头文件的宏泄漏到整个翻译单元
- include 顺序敏感
- 循环依赖需要前向声明 workaround
- 需要
#pragma once - 模板必须放在头文件里
5.2 核心语法
// math.cppm (module interface unit)
export module math;
export int add(int a, int b) { return a + b; }
int internal_helper(int x) { return x * 2; } // 未导出 = 私有
// main.cpp
import math;
import <iostream>; // header unit 导入标准库
int main() { std::cout << add(1, 2); }
过渡期的 Global Module Fragment:
module; // 开始 global fragment
#include <vector> // 旧头文件只能放这里
export module mymod; // 模块声明后才是纯模块代码
export std::vector<int> make() { return {1,2,3}; }
5.3 与头文件的本质差异
| 维度 | 头文件 | 模块 |
|---|---|---|
| 宏传播 | 泄漏到所有 includer | 完全不传播 |
| 符号泄漏 | 所有 include 的内容可见 | 只看到 export 的 |
| 编译 | 每次重新 parse | 预编译成 BMI,一次编译到处 import |
| 顺序敏感 | 是 | 否 |
| ODR | 开发者保证 | 机制保证 |
| 模板 | 必须头文件暴露 | 可以放实现文件 |
BMI(Binary Module Interface)是每编译器私有格式(Clang .pcm、MSVC .ifc、GCC .gcm),不跨编译器。
5.4 陷阱:2026 年的生产就绪度
坏消息:
- CMake 对 Modules 的支持 2024 年才稳定,复杂项目配置依然痛苦
- 构建系统有 chicken-and-egg 问题:传统
#include可以扫描文件提取依赖,Modules 需要先编译一个.cppm才知道导出什么 - 第三方库绝大多数还在用头文件
- IDE/LSP 对模块的支持比头文件弱
- 各编译器实现程度不一(Clang 最完整,MSVC 跟进,GCC 较晚)
2026 年现状:modules 可以新项目小范围试用,大型团队迁移还没到时候。这是个”未来特性”,学了可能两三年才真正用上。
6. 辅助特性(重要但非”四大件”)
6.1 consteval / constinit
consteval int square(int x) { return x * x; } // 必须编译期
constexpr int a = square(5); // OK
// int b = square(rand()); // 错误
constinit int counter = 0; // 初始化必须编译期(消除 static init order fiasco)
constexpr:可编译期也可运行期consteval:必须编译期constinit:初始化必须编译期,但变量非 const
6.2 三路比较 <=>(spaceship operator)
#include <compare>
struct Point {
int x, y;
auto operator<=>(const Point&) const = default; // 自动生成所有比较
};
返回类型:strong_ordering(int 的比较)、weak_ordering(忽略大小写的字符串)、partial_ordering(NaN 浮点数)。一次声明合成 < <= > >= == !=,消除过去写 6 个比较运算符的样板。
6.3 std::span<T>
void print(std::span<const int> s) { for (int x : s) std::cout << x; }
int arr[] = {1,2,3};
std::vector<int> v = {4,5,6};
print(arr); // C 数组
print(v); // vector
print({v.data(), 2}); // 子区间
连续内存 + 长度的非拥有视图,替代 T* + size_t 原始模式。零开销、类型安全。C++ Core Guidelines gsl::span 的标准化。
6.4 std::format
#include <format>
std::string s = std::format("x={}, y={:.2f}", 1, 3.14159);
// "x=1, y=3.14"
Python f-string 风格,类型安全。兼容性陷阱:GCC 13 才完整支持,Clang 14+ 部分,老环境还得用 fmt 库。
6.5 std::jthread
void worker(std::stop_token tok) {
while (!tok.stop_requested()) { /* ... */ }
}
std::jthread t(worker); // 析构时自动 request_stop + join
两大改进:析构自动 join(不会像 std::thread 那样 terminate)+ 协作取消。新代码的默认线程类型。
6.6 其他
std::atomic_ref<T>:把非 atomic 对象当原子访问std::atomic<std::shared_ptr<T>>:shared_ptr 原子操作终于标准化- designated initializers
{.x=1, .y=2}:从 C99 借来,C++ 要求顺序与声明一致
7. 陷阱汇总
| 陷阱 | 为什么是坑 |
|---|---|
| Coroutine frame 堆分配 | 频繁调用有开销,HALO 不可依赖 |
| 协程参数是引用 → suspend 后悬空 | 必须传值 |
| Ranges view 悬空 | 不拥有数据,小心临时 range |
filter_view 非 const-iterable | 缓存效应 |
std::format 与编译器兼容 | GCC 13 前不完整 |
| Modules 生产就绪度 | 2026 年仍是未来特性 |
| Concept 误用 | 把实现细节(has_reserve)当概念 |
| Subsumption 规则复杂 | 拆散的 trait 无法推导包含关系 |
| 两套 algorithm | std::sort vs std::ranges::sort 命名空间歧义 |
8. 关键信息来源
- cppreference.com——API 权威
- Eric Niebler, range-v3 库——ranges 的原型
- Lewis Baker, cppcoro——C++20 协程的社区事实标准
- 提案文档:P0734(Concepts)、P0896(Ranges TS)、N4775(Coroutines TS)、P1103(Modules)
- Nicolai Josuttis, C++20 - The Complete Guide
- Rainer Grimm, C++20
置信度说明:
- Concepts、Ranges、辅助特性的语法细节——确定(C++20 标准稳定多年)
- Coroutines 的 promise_type/awaiter 接口——确定
- Modules 的 2026 生产就绪度描述——需验证(建议查 cppreference compiler support 表和 CMake release notes)
std::format的 GCC 版本要求——需验证- HALO 优化的编译器实现程度——需验证,Clang 比 GCC 好是社区共识但细节在变化