尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

C++23实用特性详解:从std::expected到std::print的升级实战

C++23实用特性详解:从std::expected到std::print的升级实战 C23 正式标准已经发布一段时间了但很多人对它的印象还停留在“C20 之后的过渡版本”上。我自己的主力项目在去年下半年切到-stdc23用的编译器是 GCC 14 和 VS2022 17.6 以上版本说实话切换成本比预想低很多。真正让我觉得“值回票价”的不是什么石破天惊的新概念而是一批把日常开发体验拉高的工具型特性std::expected让错误处理终于有了不寒碜的返回方式std::print让调试输出变清爽flat_map和mdspan在性能敏感路径上直接给红利ranges 库也补上了“视图转容器”的最后一公里。如果你还在写 C17 或 C20这篇文章值得看完我会把哪些特性值得立刻上手、哪些编译器的支持还没到位、以及我踩过的坑一并讲清楚。1. 先给 C23 定位这不是炫技版本是补课版本1.1 C20 给得太满C23 负责收尾回顾一下 C20 的体量概念concepts、协程coroutines、ranges 三大块同时落地再加上format、span、jthread这些重量级库组件等于把社区积压十年的设计一次性倒出来。结果就是编译器阵营到现在还在追齐很多项目直到最近两三年才敢把concepts和ranges真正用到生产代码里。C23 的定位和 C20 完全不同。它没有试图再推出一个“颠覆性”的大特性而是把上一轮没做利索的补齐把社区呼声最高的“小工具”正式纳入标准把 C 标准库从“能用”推向“好用”。用一句话概括C20 是概念版本C23 是工具版本。这恰恰是为什么我建议现在就开始关注它——你不需要等生态重新洗牌也不需要重写任何老代码大概率只是在新写的代码里多用几个新头文件而已。1.2 C23 到底解决了哪些实际问题我切到 C23 之后最先感受到变化的是几个高频场景错误处理以前解析配置、处理 I/O 错误要么返回std::optional配一个单独的错误码要么自己包一个variant。现在std::expected直接把“正常值 错误值”做成一个类型逻辑顺了不止一点。调试输出以前随手std::cout x x现在std::println(x{}, x)格式串干净、类型安全还不用纠结流状态被谁改坏了。容器选择频繁查询、构建后基本不改的映射表我用flat_map替换std::map缓存命中率上了一个台阶。ranges 流水线以前视图算完之后要转回vector还得写std::vectorint(v.begin(), v.end())现在一行ranges::tostd::vectorint()搞定。这些听起来都不是“大新闻”但日常代码里它们出现的频率实在太高了。C23 的好是用起来之后才体会得到的。1.3 编译器与工具链现状动手前先搞清楚支持到哪一步写 C23 代码之前我建议先看清自己用的编译器支持到什么程度这不是套话是真容易踩坑的事。我实测下来的大致情况是这样编译器可用版本我的主观评价表现比较好的部分还比较缺的部分GCC13 可以试14 才敢上生产核心语言特性、ranges、expected、formatstacktrace支持滞后generator受协程 ABI 影响较大Clang16/17 可试18 之后比较舒服语言特性更新快libc 的代码质量高generator、stacktrace以及部分新视图要看 libc 版本MSVCVS2022 17.6 起可用/std:c23STL 实现跟进积极print、flat_map、协程库支持都挺快老工程开启严格模式后要修一堆警告stacktrace和generator是 C23 里两个很好的特性但它们的实现依赖运行时后端的支持目前并不是所有平台都齐。我的建议是非要用一定要先查当前编译器的 release notes别等到 CI 上失败才回来翻文档。2. std::expected错误信息终于有地方放了2.1 从 std::optional 走到 std::expected差的只是一个错误原因C17 引入std::optional之后很多人拿它当“可能失败”的返回类型用久了就发现一个尴尬问题optional只能告诉你“没有值”解释不了“为什么没有值”。想解释就得额外输出参数、全局错误码或者包一层struct。std::expectedT, E要解决的就是这个问题。它像一个“带错误信息的 optional”要么持有正常的T要么持有错误类型的E两者只会有一个存在。实际用起来的感觉非常接近函数式语言里的Result类型。一个最简单的例子解析一个字符串为整数失败时区分错误原因#include expected #include string enum class ParseError { Empty, InvalidFormat, OutOfRange }; std::expectedint, ParseError parse_int(const std::string s) { if (s.empty()) { return std::unexpected(ParseError::Empty); } try { return std::stoi(s); } catch (const std::out_of_range) { return std::unexpected(ParseError::OutOfRange); } catch (...) { return std::unexpected(ParseError::InvalidFormat); } }调用端可以这样处理auto result parse_int(42); if (result.has_value()) { int value *result; } else { switch (result.error()) { case ParseError::Empty: std::println(input empty); break; case ParseError::InvalidFormat: std::println(bad number); break; case ParseError::OutOfRange: std::println(out of range); break; } }注意那个std::unexpected(...)它不是“意外的异常”而是标准库用来构造“错误侧”的辅助函数。刚开始用容易不习惯但看多了就顺了。2.2 monadic 接口链式处理比 if/else 舒服太多std::expected真正拉开差距的是它的链式接口and_then、transform、or_else、transform_error。如果你已经熟悉std::optional的 C23 新接口这里就是同一套逻辑。区别要先说清楚transform把T映射成另一个值U回调返回普通值进入“成功”分支时会自动包装成expectedU, E。and_then回调返回的是另一个expected也就是允许你继续做“可能失败”的操作。or_else进入错误分支时执行回调可以返回一个新的expected也可以直接返回std::unexpected保持错误状态。transform_error把E转换成另一种错误类型适合跨层包装。看一个解析链的示例std::expecteddouble, ParseError calc(const std::string input) { return parse_int(input) .and_then([](int raw) - std::expecteddouble, ParseError { if (raw 0) { return std::unexpected(ParseError::OutOfRange); } return std::pow(raw, 2); }) .transform([](double x) { return x * 3.14; }) .or_else([](ParseError e) { std::println(parse failed with code {}, (int)e); return std::unexpected(e); }); }and_then里那个- std::expecteddouble, ParseError一定不能漏因为回调要返回expected。我刚开始写的时候容易漏掉显式类型标注编译器立刻报错所以提前说一下省得到处排查。2.3 实际踩过的坑std::expected不是万灵药我用了大半年印象最深的几个限制value()会抛bad_expected_access。在不需要异常或不想被抛出中断的路径上要么用*result要么用value_or(default)。引用版本expectedT, E的行为和值版本不完全一样尤其是重新赋值的语义需要看文档不要凭直觉写。错误类型E可以是任意类型一个enum就够了。不必非得是std::error_code但如果要跨模块传递错误建议实现std::error_code的映射协议方便统一处理。代码体积问题如果每个调用点都对错误做详细的 switch生成的可执行文件会膨胀。我自己的做法是写一个集中的错误处理函数调用点只返回E。另外 C23 同时给std::optional加上了同样的transform/and_then/or_else接口。之前靠手写分支处理 optional 的地方现在也能做链式数据流了这个小升级容易被人忽略但实际工程里很好用。3. std::print 和 std::println调试输出的现代化3.1 从 printf 到 format 再到 printC 世界里的输出一直很尴尬printf类型不安全iostream又重又慢还经常因为状态泄露带来各种诡异行为。C20 的std::format解决了格式串的问题但它产出的仍然是字符串你最终还得再交给cout。C23 在这一步上直接补齐了“输出”的动作添加了std::print和std::println#include print std::println(Hello from C23: {}, {}, user, 2025); std::print(stderr, error code{}\n, 42);println会自动补一个换行print不会。两者默认写到标准输出也能通过std::print(FILE*, ...)指定输出到任意FILE*比如stderr。对我这种调试靠打印的人来说这个 API 一出来cout的使用率立刻掉了一大截。3.2 为什么值得替换一部分 iostream很多人担心性能我实测过一个简单场景循环打印一万行std::println比std::cout快差距在某些编译器实现下还挺明显。原因不复杂iostream默认与 C 的stdio同步sync_with_stdio(true)每次输出都要维护两个 C 标准库流的状态std::print直接走FILE*绕开了iostream对象那一堆锁和绑定。如果你已经关掉同步差距会缩小但即便如此std::print的语义也更干净。当然这不是说要把所有老代码里的cout全部重写成std::print。迁移成本一定是有的尤其当代码里依赖cout endl的刷新语义时替换时要留意。我的建议是新代码直接用std::print系列旧代码用到哪改到哪遇到和流状态强相关的场景别硬改。3.3 平滑迁移方案先拥抱 fmt 库如果你的项目暂时没有升到 C23又想提前体验这套输出风格最简单的办法是先引用第三方的 fmt 库fmtlib/fmt然后在代码里用fmt::print和fmt::println。fmt 库是std::format的前身格式语法和标准库基本一致将来切到 C23 时把fmt::前缀批量替换成std::即可。我实际做迁移时会先保持一个兼容宏#if defined(__cpp_lib_print) #include print using print std::print; using println std::println; #else #include fmt/printf.h using print fmt::print; using println fmt::println; #endif这样第三方库代码不至于在标准升级以后全量推翻重做。这个技巧在老库维护场景里尤其好用。3.4 print 的注意点操控std::print时有几个细节容易被忽略std::print默认不刷新缓冲区。如果程序后面直接崩溃调试输出可能丢在缓冲区里。写调试日志的时候最好在关键位置显式调用std::fflush(stdout)或者直接用stderr它通常不受缓冲影响。std::print不使用std::locale所以不会因为全局 locale 切换导致数字格式变化。这是一个 feature不是 bug。部分平台的实现细节会有差异比如某些版本的 GCC/Clang 需要包含额外的头文件或者对FILE*处理方式不同。遇到编译问题第一件事是查编译器发行说明而不是怀疑自己的代码。4. flat_map 与 mdspan连续存储带来的性能红利4.1 flat_map缓存友好的有序容器std::flat_map在 C23 里正式转正但它和很多人预期的“一个存 pair 的连续数组”不完全一样。标准里的flat_map是底层用两个容器一个存 key一个存 value。这点和 boost 提供的flat_map不同boost 内部是vectorpairKey, Value。为什么标准选择分离的双容器设计一个重要原因是性能语义当需要移动 value 时不需要连带移动 key当只有一个字段需要原地构造时双容器布局更灵活。另一个考虑是异常安全移动整个 pair 数组时一旦中途抛出异常回滚成本很高分离容器可以分别控制。代价是你自己的代码访问迭代器时拿到的不是pairKey, Value而是flat_map::value_type内部包含 key 和 value 的嵌套结构。接口上它尽量向std::map靠齐干扰不大。一个基础使用示例#include flat_map std::flat_mapint, std::string config; config.emplace(3, three); config[5] five; auto it config.find(3); if (it ! config.end()) { std::println({} {}, it-first, it-second); }用起来和std::map几乎一样真正的差别在底层。flat_map适合什么场景我自己的经验是元素数量在几千以内、构建完成后查询频率远高于修改频率、并且对缓存命中敏感。比如程序启动后加载一次配置之后所有线程并发只读查询这种场景flat_map完胜std::map。因为flat_map的二分查找是连续内存访问一次 memmove 可能命中好几个缓存行而std::map的节点分散在堆里每次比较都可能触发一次主存访问。反过来如果容器会频繁插入删除flat_map会很难受。因为有序数组的插入是 O(n) 的移动操作元素一多性能立刻崩。别拿它当万能 map 用。4.2 mdspan不拥有数据的多维数组视图std::mdspan也是 C23 的重头戏之一它解决的问题很实在很多数值计算、图像处理、矩阵算法面对的都是“一维 buffer 二维形状”的布局过去只能手算偏移量。mdspan让你把“形状”直接表达为类型和值并支持不同的内存布局策略。一个最简单的二维矩阵视图#include mdspan #include vector std::vectorfloat buffer(64 * 48); std::mdspanfloat, std::extentsint, 64, 48 image(buffer.data()); float pixel image[10, 20]; // 注意是多维下标运算符 image[3, 5] 1.0f;注意这里image[10, 20]的语法。C20 里operator[]只能接一个参数C23 放开了多参数所以[10, 20]在这里不是逗号表达式而是多维下标。这个语法新特性对矩阵类库是重大利好以前要返回代理对象或者提供at()函数现在直接matrix[i, j]。mdspan支持三种布局策略layout_right行主序C 风格二维数组默认布局最常用。layout_left列主序Fortran 风格适合科学计算里按列访问的算法。layout_stride允许自定义 stride适合子块切片。示例用layout_stride做矩阵的子块访问不需要拷贝数据。using stdex_t std::dextentsint, 2; std::mdspanfloat, stdex_t, std::layout_stridefloat* sub(...);submdspan也在 C23 里转正了它允许你从一个已有的mdspan里切出一个子视图配合layout_stride可以非常优雅地表达矩阵分块、图像 ROI 这些操作。如果你的代码有手算offset y * width x的地方而它积重难返我建议认真看看mdspan。4.3 容器选型的思维转变借flat_map说一个我自己的体会别再只看大 O 复杂度了。现代 CPU 的瓶颈很多时候不在指令数而在内存访问模式。std::map的 O(log n) 跳节点每次访问很可能都 miss 缓存flat_map的 O(log n) 二分查找访问的是连续内存缓存友好度完全不同。工程上真正要权衡的是“构建频率 vs 查询频率”和“元素规模”。如果变化不频繁哪怕数据量到一万flat_map也经常赢。mdspan也是一样的思路把形状从业务逻辑里剥离出来算法只关心维度和步长底层是 vector 还是裸指针根本不重要。代码更可读转置、切块、广播这些操作也变得很自然。5. ranges 库终于补齐了最后一公里5.1 std::ranges::to视图到容器的单向桥C20 的 ranges 很强但有个尴尬点视图是惰性求值的你算完之后想拿回一个vector还是要写std::vectorint(v.begin(), v.end())。C23 的std::ranges::to终结了这个痛点#include ranges #include vector auto evens std::views::iota(0, 1000) | std::views::filter([](int x) { return x % 2 0; }) | std::ranges::tostd::vectorint();现在vector里就是那 500 个偶数中间不需要手写任何迭代器转换。ranges::to不只是对vector有效它支持所有满足容器兼容范围的容器你甚至可以指定分配器auto data nums | std::views::transform([](int x) { return x * 2; }) | std::ranges::tostd::vectorint, MyAllocator();5.2 zip、enumerate、adjacent日常算法的新写法std::views::zip可以把多个 range“拉链”成一个 range每个元素是一个 tuple。这个对于并行遍历两个容器特别自然std::vectorstd::string names {tom, jim, lucy}; std::vectorint scores {80, 90, 95}; for (auto [name, score] : std::views::zip(names, scores)) { std::println({} {}, name, score); }std::views::enumerate给 range 加索引for (auto [idx, ref] : std::views::enumerate(names)) { std::println({}: {}, idx, ref); }std::views::adjacentN做滑动窗口auto diffs nums | std::views::adjacent2 | std::views::transform([](auto p) { auto [a, b] p; return b - a; }) | std::ranges::tostd::vectorint();这些视图单独看都是小东西组合起来以后写算法流水线的体验完全改观。我以前的代码里经常出现几十行手动循环和临时变量现在一条管道链下来读代码的人一眼就知道意图。5.3 string::contains 这类“迟到但真香”的糖C23 还给字符串加了一个小方法contains以前判断子串要写s.find(xx) ! std::string::npos现在直接if (line.contains(error)) { // ... }ranges 版也有std::ranges::contains(vec, 42); std::ranges::starts_with(vec, prefix); std::ranges::ends_with(vec, suffix);这些 API 背后没有新技术但它们是那种“存在但未被充分宣传”的糖能让你每天写代码时省下不少碎碎念。5.4 新视图的几个实战注意点zip在最短 range 耗尽时结束不一定等长。如果要求等长提前断言各 range 的 size 一致。enumerate默认索引从 0 开始。想从 1 开始建议用views::transform调整不要自己造循环计数。adjacentN在元素数不足 N 时产生空视图不会崩溃这比手写循环时越界安全得多。视图不拥有数据这是 ranges 里最经典的坑。如果你把本地临时容器 “变成视图” 返回等视图真正被消费时底层数据已经销毁了。这个问题在 C20 就存在C23 没有改变它所以写的时候一定要确认视图持有的底层 range 生命周期够长。6. 协程、函数对象与语言细节的暗线更新6.1 显式 this 参数C23 里最被低估的改动C23 允许在成员函数参数里显式写出this这个特性官方叫 “deducing this”。它表面是个语法糖实际上解决了好几个老难题。最让我惊艳的是递归 lambda。在过去一个 lambda 想递归调用自己得绕道std::function或者手动传递自己。C23 的写法直接多了auto factorial [](this auto self, int n) - int { return n 1 ? 1 : n * self(n - 1); };在这里self就是当前 lambda 对象。这个写法还天然规避了捕获*this的悬垂问题。对于写模板库、实现多版本 const 业务逻辑的同学显式 this 参数基本是刚需级别的能力强烈建议看一遍提案 P0847 的示例收获很大。6.2 多参数 operator[]、static operator() 与 lambda 的扩展以前写一个二维矩阵类m[i][j]需要靠代理对象否则就得用m.at(i, j)。C23 直接放开多参数operator[]所以写float operator[](int i, int j) { return data_[i * cols_ j]; }就可以用m[i, j]了。这个改动和mdspan放在一起等于把数组下标这条链路彻底完善了。static operator()允许你把函数对象的调用运算符声明为静态成员函数只要这个函数对象不依赖自身状态编译器可以省掉this指针的传递struct IsPositive { static bool operator()(int x) { return x 0; } };lambda 函数体里现在也可以声明static和thread_local变量。C20 里这是不被允许的C23 放开了。于是可以写auto counter []() { static int calls 0; return calls; };这个例子本身没什么价值但它说明语言在逐步拆除限制很多函数式写法会因此变得自然。6.3 auto(x)、size_t 字面量后缀、if consteval 这些“小牙膏”C23 里还有一批改动属于“单个看不大组合起来很舒服”的类型。auto(x)做退化复制比如auto v auto(x)可以从有 cv 限制或引用类型中剥离出一份新对象避免意外把引用放进容器。z和uz字面量后缀直接表示std::size_tstd::size_t n 1z vec.size();以后再也不用写static_castsize_t(1)那种啰嗦的代码了也能消掉不少int和size_t比较的编译警告。if consteval可以在编译期分支和运行时分支之间做显式区分if consteval { // 编译期专用逻辑 } else { // 运行时兜底 }这个在模板元编程和协程实现里都非常有用能避免你在consteval与普通函数之间反复做重载。另外别忘了std::byteswap它生存在bit头文件里专门做字节序翻转。在网络协议和二进制解析场景一行搞定以前要自己写的循环#include bit uint32_t flipped std::byteswap(0x12345678u);6.4 std::generator协程终于有个标准生成器C20 给了我们协程语法但标准库一直没有配套的生成器导致很多人想用但无从下手。C23 加入了std::generatorT基于协程的生成器终于有了官方形态#include generator std::generatorint fibonacci() { int a 0, b 1; while (true) { co_yield a; auto next a b; a b; b next; } }消费端可以继续接入 ranges 流水线for (int x : fibonacci() | std::views::take(10)) { std::println({}, x); }这个特性帮我干掉了很多手写的迭代器状态机。但我必须诚实地说std::generator对编译器的要求比较高GCC 和 Clang 的支持仍在演进MSVC 的支持也开始落地但细节不少。上生产之前务必确定编译器的协程实现和库支持都已就绪。6.5 std::move_only_function当可调用对象只能移动时std::function要求可拷贝但现实中很多回调需要捕获std::unique_ptr这种只能移动的资源。过去你得包一层shared_ptr或者手工擦除类型。C23 新增std::move_only_function专门服务这类场景#include functional #include memory std::move_only_functionvoid() task [u std::make_uniqueint(42)]() { std::println(value: {}, *u); };它和std::function的名字很像但语义不同只允许移动不允许拷贝同时内在实现不需要保留拷贝路径调用开销也更小。做异步任务队列、事件回调这类模块时这个类型比std::function顺手得多。在整个 C23 的语言特性里move_only_function是最容易在现有工程里立刻用上的一个推荐最先尝试。到我写这篇文章为止我用 C23 写生产代码已经有大半年了。如果让我给一个切入顺序我会这样建议先把std::print和string::contains这种零风险特性用起来再逐步引入expected替换手写的错误包装随后根据实际性能瓶颈决定要不要上flat_map和mdspan最后才是generator、move_only_function这类依赖实现成熟度的特性。很多特性单独看只是“甜点”但组合起来日常代码的清晰度会明显高一个档。特别提醒一点C23 标准已经冻结但编译器对各个特性的支持参差不齐写之前多花十分钟查一眼 cppreference 和编译器发行说明比踩完坑再回来查有效率得多。
返回列表