在做后台服务、桌面客户端这类长期运行的程序时,异常处理永远绕不开,但大家平时讨论的往往停留在“该不该抛、怎么接住”这个表面层级。前阵子我们排查一个偶发崩溃,进程毫无征兆地退出,core dump 里只留下一句 "terminate called after throwing an instance of ...",查了好几天才定位到问题出在某个析构函数里偷偷抛了异常,直接打断了栈展开。这种坑属于那种不出现则已,一出现就是线上事故的级别。所以我想把 C++ 异常处理里真正值得花心思的部分系统地聊一遍:从异常安全的三个级别,到 noexcept 的契约含义,再到跨 C 边界的处理方式。这篇文章适合已经在写业务型 C++、想把异常处理从“能编译”提升到“能上线”的开发者,也适合想统一团队异常规范的技术负责人。
1. 异常安全的三个级别:先明白自己要承诺什么
1.1 基本保证、强保证与不抛保证
异常安全这套分类方法,建议每个写 C++ 的人都先背下来,因为它直接决定你的接口设计。简单说,一个函数执行中如果抛了异常,调用方最终面对的无非三种局面:
第一种是基本保证(Basic Guarantee),函数抛出异常后,对象仍然处于可用状态,所有内部不变量都保持住,但具体的数据内容可能已经变了一部分。这是最低要求,绝大多数通用代码能做到这一档就足够了,因为调用方至少还能安全地析构对象、重试或者上报错误。
第二种是强保证(Strong Guarantee),函数要么完整执行成功,要么就像什么都没发生过一样,对象状态和调用前完全一致。听上去很美好,代价也高,经典实现手段是 copy-and-swap:先在副本上完成所有可能失败的操作,成功后再一次性拷贝回原对象。这样就算中途抛异常,原对象始终没动过。
第三种是不抛保证(No-throw Guarantee),函数绝对不会抛出异常。析构函数、swap、移动构造函数这类特殊函数,都应该朝这个目标努力。
为什么这套分类有用?因为当你设计一个库函数时,你需要在接口注释里写清楚自己的承诺。我见过无数代码,注释只写了“可能抛异常”,但到底抛出来之后对象状态是什么,完全没说。调用方只能无奈地把所有操作包在 try/catch 里,然后赌一把。如果你明确写上“本函数提供强异常安全保证”“本函数保证不抛”,使用方就能放心地把多个操作组合在一起,而不用层层防御。
1.2 RAII 不是纸上谈兵,是栈展开的基石
RAII(资源获取即初始化)和异常安全是互相成就的关系。C++ 没有 finally 关键字,但这恰恰是它的一个优势:资源生命周期绑定到对象生命周期,栈展开时局部对象自动执行析构,于是锁、内存、文件句柄、数据库连接全部自动释放。不需要人肉在 catch 块里写清理代码。
最典型的反例我在不少老代码里见过:裸指针加 try/catch 手工 delete。代码大概是这个样子:
Resource* r = new Resource(); try { do_something(); } catch (...) { delete r; throw; } delete r;这段代码的问题很多。第一,如果 new 之后、try 之前还有语句抛出异常,r 直接泄漏。第二,如果 do_something 内部有多个 return 分支,delete 很容易漏掉。第三,一旦别人后续在这个 try 块里再 catch 一层,删除逻辑就开始乱套。用 RAII 改写非常简单:
std::unique_ptr<Resource> r = std::make_unique<Resource>(); do_something();正常路径和异常路径都是同一个退出逻辑,析构自动执行。锁、文件句柄同理,用 std::lock_guard、std::fstream 或者自己的 RAII 包装类,都是这个思路。
1.3 栈展开时到底发生了什么
理解栈展开(stack unwinding)很重要。当一个异常被抛出后,运行时从当前栈帧开始向上查找匹配的 catch 处理器,沿途所有已构造的局部对象调用析构函数,这就是栈展开。对象的析构顺序与构造顺序相反,成员变量按声明顺序的逆序析构。因为有了这一机制,你才能依赖局部对象自动清理资源,而不是在每一层函数里都写防御性代码。
栈展开有个隐藏雷区:如果一个析构函数在栈展开期间又抛出一个新异常,运行时同时面对两个活跃异常,无法处理,直接调用 std::terminate。所以“析构函数不抛异常”不是风格偏好,是语言层面的硬约束。很多人只知道析构函数别抛异常,但不知道为什么,一旦遇到自定义析构函数里有文件写入或网络关闭这类可能出错的操作,就会写出一堆脆弱代码。
2. 异常体系设计:从标准异常到自定义异常
2.1 标准异常库其实够用了
C++ 标准库自带异常类型虽不多,但覆盖了常见场景。logic_error 及其派生类表示程序逻辑层面的问题,比如 invalid_argument、out_of_range、length_error;runtime_error 及其派生类表示运行时才暴露的问题,比如 overflow_error、system_error。实际项目中,我建议优先使用标准异常类型,因为调用方天然熟悉它们。
如果你做的项目底层会跟操作系统或者 C 库接口打交道,std::system_error 值得多用。它内部包含一个 error_code,可以保留 errno、平台错误码等信息。catch 到之后能结构化地取出错误来源,而不是只能拿到一个字符串。这一点在日志上报和问题定位时非常有用。
2.2 自定义异常时,放什么信息才能救命
当你确实需要自定义异常类型时,我建议遵守三条经验。
第一,一定要继承 std::runtime_error 或 std::exception,保证调用方写 catch (const std::exception& e) 时一定能抓到你的异常。我见过某个模块自定义了一个完全不派生自标准异常的类,结果整条调用链只能靠 catch (...) 硬接,异常携带的错误信息根本取不出来,排查问题全靠猜。
第二,把上下文信息在构造函数里拼好,存进 what() 返回的字符串中。不要等调用方拿到异常对象再拼接信息,因为 what() 是 const 方法,而且每次调用返回同一个 const char*,你没法在里面做动态组装。更不要写那种返回堆上局部临时字符串的 what(),生命周期和安全根本没法保证。
第三,如果你需要异常带有结构化字段,比如出错的文件名、行号、重试次数,就把这些字段作为成员存起来,并提供 getter 方法。一方面调用方可以结构化地取用,另一方面日志系统打印时也可以统一格式化。
举个例子,一个配置文件解析异常可以这样设计:
class ConfigError : public std::runtime_error { public: ConfigError(const std::string& file, int line_num, const std::string& reason) : std::runtime_error(buildMessage(file, line_num, reason)), file_(file), line_(line_num) {} const std::string& file() const { return file_; } int line() const { return line_; } private: static std::string buildMessage(const std::string& file, int line_num, const std::string& reason) { std::ostringstream oss; oss << file << ":" << line_num << " -> " << reason; return oss.str(); } std::string file_; int line_; };这样 catch 到 ConfigError 后,日志里既能直接看到 what() 拼好的一行话,也能通过 file() 和 line() 取到结构化信息做精准告警。
2.3 错误码和异常别混在同一个接口层
这是最常见的接口设计恶疾。一个函数既可能返回 false,也可能抛异常,调用方心里永远没底。我处理过某个老模块,里面一半接口返回 bool 表示成功失败,另一半接口用异常表示错误,结果每个调用点都包三层判断,代码可读性极差。
通用的取舍原则是:如果错误是业务的预期分支,比如用户输入不合法、文件不存在、网络超时,用返回值表达,可以是 std::optional、std::expected、枚举结果码;如果错误是程序逻辑被打破、资源耗尽、不变量被破坏这类不可预期的意外,用异常。区别在于,前者调用方几乎必然要处理,而后者通常会在高层统一兜底。
严格来说,全用错误码也能写出好代码,全用异常也一样,最忌一类接口一半一半。写接口注释时,我会明确写出:本函数在下列情况下会抛异常、在哪些情况下返回错误结果,让调用方不用猜。
3. noexcept 不是玄学:标错位置的代价和正确姿势
3.1 noexcept 是契约,不是性能优化
很多初学者把 noexcept 当作提升性能的魔法关键字,其实它的核心语义是向编译器和其他开发者承诺:这个函数不会抛异常。既然是承诺,编译器也会信任并执行硬性惩罚:一旦真的抛了,程序立即调用 std::terminate,不进行任何栈展开。
C++11 之前有动态异常规范 throw(),早已废弃,C++17 正式移除,改由 noexcept 承担职责。noexcept 还能接受条件表达式,写成 noexcept(expr) 的形式,配合 trait 类使用,但大多数情况直接用 noexcept 声明就够了。
3.2 标错 noexcept 的代价:直接终止进程
我踩过一次挺惨的坑。某段代码给移动构造函数标了 noexcept,但移动操作内部其实调用了可能分配内存的接口,某个极端场景下触发了 bad_alloc,于是进程直接 terminate。这类问题在日志上表现得极其隐蔽,core dump 里通常只有一行 "terminate called after throwing an instance of std::bad_alloc",没有调用栈,没有现场,排查成本极高。
标 noexcept 前,你要沿着函数调用链把可能抛异常的所有点都过一遍。第三方库接口、分配内存、字符串拼接、格式转换,这些都有抛异常的可能。宁可少标,不要乱标,标了就要负起契约责任。
3.3 哪些函数真的值得标 noexcept
虽然不要乱标,但有几类函数我建议一律标:
析构函数,直接不用管,C++11 起默认就是 noexcept(true),这也是为什么析构函数里抛异常必然触发 terminate。swap 函数,如果你的交换逻辑确实不抛,标上 noexcept,标准库和各种容器算法会利用这一点获得更好性能。移动构造函数和移动赋值运算符,如果成员都是 vector、string 这类移动不抛的类型,标上 noexcept,因为标准库容器扩容时,如果移动构造不是 noexcept,vector 会退化成“拷贝-删除”策略来保证强异常安全。
这里稍微展开说一下 vector 扩容。当 vector 容量不足时,需要把元素搬移到新内存。如果移动构造函数可能抛异常,vector 为了提供强保证,会选择调用拷贝构造函数。拷贝失败时旧内存里的对象原封不动,容器状态一致;而移动一旦中途失败,旧对象可能已经被搬了一半,状态就说不清了。所以给移动构造标 noexcept,直接影响标准库容器在扩容时的策略选择,这是一个实打实的性能差异。
其他普通函数,除非你有十足的把握,否则我建议不标。特别是调用外部库、进行 IO、分配内存的逻辑,都不要轻易承诺 noexcept。
4. 容易翻车的场景清单:从构造函数到 C 边界
4.1 构造函数里抛异常:善后工作交给成员自动完成
构造函数分三个阶段:先构造基类,再按声明顺序构造成员变量,最后执行构造函数体。如果构造函数体抛出异常,已经构造完成的基类和成员会自动析构,不需要也不能手动去清理。如果你持有裸指针成员且没有 RAII 包装,这一块就是泄漏源头。
典型反例是在构造函数体里这样写:
class Connection { public: Connection() { conn_ = create_connection(); try { init(); } catch (...) { close_connection(conn_); throw; } } void* conn_; };如果 init() 抛出,确实手动关了连接,但构造函数体没有执行完毕,conn_ 这个裸指针成员本身不会被任何析构函数管,如果你在 init 之前又有个环节抛了异常,close_connection 根本不会被调用。正确写法是把 conn_ 塞进智能指针或自己的 RAII 包装类里:
class Connection { public: Connection() : conn_(create_connection(), close_connection) { init(); } private: std::unique_ptr<void, decltype(&close_connection)> conn_; };构造函数还有一个冷门但有用的语法叫函数 try 块(function-try-block),可以捕获成员初始化列表里抛出的异常:
Connection::Connection() try : conn_(create_connection()) { init(); } catch (const std::exception& e) { log_error(e.what()); throw; }不过要注意,进入这个 catch 块时,成员变量已经被视为析构完毕,你不能在 catch 块里访问成员去“修复状态”,只能记录日志、转换异常类型,然后重新抛出。
4.2 析构函数里抛异常:为什么绝对不能做
C++11 开始,析构函数默认 noexcept(true),这几乎宣告了“析构函数抛异常”行为的死刑。哪怕你在析构函数里写 try/catch 包住抛出的部分,只要异常逃出析构函数,就会触发 terminate。更严重的是在栈展开期间,析构函数作为清理过程的一部分再次抛出,运行时立刻崩掉,连后续所有对象的析构都不执行了,资源泄漏连带崩坏一起发生。
那么析构函数里遇到关闭文件失败、写入日志失败这类错误怎么办?正确思路是记录错误信息、吞掉异常、或者把失败信息转存到某个地方,事后再由上层检查。比如见到的模式:
~Logger() { try { flush_to_disk(); } catch (const std::exception& e) { std::cerr << "Logger flush failed: " << e.what() << '\n'; } catch (...) { std::cerr << "Logger flush failed: unknown error\n"; } }这段代码吞掉了异常,但至少把错误信息记录下来。千万不要抱着“我 catch 一下再重新 throw 一个更合适的异常”这种想法,那只会让程序彻底终止。
4.3 边界上的 catch(...):与 C 代码和回调函数打交道
C++ 异常如果在 C 函数边界上穿过,就是未定义行为,多数平台上进程直接崩。你自己写的 C++ 代码内部怎么抛都行,但一旦出现 extern "C" 导出函数、接入 C 回调函数、或者被 C 框架调用你的函数,就必须在最外层用 try/catch 包住整个实现,不让异常漏出去。
比如给某个 C 库注册回调,正确写法是这样:
extern "C" void on_event(void* ctx) { try { auto* handler = static_cast<EventHandler*>(ctx); handler->handle(); } catch (const std::exception& e) { std::cerr << "exception in on_event callback: " << e.what() << '\n'; } catch (...) { std::cerr << "unknown exception in on_event callback\n"; } }类似地,main 函数本身也是与运行时环境之间的边界,建议整体包一层 try/catch,防止异常直接逃出 main 导致程序以诡异方式终止。如果你需要保留异常信息供事后二次处理,可以用 std::exception_ptr 配合 std::current_exception() 保存起来,交给另一个线程或后续逻辑重新抛出。
4.4 异常与性能:零成本模型下的实际开销
很多团队不用异常是因为“异常慢”。这个概念得分清楚。现代编译器的 ABI(以 Itanium C++ ABI 为代表)采用零成本模型:没有异常发生时,函数调用路径上不插入任何额外检查指令,与普通返回相比没有开销。这一点跟错误码检查完全不同,错误码要求每个调用点都要比较返回值,而异常机制在正常路径上是零成本。
但抛出异常的路径非常贵。运行时需要做栈展开、逐帧查找 catch 处理器、调用沿途所有析构函数、做类型匹配,实测一个异常从抛出到被捕获,往往要花几百纳秒到几微秒,相比普通分支返回的几纳秒,差了上千倍。所以异常不适合做常规控制流,如果某个错误分支在业务上几乎每次都会走到,比如解析一个大概率非法的用户输入,用返回值、std::optional、std::expected 之类的方式远比抛异常合适。
5. 排查异常问题时的几个实用技巧
5.1 异常默认不带调用栈,怎么定位现场
异常最烦人的一点是它不携带调用栈信息。你 catch 到了 std::runtime_error,what() 里可能有提示,但谁在什么位置抛出来的,完全不知道。线上排查有两个路线。
第一,调试器里针对异常打断点。gdb 下可以直接用 catch throw,程序抛出异常的那一瞬间就会停住,然后通过 bt 看到完整调用栈。这在复现类问题里非常有效。第二,在 catch 块统一记录线程 ID、异常类型、what() 和关键上下文,配合自己的日志系统。前提是异常类型本身携带了足够信息,所以在设计异常类时把上下文塞进去,前面那我说的 ConfigError 就是这样用的。
如果你需要更完整的链路,可以用 thread_local 保存一个“当前操作上下文”的字符串,在异常构造函数里悄悄把上下文带进 what()。这个思路适合多层调用、但不想到处 catch 的情况。
5.2 明明 catch 了,进程还是崩了,怎么排查
这样的案例我排查过不少,按出现频率排序,原因通常是这几种。
异常没有被 catch 住,比如 catch 类型写错,抛的是 std::runtime_error,catch 写的是 const std::logic_error&,类型不匹配就会一路往上直到 terminate。异常从析构函数里逃了出去,直接触发 terminate,这点极其隐蔽,因为你根本看不到栈展开的痕迹。异常穿过了 C 边界,行为未定义,多数平台崩溃。函数标记了 noexcept 却抛了异常,同样直接 terminate。还有一类是 catch (...) 吞掉异常后又做了一些假设性操作,结果自己的处理逻辑本身就崩溃了。
排查时我会先看崩溃日志里有没有 "terminate called after throwing" 字样,有的话优先查析构函数、noexcept 函数和异常逃逸点。如果没有 terminate 字样,但进程崩溃,再考虑异常穿边界或者 catch 块本身的问题。
5.3 把异常当成 goto 用,是性能灾难也是维护灾难
还有一种常见坏味道是拿异常做流程控制。比如解析配置时,每发现一个非法字段就 throw 一个异常,上层用 try/catch 包住整个解析过程。功能上能跑,但性能很差,因为一旦非法字段密集出现,代码频繁进入昂贵的异常路径,比正常返回慢几个数量级。维护上也很难受,同事看代码的时候无法直观理解哪些情况是真正的程序意外,哪些只是普通的业务分支。
更合理的做法是把“预期内可能出错”的场景用可选值或结果类型表达。现代 C++ 里 std::optional、std::variant,以及实验性的 std::expected,都能很好地表达“结果可能没有”的语义。异常留给真正不可预期的意外,比如资源耗尽、逻辑不变量被破坏。这个边界划清楚之后,你的代码会突然变得好读很多,异常真正成了“最后手段”,而不是普通逻辑的一部分。
我个人在实际项目里形成的习惯是:先把异常安全级别写清楚,再设计异常类型,最后才考虑哪里抛哪里接。noexcept 只在分析完调用链之后才标,宁可少标绝不错标。如果你把这些细节都踩过一遍,你以后看到别人代码里乱抛异常、析构函数里偷偷干活、catch 完继续瞎猜原因,大概也会和我一样,产生一种想立刻拉着他坐下来聊聊的冲动。