Files
build_infra/skill/cpp-error-handling-design/SKILL.md
T
2026-06-16 10:23:51 +08:00

5.9 KiB

错误处理分层设计 Skill

目标

设计错误处理时,底层通用设施应尽可能开放,不替调用者决定错误处理方式;高层业务接口应收束语义,避免暴露过多错误处理范式,降低使用成本和实现复杂度。

核心原则

1. 底层通用函数:提供错误处理范式,不限制调用者

底层函数、宏、工具类、适配层应尽可能保留完整错误信息,并支持不同调用风格,例如:

  • 返回错误码
  • 返回 expected<T, E>
  • 抛出异常
  • 回调式错误处理
  • 日志记录后继续传播
  • 捕获异常并转换为统一错误对象

底层的职责不是判断哪种方式“最好”,而是提供足够通用的错误表达能力,使不同调用者可以根据自己的上下文选择合适方式。

底层设计应优先关注:

  • 错误信息完整
  • 错误来源明确
  • 可组合
  • 可转换
  • 不丢失上下文
  • 不强制调用者使用某一种错误处理模型

示例原则:

// 底层可以提供统一的错误对象
struct error_info {
    int code {};
    std::string message;
    std::string file;
    int line {};
    std::string function;
};

// 底层提供构造错误的统一入口
#define MAKE_ERROR(code, msg) \
    error_info{code, msg, __FILE__, __LINE__, __func__}

底层可以围绕这个统一错误对象,再提供不同适配形式:

// expected 风格
expected<T, error_info>

// 异常风格
throw project_exception(error_info)

// 错误码风格
return error_info

// 回调风格
on_error(error_info)

2. 高层函数:根据语义选择一种主要错误模型

高层函数不应该为了“灵活”而同时暴露多种错误处理接口。

错误示例:

foo();
foo_expected();
foo_nothrow();
foo_with_error_code();

除非确实有强需求,否则这种接口会增加维护成本,也会让调用者不知道应该使用哪个。

高层函数应根据语义选择一种主要错误模型:

简单高层函数

简单操作可以直接使用异常。

适合场景:

  • 调用失败通常不是正常业务分支
  • 调用者一般无法局部恢复
  • 失败代表流程应中断
  • 接口希望保持简洁
User load_user(UserId id); // 找不到、数据库异常等直接抛异常

复杂高层函数

复杂业务流程可以使用 expected<T, E> 表示预期内的失败结果。

适合场景:

  • 失败是业务的一部分
  • 调用者需要根据失败类型分支处理
  • 成功和失败都是接口语义的一部分
  • 不希望用异常表达正常业务分支
expected<Order, CreateOrderError> create_order(CreateOrderRequest req);

例如:

  • 参数校验失败
  • 库存不足
  • 权限不足
  • 用户状态不允许
  • 资源不存在

这些属于“预期的失败结果”,适合用 expected

3. 异常表示错误,但不等于错误处理

异常是一种错误传播机制,不是完整的错误处理策略。

异常适合表达:

  • 不可继续的错误
  • 非预期错误
  • 系统错误
  • 编程错误
  • 资源错误
  • 调用者当前层级无法处理的问题

但异常本身不负责:

  • 是否重试
  • 是否降级
  • 是否返回默认值
  • 是否记录日志
  • 是否转换成 HTTP 响应
  • 是否转换成 UI 错误提示

这些应由更高层的边界处理逻辑完成。

4. expected 表示预期结果,异常表示非预期错误

推荐约定:

expected<T, E>:用于表达业务上可预期、调用者应当处理的失败
exception:用于表达非预期、当前层级无法合理处理的错误

例如:

expected<User, LoginError> login(name, password);

登录失败是业务结果,所以适合 expected

Config load_config(path);

配置文件损坏、磁盘读取失败、解析器内部异常等,可以直接抛异常,因为这通常不是普通业务分支。

5. 边界层负责错误收束

在模块边界、线程边界、HTTP 边界、RPC 边界、插件边界,应捕获并转换错误。

例如 HTTP 层:

try {
    auto result = service.do_work(req);

    if (!result) {
        return make_business_error_response(result.error());
    }

    return make_success_response(*result);
}
catch (const project_exception& e) {
    return make_internal_error_response(e.info());
}
catch (const std::exception& e) {
    return make_unknown_error_response(e.what());
}
catch (...) {
    return make_unknown_error_response("unknown exception");
}

边界层的职责是:

  • 捕获异常
  • 统一日志
  • 统一响应格式
  • 避免异常逃逸到框架之外
  • 把内部错误转换成外部协议错误

6. 不要在每一层都处理错误

中间层如果没有恢复能力,不应该假装处理错误。

错误示例:

try {
    return lower_call();
}
catch (...) {
    log_error();
    throw;
}

如果只是重复日志、重复包装、重复捕获,可能导致:

  • 日志重复
  • 栈信息污染
  • 错误语义变模糊
  • 维护成本增加

中间层只有在以下情况才应该处理错误:

  • 能恢复
  • 能补充关键上下文
  • 能转换错误语义
  • 能降级
  • 能重试
  • 到达了模块边界

7. 推荐分层模型

底层工具层:
    提供统一错误对象、宏、异常类型、expected 适配、错误码适配

基础库层:
    尽量保留错误上下文,不强制业务语义

领域服务层:
    根据业务语义选择 expected 或 exception

接口边界层:
    捕获异常,处理 expected,转换成 HTTP/RPC/UI/日志格式

8. 总结规则

底层:

越底层,越通用,越不替调用者做决定。

高层:

越高层,越具体,越应该收束错误语义。

简单高层函数:

可以直接异常。

复杂业务函数:

优先 expected 表达预期失败。

异常:

表示错误传播,不等于错误处理。

边界层:

负责最终捕获、记录、转换、响应。