说实话,每次新标准发布,我身边总有两种声音:一种是“C++又要逼我学新东西了”,另一种是“新特性藏了这么多年,终于能写进公司代码了”。作为一个从 C++11 一路用到 C++20 的老用户,我对 C++23 的态度比较微妙——它不像 C++11 那样有“这是一门新语言”的冲击感,也不像 C++20 那样一口气塞了协程、概念、模块三个大招。它更像一次扎扎实实的“补课”和“收口”,把之前几年积攒下来的问题清单逐个划掉。
这篇文章就是写给那些想知道“C++23 到底值不值得关注”的人。我会按实际工作场景来拆,不搞特性清单式罗列。哪些特性能立刻改善你的代码,哪些还比较挑环境,哪些存在坑——这些才是我们一线开发最关心的东西。
1. C++23的版本气质:一次对C++20的“补课”
我一开始对 C++23 的预期并不高,因为 C++20 刚落地时,协程和 concept 的编译器支持还很碎片化,模块更是拖了好几年才慢慢能进生产环境。但看完 C++23 的最终提案列表,我的想法变了:这个版本虽然没有“颠覆性”的单个特性,但它在多个方向上把“最后一公里”补齐了。
1.1 三个大招之后的“贤者时间”
C++20 给人的感觉像是一口气上了三桌硬菜,但很多菜端上来之后,配套工具没跟上。比如协程有了,但没有标准库提供的 generator;format 有了,但打印到标准输出还是要绕一圈;ranges 有了,但想让视图变成容器还得手写 collect 逻辑。C++23 做的事情,很大一部分就是把这些“半成品”补完整。
另一个被很多人忽略的点是,C++23 在语言细节上做了不少“清理”。比如删掉了一些过时的语法歧义,加了一些让编译器更可能帮你提前发现错误的东西。这种“没有新闻感的改动”,实际上对工程效率的提升比一个大特性更明显。
1.2 按开发场景盘点:一张清单
我习惯把新特性按“我要解决什么问题”来分类,而不是按“这个特性属于哪一章”。下面这张表是我自己整理时用的,跳过了所有用不上的理论特性,只留下会真正碰到的:
| 开发场景 | 对应特性 | 解决的老问题 |
|---|---|---|
| 错误处理 | std::expected | 错误码不直观、异常不适合所有路径 |
| 格式化输出 | std::print/std::println | printf 不安全,iostream 又臭又慢 |
| 模板与设计 | deducing this | CRTP 套娃、const 与非 const 重载重复 |
| 容器与内存 | std::flat_map/std::mdspan | 缓存不友好、多维数组语义混乱 |
| 算法与视图 | zip / enumerate /ranges::to | 并列遍历、视图转容器的样板代码 |
| 编译期编程 | if consteval、static operator[] | 常量求值分支不直观、多维下标受限 |
| 工程化体验 | import std模块、std::generator | 编译速度、宏污染、协程没有通用生成器 |
这张表不是让我立刻去改现有代码,而是告诉我:未来半年到一年,哪些地方值得在重构时顺手换掉。
1.3 我的整体判断
C++23 不是“必须马上切换到新标准”的版本,但它非常适合作为 C++20 之后的“稳定升级点”。原因很简单:编译器支持已经相对成熟,不像 C++20 出来头两年那样到处踩兼容性坑。如果你手里的项目还在 C++17,那 C++23 反而是一个比 C++20 更平滑的跳跃目标——因为它把 C++20 那些难啃的骨头也一起带上了。
2. std::expected:错误处理不再是“码农的填空题”
在所有 C++23 新特性里,std::expected是我第一个引入生产代码的。原因很直接:我们项目里有一大堆接口靠错误码传状态,调用方拿到之后必须用手写 if 判断,少写一个分支就是线上事故。expected把“可能没有值”这件事直接表达在类型签名里,编译器能帮我们盯着。
2.1 从 error code 到 expected 的演进逻辑
老一辈 C 风格错误码的思路是“返回值给结果,错误码给原因”,但问题在于两个通道经常混在一起。后来有了异常,可异常并不适合所有场景——比如高频调用路径、嵌入式环境、以及“失败是可预期的业务分支”而不是“真正的意外”。
std::expected<T, E>的思路很接近 Rust 的 Result:一个变量要么有T,要么有E,必须显式检查。相比optional,它多带了错误信息;相比异常,它把失败当普通值传递,不会打断控制流,也没有栈展开成本。
2.2 用法示范与链式调用
直接上代码,这是最常见的解析场景:
#include <expected> #include <string> #include <charconv> #include <system_error> std::expected<int, std::string> parse_int(std::string_view s) { int value = 0; auto [ptr, ec] = std::from_chars(s.data(), s.data() + s.size(), value); if (ec == std::errc()) { return value; } return std::unexpected("not a number"); } // 调用方可以链式处理 std::expected<int, std::string> result = parse_int("42") .and_then([](int v) -> std::expected<int, std::string> { if (v > 100) return std::unexpected("too large"); return v * 2; }) .or_else([](std::string err) { // 记录日志后可以恢复默认值 return std::expected<int, std::string>(0); });这段代码里,and_then负责在成功时继续处理,or_else负责在失败时兜底。老写法是写四五个 if 分支,新写法把主流程和容错逻辑分开了。还有个细节:*result可以直接访问值,但如果确认无值还解引用,行为是未定义的,所以生产代码里我通常显式调用value(),它会在无值时抛出std::bad_expected_access。
2.3 什么时候还是应该用异常
expected再好,也不是用来消灭异常的。我的经验是:它们俩服务的是不同的失败语义。
- 异常:用于“这里不应该失败”的不变量破坏,比如分配内存失败、内部状态崩溃。调用方通常没有合理的恢复路径。
expected:用于“业务上可能失败且调用方需要处理”的分支,比如用户输入错误、网络超时、配置缺失。
还有一个衡量标准:失败频率。如果一条路径 10% 的概率会失败,那它大概率适合expected;如果失败概率接近 0%,异常更合适。否则你会在每个调用栈帧都写and_then,反而比异常还啰嗦。
2.4 实操注意
有几件事是文档不会强调的:第一,expected<T, E>要求T和E不能是同一类型,否则代码环境会一脸懵;第二,链式调用里如果T是移动昂贵的大对象,要注意别复制;第三,E的类型建议用类似std::error_code这样的轻量值,而不是把一长串字符串往里面塞,否则失败路径本身就会带来分配开销。
我们项目里曾经把E直接定义为std::string,结果发现高频失败路径上内存分配数量翻倍。后来换成std::error_code+ 可选的日志上下文,性能才恢复正常。
3. deducing this:模板类从此告别 CRTP 套娃
模板库的开发者一定对 CRTP 不陌生:为了让基类调用到派生类的成员,必须把派生类作为模板参数传进去。写法绕、报错长、可读性差。C++23 的 deducing this(显式对象参数)至少把这条路走通了一大半。
3.1 显式对象参数到底是什么
原理其实很简单:成员函数可以显式声明this参数,让编译器根据实际调用对象来推导。最常见的形态是this auto&& self,它会自动匹配左值、右值、const 和非 const:
struct Container { int data = 0; int& get(this Container& self) { return self.data; } int const& get(this Container const& self) { return self.data; } };上面这个例子看起来只是语法变化,但真正厉害的是模板推导。你可以在模板里定义一个函数,让它在派生类上自动推导出实际类型,而不需要 CRTP:
template <typename Derived> struct Base { void speak(this Derived& self) { // 这里直接调用派生类自己的实现 self.impl(); } }; struct Dog : Base<Dog> { void impl() { /* ... */ } };这个写法比 CRTP 直观太多了。你不必先把Derived存成类型别名,也不用担心static_cast<Derived&>(*this)这种丑陋写法。只要把this Derived&写在参数列表里,编译器就能推导出当前对象的具体类型。
3.2 消除 const 与非 const 重复代码
如果你写过const T& at()和T& at()双版本,一定感受过代码重复的痛。有了 deducing this,可以做一个通用的转发实现:
struct Buffer { int data[100] = {}; decltype(auto) at(this auto&& self, std::size_t i) { return self.data[i]; } };传非 const 对象返回int&,传 const 对象返回const int&。标准库甚至已经有不少组件开始用这种技术来简化自身的接口实现。
3.3 静态多态与递归 lambda
还有一个趁手用法:递归 lambda。以前 lambda 要递归必须用std::function包一层,或者用 Y combinator 那套理论。现在直接给参数一个this auto self:
auto factorial = [](this auto self, int n) -> int { return n <= 1 ? 1 : n * self(n - 1); };这段代码跑起来没有任何额外分配,self就是 lambda 自身。调试的时候也能正常显示栈帧,不会像std::function一样深不见底。
3.4 使用边界
三件事不能做:第一,显式对象参数不能用于虚函数,因为虚函数的调用约定和静态推导天然冲突;第二,不能把它用在已经隐式带this的 lambda 上,否则会有歧义;第三,如果你同时在类里写了普通成员函数和显式对象参数版本,重载决议会变得非常难读,我一般不建议混用。
如果你维护的库还在用 CRTP 写法,别急着全面重写,可以先挑一两个高频模板类试试,重点体验报错信息变友好的过程——这往往是重构 CRTP 时最爽的瞬间。
4. 多维下标、static operator[] 与 if consteval:语言底盘上的小钢炮
这几个特性单个看都不大,但组合起来,能省掉不少“模拟”工作。尤其是写矩阵、图像处理、解析器这类代码的人,会很有体感。
4.1 多维下标 operator[](i, j) 终于合法了
在 C++23 之前,如果你想写matrix[i, j],基本上会被当作逗号表达式处理,变成matrix[(i, j)],也就是把j当作唯一下标。真正能工作的是operator()(i, j),所以很多矩阵库都规定用圆括号访问。
C++23 改了规则:operator[]可以接受多个参数,而且a[i, j]的多个下标会被当成真正的“下标表达式列表”。于是可以写出很自然的矩阵类:
#include <cstddef> template <typename T> class Matrix { T* data_; std::size_t rows_, cols_; public: Matrix(T* data, std::size_t r, std::size_t c) : data_(data), rows_(r), cols_(c) {} T& operator[](std::size_t r, std::size_t c) { return data_[r * cols_ + c]; } T const& operator[](std::size_t r, std::size_t c) const { return data_[r * cols_ + c]; } }; // 用法 double raw[12]; Matrix<double> m(raw, 3, 4); m[1, 2] = 3.14;对库作者来说,这是个福音。你再也不用为了“用户习惯哪个访问语法”而纠结了,因为operator[]和operator()都能做多维访问。对用户来说,读代码的时候也更直观。
4.2 static operator[] 和 static operator() 的意义
operator[]或者operator()传统上都是成员函数,必须绑定到对象上。C++23 允许把它们声明为静态成员,也就是说,当你的运算符重载不需要访问成员状态时,可以直接写成静态的:
struct Key { static constexpr int operator[](char c) { return c - 'a'; } }; int idx = Key{}['z']; // 25这个东西的用途更多是在模板元编程里。以前为了在编译期做映射,得写一大堆constexpr辅助函数;现在可以直接用重载运算符表达,代码紧凑不少。虽然有点小众,但一旦用上,属于那种“再也回不去”的体验。
4.3 if consteval 与编译期分支
std::is_constant_evaluated()是一个标准库函数,用来判断当前是否处于常量求值上下文。问题在于它太啰嗦,而且放在if里容易让编译器优化分叉。C++23 直接提供了语言级的关键字:
constexpr int process(int x) { if consteval { // 编译期路径:可以做模板元编程的精细操作 return x * 2; } else { // 运行期路径:可以调用运行期才能用的函数 return x + std::rand(); // 示意,实际别在constexpr里用rand } }这个特性是给标准库作者和底层库作者准备的。普通业务代码用得不多,但如果你在写类似to_chars这种同时要支持编译期和运行期的函数,if consteval比std::is_constant_evaluated()清晰得多。
5. std::print:格式化输出进入“现代”时代
我很难描述第一次用std::println的感受——就是那种“早该有”的释然。C 的 printf 用了几十年,类型不安全;C++ 的 iostream 倒是安全了,但写起来又长又慢。C++23 终于给出了正解。
5.1 printf 和 iostream 的各自死穴
我一直觉得 printf 最大的问题不是格式字符串丑,而是你永远不知道参数类型对不对。%d和%s写错,编译器不一定报错,线上崩了才知道。iostream 反过来,类型安全有了,但<<那一长串拼接在生成日志时,性能经常是瓶颈,而且这是众所周知的。
5.2 基本用法与自定义类型
std::print底层复用了 C++20 的std::format语法,所以在大多数场景下可以直接替换:
#include <print> int main() { std::println("Hello, {}! The answer is {}.", "world", 42); std::print("{} {}\n", "keep", "going"); }格式化语法支持对齐、填充、进制、精度控制。比如保留两位小数:{:.2f};十六进制:{:#x}。真正省心的是自定义类型,只要特化std::formatter,以后所有输出这个类型的语句都会自动走格式化逻辑:
struct Point { int x, y; }; template <> struct std::formatter<Point> { char type = 'n'; constexpr auto parse(auto& ctx) { auto it = ctx.begin(); if (it != ctx.end() && *it == 's') type = *it++; return it; } template <typename FormatContext> auto format(Point p, FormatContext& ctx) const { if (type == 's') return std::format_to(ctx.out(), "({}, {})", p.x, p.y); return std::format_to(ctx.out(), "Point[{} @ {}]", p.x, p.y); } };如果你觉得写 formatter 麻烦,可以先只特化format基础版本,后续按需增加控制语法。比起 iostream 里重载operator<<,这套机制干净得多。
5.3 性能与兼容性
性能方面,std::print在多数实现里接近 printf,远远好于std::cout的流式拼接。原因是格式化在编译期解析,运行时只需要按占位符填充。下面是三种方式的大致对比:
| 方案 | 类型安全 | 可读性 | 性能 | 紧凑性 |
|---|---|---|---|---|
| printf | 差 | 一般 | 高 | 好 |
| iostream | 好 | 差(拼接长) | 中低 | 差 |
| std::print / std::format | 好 | 好 | 高 | 好 |
一个需要注意的兼容性问题:在 Windows 上往 stdout 输出 Unicode 时,std::print的编码行为在不同工具链下不完全一致,和终端代码页有关。如果你的程序要兼容 Windows 控制台的旧代码页,建议先做小范围测试。Linux 下基本没有这个问题。
6. ranges生态补齐最后一公里:zip、enumerate、to
C++20 的 ranges 库给了我们视图、管道、惰性求值,但总觉得差口气——比如我想同时遍历两个容器,想带下标遍历,想把过滤后的结果收集成 vector,都得自己写辅助代码。C++23 把这些高频工具一次性补齐了。
6.1 常用新视图:zip、enumerate、chunk
views::zip允许你并行遍历多个 range,每次给一个元组。views::enumerate给每个元素配上索引。views::chunk按指定大小切成一段一段。这三个是我用的最多的:
#include <ranges> #include <vector> #include <print> int main() { std::vector<int> a{1, 2, 3}; std::vector<int> b{10, 20, 30}; for (auto [x, y] : std::views::zip(a, b)) { std::println("{} {}", x, y); } for (auto [idx, val] : std::views::enumerate(a)) { std::println("a[{}] = {}", idx, val); } for (auto chunk : a | std::views::chunk(2)) { for (int v : chunk) { std::print("{} ", v); } std::println("|"); } }有了zip,以前那种for (size_t i = 0; i < a.size() && i < b.size(); ++i)的样板代码就消失了。enumerate在处理下标和值同时需要的算法时非常顺手。
如果你还想做滑动窗口,views::slide也能满足,它跟chunk的区别是窗口会重叠,适合做相邻元素差分。
6.2 std::ranges::to:视图转容器的标准答案
C++20 里一个很尴尬的事:你用std::views::filter过滤了半天,结果不能直接构造std::vector,得手动emplace_back。C++23 的std::ranges::to把这个空白填上了:
#include <ranges> #include <vector> #include <print> int main() { auto even = std::views::iota(1, 100) | std::views::filter([](int n) { return n % 2 == 0; }) | std::ranges::to<std::vector<int>>(); std::println("count: {}", even.size()); }管道后面直接接std::ranges::to,读起来一气呵成。如果你有自己的容器类型,也可以实现std::ranges::to的定制点,不过这属于进阶玩法,业务代码里用标准容器就够了。
6.3 惰性求值的生命周期陷阱
视图是惰性的,这意味着它内部保存的迭代器、子视图都只是引用。如果你把一个临时容器传给 view,view 活得比容器久,那运行时就是未定义行为:
auto view = std::views::zip(std::vector<int>{1, 2, 3}, std::vector<int>{4, 5, 6}); // view 里的两个 vector 已经被销毁了,绝对不能遍历所以我的个人习惯是:如果视图生命周期跨越多个函数,宁可先转成std::vector或std::tuple容器,也不要图方便一直持有 view。惰性求值是性能利器,但也是悬在头上的剑。
7. 数据结构与内存布局:mdspan 和 flat_map 的取舍
性能敏感的项目里,很重要的一个话题是缓存命中率。std::mdspan和std::flat_map都是冲着“把数据排得紧密”去的,但适用场景完全不同。
7.1 std::mdspan:非拥有所有权的高维数组视图
mdspan是一个多维数组“视图”,它自己不持有数据,只描述数据的布局和维度。数值计算、图像处理、科学计算里,这特别有用:你的数据可能已经存在一个vector或裸数组里,但想用二维或者三维的方式去解释它。
#include <mdspan> #include <vector> int main() { std::vector<double> raw(12); // 看作 3 行 4 列 std::mdspan<double, std::extents<std::size_t, 3, 4>> mat(raw.data()); mat(0, 1) = 1.0; mat(2, 3) = 2.0; // 可以用 submdspan 取子矩阵,不复制数据 auto sub = std::submdspan(mat, 1, std::full_extent); }mdspan默认是行优先,也可以指定layout_left、layout_stride或自定义布局策略。接口设计得相当克制,基本就是“把数据解释成多维结构”这一件事做好,不做多余的所有权管理。
7.2 std::flat_map:排序 vector 的二分查找容器
flat_map的底层逻辑一句话就能讲清楚:把键值对放在两个相邻的 vector 里,用二分查找找键,插入删除时维护有序。它不追求 O(1) 的哈希查找,也不追求树结构的稳定迭代器,图的是省内存、高缓存命中率。
#include <flat_map> #include <string> int main() { std::flat_map<int, std::string> fm; fm[1] = "one"; fm[2] = "two"; auto it = fm.find(2); if (it != fm.end()) { // ... } }初次上手要注意:flat_map的迭代器在插入后可能失效,因为底层 vector 会重新分配内存。而且插入不是 O(log n),最坏是 O(n),因为要搬移元素。它和map的取舍很清晰——读多写少、数据量中等、对缓存命中敏感时选flat_map;写操作频繁时老老实实用map或unordered_map。
7.3 选择决策表
| 容器 | 查找 | 插入 | 迭代稳定性 | 内存占用 | 适用场景 |
|---|---|---|---|---|---|
std::map | O(log n) | O(log n) | 稳定 | 高(指针节点) | 写多发频、需要稳定迭代器 |
std::unordered_map | O(1) 平均 | O(1) 平均 | 不稳定 | 高(桶+链表) | 键控查找、无所谓顺序 |
std::flat_map | O(log n) | O(n) | 不稳定 | 低 | 读多写少、内存敏感 |
我在实际项目里用flat_map最多的地方是配置表和协议映射表——启动时一次性构建,运行期间只做只读查询,性能比map好了不少。如果你的热点代码段就是查表,值得测一测。
8. import std 与协程生成器:工程化体验的加分项
这个章节里的两个东西,一个影响编译期体验,一个影响运行期写法。它们都不算“必须”,但是能明显提升开发感受。
8.1 import std 为什么能加速编译
C++ 头文件几十年来一直是编译速度的痛。import std把整个标准库作为模块导出,理论上预编译一次,后面再也不要一而再再而三地解析vector、string等头文件。这能省掉多少编译时间?我见过某个测试工程,早期实测能减少三到五成编译时间。
import std; int main() { std::vector<int> v = {1, 2, 3}; for (int x : v) std::println("{}", x); }import std还有一个隐藏收益:宏污染被隔离了。以前某个头文件里#define一个名字,可能影响整个翻译单元。模块环境下,标准库内部的宏不会渗透出来,干净很多。
8.2 实际迁移障碍
不过,别高兴得太早。import std对构建系统是有要求的:编译器得真正支持模块分区,CMake 等构建工具也得配合处理 BMI(模块缓存文件)。目前 GCC 和 Clang 对import std的支持一直在追赶,MSVC 支持相对积极,但不同版本之间还有不少细节差异。
所以我给的建议是:新项目可以大胆试点,老项目不要急着全面铺开。先把构建系统切换到支持模块的版本,再挑一两个不依赖复杂第三方头文件的模块试跑,稳定后再推广。
8.3 std::generator 让协程落地
C++20 的协程一直被诟病“没有一个好用的生成器”,导致很多想用co_yield的场景都得自己封装。C++23 的std::generator直接提供了标准方案:
#include <generator> #include <print> std::generator<int> fibonacci() { int a = 0, b = 1; while (true) { co_yield a; int next = a + b; a = b; b = next; } } int main() { int count = 0; for (int n : fibonacci()) { std::println("{}", n); if (++count >= 10) break; } }generator的好处是惰性求值,你不再需要提前算好整个序列存内存里,而且调用方可以用普通 range-for 来消费,跟遍历容器没区别。性能上,只要不频繁跨线程传递,它是很轻的。注意std::generator不能co_await,它是单向的同步生成器,想混入异步得用其他协程封装。
顺带一提,C++23 还加入了std::stacktrace,配合std::expected或异常做错误诊断时很有用,我一般在日志系统里直接打std::stacktrace::current(),定位问题效率高不少。还有std::move_only_function,比std::function少了一份拷贝要求,适合移动型回调对象。
9. 从 C++17/20 迁移到 C++23:我的实操路线
最后聊点实在的:怎么把一个 C++17/20 的老项目,一步步迁移到 C++23。我不是建议搞“大爆炸式重写”,那在商业项目里几乎必死。我的路线是分四步走,每一步都能独立交付价值。
9.1 先动手改的小工具特性
第一优先级是那些改了不伤筋动骨、收益立竿见影的小函数:
std::to_underlying(enum)一行搞定枚举转整数,再也不用写static_cast<int>或者看起来诡异的(int)val。std::unreachable()告诉编译器某个分支永远不会执行,优化器能删掉边界检查。注意人不能走到,否则是 UB。std::byteswap做字节序转换,比手写位移安全。
这些小改动通常只涉及头文件替换和局部函数,不需要重设计接口,风险极低。
9.2 第二步再动核心错误处理
如果你决定引入std::expected,不要全仓库同时推。先选一两个独立模块,比如解析器、网络协议层,把返回错误码的接口改成expected。测试策略上,迁移前先给旧接口准备好兼容封装,或者直接用新版本,依赖调用方的错误映射逻辑同步调整。
我自己的体会是,如果团队里有人不熟悉函数式链式调用,先别教他们and_then和transform,就先用if (!result)这种直观判断,也能吃到大半红利。等大家习惯了,再慢慢过渡到链式写法。
9.3 编译器与构建系统支持先检查
C++23 的编译器支持已经不像 C++20 早期那么碎片化,但仍有差异。GCC 13、Clang 16、MSVC v19.3x 左右基本都能覆盖大部分常用特性。模块和import std的支持最不稳定,要单独验证。
我的建议是:先升级编译器到最新稳定版本,在 CI 里跑一遍全量编译,把-std=c++23打开,看看有多少编译错误。遗漏的通常集中在移除的旧特性和行为变化上,一样样改掉就好。这一步做完,代码已经可以以 C++23 模式编译,即使还没有用新特性,也是在为后续铺路。
9.4 压测和观察
每次用新特性改动性能相关代码,我都会先跑基准测试,再上线。flat_map在查找密集场景通常领先map,但如果你的数据量太小,差别可能完全看不出来。std::print在高频日志场景下比std::cout快,但如果写日志不是热点,那更多是代码风格红利。
我个人最喜欢的方式是:把迁移分三个版本周期来做。第一个周期只切编译标准和小工具;第二个周期换掉一两个核心路径的错误处理和输出;第三个周期才动数据结构与视图类代码。这样每个版本都有明确验收点,不会一次吞太多变化。
如果你已经在一个 C++20 项目上稳定跑了一两年,现在确实是切换到 C++23 的好时机。别急着把所有新特性都用上,先挑那些能解决你手上最痛、最重复的问题的特性,你会发现这个版本比想象中扎实。