C++ / Modern C++ · 工程能力

今日学习:C++ 错误处理策略——异常、错误码与 std::expected

2026-08-23 · 每日技术学习 · 前端架构师 → C++/Qt 客户端架构师

①

今日主题:为什么必须学它

从 JS/TS 转 C++ 的开发者最不适应的不是语法,而是"错误到底该怎么处理":JS 里随手 try/catch 是日常,但 C++ 社区对异常的态度严重分裂——Google 全面禁用异常、LLVM 用 Error/Expected 替代异常、Qt 官方惯例是错误枚举+日志、而标准库和 Boost 又重度依赖异常。选错策略会导致性能灾难、难以定位的静默失败,甚至直接 terminate 崩溃。作为 Team Leader,你写的不是代码而是规范——只有把三种策略的底层原理与适用边界吃透,才能给团队定下可执行、可 review 的错误处理纪律。

②

核心知识:三种策略的底层原理

异常的运行时模型:零成本不是免费

在 Linux/macOS 上,C++ 异常遵循 Itanium ABI 的"零成本模型"(Zero-Cost Exception Handling):正常路径完全不产生运行时指令开销——try 块没有额外的帧登记代码,这是与 MSVC 的 /EHsc 基于栈帧登记模型最大的区别(后者正常路径每进一次 try 块都有少量开销)。代价藏在别处:编译器要为每个可能抛出异常的函数生成 .gcc_except_table 异常表(记录每个可能抛异常的调用点的展开信息),代码体积增加 5%-15%;真正抛出异常时,通过 libgcc 的 _Unwind_RaiseException 执行栈展开(stack unwinding),逐帧析构局部对象、查找匹配的 catch,这条路径比正常返回慢 10-100 倍,而且会打断 CPU 的指令预取与分支预测。

还有一个容易被忽略的隐性成本:优化受限。编译器必须假设函数体内的调用可能抛异常,很多跨调用点的优化(如把计算提升到调用之前、删除看似无用的赋值)会被阻止。所以"性能热点循环内抛异常"是双重灾难。

noexcept:异常与终止的分界线

noexcept 不是优化建议,而是一份"我绝不抛"的契约:函数体内一旦抛出,不会走栈展开,而是直接调用 std::terminate(即 abort)。它真正的威力在泛型与容器:std::vector 扩容时能否用移动构造搬移元素,取决于 std::is_nothrow_move_constructible_v<T>——如果 move 可能抛异常,vector 为了保证强异常安全(要么全成功要么不变),只能退回拷贝,性能断崖。因此业界铁律:析构函数、move 构造/赋值、swap 必须 noexcept。

错误码:零开销但靠自觉

错误码(errno、HRESULT、Qt 的 QFile::FileError、QJsonParseError)是最古老也最底层的方案:零堆分配、零展开、完全确定性的执行路径,适合极致性能与嵌入式场景。代价是三重风险:类型不安全(int 无法表达错误语义)、易被忽略(C++11 起 errno 是线程局部变量,但忘记检查就是静默失败)、错误路径代码膨胀(每个调用点都要 if 判断)。

std::expected<T, E>:C++23 的答案

std::expected(P0323,GCC 13+ 的 libstdc++ 在 -std=c++23 下可用;C++17 项目用 TartanLlama 的 tl::expected)把"值或错误"建模为一个显式类型:要么持有 T,要么持有 E。它像 Rust 的 Result、TS 的 discriminated union。关键 API:has_value()/operator bool、value()(无值时抛 bad_expected_access)、error()、以及 monadic 操作 and_then / transform / or_else——把"层层检查错误"压平成一条链。配合 [[nodiscard]] 标记,编译器会强制调用方处理结果,从机制上消灭"忘了检查"。它的本质:把错误的传播从"控制流"变成"值",可拷贝、可存储、可跨线程传递。

Qt 的立场:异步边界只能用值

Qt 6 默认开启异常,Qt 自身代码也允许异常,但官方惯例是"返回值 + 错误枚举 + qWarning 日志":QNetworkReply 用 error()/errorString() + errorOccurred 信号,QJsonDocument 用 QJsonParseError 返回解析错误。根本原因:异常无法跨线程、跨事件循环传播——在 QThread 工作线程里 throw,主线程根本接不住,只会 terminate。异步架构里,错误必须作为值随信号槽传递。

③

实际案例:大厂客户端怎么选

Chrome(Chromium):构建配置 -fno-exceptions,代码零异常,错误统一用 base::Status / base::StatusOr(即 absl::Status 体系)。原因:多进程架构下异常无法跨进程传播,且浏览器对代码体积、启动性能极度敏感,Google C++ Style Guide 明确"Exceptions 不用"。代价是每个调用点显式检查,代码啰嗦——但他们认为可预测性比啰嗦重要。

LLVM/Clang(Qt Creator 的 C++ 代码模型就构建在其上):用 llvm::Error / llvm::Expected<T> 做错误传播,同样是 -fno-exceptions 构建。编译器前端要精确控制"哪个错误在哪一层被消化",Expected 的 monadic 链正好表达这种精确传播。Qt Creator 的 ClangCodeModel 消费这些 Expected,你在 Creator 里看到的代码诊断就是这套体系在跑。

Qt 网络栈:QNetworkReply 是异步 API 的教科书——错误不进异常、不进返回值,而是通过 errorOccurred 信号把 QNetworkReply::NetworkError 枚举传出来,配合 errorString() 给人看的文案。为什么?请求结果到达时早已不在发起调用的栈上,异常机制在这种"回调时空"里完全失效。

国内大型客户端(微信/QQ 桌面端等):出于历史原因(早期编译器异常支持不完善、跨平台移植成本、二进制兼容),长期采用"错误码 + 日志"路线,配合 qWarning/qCritical 打点。你入职这类团队时,第一件事就是看他们的错误处理规范,而不是坚持"现代 C++ 就该用异常"。

VSCode / Electron 原生层:TS 层异常随便用;但原生模块边界(NAPI)必须返回 napi_status 错误码——C++ 异常跨语言边界传播是未定义行为。这印证了一条通用规律:每跨一道边界(语言、线程、进程、ABI),异常就失效一次,错误就得变成值。

④

常见错误:5 个高危写法

错误1:在 noexcept 函数里抛异常。析构函数、move 构造/赋值、swap 一旦抛出,直接 std::terminate 崩溃——不是"没接住",是进程直接没了。避免:这三个成员永远标 noexcept;模板里用 static_assert(std::is_nothrow_move_constructible_v<T>) 在编译期拦截。

错误2:用异常做常规控制流。在解析循环里对每条记录 try/catch:异常路径慢 10-100 倍 + 异常表撑大代码体积 + 阻止优化。避免:预期内的"坏数据"用 expected/错误码,异常只留给"真没想到会失败"。

错误3:跨线程抛异常。QThread 工作线程里 throw,主线程幻想能 catch——栈都不在同一根上,结果只有 terminate。避免:线程边界只传值(信号槽、QPromise、std::expected 随结果一起发回去)。

错误4:catch(...) 空吞。catch 住一切然后什么都不做,等于把 bug 变成静默的数据损坏,比崩溃更难查。避免:要么 qWarning 记录完整上下文后重抛,要么明确"此处吞掉是设计"并写注释。

错误5:忽略错误码。if (!file.open()) { /* 忘写处理 */ } 然后继续读写一个没打开的文件。避免:自定义结果类型一律标 [[nodiscard]];开启 -Wunused-result 相关告警;review 时重点看"每个失败分支是否留下了可诊断的日志"。

⑤

最佳实践:5 条可落地的纪律

1. 用"预期 vs 意外"划分策略(最重要的原则)。预期内会发生的失败——文件不存在、网络超时、配置格式错、用户输入非法——用 std::expected/错误码表达,调用方必须处理;意外失败——不变量被破坏、逻辑 bug——用异常或 assert,让它尽早炸出来。何时不用:性能热路径、跨线程/跨 DLL 边界一律不用异常。

2. noexcept 边界纪律。析构、move、swap 永远 noexcept;对外插件接口(Q_DECLARE_INTERFACE 的虚函数)绝不抛异常,因为插件可能由不同编译器/异常开关构建,跨 ABI 抛异常是未定义行为。Trade-off:noexcept 让你失去错误恢复能力,但换来容器性能与 ABI 安全,值。

3. 错误类型要可诊断。错误枚举 + 上下文信息(哪个文件、哪一行、期望值/实际值),模仿 QJsonParseError 的 offset 字段设计。别返回裸 int 或空字符串——"失败"本身要能回答"为什么失败"。Trade-off:信息越多构造越重,热路径上可以只放枚举,把上下文留给日志层。

4. 库代码克制,应用代码灵活。你写的模块如果可能被外部复用(尤其插件、SDK、跨团队库),默认用 expected/错误码,避免强制调用方开启异常、避免 ABI 依赖 libstdc++ 的异常实现;应用层内部可以适当用异常简化逻辑。何时不用:纯内部、单二进制的小工具,怎么顺手怎么来,但规范要一致。

5. 跨边界只传值。线程、进程、语言、DLL 边界,错误一律序列化成值(枚举 + 字符串)传递。Qt 里就是信号槽参数 + qWarning 落日志。这条没有例外。

⑥

与 Web 技术的联系:把已有认知搬过来

JS try/catch ↔ C++ 异常:语义对应,但成本完全不同。V8 对异常做了大量优化(栈上 try/catch 近乎零成本),所以前端随手 throw 是合理的;C++ 的异常路径慢 10-100 倍且胀代码,同样的习惯就是坏味道。你已懂 try/catch 的语义,缺的只是"成本意识"。

Promise rejection ↔ std::expected:这是最顺的迁移桥梁。Promise.resolve 成功分支 ≈ expected 持有 T;.catch() ≈ .error() 分支;.then() 链式变换 ≈ and_then/transform monadic 链;Promise.all 的短路语义 ≈ expected 链中任一步失败即短路。你写 .then 链的手感,平移过来就是写 expected 的 and_then。

Node 错误优先回调 (err, data) ↔ 错误码风格:两者同构——错误显式出现在签名里,靠自觉检查。Go 的 if err != nil 同理。TS 的 discriminated union({ok:true,value} | {ok:false,error})与 std::variant<T,E>/std::expected 是同一个建模,你早就熟悉。

fetch 的 response.ok + status ↔ QNetworkReply::error():异步网络边界,两边都放弃异常、用状态码+枚举表达失败——因为响应到达时发起调用的栈早已不在了。你在浏览器里早已接受"网络错误是值不是异常",到 Qt 只是换了个名字。

React/Vue 的 ErrorBoundary ↔ 异常只在一层处理:ErrorBoundary 的思想是"错误向上冒泡到边界统一兜底",对应 C++ 里"异常从深调用点抛到高层唯一的 catch"。区别:前端边界是组件树,C++ 边界是栈帧——但"不要在每一层都 catch,只在边界处理"的直觉完全一致。

⑦

管理者视角:Team Leader 怎么落地

你带的是从 Web 转 C++ 的团队,最怕的是每个人带着 JS 习惯各写各的。三件事:

第一,定规范并写进 wiki。明确"预期失败用 expected/错误枚举,意外失败用异常,跨线程/跨 ABI 只传值,析构/move 必须 noexcept"。规范不需要长,但要能回答 review 时的一切争议。

第二,Code Review 四个必查点:① 析构/move/swap 是否 noexcept;② catch 是否空吞(catch(...) 后必须有日志或注释);③ 错误码/expected 是否被忽略(结果类型有没有 [[nodiscard]]);④ 线程函数里有没有可能抛异常(QThread::run 里 throw 就是 terminate)。

第三,用工具卡住人。CI 里 -Wall -Wextra -Werror 起步,加 clang-tidy 的 bugprone-exception-escape 检查(自动找出"noexcept 函数里可能抛出"的路径);对 expected 用 [[nodiscard]] 让编译器强制检查。规范写进代码,而不是靠人肉记忆。

培养视角:让成员理解"异常不是错误,是控制流;错误是数据"。review 时多问一句"这个失败是预期的吗?预期就走值"——这一问能纠正 80% 的错误写法。

⑧

延伸阅读

1. P0323R12(std::expected 标准提案)——看 Motivation 章节,理解为什么社区花了十年才把 Result 类型标准化,以及 monadic 接口的设计取舍。

2. Itanium C++ ABI: Exception Handling 规范——零成本异常模型的权威文档,读懂 .gcc_except_table 与展开器,性能讨论就不再是玄学。

3. Google C++ Style Guide 的 Exceptions 章节——看一个顶级团队如何论证"为什么禁用异常",以及他们为禁用付出的工程代价(StatusOr 工具链)。

4. tl::expected(TartanLlama)文档——C++17 项目可直接落地,示例代码即最佳实践范本。

5. Qt 官方 QJsonParseError / QNetworkReply 文档——看 Qt 如何用"错误枚举 + 信号"设计异步错误传播,这是 Qt 惯例的第一手教材。

⑨

今日思考题

Q1:std::vector 扩容时,为什么 T 的 move 构造不是 noexcept,vector 就退化为拷贝?这背后是哪条异常安全保证在起作用?

Q2:如果让你给 Qt 设计一个返回 std::expected<QJsonValue, JsonError> 的解析 API 替代 QJsonDocument::fromJson,错误类型里应该放哪些字段才算"可诊断"?

Q3:Windows 上跨 DLL 边界抛异常有哪些真实风险(/EHsc 与 /EHa 的差异、不同编译器异常实现的兼容性)?为什么插件架构必须禁异常?

Q4:fetch 的 response.ok + status 与 Promise rejection,分别对应 expected 的哪个分支?为什么网络层倾向于"失败是值"?

Q5:你的团队规范里,如果规定"函数签名中出现异常作为唯一错误通道即视为不合格",会逼出哪些更好的设计?

⑩

今日实践任务:用 std::variant 造一个 expected(约 35 分钟)

目标:用 C++17 手写一个 Result<T,E>(语义即 std::expected),并用它解析端口配置——同时写一个"忽略错误"的对照组,观察 [[nodiscard]] 的编译告警。C++23 环境可直接换成 std::expected(GCC 13+ 加 -std=c++23)。

// result_demo.cpp — g++ -std=c++17 -Wall -Wextra -Werror -o result_demo result_demo.cpp
#include <cstdio>
#include <string>
#include <variant>
#include <utility>

enum class ConfigError { MissingKey, BadNumber };

template <typename T, typename E>
class [[nodiscard]] Result {          // 忽略返回值 = 编译错误
public:
    static Result ok(T v)   { return Result(std::in_place_index<0>, std::move(v)); }
    static Result err(E e)  { return Result(std::in_place_index<1>, std::move(e)); }
    bool hasValue() const   { return std::holds_alternative<T>(data_); }
    T& value()              { return std::get<0>(data_); }
    E error() const         { return std::get<1>(data_); }
private:
    template <std::size_t I, typename... A>
    Result(std::in_place_index_t<I>, A&&... a) : data_(std::in_place_index<I>, std::forward<A>(a)...) {}
    std::variant<T, E> data_;
};

// 模拟"解析端口配置":预期失败用值返回,绝不抛异常
Result<int, ConfigError> parsePort(const std::string& s) {
    if (s.empty()) return Result<int, ConfigError>::err(ConfigError::MissingKey);
    try {
        return Result<int, ConfigError>::ok(std::stoi(s));   // stoi 可能抛,此处就地消化
    } catch (const std::exception&) {
        return Result<int, ConfigError>::err(ConfigError::BadNumber);
    }
}

int main() {
    auto r = parsePort("8080");
    if (r.hasValue())
        std::printf("port = %d\n", r.value());
    else
        std::printf("error = %d\n", static_cast<int>(r.error()));
    parsePort("");   // 故意忽略:-Werror 下编译失败,体会 [[nodiscard]] 的强制力
    return 0;
}

任务清单:① 编译运行,观察 [[nodiscard]] 告警被 -Werror 升级为错误;② 删掉 [[nodiscard]] 再编译,体会"机制强制 vs 自觉"的差别;③ 把 try/catch 换成手写数字解析(isdigit 循环),让 parsePort 变成真正的零异常路径,对比两种实现的取舍;④ 思考:如果这个 Result 要跨 QThread 传回主线程,需要额外做什么(提示:错误枚举可 Q_DECLARE_METATYPE 注册)。

⑪

一句话总结

预期内的失败走值(std::expected / 错误枚举),意外失败才走异常,跨线程跨 ABI 边界只传值——把这条纪律写进团队规范,用 [[nodiscard]] 和 noexcept 让编译器替你执法。