📑 C++ 演进系列总览 | C++11 | C++14 | C++17 | C++20 | C++23 | C++26

C++26 已于 2025 年年中完成特性冻结(feature freeze),预计 2026 年正式发布。它继续强化元编程、安全性、并发和标准库表达能力。下面逐个特性展开,讲清它解决的问题、背后的设计以及使用时要注意的语义。

静态反射(Static Reflection)

C++26 引入了基于元对象(metaobject)的编译期反射(P2996),可以在编译期查询类型、成员、枚举、基类等元信息,并用 splice 把反射信息重新变回代码。这是 C++ 长期以来最被期待的特性之一。

为什么需要它。 C++ 此前的反射要么依赖宏 + 代码生成(反射库要求手写 X(T) 宏列表,每加一个字段就要改一次宏),要么依赖 Clang 的 AST 接口或 RTTR 等非标准运行期方案。前者侵入式、易漏写;后者要么不可移植,要么引入运行期开销与类型擦除。反射成为语言原生能力后,“枚举转字符串”“结构体序列化”"按字段名访问"这类需求可以在编译期零成本完成,且类型一旦改动代码自动跟上。

核心三件套。

  • ^^Type / ^^expression:对类型或值取反射,得到一个 std::meta::info 常量。info 是编译期"句柄",代表被反射的实体(类型、成员、枚举值等),它本身是个平凡的类型。
  • [:info:]:splice,把 info 重新"粘回"成代码——可以是类型、值或成员名,从而在编译期动态地"写出"类型与访问。这是反射与生成代码之间的桥梁。
  • std::meta 命名空间:对 info 进行查询的函数,如 members_ofnonstatic_data_members_ofenumerators_oftype_ofidentifier_ofbases_ofhas_template_arguments 等。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
#include <meta>
#include <string_view>

// 枚举转字符串——以前要手写 switch 或宏,现在纯编译期完成
template <typename E>
constexpr std::string_view enum_to_string(E value)
requires std::is_enum_v<E>
{
template for (constexpr auto e : std::meta::enumerators_of(^^E)) {
if (value == [:e:]) {
return std::meta::identifier_of(e);
}
}
return "<unknown>";
}

enum class Color { Red, Green, Blue };

static_assert(enum_to_string(Color::Red) == "Red");

关于 template for 它叫 expansion statement,配合反射尤为关键:在编译期对 info 序列逐项展开循环体,每次迭代 e 都是不同的编译期常量,[:e:] 才能把每个枚举值 splice 出来。普通的 for 做不到这件事——它的循环变量不是常量表达式,无法用于 splice。换句话说,template for 是"在循环体里写编译期代码"的专用语法,相当于把一串手写的特化自动展开。

更实用的例子:反射式序列化。 自动遍历结构体的非静态数据成员,按字段名生成输出,无需为每个结构体手写代码。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
#include <meta>
#include <string>

struct Point { int x; int y; double weight; };

// 遍历非静态数据成员,反射式地访问
template <typename T>
std::string to_string(const T& obj) {
std::string s = "{";
bool first = true;
template for (constexpr auto m : std::meta::nonstatic_data_members_of(^^T)) {
if (!first) s += ", ";
first = false;
s += std::meta::identifier_of(m); // 字段名 "x"
s += "=";
s += std::to_string(obj.[:m:]); // obj.[:m:] 动态访问成员
}
s += "}";
return s;
}

// to_string(Point{1, 2, 3.5}) == "{x=1, y=2, weight=3.500000}"

obj.[:m:] 是 splice 在成员访问中的用法:m 是代表某个数据成员的 info,splice 后等价于写出该成员名。这样无需宏、无需为每个结构体维护一份字段列表——类型一旦改动,反射代码自动跟上。结合 static_assert 还能在编译期对类型结构做校验,例如断言某个类恰有 N 个成员、某个成员是 int 等,把"接口约定"提升为编译期检查。

注意点。 反射查询返回的 info 序列按声明顺序;私有成员默认也会被 nonstatic_data_members_of 返回,访问时需注意封装边界。反射目前主要面向编译期,运行期反射(如通过字符串字段名访问)仍需自行在编译期建立映射表。

契约(Contracts)

契约式编程原生化(P2900)。函数可以在签名处声明 pre(前置条件)和 post(后置条件),函数体内用 contract_assert 做断言。

它的价值不在"多一种 assert",而在于把调用约定写进函数签名。 "调用方必须保证什么、被调用方承诺什么"这类约定,过去只能埋在注释或运行期 assert 里——注释会过时,assertNDEBUG 下被去掉。契约把它提升为接口的一部分,让编译器、静态分析工具和代码阅读者都能看到,并可在多种检查级别下统一管理。

1
2
3
4
5
6
7
8
int divide(int a, int b)
pre(a >= 0) // 前置条件:a 必须非负
pre(b != 0) // 前置条件:b 不能为 0
post(r: r >= 0) // 后置条件:结果非负
{
contract_assert(a < 1000); // 断言
return a / b;
}

语义要点。

  • post(r: ...) 中的 r 是给返回值起的名字,后置条件里用它引用函数结果;多个后置条件可分别命名或共用同一名字。
  • 契约是接口的一部分:前置条件由调用方负责,后置条件由被调用方承诺。违反契约意味着程序员的 bug,而非可预期的运行错误——因此不要用契约去处理正常的失败路径(如文件未找到、解析失败),那应交给 std::expected 或异常。
  • 默认行为是违反契约时调用 std::terminate()(与 assert 类似)。可通过安装 violation handler 自定义处理(如记录日志后继续或终止),由实现提供 ABI 入口,整个程序共享一个处理函数。

old(expr) 表达"变化量"约定。 后置条件常需引用函数入口时的旧值,例如"调用后大小恰好 +1"。

1
2
3
4
5
6
7
// 后置条件引用入口时的旧值,表达"变化量"约定
void push_back(std::vector<int>& v, int x)
pre(v.size() < v.max_size()) // 调用前容器未满
post(v.size() == old(v.size()) + 1) // 调用后大小恰好 +1
{
v.push_back(x);
}

old(expr) 在函数入口处求值并缓存,在后置条件检查时使用,便于表达"调用前后状态差异"这类约定。注意 old 只能引用入口时已存在的状态,不能引用函数内新创建的局部对象。

assert 的区别。 契约默认在发布构建中仍可保留(由实现开关控制检查级别),而 assertNDEBUG 下会被完全去掉。C++26 最终方案放弃了早期提案中的 audit/default/axiom 三级检查模型,统一为可由实现控制的单一检查级别,简化了语义。契约不应有副作用——它的求值结果只用于判断真伪,实现可能在关闭检查时根本不求值。

Pack Indexing

直接通过下标访问参数包的第 N 个元素(P2663),无需递归展开或 std::tuple_element

解决的问题。 此前要从 Ts... 取第 i 个类型,只能写递归模板特化(head/tail 一层层剥),或借助 std::tuple_element 绕一层,代码冗长且可读性差。Pack Indexing 让它像数组下标一样直观,Ts...[N] 直接得到第 N 个类型,Values...[N] 直接得到第 N 个值。

1
2
3
4
5
6
7
8
9
10
11
12
// 获取参数包的第 i 个类型/值
template <typename... Ts>
using first_t = Ts...[0];

template <typename... Ts>
constexpr std::size_t count = sizeof...(Ts);

template <auto... Values>
constexpr auto first_value = Values...[0];

static_assert(std::is_same_v<first_t<int, double, char>, int>);
static_assert(first_value<1, 2, 3> == 1);

负下标与边界。 下标可以是常量表达式,负数从末尾倒数——Ts...[-1] 取最后一个类型,在写"取包尾类型"的 trait 时特别方便,不必再写递归。

1
2
3
4
template <typename... Ts>
using last_t = Ts...[-1];

static_assert(std::is_same_v<last_t<int, double, char>, char>);

类型包与值包都支持。 Ts...[N] 得到类型(可用于 using 别名或作模板实参),Values...[N] 得到值。下标越界是编译期错误,而非未定义行为——错误在编译阶段就被挡住。配合 sizeof...(Ts) 可在编译期做边界判断后再取下标,避免越界。这一特性大大简化了变参模板的元编程,许多过去需要几十行递归的 trait 现在一行就能写完。

= delete("reason")

删除函数时可以附带诊断信息(P2573)。

解决的问题。 = delete 用于禁用某些函数(如禁止拷贝、禁止特定参数类型的隐式转换重载),但被删除的函数一旦被调用,编译器只报"deleted function"之类泛泛的错误,不告诉用户为什么删除。delete("reason") 让删除原因直接进入诊断信息,使用者立刻明白意图,而不必去翻文档或源码注释。

1
2
3
4
5
6
struct Handle {
Handle(const Handle&) = delete("Handle 不可复制:持有独占资源");
Handle& operator=(const Handle&) = delete("Handle 不可赋值:持有独占资源");
};

// Handle h2 = h1; // 编译错误:Handle 不可复制:持有独占资源

用途不止于禁用拷贝。 删除特定重载是阻止危险隐式转换的常用手段,配上原因后错误信息更有指导性:

1
2
3
4
5
struct Logger {
// 阻止以指针为 0/1 的隐式转换误调用
void log(long) = delete("请传入字符串,不要传指针转成的整数");
void log(std::string_view msg);
};

reason 是字符串字面量,由编译器在诊断中原样输出,本身不参与类型系统。它是纯文档性的增强,无运行期影响,却能让库的接口意图直接传达给调用者,减少误用与排障成本。

结构化绑定改进

C++26 允许在结构化绑定中使用属性和 _ 占位符(P0963/P2169)。

解决的问题。 此前 auto [x, y] = pair 必须为每个元素命名,即使只关心其中一个也得起个名字,容易触发"未使用变量"警告,也污染命名空间。_ 占位符表示"忽略这个位置",属性则可用于修饰整个绑定(如 [[nodiscard]] 提醒不要丢弃结果)。

1
2
3
4
5
6
7
8
9
std::pair<int, int> p{1, 2};

[[nodiscard]] auto [x, _] = p; // 忽略第二个元素
// x = 1

std::map<int, std::string> m;
if (auto [it, ok] = m.insert({1, "one"}); ok) {
// 在 if 条件中使用结构化绑定(C++26 进一步放宽)
}

_ 的语义。 它不是普通变量名,而是语言层面的占位符:同一个作用域内可以多次使用 _ 忽略多个位置,互不冲突,也不产生"重复声明"错误。这在解构返回多值的函数时很实用——只取关心的字段,其余用 _ 略过。属性方面,C++26 放宽了结构化绑定上属性的适用范围,使 [[maybe_unused]][[nodiscard]] 等能正确作用于绑定整体,避免误报警告或误丢结果。

std::execution / Sender-Receiver

标准库引入统一的异步执行模型(P2300 std::execution)。

解决的根本问题。 C++ 异步生态长期碎片化:std::asyncstd::thread、各厂商运行时(TBB、HPX、ASIO)各有各的抽象,互不兼容、无法组合。把一个线程池的任务结果接到另一个执行上下文,往往要手写 future 适配。Sender-Receiver 提供一套可组合的异步原语,作为未来异步 C++ 的统一底座:发送器(sender)描述"将来会产生数据并完成",接收器(receiver)消费结果,二者用 | 管道串成有向无环图,由调度器(scheduler)决定在哪个执行上下文运行。

1
2
3
4
5
6
7
8
9
10
11
12
13
#include <execution>
#include <iostream>

namespace ex = std::execution;

int main() {
// 构造一个发送器链:产生 42,然后打印
auto snd = ex::just(42)
| ex::then([](int v) { std::cout << v << "\n"; });

// 同步等待完成
ex::sync_wait(std::move(snd));
}

几个关键设计。

  • 惰性just(42) | then(...) 只是构造了一个发送器对象,什么都没执行——直到有接收器订阅(如 sync_wait)才真正启动。这与 std::async 立即启动形成对比,使异步逻辑可组合、可调度、可取消,而不产生意外的并发。
  • | 管道thenupon_errorlet_valuesplitwhen_all 等适配器像 ranges 视图一样链式组合,没有深层回调嵌套(callback hell)。
  • 错误与取消是一等公民:发送器有"值通道"“错误通道”"完成通道"三路,upon_error 处理异常,stop_token 支持协作取消——异步失败不再被默默吞掉。

跨执行上下文迁移是它的拿手好戏。transfer 在调度器间切换而不阻塞,把"在哪个线程跑"从代码逻辑中解耦:

1
2
3
4
5
6
// pool.get_scheduler() 返回某个执行上下文(如线程池)的调度器
auto work = ex::just(42)
| ex::transfer(pool.get_scheduler()) // 切到线程池执行后续
| ex::then([](int v) { return v * 2; }) // 在线程池上计算
| ex::transfer(ex::inline_scheduler{}); // 切回内联
ex::sync_wait(std::move(work));

when_all 可并行组合多个发送器,等待全部完成——这是构建任务 DAG 的基础。整个模型是 C++26 中体量最大、影响最深的标准库新增:它不强迫你抛弃现有运行时,而是提供一套通用协议,让库作者能在其上实现线程池、IO 运行时等,最终用户则用统一的 sender 组合语法编写可移植的异步代码。

std::simd

标准 SIMD 类型(P1928),把"用 CPU 向量寄存器一次处理多个数据"的数据并行计算纳入标准库。

解决的问题。 数据并行计算(如图像处理、科学计算、音频)依赖 SIMD 指令一次处理多个数据。此前要么靠编译器自动向量化(不可控,换个写法就向量化失败),要么手写 intrinsic(_mm_add_ps 等,不可移植、绑定具体指令集,x86 与 ARM 代码完全不同)。std::simd<T> 抽象掉指令集差异,同一段代码在 x86 AVX、ARM NEON 上都能向量化,读起来还像普通标量代码。

1
2
3
4
5
6
7
8
9
#include <simd>

float data1[] = {1.0f, 2.0f, 3.0f, 4.0f};
float data2[] = {5.0f, 6.0f, 7.0f, 8.0f};
std::simd<float> a(data1, std::element_aligned); // 从内存加载
std::simd<float> b(data2, std::element_aligned);
std::simd<float> c = a + b; // 向量化加法

std::cout << c[0] << "\n"; // 6

宽度的选择。

  • std::simd<T>:宽度由实现决定,映射到目标平台原生寄存器宽度(如 4 个 float 或 8 个),是最常用的"够用就好"选择。
  • std::native_simd<T>:显式取原生宽度,语义更明确。
  • std::fixed_size_simd<T, N>:固定 N 个元素,不足一个寄存器时填充,超出时分多个寄存器——需要可移植的固定布局时用。

加载/存储与对齐。 通过 copy_to / copy_from 配对齐标签完成,std::element_aligned(元素对齐)最常用,另有 vector_alignedoveraligned<N> 表达更强的对齐保证,帮助生成更高效的加载指令(对齐的加载通常更快)。算术运算符(+-*)逐通道作用,写法和标量代码几乎一样——这正是它的目标:让向量化代码读起来像普通循环。

1
2
3
4
// 逐通道条件选择:where(mask, true_val, false_val)
std::simd<float> x, y;
auto mask = x > y; // 逐通道比较,得到 mask
std::simd<float> max = std::where(mask, x, y); // 逐通道取较大值

where 配合比较产生的 mask 做逐通道选择,是写分支-free 向量化代码的常用模式,比手写 blend 指令清晰得多。对长度不是寄存器宽度整数倍的循环,通常用主循环处理整块、标量尾循环处理余数,std::simd 让这两段都能写得干净。

views::concat

将多个范围连接成一个视图(P2542)。

解决的问题。 把多个序列"首尾相连"遍历,过去要么先把数据拷进一个容器(有开销、改动原数据),要么手写迭代器适配(繁琐)。views::concat 把任意多个范围惰性拼成一个视图,不拷贝、不分配,按顺序遍历即可。它要求各范围的元素类型可公共引用(common reference),通常要求相同或可转换。

1
2
3
4
5
6
7
8
9
10
#include <ranges>
#include <vector>
#include <list>

std::vector<int> v{1, 2, 3};
std::list<int> l{4, 5, 6};

for (int x : std::views::concat(v, l)) {
std::cout << x << " "; // 1 2 3 4 5 6
}

惰性与组合。 作为视图它是惰性的——只在遍历时才逐个读取底层范围,不分配中间容器。可以和其它视图管道组合,例如 concat(v, l) | views::filter(...) | views::transform(...),整条链一次遍历完成。元素类型不同的范围只要能找到公共引用类型即可拼接(如 intlong 拼成 long 视图)。当所有底层范围都满足 common_range(begin/end 类型相同)且是随机访问时,concat 视图也具备相应性质,可随机访问下标。

std::span::at

带边界检查的 span 元素访问。

解决的问题。 span::operator[] 不做边界检查,越界是未定义行为——在 Release 下可能静默读越界内存,难以排查。span::at 在越界时抛出 std::out_of_range,把错误变成可定位的异常,适合在不可信输入或调试阶段使用。这与 vector::at / array::at 的设计一致,补齐了 span 的边界检查接口。

1
2
3
4
5
6
7
#include <span>

int arr[] = {10, 20, 30};
std::span<int> s{arr};

std::cout << s.at(0) << "\n"; // 10
// s.at(10); // 抛出 std::out_of_range

何时用 at vs [] 性能敏感的热路径、已通过其它方式保证下标合法时用 [] 避免检查开销;处理外部输入、下标来源不可信或调试时用 at,把潜在越界从"未定义行为"变成"明确异常"。at 的检查有少量运行期开销,但换来的是确定性和可调试性,是安全性与性能之间的显式权衡开关。

饱和算术(Saturation Arithmetic)

溢出时进行钳位(clamp),而不是回绕(wrap-around)(P0543)。

解决的问题。 无符号整数溢出回绕、有符号溢出是未定义行为,两者都可能在信号处理、图像像素、音频采样等场景产生严重 bug——例如像素值 200 + 100 回绕成 44 而非钳到 255,导致颜色突变。饱和算术让运算结果在溢出时停在类型的上下界,语义明确且符合许多领域(DSP、图像、定点运算)的实际需求。

1
2
3
4
5
6
#include <numeric>

unsigned char a = 200;
unsigned char b = 100;
auto c = std::add_sat(a, b); // 255(饱和到最大值)
auto d = std::sub_sat(a, b); // 100

提供的函数族。 add_satsub_satmul_satdiv_sat 分别对应四则运算的饱和版本,还有 saturate_cast<T>(x) 把值转换到目标类型范围并钳位。它们是 constexpr,可在编译期使用;对有符号类型同样适用,负溢出饱和到最小值。在需要精确控制溢出行为的代码中(如定点数运算、色彩空间转换),用饱和算术替代手写 if 钳位,既清晰又不易出错。

std::breakpoint

标准调试断点支持(P2513)。

解决的问题。 触发调试器断点过去依赖平台相关手段——x86 上内嵌 __asm__ int 3、Windows 上 __debugbreak()、各平台不一致且不可移植。std::breakpoint 提供标准化的"在此处停下调试器"原语,跨平台一致。

1
2
3
4
5
6
7
8
9
10
#include <debugging>

void inspect(int value) {
if (value < 0) {
std::breakpoint(); // 调试器在此处中断
}
}

// 仅在调试器附加时才中断
std::breakpoint_if_debugging();

两个函数的区别。 std::breakpoint() 无条件触发:若有调试器附加则中断,无调试器时行为由实现定义(通常忽略或引发陷阱)。std::breakpoint_if_debugging() 只在检测到调试器附加时才中断,否则空操作——适合留在生产代码里做条件调试,不会影响正常运行。它们是无副作用的调试辅助,正式发布时可按需保留 breakpoint_if_debugging 而不影响性能。

Hazard Pointers

无锁并发中安全回收对象的机制(P2530)。

解决的问题。 无锁数据结构(如无锁栈、队列)中,一个线程从链表摘下节点后,其它线程可能仍持有该节点指针在读——若直接释放,会触发 use-after-free;若不释放,则内存泄漏(经典 ABA 与回收难题)。Hazard Pointer 的思路是:读者在访问共享节点前,先把这个指针"登记"到一个全局可见的 hazard 槽,写者回收节点前扫描所有 hazard 槽,凡是被登记的节点就延迟回收,从而保证读者访问期间对象不会被释放。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#include <hazard_pointer>

struct Node {
int value;
std::atomic<Node*> next;
};

std::atomic<Node*> head;

Node* read() {
std::hazard_pointer hp = std::make_hazard_pointer();
Node* curr = hp.protect(head); // 原子加载并保护当前值
// 使用 curr 期间对象不会被回收
return curr;
}

工作流程。 make_hazard_pointer() 获取一个 hazard 槽(通常线程局部,避免争用);hp.protect(head) 原子地加载 head 并写入 hazard 槽,通过重试保证读到的一致(防止加载与登记之间指针被换走);读者使用 curr 期间,写者即使替换了 head 也不会回收被保护的节点;hp 析构时释放槽位。回收时机由定期的清扫阶段决定,扫描 hazard 槽后回收未被保护的 retired 节点。相比 RCU,hazard pointer 保护粒度更精确(单个指针),开销更可预测,适合节点生命周期短、读者多的场景。

User-space RCU

用户空间读-拷贝-更新机制(P2545),适合读多写少场景。

解决的问题。 与 hazard pointer 解决同一类问题(无锁数据结构的对象安全回收),但策略不同。RCU(Read-Copy-Update)的核心是:读者完全不加锁、不登记,极速读;写者先构造新版本,原子替换指针,旧版本延迟到"所有读者都退出宽限期"后才回收。这使读路径几乎零开销,代价是写路径较重、回收有延迟。适合读远多于写、且能容忍旧数据短暂存在的场景(如配置表、路由表)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
#include <rcu>

struct Node : std::rcu_obj_base<Node> {
int value;
std::atomic<Node*> next;
};

std::atomic<Node*> global_ptr;

// 读者
void reader() {
std::rcu_reader guard;
Node* p = global_ptr.load(std::memory_order_acquire);
// 在 guard 生命周期内安全读取 p
}

// 写者
void writer(Node* new_node) {
Node* old = global_ptr.exchange(new_node, std::memory_order_acq_rel);
old->retire(); // 异步、安全地回收旧节点
}

读者与写者的协作。 读者进入临界区前获取 rcu_reader guard(标记自己处于读侧),读 global_ptr 后在 guard 生命周期内安全使用;guard 析构即退出读侧。写者用 exchange 原子替换指针,然后对旧节点调用 retire()——它不会立即释放,而是登记到回收队列,等所有在读宽限期内的读者都退出后才真正回收。rcu_obj_base 提供了 retire 的钩子(可自定义释放逻辑,默认 delete)。

RCU vs Hazard Pointer。 RCU 读路径更轻(无登记开销),但回收有延迟、写路径需要追踪宽限期;hazard pointer 保护精确、回收及时,但每次读都要登记指针。读极多写极少选 RCU,读写较均衡或需要及时回收选 hazard pointer。两者都是 C++26 并发库的重要补充,让无锁数据结构终于有标准化的内存回收方案。

常用特性总结

最常用的 C++26 特性:

  • 静态反射(std::meta
  • 契约(Contracts)
  • Pack Indexing
  • std::execution / sender-receiver
  • std::simd
  • views::concat
  • 饱和算术
  • std::span::at