跳转到正文
zeno's blog
返回

现代 C++(三):Concepts、Ranges、Coroutines、Modules 如何重定义 C++

专题: 现代 C++

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));

三个痛点:

  1. iterator pair 冗长,每次都要 v.begin(), v.end()
  2. 无法组合,每步都要显式中间容器
  3. 强制 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 / valuesmap 取键/值
views::zip(C++23)多 range 并行
views::enumerate(C++23)带下标

3.5 惰性求值原理

每个 view 是”轻量对象 + 自定义 iterator”。operator++ 时才去底层 range 拿下一个元素并应用变换。filter_viewoperator++ 大致逻辑:

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 且有缓存效应——第一次调用会遍历找到第一个满足的元素并记住位置。这意味着:

  1. 不能对 const filter_view 调用 begin
  2. begin() 不是 O(1)(是 O(n) 到第一个匹配元素)
  3. 多次调用返回缓存值(有 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 编译器如何转换协程

核心:每个协程函数被编译器改写成一个状态机。概念层面:

  1. 创建 coroutine frame:堆分配的结构体,含 promise_type 实例、跨 co_await 存活的局部变量、状态字段、参数的拷贝
  2. 函数体重写为 resume 函数:顺序代码变成大 switch,case 对应 suspend point
  3. 每个 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++ 选无栈的理由:

  1. 零开销原则:不用时不付代价,有栈协程必须分配固定栈
  2. 与 ABI 兼容:不需要引入新的调用约定
  3. HALO 优化空间:无栈 frame 有机会被内联掉
  4. 可组合性:无栈协程可以在任何调用上下文恢复

代价:async 传染性(只能在协程边界 suspend)、写起来比有栈复杂得多。

4.5 “C++20 协程是库特性”的真正含义

C++20 只给了语言机制(关键字 + 状态机转换 + promise_type/awaiter 接口约定),标准库没有任何现成协程类型

想用协程写点东西?自己写 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 问题:头文件的所有原罪

  1. 编译慢:每个 .cpp 都重新预处理、重新解析所有 #include
  2. ODR 违反:同一符号在不同翻译单元被不同定义
  3. 宏污染:头文件的宏泄漏到整个翻译单元
  4. include 顺序敏感
  5. 循环依赖需要前向声明 workaround
  6. 需要 #pragma once
  7. 模板必须放在头文件里

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 年的生产就绪度

坏消息

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)

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 其他


7. 陷阱汇总

陷阱为什么是坑
Coroutine frame 堆分配频繁调用有开销,HALO 不可依赖
协程参数是引用 → suspend 后悬空必须传值
Ranges view 悬空不拥有数据,小心临时 range
filter_view 非 const-iterable缓存效应
std::format 与编译器兼容GCC 13 前不完整
Modules 生产就绪度2026 年仍是未来特性
Concept 误用把实现细节(has_reserve)当概念
Subsumption 规则复杂拆散的 trait 无法推导包含关系
两套 algorithmstd::sort vs std::ranges::sort 命名空间歧义

8. 关键信息来源

置信度说明:


分享这篇文章:

上一篇
Go 基础:正则表达式、自动机与 regexp 的线性时间保证
下一篇
Go 基础:interface 与 first-class function 如何消解 GoF 模式