题干与适用场景
订单服务依次执行输入校验、库存预留和扣款。库存不足、余额不足和外部依赖超时都是可预期失败;程序员错误、违反类不变量和无法继续的资源故障需要单独处理。面试官要求你使用 C++23 std::expected 设计返回类型、组合调用和错误记录。
这道题考察错误契约和可组合代码。std::expected<T, E> 表示一个对象要么持有值 T,要么持有错误 E;它不会自动替你重试、记录或回滚。回答应先定义错误域,再说明调用方如何检查结果,最后交代异常边界和资源补偿。
面试官在评估什么
- 能否区分业务失败、外部失败和程序不变量破坏。
- 能否正确使用
has_value、value、error、unexpected和[[nodiscard]]。 - 能否用
andthen、transform、orelse组合不抛异常的失败路径。 - 能否避免把
std::expected当成全局异常替代品或裸字符串错误。 - 能否处理错误映射、日志去重、幂等和回滚边界。
回答前需要澄清的问题
- 代码库是否已启用 C++23、对应标准库是否实现目标 API?先确认编译器和库版本。
- 错误是给同一层调用方处理,还是要跨服务边界序列化?跨边界需要稳定错误码。
- 库函数是否拥有锁、文件句柄或预留库存等资源?错误返回前必须说明清理与补偿。
- 订单步骤是否幂等?重试扣款和重复释放库存的风险不同。
- 需要保留哪些诊断字段?用户可见消息和内部日志不应混用。
30 秒回答框架
“我先把可预期业务失败建模为 std::expected<T, OrderError>,让调用方显式处理;用 [[nodiscard]] 防止忽略结果,再用 andthen 串起校验、预留和扣款,用 orelse 统一错误映射和指标。异常保留给违反不变量、初始化失败或无法在当前边界安全恢复的情况。每一步都要有幂等键、资源补偿和不泄露敏感信息的日志。”
深度回答步骤
先划分错误域
OrderError 应表达调用方能采取行动的类别,例如 invalidrequest、outofstock、paymentdeclined 和 dependency_timeout,并携带稳定的内部上下文。不要把用户文案、堆栈和供应商原始错误直接塞进一个字符串。程序员错误和类不变量破坏不应被静默转换为普通业务失败。
选择值类型与错误类型
校验函数可以返回 std::expected<ValidatedOrder, OrderError>,预留函数返回 std::expected<Reservation, OrderError>,扣款函数返回 std::expected<Receipt, OrderError>。让错误类型可复制、可移动且足够小;若需要大诊断对象,用受控引用或共享所有权,但要明确生命周期。结果函数应标注 [[nodiscard]],避免调用方忘记检查。
用显式检查保证清晰边界
当步骤少或需要在不同错误上采取不同动作时,显式检查很清楚:
[[nodiscard]] auto reserve(const ValidatedOrder& order)
-> std::expected<Reservation, OrderError>;
auto create_order(Input input) -> std::expected<Receipt, OrderError> {
auto valid = validate(std::move(input));
if (!valid) return std::unexpected(valid.error());
auto held = reserve(*valid);
if (!held) return std::unexpected(held.error());
return charge(*valid, *held);
}operator* 和 value() 只应在已确认有值时使用;错误路径必须保留原始类别,不能把库存不足伪装成通用失败。
在同构链路中使用 monadic 操作
当每一步都返回 std::expected 时,andthen 可以在有值时继续下一步,遇到错误时短路;transform 用于把成功值映射成另一种值;orelse 用于记录或转换错误。C++23 标准库还提供 transform_error,可在边界统一错误类型。组合操作应保持副作用顺序可见,不能把扣款、重试和补偿隐藏在难以审计的链中。
定义异常边界
网络超时、库存不足和支付拒绝通常是可预期结果,返回 expected 让业务层决定重试、提示或人工处理。违反类不变量、内存分配失败或初始化阶段无法建立服务时,异常仍可表达“当前调用方无法安全恢复”。边界必须统一:不要让同一个函数有时返回业务错误、有时把同一类错误抛异常。
处理副作用、幂等与补偿
expected 只传递结果,不会撤销已经完成的副作用。库存预留成功、扣款失败时,需要释放预留或进入可重试的补偿状态;扣款重试必须带幂等键。错误对象应携带订单号、步骤和重试建议等非敏感诊断字段,日志系统再关联 trace,而不是把支付凭据写入错误。
设计可测试的错误契约
测试每个错误分支、组合短路、错误映射和补偿顺序;还要测试意外的异常是否穿过边界并被统一捕获。编译器版本、标准库特性宏和构建选项要在 CI 固定。跨服务时把 OrderError 映射到稳定协议码,避免把 C++ 类型名当作 API 合约。
高质量示范回答
“我会定义 OrderError,包含稳定的类别和内部诊断字段;校验、预留和扣款分别返回带 [[nodiscard]] 的 std::expected。业务失败如库存不足、支付拒绝和超时由调用方显式处理,不能把它们全部变成异常。
步骤少时我用 if (!result) 传播 std::unexpected(result.error()),保证每个副作用边界可审计;同构的纯转换可以用 andthen 和 transform,用 orelse 统一记录和错误映射。value() 只在已确认成功时访问。
预留成功后扣款失败不会被 expected 自动回滚,所以我会用幂等键记录状态,并执行释放预留或补偿任务。异常保留给不变量破坏、初始化失败等当前边界无法安全恢复的情况。CI 固定 C++23 工具链,测试所有错误分支、短路和补偿,并在服务边界把错误映射为稳定协议码。”
常见错误
- 把
std::expected当自动重试器:返回类型不提供重试语义 → 在业务层按错误类别和幂等策略决定。 - 用
std::optional丢掉错误原因:调用方无法区分库存不足与超时 → 使用稳定错误类型。 - 忽略
[[nodiscard]]:调用方可能直接丢弃失败 → 为结果函数加属性并在 CI 检查警告。 - 未检查就调用
value():失败状态会触发badexpectedaccess→ 先判断或使用明确的分支。 - 把所有异常改成错误码:不变量破坏可能被静默吞掉 → 保留清晰的异常边界。
- 错误对象写入敏感信息:日志或序列化泄露凭据 → 分离用户消息、稳定码和内部诊断。
- 忽略已完成副作用:扣款失败后库存预留仍存在 → 设计幂等状态和补偿路径。
- 跨服务直接传 C++ 类型:版本升级破坏协议 → 映射到稳定协议码和版本化字段。
追问与回答
追问 1:std::expected 和异常怎么选?
可预期、调用方能处理的业务结果适合 expected;违反不变量、初始化失败或当前边界无法安全恢复的情况可以用异常。关键是同一错误类别在一个边界内保持一致,不要让调用方猜测控制流。
追问 2:为什么不用 std::optional<T>?
optional 只能表示有值或无值,不能携带失败原因。订单流程需要根据库存、支付和依赖超时采取不同动作,因此需要 expected<T, E>。
追问 3:and_then 里可以执行扣款吗?
可以,但要让副作用边界清晰,并保证每一步可重试或可补偿。若链路过长、错误策略不同,显式 if 往往更易审计;不要为了函数式外观隐藏状态变更。
追问 4:如何统一多个下游的错误类型?
在服务边界定义稳定的领域错误,底层供应商错误映射到有限类别并保留内部原因。用 transformerror 或 orelse 做映射,同时记录供应商码和 trace,不把供应商文案直接暴露给用户。
追问 5:错误返回会不会比异常更慢?
应测量实际路径。expected 让常见失败成为显式返回,但可能复制错误对象或增加分支;异常通常把成本集中在抛出路径。选择应根据延迟目标、编译器和错误频率基准,而不是绝对宣称一种更快。
追问 6:如何保证调用方不会忘记检查?
给返回类型和关键函数加 [[nodiscard]],把编译警告提升为 CI 失败,并在代码审查中检查 value() 使用点。对跨语言 API,再用协议层状态和契约测试补充编译器无法覆盖的保证。