C++ 异常处理大概是 C++ 话题里最容易被两个极端撕裂的内容。我这几年代码写过不少,见过团队规范里直接写“禁止异常”,见过有人把catch (...)当成万金油,也见过一个只有几百行的模块因为异常安全没做好,线上出现各种诡异状态,最后一群人把锅全扣在“异常机制”头上。其实异常工具本身没有这么大仇恨,真正的冲突来自两点:第一,很多人没理解异常在底层到底是怎么运转的;第二,很多代码把异常当成一种“大号返回值”,根本不去想抛出后系统处于什么状态。这篇文章我会从栈展开机制讲起,再结合自己项目里踩过的坑,把异常安全等级、RAII 配合、那些不该用异常的场景,以及 C++17 之后的变化逐个说清楚。适合正在写实际业务代码、想把错误处理做得更稳的 C++ 开发者。
1. 先把栈展开的具体机制搞明白,异常设计都建立在这上面
1.1 异常不是跳转,而是一次跨层强制返回
很多程序员把throw理解成“远程 goto”,甚至会拿longjmp来类比。如果你也这么想,请先修正这个模型,因为栈展开才是异常处理的灵魂,它决定了你整个错误处理代码的边界。
当执行throw someException;时,实际发生的过程包括四步:
- 编译器先构造一个异常对象。这个对象不是存在某个局部变量里,而是被拷贝或移动到一块专门管理的内存区域,这块区域跟当前线程绑定,供异常传播的整个生命周期使用。
- 系统开始沿着调用链向上查找匹配的
catch分支,依据的是当前活跃的 try 块和各个函数的动态类型信息。 - 从抛出点往上,每退出一个函数作用域,编译器都会调用该作用域内所有自动存储期对象的析构函数。这一步不可跳过,也就是所谓的栈展开。
- 找到匹配的
catch后,程序转入catch块执行;异常对象会一直保留到catch块结束,然后在结束时一并销毁。
从这段过程里,你应该得出一个关键直觉:异常传播路径上的每一层函数,都必须保证“即使我没处理这个异常,我也正确清理了自己创建的资源”。如果你创建了一个资源,只在函数末尾手动释放,那么函数中途一旦抛异常,末尾那段代码根本不会执行——除非你把释放交给某个 RAII 对象的析构函数。这是后面要讲 RAII 的伏笔。
还有一个经常被忽略的点是异常成本模型。现代主流实现,例如基于查表展开的 Itanium ABI 以及微软近些年的 64 位实现,在正常不抛异常的热路径上几乎不产生额外开销,没有那种每个函数入口都要做的强制登记;真正昂贵的部分全在抛出路径上。运行时需要做类型匹配、栈帧遍历、逐层析构。用一句直白话总结:异常的成本结构是“不犯规不交钱,犯规一次交大钱”。
1.2 函数边界与异常委托:你得主动规划异常飞多远
栈展开的另一个推论是:异常会穿透所有不处理它的函数,而这些函数并不会从编译期收到任何“这里可能抛异常”的提示。比如你写了一个void buildConfig(),内部调用了某个可能抛std::filesystem::filesystem_error的函数,但buildConfig自己不 catch,调用者也无法从声明里看出它会抛异常。除非你显式写了 try-catch,否则异常会一路飞到最近一个匹配的 catch,或者直接触发std::terminate。
这就带来工程上的核心问题:函数边界上的异常传播其实是一份隐式契约。你在哪里 catch,决定了错误在哪里被处理;中间那些层只是“感觉不到正在传”。设计上你必须主动选择“让异常穿过哪些层”,而不是把catch随意洒在每个函数里。
我在工程上比较推荐的分层做法是:
- 底层组件,例如数据库访问层、网络收发层,在关键操作失败时直接抛异常,并携带原始错误码和上下文。
- 中间业务模块通常不急着 catch,让异常继续向上走;如果需要在某层回滚,比如释放已加锁资源、还原状态,通过 RAII 自动完成。
- 最外层,通常是进程入口或线程入口,用统一的 catch 接住,记录完整日志后决定是退出线程、重启任务还是降级重试。
这个结构下,中间层很少写 catch,但对 RAII 的依赖会变得非常明显。所以下面要聊的异常安全承诺,是判断“代码在异常抛出后系统处于什么状态”的明确答案。
1.3 自定义异常类:别只抛一个裸字符串
顺带说一个实用设计。很多人直接throw std::runtime_error("something failed"),遇到需要上层决策的场景,上层只能靠what()里做字符串匹配,非常脆弱。更稳的做法是定义自己的异常层级,派生自std::runtime_error,并在里面附带结构化字段:
class DatabaseError : public std::runtime_error { public: DatabaseError(std::string msg, int nativeErrorCode, std::string query) : std::runtime_error(std::move(msg)), nativeCode_(nativeErrorCode), query_(std::move(query)) {} int nativeCode() const noexcept { return nativeCode_; } const std::string& query() const noexcept { return query_; } private: int nativeCode_; std::string query_; };这样上层既能拿到人话描述,也能拿到底层原始错误码,需要重试或展示提示时都有依据。只靠字符串协议传递错误,是异常信息在多层传输中逐步丢失的最大原因之一。
2. 异常安全承诺分三档,动手写码前先想好你承诺哪一档
2.1 基本承诺:资源不泄漏,对象状态合法
“异常安全”这个概念是上世纪九十年代被明确提出的,核心就一句话:当代码抛出异常时,它能对调用方承诺什么。
最低一档是基本保证:如果抛了异常,程序不会泄漏资源,也不会留下悬垂指针;所有对象仍然处于合法状态,可以安全析构或继续使用,但这个状态不一定与调用前一致。
举一个典型例子:一个链表类实现insertAfter(node, value)。如果给新节点分配内存时抛出std::bad_alloc,而新节点的指针是用裸Node*变量持有的,这次抛出就会泄漏内存。要达到基本保证,最简单的方式是把新节点包在std::unique_ptr<Node>里,等真正把节点链进链表或转移所有权之后再release()。这样一来,就算中途异常,智能指针也会把已分配的内存回收。
基本保证看着门槛不高,但实际很多“看起来没问题”的代码连这一档都达不到。我见过大量裸指针加手动释放的项目,排查内存泄漏时根本分不清是正常路径漏的还是异常路径漏的——因为它们都没在任何路径上保证释放。
2.2 强承诺:要么成功,要么原样
第二档是强保证:要么操作成功完成,要么异常抛出后对象状态与调用前完全一致。这是业务中最常用的一档,因为它能简化调用方的推理——调用方只需要知道成功还是失败,不需要逐步判断对象被改到了哪一步。
实现强保证最经典的套路是 copy-and-swap。思路是:先用临时副本做所有可能失败的操作,等一切成功之后,再用一条不抛异常的操作,通常是swap,把新状态一次性替换进去。
比如我要实现一个ConfigManager::update(const Config&):
class ConfigManager { public: void update(const Config& newConfig) { Config tmp(newConfig); // 拷贝或构造可能抛异常,失败时原对象不受影响 internalSwap(tmp); // swap 本身必须 noexcept } private: void internalSwap(Config& other) noexcept { using std::swap; swap(version_, other.version_); swap(map_, other.map_); } };只要internalSwap保证不抛异常,这个update就满足强保证。异常发生在tmp构造期间时,原有的version_和map_都没动过。异常后对象仍然保存旧配置,调用方可以继续重试或者回滚到自己的旧状态,逻辑非常干净。
这里要提醒一个细节:copy-and-swap 会引入一次不能无视的拷贝成本。配置这种低频场景完全没问题,但如果一个函数每分钟被调用几十万次、拷贝内容又大,纯强保证方案会成为性能瓶颈。这时候可以退一步用基本保证加前置校验来降低实际失败概率,或者把修改拆成更细粒度的操作,而不是每次都整体拷贝。
2.3 无抛出承诺:noexcept 的正确用法和常见翻车
第三档是无抛出保证:函数绝不抛异常,用noexcept声明。这个声明不是给编译器看的愿望,而是给调用方和库的硬承诺。如果函数实际抛出了异常,程序会直接调用std::terminate终止,根本没有恢复机会。
两个最典型的使用点:
- 移动构造函数。
std::vector扩容时,如果元素类型的移动构造函数标了noexcept,可以安全地移动旧元素,否则只能退而复制,性能差距明显。 swap操作。copy-and-swap 里的swap必须声明noexcept,否则你声称的强保证就打了折扣。
noexcept最常见的坑,是有人在移动构造函数里调用一个可能抛异常的操作,比如做深拷贝、打开文件,却照样标noexcept。某次运行时真冒出异常,程序直接终止,连崩溃都算不上优雅。我见过的可靠做法是:移动构造函数里只用成员自身的移动构造,并要求这些成员移动构造是noexcept;如果成员不是noexcept移动的,那你的移动构造函数也别标noexcept,宁可让容器走复制路径。
另外还要注意noexcept(expr)这种条件形式。它允许你根据某个编译期常量表达式或操作特性来决定最终是不是noexcept。标准库在模板里大量使用这种写法,你在写模板时也会直接体会到它的价值。
3. RAII 和异常是一体两面,没有 RAII 的异常处理等于裸奔
3.1 栈展开给了析构函数唯一一次兜底机会
前面反复提到,异常在栈展开时会调用沿途所有自动对象的析构函数。这个机制让 RAII 成为异常安全里不可替代的地基。
看一个反面例子:
void legacyProcess() { FILE* f = fopen("data.txt", "rb"); // 中间做一堆可能抛异常的处理 fclose(f); }如果中间抛了异常,fclose永远执行不到,文件句柄泄漏。你需要在每层函数写 catch 再清理,但异常路径越复杂,就越容易漏一处。换成 RAII 风格:
void modernProcess() { std::ifstream f("data.txt"); // 中间做一堆可能抛异常的处理 } // 无论正常返回还是异常展开,f 的析构函数都会关闭文件f是栈上对象,它一定会在作用域结束时析构,不管离开作用域的通道是return、break、continue还是抛异常。这是 C++ 资源管理最扎实的底层保障。
工程里常见的 RAII 包装对象包括:文件句柄、socket、内存指针、锁、数据库连接、临时文件、管道、信号量等。标准库现成的东西非常多:std::unique_ptr、std::shared_ptr、std::lock_guard、std::scoped_lock、std::ofstream、各种容器。你要做的只是别再用裸指针加手动释放。
3.2 把裸指针换成智能指针后,异常路径才可预测
我之前 review 过一个消息队列客户端,线上偶发崩溃。核心代码大致长这样:
Message* msg = new Message(payload); try { channel->send(msg); } catch (const SendError& e) { // 只记日志,然后呢?没人删 msg } delete msg;这里有两个问题。第一,send内部可能抛出没被 catch 住的异常类型,异常直接飞出函数,delete msg永远不执行。第二,即使 catch 了一部分异常,也照样没释放msg。崩溃大概率来自资源耗尽或后续模块误用了同一块内存。
改成std::unique_ptr以后,问题直接消失:
auto msg = std::make_unique<Message>(payload); try { channel->send(msg.get()); } catch (...) { log("send failed"); }msg离开作用域时自动释放。两行改动,避免一次加班追内存泄漏。如果你所在项目还在大规模裸指针加delete的风格,先别急着重构异常处理,把关键资源管理类改成 RAII 是更优先的事项。
3.3 析构函数默认 noexcept,让它抛异常基本等于自杀
从 C++11 开始,析构函数默认就是noexcept(true)。除非你显式写成noexcept(false),否则析构函数一旦抛出异常,会直接调用std::terminate。
为什么标准要这样设计?因为析构函数经常在两种场景被调用:正常作用域退出,或者异常传播导致的栈展开。如果正在栈展开期间,析构函数又抛出一个异常,系统同时存在两个异常,运行时无法定义“嵌套异常传播”的行为,直接终止程序。
所以我在工程里坚持一条规则:析构函数里永远不抛异常。清理时遇到错误,先记录日志,或者把失败状态存进成员变量,延后由显式接口检查。锁释放、文件关闭这类操作理论上有失败可能,但一般选择不抛异常的关闭接口;实在保证不了,就把失败标记记下来统一上报,而不是在析构函数里制造二次异常。
4. 我踩过的异常相关坑,以及对应的排查修复套路
4.1 析构函数里再抛一个异常,程序当场 terminate
有一回我们做批量任务调度组件,里面有个Worker类的析构函数写了类似下面这样:
~Worker() { if (!tasksDrained()) { throw std::runtime_error("tasks not drained"); } }本意是析构时发现还有任务没处理完,用异常提醒调用方。结果很惨:只要这个析构是在异常展开路径上被触发的,比如更外层已经抛了一个network_error,这里再抛一个runtime_error,程序立刻std::terminate,连 catch 的机会都没有。
排查时我们一开始完全没头绪,因为崩溃现场没有任何 catch 日志。后来开满调试信息,加上全量日志,才还原出“双重异常导致 terminate”的链路。修复也直白:析构函数只做清理,不抛错。任务没处理完的状态写到成员变量里,调用方通过显式drain()接口检查。
这条经验后来成了我代码评审的固定检查项:看到析构函数里有可能抛异常的调用,我就问一句“抛出后你打算让谁处理”。答不上来就把异常去掉。
4.2 catch 顺序写反,派生类异常永远轮不到
多态异常类型的 catch 分支顺序太容易写错,看这段:
try { doSomething(); // 可能抛 BadInput,而 BadInput 继承自 std::runtime_error } catch (const std::exception& e) { log("standard error", e.what()); } catch (const BadInput& e) { log("bad input", e.detail()); }如果BadInput确实继承自std::exception,那么第一个分支会先匹配它,第二个分支永远执行不到。detail()里的关键信息被丢掉,你只能看到what()里的一句话说辞。
正确的顺序是从特殊到通用,派生类放前面:
} catch (const BadInput& e) { log("bad input", e.detail()); } catch (const std::exception& e) { log("standard error", e.what()); }另外,推荐统一用const T&接收异常对象,不要按值接收。按值接收会拷贝一次异常对象,并且丢弃动态类型;const T&没有拷贝,配合重新抛时还能保留原异常的真实类型。
4.3 throw; 和 throw e; 差在哪:异常对象的切片与复制
在 catch 块里需要重新抛时,很多人会把这两者写混。比较下面两种写法:
catch (const std::exception& e) { // 错误:重新抛出的是一个 std::exception 拷贝,派生类型信息被切掉 throw e; } catch (const std::exception& e) { // 正确:原样重新抛出正在传播的异常对象,动态类型保留 throw; }throw e;会把捕获到的对象当成新表达式来构造异常。由于e的静态类型是const std::exception&,新异常对象就是std::exception级别,BadInput::detail()直接没了。同时它还会丢弃原异常对象,另造一个新对象,既慢又少信息。throw;是空操作重新抛出,动态类型、栈上上下文全部保留。
我在这件事上长期保持一句口诀:在 catch 块里想“原样往上抛”,只写throw;;想“包装成新异常”,用throw_with_nested或手动构造新异常并保留原信息,别靠赋值硬来。
有次排查一个诡异 bug,上层拿到的永远是std::exception,无论底层抛什么子类都一样,日志全是一条what()。搞了一个多星期才在某次 code review 里发现是throw e;引起的对象切片。
4.4 空 catch 块和吞异常,比崩溃更危险
代码评审时最怕看到:
catch (...) { }空 catch 块把所有异常都遮住了,问题不暴露,系统继续运行在未知状态;后续故障排查的线索也会断在这里。如果确实需要忽略未知类型异常,至少要记日志,或者明确写注释说明为什么这里有意忽略。没有记录的空 catch,基本就是给线上埋雷。
类似的模式还有把异常全部转成 false 返回:
bool ok = false; try { process(); ok = true; } catch (const std::exception& e) { ok = false; // 失败原因丢了 }如果process()抛出的异常能告诉你“重试解决不了,需要降级”,这里只保留一个ok=false,上层就会做出错误决策。至少要记录日志,或者用std::exception_ptr把异常对象存起来,供上层用std::rethrow_exception恢复后重新判断。
想多提一句std::exception_ptr:它非常实用,可以把异常保存到普通对象里,甚至跨线程传递。比如工作线程池可以将异常包装成exception_ptr返回给主线程,主线程再rethrow_exception取出来做类型判断。这让异常不只是“必须在调用栈上裸传”的机制,这是它被低估的能力之一。
4.5 老库边界:编译选项关掉异常,异常跨不过去
这是存量项目里常见的坑。某些老代码编译时直接关掉了异常支持,比如统一加了类似-fno-exceptions的选项,而新模块是标准异常编译。两个模块链接在一起后,新模块抛出的异常一旦穿过老库的函数边界,行为就可能变得很不可控——老库没有记录展开所需的信息,运行时无法正确恢复调用栈。
处理方式通常是这样:
- 老库是第三方且改不了:新模块在调用老库的边界处主动 catch,把异常翻译成错误码或回调再进入老库。
- 老库代码能改,但维持关闭异常:同样在边界层做一个薄的适配层,用错误码加结果参数沟通。
- 老库改造可控:可以考虑逐步把关键模块从关闭异常改成标准异常编译,但回归测试必须充分,因为异常展开所需的信息表会让二进制体积增加。
说白了,异常跨模块传播不是语言层面绝对保证的强行为,而是 ABI 和编译选项的配套结果。因此在架构文档里,明确“异常能否跨这道边界”必须写清楚。
5. 别把异常当成万能错误通道:这些场景用错误码或 std::expected
5.1 跨模块边界优先用错误码,别赌异常能飞过去
在之前的实时渲染项目里,渲染引擎作为动态库被应用主程序加载。引擎和主程序之间并不是同一套 C++ 运行时,也不是同一个编译器版本编译出来的。最初我们让引擎内部异常直接穿透 DLL 接口,结果在部分运行环境下就出现崩溃或信息丢失。
后来我们把 DLL 接口统一改成错误码加输出参数的模式:
extern "C" int RenderEngine_Start( RenderHandle* outHandle, char* errBuf, int errBufSize);引擎内部仍然可以用异常处理复杂错误,但在导出的 C 接口边界处 catch 所有异常,转成错误码和错误描述字符串再返回。这样两边的编译器、运行时互不干扰,异常只在同一个 ABI 内部流动。调用方不需要猜测异常抛出来了没有,只需看返回码。
这是一条我很少看到有人质疑的经验:异常只在同一个 ABI 内部可信,跨动态库时先准备好翻译层。
5.2 高频路径上异常的开销,实测一次就明白差距
异常的主要成本集中在“抛出”那一下。正常路径影响很小这一点前面说过,但一旦频繁在某段代码里抛收异常,性能会很明显。我简单跑过基准:一个函数进来直接throw std::runtime_error("x"),外层 catch,循环做几十万次,耗时达到几百毫秒甚至秒级;而同样调用用返回小结构体表示失败,几乎无感。虽然现代 CPU 分支预测能拉近部分差距,但把异常用在高频常规路径上仍然是最不经济的写法。
所以我会在团队里经常提醒:用异常表达“意外情况”,不要用它表达“可预期业务分支”。比如网络握手时“对端断开”可能是常态,那就不该用异常实现重连逻辑;而“读写遇到未预期的协议错误”适合用异常中断整个处理链,因为这种错误很少发生。把预期偏差塞进异常,你会频繁支付昂贵的 throw,还会让日志里全是噪音。
5.3 预期内的失败交给 std::expected,表达力也不差
C++23 正式带来了std::expected<T, E>。它解决的是“操作可能失败,失败本身也是调用方预期的一部分”这类场景,比如解析配置、读取文件、执行查询。调用方大概率要显式检查结果并分支处理。
相比错误码,std::expected保留了完整错误信息和类型安全;相比异常,它不会触发栈展开,也没有跨层反转控制流,调用方必须显式处理。这两点恰好命中“高频小失败”场景。
std::expected<Config, ParseError> loadConfig(std::string_view path) { std::ifstream file(path.data()); if (!file) { return std::unexpected(ParseError{ErrorCode::CannotOpen, std::string(path)}); } Config cfg; // 解析逻辑 return cfg; } auto result = loadConfig("app.conf"); if (!result) { std::cerr << "parse failed: " << result.error().message << "\n"; } else { applyConfig(*result); }std::expected还支持链式调用:.transform、.and_then、.or_else。我在新模块里已经用它替代了相当一部分“返回错误码加额外 error 枚举”的旧写法。函数签名干净很多,错误上下文也不像错误码那样只能表达枚举值。
5.4 错误处理选型的个人决策清单
我整理一下常用的选择标准,供你参考:
- 跨模块 ABI 边界:一律错误码或 C 接口,不传播异常。
- 预期内的小分支、常规失败,比如文件不存在、格式不合规:优先
std::expected或错误码。 - 深调用链中的意外失败,需要中断整个流程并携带上下文:用异常。
- 资源释放在栈展开期间必须保证:用 RAII,配合异常天然成立。
- 高频循环中反复出现的失败条件:绝对不要用异常表达,改成显式分支。
- 需要表达丰富错误上下文,比如原始错误码、文件路径、行号、嵌套原因:用
std::expected里的自定义错误结构体,或者用异常对象携带这些信息。
错误码适合错误类型少、不需要额外上下文的简单场景。一旦需要在错误里塞路径、行号、原始 errno、嵌套错误链,错误码会变得笨重,这时更适合自定义的错误对象或者异常对象。
6. C++17 之后与异常相关的几个变化,值得更新认识
6.1 std::uncaught_exceptions 让析构函数里的判断更准
C++17 之前,std::uncaught_exception()只返回一个 bool,只能回答“此刻有没有正在传播的异常”。它在析构函数里经常帮不上忙,因为析构函数无法区分自己是正常销毁,还是被异常传播引发的栈展开销毁。
C++17 引入了std::uncaught_exceptions(),返回当前“已经抛出但尚未被捕获”的异常数量。当析构函数看到返回值大于 0 时,基本可以判断此刻正处在栈展开过程中,从而决定要不要做某些可能引发新异常的收尾动作。
我常用它来实现日志清理工具类:如果返回值大于 0,说明外层已经出问题,析构时只做安全记录,不做可能失败的重操作;如果等于 0,说明是正常退出,可以走更完整的清理流程。注意,它只是一个判断依据,并没有改变“析构函数尽量不抛异常”的根本原则。
6.2 noexcept 进入类型系统后,模板设计要留神
自 C++17 起,noexcept正式成为函数声明的一部分,参与函数指针类型、重载决议和模板推导。void f() noexcept;和void f();现在是两种类型。一个带noexcept的函数指针不能直接赋给没有noexcept的函数指针,需要显式适配。
这对模板设计影响不小。写模板时想知道回调是否 noexcept,可以用条件表达式:
template <typename F> void invoke(F&& f) noexcept(noexcept(f())) { f(); }这种条件 noexcept 在标准库的容器和算法里大量出现。做模板库开发的读者,建议把noexcept当成参与重载和特化的一部分,而不只是文档标记。
6.3 std::expected 和 source_location 搭配,调试体验明显提升
C++20 提供了std::source_location,C++23 又带来正式的std::expected。这两个东西组合起来有个很实用玩法:在自定义错误结构体里保存source_location,解析失败时直接拿到确切的文件、函数、行号。同样的思路也适用于自定义异常类,异常被传播好几层之后,what()里仍然能看到最初触发的位置。
以std::expected为例:
struct ParseError { std::string message; std::source_location loc; }; std::expected<Config, ParseError> loadConfig( std::string_view path, std::source_location loc = std::source_location::current()) { if (!fileOk(path)) { return std::unexpected(ParseError{"cannot open", loc}); } return Config{}; }当错误信息从底层传到上层时,它已经带着产生错误的位置,不再需要靠日志关键字猜崩溃现场。我个人现在写新错误处理代码时,已经习惯把source_location塞进错误对象,这比在日志里打一层层函数名有效得多。
我自己的体会是:异常处理的关键不是先选“用还是不用”,而是先把设计框架定下来——底层异常带着结构化上下文抛出,中间层靠 RAII 自动回滚,顶层或 ABI 边界统一决策,高频路径和预期失败则交给std::expected或错误码。把这些边界弄清楚,C++ 异常处理就不再是站队题,而是一套可以落地的工程决策。