资讯动态

C++23工程实践:那些能立刻改善代码的新特性

发布时间:2026/10/14 23:01:11 来源:尧图企业网站定制
说实话每次新标准发布我身边总有两种声音一种是“C又要逼我学新东西了”另一种是“新特性藏了这么多年终于能写进公司代码了”。作为一个从 C11 一路用到 C20 的老用户我对 C23 的态度比较微妙——它不像 C11 那样有“这是一门新语言”的冲击感也不像 C20 那样一口气塞了协程、概念、模块三个大招。它更像一次扎扎实实的“补课”和“收口”把之前几年积攒下来的问题清单逐个划掉。这篇文章就是写给那些想知道“C23 到底值不值得关注”的人。我会按实际工作场景来拆不搞特性清单式罗列。哪些特性能立刻改善你的代码哪些还比较挑环境哪些存在坑——这些才是我们一线开发最关心的东西。1. C23的版本气质一次对C20的“补课”我一开始对 C23 的预期并不高因为 C20 刚落地时协程和 concept 的编译器支持还很碎片化模块更是拖了好几年才慢慢能进生产环境。但看完 C23 的最终提案列表我的想法变了这个版本虽然没有“颠覆性”的单个特性但它在多个方向上把“最后一公里”补齐了。1.1 三个大招之后的“贤者时间”C20 给人的感觉像是一口气上了三桌硬菜但很多菜端上来之后配套工具没跟上。比如协程有了但没有标准库提供的 generatorformat 有了但打印到标准输出还是要绕一圈ranges 有了但想让视图变成容器还得手写 collect 逻辑。C23 做的事情很大一部分就是把这些“半成品”补完整。另一个被很多人忽略的点是C23 在语言细节上做了不少“清理”。比如删掉了一些过时的语法歧义加了一些让编译器更可能帮你提前发现错误的东西。这种“没有新闻感的改动”实际上对工程效率的提升比一个大特性更明显。1.2 按开发场景盘点:一张清单我习惯把新特性按“我要解决什么问题”来分类而不是按“这个特性属于哪一章”。下面这张表是我自己整理时用的跳过了所有用不上的理论特性只留下会真正碰到的开发场景对应特性解决的老问题错误处理std::expected错误码不直观、异常不适合所有路径格式化输出std::print/std::printlnprintf 不安全iostream 又臭又慢模板与设计deducing thisCRTP 套娃、const 与非 const 重载重复容器与内存std::flat_map/std::mdspan缓存不友好、多维数组语义混乱算法与视图zip / enumerate /ranges::to并列遍历、视图转容器的样板代码编译期编程if consteval、static operator[]常量求值分支不直观、多维下标受限工程化体验import std模块、std::generator编译速度、宏污染、协程没有通用生成器这张表不是让我立刻去改现有代码而是告诉我未来半年到一年哪些地方值得在重构时顺手换掉。1.3 我的整体判断C23 不是“必须马上切换到新标准”的版本但它非常适合作为 C20 之后的“稳定升级点”。原因很简单编译器支持已经相对成熟不像 C20 出来头两年那样到处踩兼容性坑。如果你手里的项目还在 C17那 C23 反而是一个比 C20 更平滑的跳跃目标——因为它把 C20 那些难啃的骨头也一起带上了。2. std::expected错误处理不再是“码农的填空题”在所有 C23 新特性里std::expected是我第一个引入生产代码的。原因很直接我们项目里有一大堆接口靠错误码传状态调用方拿到之后必须用手写 if 判断少写一个分支就是线上事故。expected把“可能没有值”这件事直接表达在类型签名里编译器能帮我们盯着。2.1 从 error code 到 expected 的演进逻辑老一辈 C 风格错误码的思路是“返回值给结果错误码给原因”但问题在于两个通道经常混在一起。后来有了异常可异常并不适合所有场景——比如高频调用路径、嵌入式环境、以及“失败是可预期的业务分支”而不是“真正的意外”。std::expectedT, E的思路很接近 Rust 的 Result一个变量要么有T要么有E必须显式检查。相比optional它多带了错误信息相比异常它把失败当普通值传递不会打断控制流也没有栈展开成本。2.2 用法示范与链式调用直接上代码这是最常见的解析场景#include expected #include string #include charconv #include system_error std::expectedint, 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::expectedint, std::string result parse_int(42) .and_then([](int v) - std::expectedint, std::string { if (v 100) return std::unexpected(too large); return v * 2; }) .or_else([](std::string err) { // 记录日志后可以恢复默认值 return std::expectedint, 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 实操注意有几件事是文档不会强调的第一expectedT, E要求T和E不能是同一类型否则代码环境会一脸懵第二链式调用里如果T是移动昂贵的大对象要注意别复制第三E的类型建议用类似std::error_code这样的轻量值而不是把一长串字符串往里面塞否则失败路径本身就会带来分配开销。我们项目里曾经把E直接定义为std::string结果发现高频失败路径上内存分配数量翻倍。后来换成std::error_code 可选的日志上下文性能才恢复正常。3. deducing this模板类从此告别 CRTP 套娃模板库的开发者一定对 CRTP 不陌生为了让基类调用到派生类的成员必须把派生类作为模板参数传进去。写法绕、报错长、可读性差。C23 的 deducing this显式对象参数至少把这条路走通了一大半。3.1 显式对象参数到底是什么原理其实很简单成员函数可以显式声明this参数让编译器根据实际调用对象来推导。最常见的形态是this auto self它会自动匹配左值、右值、const 和非 conststruct Container { int data 0; int get(this Container self) { return self.data; } int const get(this Container const self) { return self.data; } };上面这个例子看起来只是语法变化但真正厉害的是模板推导。你可以在模板里定义一个函数让它在派生类上自动推导出实际类型而不需要 CRTPtemplate typename Derived struct Base { void speak(this Derived self) { // 这里直接调用派生类自己的实现 self.impl(); } }; struct Dog : BaseDog { void impl() { /* ... */ } };这个写法比 CRTP 直观太多了。你不必先把Derived存成类型别名也不用担心static_castDerived(*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 selfauto 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) 终于合法了在 C23 之前如果你想写matrix[i, j]基本上会被当作逗号表达式处理变成matrix[(i, j)]也就是把j当作唯一下标。真正能工作的是operator()(i, j)所以很多矩阵库都规定用圆括号访问。C23 改了规则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]; Matrixdouble m(raw, 3, 4); m[1, 2] 3.14;对库作者来说这是个福音。你再也不用为了“用户习惯哪个访问语法”而纠结了因为operator[]和operator()都能做多维访问。对用户来说读代码的时候也更直观。4.2 static operator[] 和 static operator() 的意义operator[]或者operator()传统上都是成员函数必须绑定到对象上。C23 允许把它们声明为静态成员也就是说当你的运算符重载不需要访问成员状态时可以直接写成静态的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里容易让编译器优化分叉。C23 直接提供了语言级的关键字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 倒是安全了但写起来又长又慢。C23 终于给出了正解。5.1 printf 和 iostream 的各自死穴我一直觉得 printf 最大的问题不是格式字符串丑而是你永远不知道参数类型对不对。%d和%s写错编译器不一定报错线上崩了才知道。iostream 反过来类型安全有了但那一长串拼接在生成日志时性能经常是瓶颈而且这是众所周知的。5.2 基本用法与自定义类型std::print底层复用了 C20 的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::formatterPoint { 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、toC20 的 ranges 库给了我们视图、管道、惰性求值但总觉得差口气——比如我想同时遍历两个容器想带下标遍历想把过滤后的结果收集成 vector都得自己写辅助代码。C23 把这些高频工具一次性补齐了。6.1 常用新视图zip、enumerate、chunkviews::zip允许你并行遍历多个 range每次给一个元组。views::enumerate给每个元素配上索引。views::chunk按指定大小切成一段一段。这三个是我用的最多的#include ranges #include vector #include print int main() { std::vectorint a{1, 2, 3}; std::vectorint 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视图转容器的标准答案C20 里一个很尴尬的事你用std::views::filter过滤了半天结果不能直接构造std::vector得手动emplace_back。C23 的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::tostd::vectorint(); std::println(count: {}, even.size()); }管道后面直接接std::ranges::to读起来一气呵成。如果你有自己的容器类型也可以实现std::ranges::to的定制点不过这属于进阶玩法业务代码里用标准容器就够了。6.3 惰性求值的生命周期陷阱视图是惰性的这意味着它内部保存的迭代器、子视图都只是引用。如果你把一个临时容器传给 viewview 活得比容器久那运行时就是未定义行为auto view std::views::zip(std::vectorint{1, 2, 3}, std::vectorint{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::vectordouble raw(12); // 看作 3 行 4 列 std::mdspandouble, std::extentsstd::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_mapint, 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::mapO(log n)O(log n)稳定高指针节点写多发频、需要稳定迭代器std::unordered_mapO(1) 平均O(1) 平均不稳定高桶链表键控查找、无所谓顺序std::flat_mapO(log n)O(n)不稳定低读多写少、内存敏感我在实际项目里用flat_map最多的地方是配置表和协议映射表——启动时一次性构建运行期间只做只读查询性能比map好了不少。如果你的热点代码段就是查表值得测一测。8. import std 与协程生成器工程化体验的加分项这个章节里的两个东西一个影响编译期体验一个影响运行期写法。它们都不算“必须”但是能明显提升开发感受。8.1 import std 为什么能加速编译C 头文件几十年来一直是编译速度的痛。import std把整个标准库作为模块导出理论上预编译一次后面再也不要一而再再而三地解析vector、string等头文件。这能省掉多少编译时间我见过某个测试工程早期实测能减少三到五成编译时间。import std; int main() { std::vectorint 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 让协程落地C20 的协程一直被诟病“没有一个好用的生成器”导致很多想用co_yield的场景都得自己封装。C23 的std::generator直接提供了标准方案#include generator #include print std::generatorint 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它是单向的同步生成器想混入异步得用其他协程封装。顺带一提C23 还加入了std::stacktrace配合std::expected或异常做错误诊断时很有用我一般在日志系统里直接打std::stacktrace::current()定位问题效率高不少。还有std::move_only_function比std::function少了一份拷贝要求适合移动型回调对象。9. 从 C17/20 迁移到 C23我的实操路线最后聊点实在的怎么把一个 C17/20 的老项目一步步迁移到 C23。我不是建议搞“大爆炸式重写”那在商业项目里几乎必死。我的路线是分四步走每一步都能独立交付价值。9.1 先动手改的小工具特性第一优先级是那些改了不伤筋动骨、收益立竿见影的小函数std::to_underlying(enum)一行搞定枚举转整数再也不用写static_castint或者看起来诡异的(int)val。std::unreachable()告诉编译器某个分支永远不会执行优化器能删掉边界检查。注意人不能走到否则是 UB。std::byteswap做字节序转换比手写位移安全。这些小改动通常只涉及头文件替换和局部函数不需要重设计接口风险极低。9.2 第二步再动核心错误处理如果你决定引入std::expected不要全仓库同时推。先选一两个独立模块比如解析器、网络协议层把返回错误码的接口改成expected。测试策略上迁移前先给旧接口准备好兼容封装或者直接用新版本依赖调用方的错误映射逻辑同步调整。我自己的体会是如果团队里有人不熟悉函数式链式调用先别教他们and_then和transform就先用if (!result)这种直观判断也能吃到大半红利。等大家习惯了再慢慢过渡到链式写法。9.3 编译器与构建系统支持先检查C23 的编译器支持已经不像 C20 早期那么碎片化但仍有差异。GCC 13、Clang 16、MSVC v19.3x 左右基本都能覆盖大部分常用特性。模块和import std的支持最不稳定要单独验证。我的建议是先升级编译器到最新稳定版本在 CI 里跑一遍全量编译把-stdc23打开看看有多少编译错误。遗漏的通常集中在移除的旧特性和行为变化上一样样改掉就好。这一步做完代码已经可以以 C23 模式编译即使还没有用新特性也是在为后续铺路。9.4 压测和观察每次用新特性改动性能相关代码我都会先跑基准测试再上线。flat_map在查找密集场景通常领先map但如果你的数据量太小差别可能完全看不出来。std::print在高频日志场景下比std::cout快但如果写日志不是热点那更多是代码风格红利。我个人最喜欢的方式是把迁移分三个版本周期来做。第一个周期只切编译标准和小工具第二个周期换掉一两个核心路径的错误处理和输出第三个周期才动数据结构与视图类代码。这样每个版本都有明确验收点不会一次吞太多变化。如果你已经在一个 C20 项目上稳定跑了一两年现在确实是切换到 C23 的好时机。别急着把所有新特性都用上先挑那些能解决你手上最痛、最重复的问题的特性你会发现这个版本比想象中扎实。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑