“没有读数”和“读数字符串不合法”不是同一回事。需要让调用方区别处理失败原因时,只有一个空值往往不够。C++23 的 expected<T, E> 把成功值与错误值放进返回类型,move_only_function 则让持有独占资源的行为也可以作为对象传递。
本文是内存数据示例,所有“策略”都只计算整数,不连接设备、不下发动作。上一节见 C++20 的约束、范围与视图,目录见 程序设计知识分类。
1. 先给失败原因一个明确位置
expected<int, Error> 表示成功时持有整数,失败时持有 Error。与 C++17 的 optional<int> 相比,差别不是能否表达“无结果”,而是失败原因成为了接口的一部分。unexpected 用于明确构造错误分支。
示例先解析十进制整数,再检查毫米数是否处于 0 到 1000,最后转换成整数厘米。解析失败和业务范围不合要求分别使用错误码 1、2。超出 int 表示范围的文本也算解析失败;这是本程序明确选择的错误分类。
2. 完整程序:结果流水线与独占策略
下载 cpp23_expected.cpp,或直接保存下列完整程序。
|
在源码所在目录运行:
g++ -std=c++23 -Wall -Wextra -Werror -pedantic cpp23_expected.cpp -o cpp23_expected |
准确输出:
120: cm=12 |
已在 GCC 14.2.0、libstdc++ 14(__GLIBCXX__=20250315)下编译运行。本环境的 __cpp_lib_expected 为 202211L,因此实际运行上面的 monadic 分支。仅写 -std=c++23 不保证某套编译器与标准库已经实现所有功能;同一个编译器还可能搭配不同标准库。
3. expected 是一种结果约定,不是异常的自动替代品
调用方先检查 result,再读取 *result 或 result.error()。解引用要求当前确实有值;error() 要求当前确实是错误。value() 在错误状态会抛出 bad_expected_access,因此“返回了 expected”不代表整个程序从此不会抛异常。std::expected 提案 P0323R12
expected 本身也不会逼调用方处理返回值;项目仍可在重要函数上使用 [[nodiscard]]、告警和评审约定。适合放进 E 的是调用方能据此采取行动的信息,例如错误种类、字段或偏移位置,而不是把所有实现细节随意塞进一条字符串。
本例选择将可预期的输入错误作为返回值。内存分配失败、第三方库异常以及策略中的异常不会自动转换为 Error。在边界层统一转换、向上传播或终止,应由系统的错误处理约定决定。
解析器会要求字符全部被消费,因此 120mm 会失败;from_chars 也不会自动跳过开头空白。整数厘米转换使用除法,所以 129 毫米会得到 12 厘米;如果业务需要保留小数,就应改变成功类型和精度约定。
4. and_then 连接可能失败的步骤,transform 转换成功值
and_then(validate_mm) 的函数本身返回 expected,适合连接下一个仍可能失败的阶段。transform 的函数返回普通值,这里把毫米转换为厘米,再由设施包装成新的成功结果。前一步已经失败时,后续的成功处理函数不会执行,错误继续传递。Monadic Functions for std::expected,P2505R5
当前链中的错误类型一直是 Error,没有暗中把错误码改成字符串。需要转换错误类型时,可考虑 transform_error;需要在失败后接另一条返回结果的处理路径时,可考虑 or_else。应先明确传播或恢复规则,再决定是否写成链式形式。
“monadic”在这里不需要先学完整的抽象代数:可以先把它理解为遵守成功与失败传播约定的组合接口。短链有助于表达数据流;复杂恢复、记录上下文或多路合并时,明确的 if 分支往往更好读。
5. 特性宏必须区分基础接口和后续扩展
本文包含 <version>,并同时包含实际设施的头文件。两个 expected 检查点不能混用:
__cpp_lib_expected >= 202202L:支持基础expected。__cpp_lib_expected >= 202211L:支持本文使用的 monadic 操作。__cpp_lib_to_underlying >= 202102L:支持to_underlying。__cpp_lib_move_only_function >= 202110L:支持move_only_function。
代码在 expected 只有基础接口时使用显式分支实现同样的传播逻辑;其他两个设施是本完整程序的必需项。缺少头文件或语言支持时,仍需升级或更换工具链,特性宏不是补齐实现的机制。各宏值及 libstdc++ 的实现记录见 官方状态表。
to_underlying 来自 <utility>,把枚举值转换为其底层整数类型,省去手写可能与声明不一致的强制转换。它不会校验外部整数是否对应合法枚举项,也不会自动决定错误码的长期兼容策略。
6. move_only_function:让策略可以拥有独占资源
程序把 unique_ptr<int> 移入 lambda 的初始化捕获中。lambda 因为持有不可复制的 unique_ptr,也不能被复制,但可以移动。C++11 的 std::function 要求所保存目标可复制,所以不能直接保存这个目标;C++23 的 move_only_function 能承接它。
这里形成一个小型 Strategy 结构:调用方依赖 int(int) 这一行为接口,具体如何使用增益由捕获状态的策略对象决定。类型擦除带来灵活性,也可能引入间接调用和存储管理成本;如果编译期就知道唯一实现,普通 lambda 或模板参数通常更简单。
move_only_function 自身只能移动,不能复制。不要在移动后继续假定源包装器仍有原先的行为,也不要把“能移动”误读成“自动线程安全”。调用前的 strategy 判断很重要:空的 move_only_function 不满足调用前提,不能期待它像空 std::function 那样抛出 bad_function_call。N4950:func.wrap.move.inv
捕获 unique_ptr 是把所有权移动进闭包;捕获普通引用则仍是借用,包装器不会延长被引用对象的生命期。若策略进入队列或异步任务,仍然要检查它依赖的对象能否一直存活。
7. 新标准应让责任更清楚
本例的新内容是 C++23 的 expected、其 monadic 操作、to_underlying 与 move_only_function。其中 from_chars 与结构化绑定来自 C++17,lambda 初始化捕获和 make_unique 来自 C++14,unique_ptr、移动语义与 std::function 来自 C++11,RAII 的历史更早。
新设施值得采用的理由,是它们让“成功还是失败”“错误由谁解释”“资源能否复制”“行为由谁持有”更清楚。若这些责任仍然含糊,换一个更现代的类型名不会自动改善设计。
返回 程序设计知识分类,或回看 C++17 的值、状态与借用 与 C++20 的约束、范围与视图。