C++26 - 下一个里程碑
📑 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_of、nonstatic_data_members_of、enumerators_of、type_of、identifier_of、bases_of、has_template_arguments等。
1 | |
关于 template for。 它叫 expansion statement,配合反射尤为关键:在编译期对 info 序列逐项展开循环体,每次迭代 e 都是不同的编译期常量,[:e:] 才能把每个枚举值 splice 出来。普通的 for 做不到这件事——它的循环变量不是常量表达式,无法用于 splice。换句话说,template for 是"在循环体里写编译期代码"的专用语法,相当于把一串手写的特化自动展开。
更实用的例子:反射式序列化。 自动遍历结构体的非静态数据成员,按字段名生成输出,无需为每个结构体手写代码。
1 | |
obj.[:m:] 是 splice 在成员访问中的用法:m 是代表某个数据成员的 info,splice 后等价于写出该成员名。这样无需宏、无需为每个结构体维护一份字段列表——类型一旦改动,反射代码自动跟上。结合 static_assert 还能在编译期对类型结构做校验,例如断言某个类恰有 N 个成员、某个成员是 int 等,把"接口约定"提升为编译期检查。
注意点。 反射查询返回的 info 序列按声明顺序;私有成员默认也会被 nonstatic_data_members_of 返回,访问时需注意封装边界。反射目前主要面向编译期,运行期反射(如通过字符串字段名访问)仍需自行在编译期建立映射表。
契约(Contracts)
契约式编程原生化(P2900)。函数可以在签名处声明 pre(前置条件)和 post(后置条件),函数体内用 contract_assert 做断言。
它的价值不在"多一种 assert",而在于把调用约定写进函数签名。 "调用方必须保证什么、被调用方承诺什么"这类约定,过去只能埋在注释或运行期 assert 里——注释会过时,assert 在 NDEBUG 下被去掉。契约把它提升为接口的一部分,让编译器、静态分析工具和代码阅读者都能看到,并可在多种检查级别下统一管理。
1 | |
语义要点。
post(r: ...)中的r是给返回值起的名字,后置条件里用它引用函数结果;多个后置条件可分别命名或共用同一名字。- 契约是接口的一部分:前置条件由调用方负责,后置条件由被调用方承诺。违反契约意味着程序员的 bug,而非可预期的运行错误——因此不要用契约去处理正常的失败路径(如文件未找到、解析失败),那应交给
std::expected或异常。 - 默认行为是违反契约时调用
std::terminate()(与assert类似)。可通过安装 violation handler 自定义处理(如记录日志后继续或终止),由实现提供 ABI 入口,整个程序共享一个处理函数。
old(expr) 表达"变化量"约定。 后置条件常需引用函数入口时的旧值,例如"调用后大小恰好 +1"。
1 | |
old(expr) 在函数入口处求值并缓存,在后置条件检查时使用,便于表达"调用前后状态差异"这类约定。注意 old 只能引用入口时已存在的状态,不能引用函数内新创建的局部对象。
与 assert 的区别。 契约默认在发布构建中仍可保留(由实现开关控制检查级别),而 assert 在 NDEBUG 下会被完全去掉。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 | |
负下标与边界。 下标可以是常量表达式,负数从末尾倒数——Ts...[-1] 取最后一个类型,在写"取包尾类型"的 trait 时特别方便,不必再写递归。
1 | |
类型包与值包都支持。 Ts...[N] 得到类型(可用于 using 别名或作模板实参),Values...[N] 得到值。下标越界是编译期错误,而非未定义行为——错误在编译阶段就被挡住。配合 sizeof...(Ts) 可在编译期做边界判断后再取下标,避免越界。这一特性大大简化了变参模板的元编程,许多过去需要几十行递归的 trait 现在一行就能写完。
= delete("reason")
删除函数时可以附带诊断信息(P2573)。
解决的问题。 = delete 用于禁用某些函数(如禁止拷贝、禁止特定参数类型的隐式转换重载),但被删除的函数一旦被调用,编译器只报"deleted function"之类泛泛的错误,不告诉用户为什么删除。delete("reason") 让删除原因直接进入诊断信息,使用者立刻明白意图,而不必去翻文档或源码注释。
1 | |
用途不止于禁用拷贝。 删除特定重载是阻止危险隐式转换的常用手段,配上原因后错误信息更有指导性:
1 | |
reason 是字符串字面量,由编译器在诊断中原样输出,本身不参与类型系统。它是纯文档性的增强,无运行期影响,却能让库的接口意图直接传达给调用者,减少误用与排障成本。
结构化绑定改进
C++26 允许在结构化绑定中使用属性和 _ 占位符(P0963/P2169)。
解决的问题。 此前 auto [x, y] = pair 必须为每个元素命名,即使只关心其中一个也得起个名字,容易触发"未使用变量"警告,也污染命名空间。_ 占位符表示"忽略这个位置",属性则可用于修饰整个绑定(如 [[nodiscard]] 提醒不要丢弃结果)。
1 | |
_ 的语义。 它不是普通变量名,而是语言层面的占位符:同一个作用域内可以多次使用 _ 忽略多个位置,互不冲突,也不产生"重复声明"错误。这在解构返回多值的函数时很实用——只取关心的字段,其余用 _ 略过。属性方面,C++26 放宽了结构化绑定上属性的适用范围,使 [[maybe_unused]]、[[nodiscard]] 等能正确作用于绑定整体,避免误报警告或误丢结果。
std::execution / Sender-Receiver
标准库引入统一的异步执行模型(P2300 std::execution)。
解决的根本问题。 C++ 异步生态长期碎片化:std::async、std::thread、各厂商运行时(TBB、HPX、ASIO)各有各的抽象,互不兼容、无法组合。把一个线程池的任务结果接到另一个执行上下文,往往要手写 future 适配。Sender-Receiver 提供一套可组合的异步原语,作为未来异步 C++ 的统一底座:发送器(sender)描述"将来会产生数据并完成",接收器(receiver)消费结果,二者用 | 管道串成有向无环图,由调度器(scheduler)决定在哪个执行上下文运行。
1 | |
几个关键设计。
- 惰性:
just(42) | then(...)只是构造了一个发送器对象,什么都没执行——直到有接收器订阅(如sync_wait)才真正启动。这与std::async立即启动形成对比,使异步逻辑可组合、可调度、可取消,而不产生意外的并发。 |管道:then、upon_error、let_value、split、when_all等适配器像 ranges 视图一样链式组合,没有深层回调嵌套(callback hell)。- 错误与取消是一等公民:发送器有"值通道"“错误通道”"完成通道"三路,
upon_error处理异常,stop_token支持协作取消——异步失败不再被默默吞掉。
跨执行上下文迁移是它的拿手好戏。 用 transfer 在调度器间切换而不阻塞,把"在哪个线程跑"从代码逻辑中解耦:
1 | |
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 | |
宽度的选择。
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_aligned、overaligned<N> 表达更强的对齐保证,帮助生成更高效的加载指令(对齐的加载通常更快)。算术运算符(+、-、*)逐通道作用,写法和标量代码几乎一样——这正是它的目标:让向量化代码读起来像普通循环。
1 | |
where 配合比较产生的 mask 做逐通道选择,是写分支-free 向量化代码的常用模式,比手写 blend 指令清晰得多。对长度不是寄存器宽度整数倍的循环,通常用主循环处理整块、标量尾循环处理余数,std::simd 让这两段都能写得干净。
views::concat
将多个范围连接成一个视图(P2542)。
解决的问题。 把多个序列"首尾相连"遍历,过去要么先把数据拷进一个容器(有开销、改动原数据),要么手写迭代器适配(繁琐)。views::concat 把任意多个范围惰性拼成一个视图,不拷贝、不分配,按顺序遍历即可。它要求各范围的元素类型可公共引用(common reference),通常要求相同或可转换。
1 | |
惰性与组合。 作为视图它是惰性的——只在遍历时才逐个读取底层范围,不分配中间容器。可以和其它视图管道组合,例如 concat(v, l) | views::filter(...) | views::transform(...),整条链一次遍历完成。元素类型不同的范围只要能找到公共引用类型即可拼接(如 int 与 long 拼成 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 | |
何时用 at vs []。 性能敏感的热路径、已通过其它方式保证下标合法时用 [] 避免检查开销;处理外部输入、下标来源不可信或调试时用 at,把潜在越界从"未定义行为"变成"明确异常"。at 的检查有少量运行期开销,但换来的是确定性和可调试性,是安全性与性能之间的显式权衡开关。
饱和算术(Saturation Arithmetic)
溢出时进行钳位(clamp),而不是回绕(wrap-around)(P0543)。
解决的问题。 无符号整数溢出回绕、有符号溢出是未定义行为,两者都可能在信号处理、图像像素、音频采样等场景产生严重 bug——例如像素值 200 + 100 回绕成 44 而非钳到 255,导致颜色突变。饱和算术让运算结果在溢出时停在类型的上下界,语义明确且符合许多领域(DSP、图像、定点运算)的实际需求。
1 | |
提供的函数族。 add_sat、sub_sat、mul_sat、div_sat 分别对应四则运算的饱和版本,还有 saturate_cast<T>(x) 把值转换到目标类型范围并钳位。它们是 constexpr,可在编译期使用;对有符号类型同样适用,负溢出饱和到最小值。在需要精确控制溢出行为的代码中(如定点数运算、色彩空间转换),用饱和算术替代手写 if 钳位,既清晰又不易出错。
std::breakpoint
标准调试断点支持(P2513)。
解决的问题。 触发调试器断点过去依赖平台相关手段——x86 上内嵌 __asm__ int 3、Windows 上 __debugbreak()、各平台不一致且不可移植。std::breakpoint 提供标准化的"在此处停下调试器"原语,跨平台一致。
1 | |
两个函数的区别。 std::breakpoint() 无条件触发:若有调试器附加则中断,无调试器时行为由实现定义(通常忽略或引发陷阱)。std::breakpoint_if_debugging() 只在检测到调试器附加时才中断,否则空操作——适合留在生产代码里做条件调试,不会影响正常运行。它们是无副作用的调试辅助,正式发布时可按需保留 breakpoint_if_debugging 而不影响性能。
Hazard Pointers
无锁并发中安全回收对象的机制(P2530)。
解决的问题。 无锁数据结构(如无锁栈、队列)中,一个线程从链表摘下节点后,其它线程可能仍持有该节点指针在读——若直接释放,会触发 use-after-free;若不释放,则内存泄漏(经典 ABA 与回收难题)。Hazard Pointer 的思路是:读者在访问共享节点前,先把这个指针"登记"到一个全局可见的 hazard 槽,写者回收节点前扫描所有 hazard 槽,凡是被登记的节点就延迟回收,从而保证读者访问期间对象不会被释放。
1 | |
工作流程。 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 | |
读者与写者的协作。 读者进入临界区前获取 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-receiverstd::simdviews::concat- 饱和算术
std::span::at









