std::expected 跨边界:错误类型膨胀与映射成本
沿库边界拆开错误域映射、异常边界与日志关联,说明为什么随便 map_error 会把诊断信息抹平;给出分层错误模型与可回放的失败路径验收。

1. 一个 map_error 把事故压成一句「定位失败」
上次调一条视觉定位链路,现场只看到上层返回 LocalizationError::Failed。日志里也只有一句「定位失败」,没有相机包序号、没有内参版本、没有是解码失败还是特征过少。开发机重放同一段 bag 时算法又能跑通,最后才发现线上加载的是另一份畸变表,底层配置解析其实已经报过 MissingKey("distortion_model"),中间被两次 map_error 抹成了 ConfigError::Invalid,再到产品层变成 Failed。std::expected 没有错,错在把跨边界映射当成机械翻译:只保留一个 enum 值,丢掉导致失败的证据。
expected<T, E> 适合表达「函数可能失败,失败可被调用方处理」。但一旦函数跨过库边界,E 就不再只是局部实现细节,而是调用方可观察、可聚合、可复盘的契约。边界越多,错误域越多:文件系统有 std::error_code,yaml 解析器有行列号,驱动协议有帧序号和 CRC 状态,算法域有退化几何、收敛失败、质量门限。若每层都把别人的错误硬塞进自己的大 enum,类型会膨胀;若每层都只映射成一个 Unknown,诊断会塌陷。真正要设计的是「哪些信息在本层决策,哪些信息必须随失败穿过边界」。
2. 错误域不是命名空间,是责任边界
错误域的第一条线不是 C++ namespace,而是责任。TransportError::Timeout 说明传感器链路或线程调度需要排查;DecodeError::CrcMismatch 指向协议或线缆;CalibrationError::MissingKey 指向配置发布;TrackerError::NotEnoughInliers 指向图像质量或场景退化。它们可能都发生在同一帧,也可能最终让同一个 service 返回 success=false,但处理动作完全不同。把它们提前合并成 PipelineError::BadInput,调用方就无法判断是重试、跳帧、降级、停机,还是提醒配置回滚。
一个库的错误类型应该先服务本库调用方的决策,而不是试图覆盖全产品。驱动库内部可以用小而稳定的 DriverError,含协议状态和原始序号;标定加载库可以暴露带路径和键名的 ConfigError;视觉算法库可以暴露 TrackingError,强调几何退化和质量门限。跨到应用边界时再做归并:比如产品只关心 RetryableInput、BadConfiguration、UnsafeState。这不是降低精度,而是把决策层级写清楚。局部错误域越贴近机制,上层公共错误域越贴近动作,中间映射才有意义。
3. map_error 的成本:类型变小,证据也变少
map_error 最容易被误用成「把编译器要求的 E 对齐」。链式写法很顺手:解析配置、打开设备、读取第一帧、初始化跟踪器,每一步都 map_error(to_init_error)。代码表面干净,事故时却发现所有错误只有 InitError::Failed。这是因为映射函数若只返回目标 enum,原始错误里的路径、偏移、errno、帧序号、阈值、算法迭代状态都没地方放。类型统一了,诊断信息被压缩成不可逆的投影。
判断一次映射是否安全,可以问三件事:调用方还需不需要根据原始域做不同动作;日志和 metric 是否仍能按原始原因聚合;同一输入是否能在离线环境重放到同一个失败点。三者任一为真,映射就不能只保留目标 enum。map_error 应该像数据库 schema migration 一样被审查:源字段是什么,目标字段是什么,哪些字段被主动丢弃,丢弃是否有明确理由。随手写的 lambda 往往比显式函数危险,因为 review 时看不到它是一个边界转换。
auto cfg = load_calibration(path)
.transform_error([](ConfigError e) {
return InitError{InitErrorKind::BadConfiguration};
});上面这段可编译,却把 path、key、line、errno 全部丢掉。更合理的写法不是把 InitError 做成吞万物的大结构,而是在边界保留可审计的来源摘要。
InitError to_init_error(ConfigError e, OperationId op) {
return InitError{
.kind = InitErrorKind::BadConfiguration,
.op = op,
.source = ErrorSource::CalibrationFile,
.reason = e.kind,
.detail = ConfigDetail{.path = e.path, .key = e.key, .line = e.line},
};
}4. 分层错误模型:叶子精确,边界保真,产品可行动
可维护的模型通常分三层。第一层是叶子错误,贴近实现机制,给本库测试和局部调用方使用。它应该小、稳定、可穷举,不要塞长字符串。第二层是边界错误,负责把叶子错误带出库,同时补上上下文:操作 ID、输入摘要、组件名、版本、可重放标识。第三层是产品错误,面向 UI、service result、告警和降级策略,数量更少,语义是动作而不是原因。叶子层回答「哪里坏了」,产品层回答「系统怎么处理」,边界层保证两者之间不断链。
这种模型可以避免错误类型膨胀。若上层每接入一个库都把它的 enum 值复制进 AppErrorKind,一年后 AppErrorKind 会变成所有团队的垃圾桶。反过来,若 AppErrorKind 只有 Unknown,所有事故都要找底层同学翻历史日志。折中不是平均,而是分层:产品层保留稳定的动作分类,边界层保留原始域名和原始码,必要字段结构化存储。公共 API 不必暴露所有叶子 enum,但必须能让工程师追回叶子失败。
enum class AppErrorKind { RetryableInput, BadConfiguration, UnsafeState, Internal };
struct ErrorTrace {
std::string_view domain;
uint32_t code;
OperationId op;
FrameId frame;
SmallContext context;
};
struct AppError {
AppErrorKind kind;
ErrorTrace trace;
};ErrorTrace 不是给终端用户看的文案,而是给日志、metric、回放脚本和 oncall playbook 用的证据。它不应该持有随生命周期飘走的引用,也不应该把任意大对象塞进错误路径。热路径可以用小 buffer、枚举码和固定字段;重对象进入离线 artifact,由 trace 里的 ID 关联。
5. 映射函数要命名成边界,而不是工具函数
如果代码里到处都是 to_error、convert、map,很难看出谁在定义契约。边界映射应有明确名字,例如 config_to_init_boundary_error、driver_to_node_error、tracker_to_service_error。名字啰嗦一点没关系,它提醒调用方:这是一次语义裁剪,不是类型体操。映射函数也应该集中在 adapter 层,而不是散落在每个 call site。散落的映射会让同一个 CrcMismatch 在 A 节点变成 RetryableInput,在 B 节点变成 Internal,事故聚合时看起来像两个问题。
边界函数里要写清楚默认分支。对叶子 enum 的 switch 尽量穷举,不要一开始就 default: Unknown。当底层新增错误码时,编译失败比线上默默归类更好。公共 ABI 需要保守时,可以有 UnknownSourceCode,但同时记录原始整数码和库版本。映射还要决定日志归属:通常在边界打一条结构化日志,库内返回错误不主动 log。否则同一次失败会在三层各打一遍,既刷屏,也让人误以为发生了三次失败。
std::expected<Tracker, InitError> make_tracker(const Paths& paths, OperationId op) {
auto calib = load_calibration(paths.calib);
if (!calib) return std::unexpected(config_to_init_boundary_error(calib.error(), op));
auto model = load_model(paths.model);
if (!model) return std::unexpected(model_to_init_boundary_error(model.error(), op));
return Tracker{*calib, *model};
}这段代码没有追求链式最短,而是让两个边界映射可见。工程代码里,可读的失败路径比炫技式 monadic 链更值钱。
6. 异常边界:外部库可以抛,内部契约不要漂移
现实工程里不会所有依赖都返回 expected。文件库可能抛 filesystem_error,yaml 库可能抛解析异常,vendor SDK 可能把 timeout、断连和协议错误都放进 std::runtime_error。问题不在于这些库抛异常,而在于异常能不能越过本模块边界。边界适配层应尽早 catch,转换成本域错误,并记录可回放字段。让异常穿过 rclcpp callback、插件 ABI、线程入口或 C 接口,都是把控制流交给未约定的地方。
异常到 expected 的转换也不能只写 catch (...) { return Unknown; }。至少要区分可恢复输入错误、配置错误、资源耗尽、编程错误。编程错误比如不变量破坏、越界访问、空指针解引用,不应被包装成业务失败继续运行;这些路径要 assert、fail fast 或进入安全态。可恢复错误才进入 expected。这条线如果不清楚,系统会把 bug 当成坏数据吞掉,后续状态被污染,复盘反而更难。
std::expected<YamlDoc, ConfigError> parse_yaml(Path p, OperationId op) {
try {
return YamlDoc::load(p);
} catch (const yaml::ParserException& e) {
return std::unexpected(ConfigError::syntax(p, e.line(), e.column(), op));
} catch (const std::filesystem::filesystem_error& e) {
return std::unexpected(ConfigError::io(p, e.code(), op));
}
}catch 的位置靠近外部库,错误类型属于本库。上层不需要知道 yaml 库的异常类,但仍能知道行列号和路径。
7. 日志关联 ID:错误对象不是日志替代品
错误对象负责把决策所需信息带到调用方,日志负责把一次运行的证据落盘。两者要关联,而不是互相替代。常见反模式是错误里塞一大段 human message,日志再打印同一段字符串;看似信息丰富,实际无法聚合,也无法和 bag 时间轴对齐。更稳的做法是为每次外部请求、每帧输入或每次启动流程生成 OperationId,在错误对象、日志、metric 和回放 artifact 中复用。同一个失败从底层到 service result 应共享这个 ID。
关联 ID 不一定是 UUID。实时链路里,boot_id + node_instance + frame_seq + stage 往往更可读;离线初始化流程里,config_digest + op_counter 足够。关键是稳定、低成本、能定位输入。日志字段至少应包含 event、error_domain、error_code、app_error_kind、op_id、frame_id 或 config_digest。可读文案可以有,但不能替代结构化字段。metric 则按低基数字段聚合,不要把路径和随机 ID 放进 label。
RCLCPP_WARN(logger,
"event=tracker_init_failed app_error=%u error_domain=%s error_code=%u op_id=%s config_digest=%s",
to_u32(err.kind), err.trace.domain.data(), err.trace.code,
err.trace.op.to_string().c_str(), digest.c_str());日志只在边界打一条,内容来自错误对象。这样 service 返回、metric 计数和日志检索能指向同一个失败,而不是三套互不相干的字符串。
8. std::expected 的 API 形状要保护调用方
跨边界 API 返回 std::expected<T, E> 时,E 的可复制、可移动和生命周期都要想清楚。错误路径不能引用栈上临时字符串;不能要求调用方在错误还活着时保持某个解析器对象不析构;也不要把 std::exception_ptr 当万能错误塞出去。exception_ptr 可以用于线程边界转交未处理异常,但它不是稳定的产品错误契约,调用方无法穷举,也难以序列化。
E 还要避免过度模板化。expected<T, variant<A, B, C, D>> 表面保留了所有域,实际把每个调用方都变成模式匹配专家;expected<T, std::string> 表面简单,实际失去结构。边界层的 AppError 或 InitError 可以保留 domain + code + context,既不要求上层 include 所有底层头文件,也不牺牲回溯能力。ABI 稳定场景下,公共错误码用固定宽度整数,结构体字段版本化;C++ 内部再用强 enum 和构造函数保证类型安全。
调用方侧也要有纪律。value() 只适合测试或已经由控制流证明成功的地方;生产边界应显式检查并把错误交给统一处理函数。or_else 里不要偷偷 log 后继续返回默认值,除非产品策略明确允许降级。默认值是最隐蔽的信息损失:配置坏了却用默认内参启动,比直接失败更危险,因为后续所有算法结果都看似合法。
9. 可回放失败路径:验收不止断言 !result
错误模型设计完,必须用失败路径验收,而不是只测成功路径。最小单测要覆盖每个叶子错误到边界错误的映射,断言 kind、domain、code、关键上下文字段都在。再往上,要有 fault injection:缺配置键、坏 CRC、空帧、模型文件权限错误、特征点不足,分别触发不同产品动作。若五种输入最后都只得到 Internal,说明映射仍在抹平信息。
可回放验收关注的是「事故发生后能否复现同一失败」。因此每个失败日志都要能指向输入:bag 名、frame 序号、配置 digest、模型版本、随机种子、重要阈值。CI 可以保存小型 golden artifact:一段 corrupt packet、一个缺键 yaml、一个退化图像序列。测试断言不仅是返回失败,还要断言 replay 脚本能用日志里的字段找到 artifact,并在同一阶段得到同一 error_domain/code。这比「错误消息包含某中文短语」可靠得多。
边界映射也要做回归测试。底层新增 DecodeError::UnsupportedVersion 时,测试应迫使 adapter 明确映射到 BadConfiguration 还是 RetryableInput,并决定是否进入告警。若使用 default 自动吞掉,测试就失去意义。对高风险链路,可以把错误码分布作为回放报告的一部分:同一数据集升级后,成功率变化不够,还要看失败原因是否从 NotEnoughInliers 漂到 CalibrationMissing。错误域变化经常比总体失败数更早暴露集成问题。
10. 公共错误码要版本化,不能复用旧语义
跨库边界一旦进入公共头文件、插件 ABI、service result 或日志字段,错误码就有了兼容性成本。最忌讳的是「这个码没人用了,拿来表示新错误」。历史 bag、旧 dashboard、客户现场脚本仍会按旧语义解释它,复盘时就会把配置错误看成协议错误。公共错误码应只追加、不复用;废弃码保留名字和文档,映射层可以不再产生,但不能改含义。若必须重排内部 enum,公共输出也要用显式数值和转换函数,不要直接 static_cast 内部枚举。
版本化还影响 map_error 的默认策略。底层库升级后,adapter 遇到未知叶子码时可以返回 Internal 或 UnknownSourceCode,但日志必须包含原始 domain、整数码、库版本和构建 SHA。这样 oncall 至少知道「上层不认识这个错误」,而不是误判为业务失败。对于跨语言边界,公共 schema 里应把 kind、domain、code、context_version 分开;新增 context 字段时旧客户端可以忽略,新客户端能读取。把所有内容塞进 message 字符串,短期省事,长期会让每个消费者都写一套脆弱解析器。
文档也属于契约。错误码表不需要写成厚手册,但每个公共 AppErrorKind 至少要有含义、典型来源、调用方动作和可观测字段。例如 BadConfiguration 的动作是停止启动并提示回滚配置,典型来源包括 yaml 缺键、模型版本不匹配、标定 digest 不在白名单;RetryableInput 的动作是跳过当前帧并计数,典型来源包括 CRC 错、空帧、临时 timeout。这样评审映射函数时,大家讨论的是动作是否正确,而不是名字是否顺眼。
11. 端到端路径:从坏帧到 service result 不断链
把模型落到一条帧路径上更直观。输入包进入 driver,driver 只负责协议事实:长度、版本、CRC、序号。它返回 expected<Packet, DriverError>,错误里有 frame_seq、wire_code 和接收时间。node adapter 把 DriverError::CrcMismatch 映射到 NodeError{RetryableInput},保留 domain=driver、code=CrcMismatch、frame_seq,并递增 input_errors_total{domain="driver", code="crc_mismatch"}。tracker 完全不接触坏包,因为动作是跳帧;service result 若需要反馈,只返回本次请求失败和 op_id,不暴露底层协议细节。
另一条路径是配置缺键。配置库返回 ConfigError::MissingKey(path, key, line);初始化 adapter 映射为 InitError{BadConfiguration},日志包含配置 digest、路径摘要、键名和行号;lifecycle node 停在 inactive,service result 给出稳定错误码和 op_id。这里不能被映射为 RetryableInput,因为重试同一输入不会变好;也不能吞掉后用默认值启动,因为后续定位漂移会把根因藏到算法层。相同的 std::expected 形状,动作却完全不同,差别来自错误域和边界上下文。
auto packet = driver.read_next(op);
if (!packet) {
auto err = driver_to_node_error(packet.error(), op);
log_boundary_error(err);
metrics.count(err);
return Action::SkipFrame;
}
auto pose = tracker.track(*packet);
if (!pose) {
auto err = tracker_to_node_error(pose.error(), op);
log_boundary_error(err);
return policy.decide(err);
}这段流程的重点不是少写几行,而是每个失败只在自己的边界转换一次。driver 不知道产品策略,tracker 不知道协议细节,node policy 不需要 include 所有底层错误类型,却能按 domain/code/op_id 找回原始失败。若现场报「定位服务失败」,工程师应能从 result 的 op_id 查到边界日志,再用 frame_seq 或 config_digest 找到输入,离线重放到同一个 driver 或 config 错误。能走通这条链,错误处理才算被验收;只看到 expected 返回失败,还远远不够。
12. 边界所有权:谁映射,谁维护诊断能力
错误映射需要明确 owner。底层库维护叶子错误和本库单测,adapter 维护跨边界转换,产品层维护动作分类和告警策略。若没人拥有 adapter,映射函数就会随调用方复制粘贴;若底层随意改 enum 名称,adapter 又会被迫在每个版本里救火。一个可执行的规则是:新增叶子错误码的 PR 必须同时更新边界映射测试;新增产品动作分类的 PR 必须说明哪些底层域会映射进来,以及旧错误是否需要迁移。这样错误模型跟代码一起演进,而不是事故后补文档。
评审时不要只看 expected 有没有检查,还要看失败是否仍可诊断。return std::unexpected(AppError::internal()) 这种代码应触发追问:原始域是什么,是否有 op_id,是否能被 metric 聚合,是否有回放输入。若答案都没有,它只是把异常换成了返回值。相反,某些地方保留底层码并不等于泄露实现细节;只要公共动作稳定、上下文版本化,记录 domain=decoder code=crc_mismatch 是诊断能力,不是 API 混乱。
团队还要约定错误字符串的角色。字符串适合给人读,不能作为机器契约。中文、英文、标点、库升级都会改变字符串,测试和告警若依赖 substring,迟早失效。机器契约是 enum、整数码、固定字段和 schema 版本;展示层再把它们翻译成文案。这样 service 可以返回克制的用户信息,日志保留工程字段,dashboard 按码聚合,三者不互相污染。
最后,错误边界应写进模块 README 或 ADR 的一小节:本模块对内返回什么 E,对外映射成什么公共错误,哪些异常会被 catch,哪些不变量失败会直接终止,哪些字段用于回放。文档短,但必须贴着代码和测试。没有这个所有权,std::expected 很快会退化成另一种 int error_code,只是外面包了一层现代 C++ 语法。
13. 取舍:让错误足够厚,但不要变成黑匣子
错误对象需要带上下文,但不能无限长。热路径里复制大字符串、完整 JSON、图像块或矩阵,会把失败处理变成新的性能问题。可以把大对象写入 artifact store 或 debug dump,用 OperationId 关联;错误里保留摘要、哈希、尺寸、关键阈值和原始域码。边界层也不要为了「以后可能有用」把所有底层类型暴露出去,否则公共 API 会被实现细节绑死。厚度的判断标准很朴素:调用方能做动作,oncall 能定位输入,开发者能复现失败。
还有一个容易被忽略的取舍是隐私和体积。配置路径可能暴露客户目录,图像摘要可能关联现场数据,异常 message 可能带第三方库内部路径。边界错误应区分「可进日志的字段」和「只能进本地 artifact 的字段」,必要时做哈希或脱敏。诊断能力不是把所有东西都打印出来,而是在合规范围内留下足够索引,让授权工程师能沿索引找到原始证据。
这条线提前定清楚,排障时才不会在「想多留证据」和「不能扩大暴露面」之间临时摇摆。
规则越早固化,事故中越少临场猜测。
std::expected 的价值不是让代码看起来没有异常,而是把失败作为可检查的值传递。跨库边界时,这个值同时承担契约、证据和降级入口。随便 map_error 会让类型系统安静,却让事故复盘失声;完全不映射又会让上层被底层细节淹没。工程上可落地的答案是分层错误域、显式边界映射、一次结构化日志、稳定关联 ID,以及能回放到同一失败点的验收。做到这些,expected 才不只是语法选择,而是系统可诊断性的骨架。
相关
也可以看看
- ·16 分钟阅读
C++ 协程的取消与生命周期:悬空 coroutine_handle 从哪里来
沿 promise、awaiter、continuation 和 frame 所有权拆开协程销毁契约;用结构化并发封装,并以等待方先销毁、I/O 晚完成三类竞态验收。
- ·9 分钟阅读
std::pmr 帧级内存池:释放快不等于对象能跨帧活
用 monotonic_buffer_resource 管理帧级临时对象,讲清 memory_resource、upstream、allocator 传播、release 后悬垂、线程边界,以及无堆分配测试与基准方法。
- ·3 分钟阅读
std::jthread 停线程:自动 join 不等于能打断阻塞
用 C++20 stop_token、condition_variable_any 和成员析构顺序,做一条能在关闭时可靠退出的传感器工作线程。
johan's blog