说实话,我在不少 C++ 项目里做过代码评审,几乎每次都能看到异常处理被写成一团浆糊:有人catch(...)然后假装无事发生,有人把throw当成return用,还有人干脆在析构函数里扔异常。C++ 的异常一直是个争议话题——抛出和捕获的姿势、重新抛出的时机、自定义异常体系怎么搭、优缺点到底怎么算,随便一个问题都能聊上很久。这篇就把这些年我在异常上踩过的坑、琢磨明白的东西,整理成一份能直接用的进阶笔记,重点放在几个平时容易被忽略但特别关键的细节上。
适合谁看?已经能把 C++ 语法写熟、但异常只在"程序崩溃后被迫接触"的开发者,尤其是想把手里的工具类、业务库做得更稳的人。不会从"什么是 try 和 catch"这种最基础的语法开始铺开,但会把机制讲透,让新手也能一遍看懂。
1. throw 出去的到底是什么:异常传播的底层机制
很多人对异常的理解停留在"抛出-捕获"两层,实际上这中间藏着一个完整的运行时过程,搞懂这个过程,比背一百条用法都管用。
1.1 一次 throw 背后发生的事
先看一段再普通不过的代码:
#include <iostream> #include <stdexcept> void inner(int x) { if (x < 0) { throw std::runtime_error("x must be non-negative"); } std::cout << "inner ok: " << x << "\n"; } void outer(int x) { int local = x * 2; inner(local); std::cout << "outer done\n"; } int main() { try { outer(-1); } catch (const std::exception& e) { std::cerr << "caught: " << e.what() << "\n"; } }当inner里执行throw std::runtime_error(...)时,至少发生了三件事。
第一,throw表达式的实参会被用来构造一个异常对象。这个对象不是局部变量,也不是inner帧上的对象,而是由编译器在独立异常存储区中构造的。后面讲生命周期时我会展开,这里记住一点:异常对象不随函数返回而消失。
第二,运行时开始向上查找匹配的 catch 子句。查找范围先从当前函数内的 try 块开始,如果当前函数不在任何 try 块里,就沿着调用栈往外走。这里有个常见误解:以为异常像return一样直接回到调用者。不是,它会沿着调用栈一层层往上,直到找到一个能接住它的 catch。
第三,一旦命中某个catch,就会发生栈展开。从抛出点那一帧开始,逐层销毁沿途栈帧上的所有局部对象,调用它们的析构函数,再进入 catch 所在的帧。RAII 之所以能成为 C++ 资源管理最重要的武器,就是因为它借了栈展开这趟"顺风车"——你不需要在每层函数里手动写清理代码,析构会被自动执行。
理解这个过程,很多坑就自然明白了。比如你抛出的是一个需要临时计算才能构造的对象,那这个构造是在抛出点发生的,如果构造过程本身抛异常,那就变成了另一个异常,原本要抛的那个异常对象直接作废。这一点在自定义异常体系设计时很重要,后面会再提。
1.2 异常匹配不是隐式转换
catch (const std::runtime_error& e)能接住std::runtime_error,也能接住它的派生类,但catch (int)接不住throw short(1)。异常匹配规则比函数重载严格得多:它允许的只有精确类型匹配、忽略 cv 限定符的匹配、以及向基类的匹配,不会做整型提升,也不会做用户自定义类型转换。
这带来一个非常实际的后果:你抛出的异常类型必须和你 catch 的类型严丝合缝。如果某天你把throw std::runtime_error(...)改成了throw std::logic_error(...),只有 catchstd::logic_error或基类std::exception的地方能接住,原本 catchstd::runtime_error的代码段会直接漏接。
顺着这个规则还有一个经典问题:多个 catch 子句是按从上到下的顺序匹配的,而不是按"最匹配"。这意味着你必须把派生类写在前面,基类写在后面。
try { throw std::invalid_argument("bad"); } catch (const std::invalid_argument& e) { // 能到这里 } catch (const std::logic_error& e) { // 永远不会到这里 }反过来写:
try { throw std::invalid_argument("bad"); } catch (const std::logic_error& e) { // 先命中的是这里! }很多新手在这上面栽过跟头:明明有更具体的 catch,结果被基类提前截胡,排错排到怀疑人生。我自己的经验是:把catch(...)永远放在最后,把派生类靠前放,并且尽量只在抽象层接口处用基类 catch。
1.3 栈展开期间禁止抛异常
栈展开有一个容易被忽略的硬性规则:在展开过程中,如果某个析构函数又抛出异常,程序会直接调用 std::terminate。因为此时运行时正处理当前异常,再冒出来一个异常根本没法同时处理,只能终止。
C++11 之后,析构函数默认noexcept(true),只要你在析构函数里抛异常,就会触发 terminate,所以这个问题其实已经被语言层面强制约束了。但很多人的析构函数里调用了第三方函数,那个函数可能内部抛异常却没被 catch,如果你不小心让异常穿了析构函数,程序立刻崩。所以我的习惯是:析构函数里凡是可能抛异常的调用,要么套一层自己的 try/catch,要么明确避免调用这些接口。
2. 捕获的姿势与异常对象的一生
捕异常的写法直接决定了代码的健壮性,这里面的坑一点都不比抛异常的少。很多老代码在 catch 上翻车,往往不是不懂语法,而是没搞懂异常对象是怎么活的。
2.1 按引用捕获、按值捕获、按指针捕获
我一贯的建议是:catch 参数用 const 引用,绝大多数场景下按值 catch 是错的,按指针 catch 更是灾难。
按值捕获的问题有两个。一是会拷贝异常对象,多一次拷贝开销倒还好,更麻烦的是切片。假设你有自定义异常class MyError : public std::runtime_error,里面存了错误码和上下文,然后你catch (std::runtime_error e)——恭喜,多态信息全部切掉,e只是一个普通 runtime_error,自定义字段全拿不到。
二是按值捕获时,e是通过异常对象拷贝出来的一个全新对象,你在 catch 块里对e做的任何修改都不会影响异常对象本身。这虽然通常不影响使用,但结合切片问题就麻烦大了。
按指针捕获更糟。throw new MyError(...)会留下一裸指针,谁负责 delete?不定。而且如果抛的是指向栈上对象的指针,那个对象在栈展开时就被析构了,catch 拿到的是悬垂指针,解引用就是未定义行为。这种写法我只在遗留项目的陈年代码里见过,每次看到都想立刻改掉。
正确写法应该是这样:
try { // 可能会抛 MyError / runtime_error / 其他 } catch (const MyError& e) { handleMyError(e); } catch (const std::runtime_error& e) { handleRuntimeError(e); } catch (const std::exception& e) { handleGenericError(e); }这里const std::exception&兜底是因为标准库异常都继承自它,而且自定义异常体系最终也都会挂到它下面。
2.2 异常对象到底活在什么时候
异常对象在throw表达式处构造,经过栈展开、匹配 catch,直到 catch 块执行完毕后才被销毁。这是标准规定的:异常对象的生命周期至少覆盖到 catch 参数完成初始化,实际实现里还会活到 catch 块结束。
这个"至少"意味着在某些实现里,异常对象可能比 catch 块活得更久,但你没有理由依赖这一点。你只需要知道:在 catch 块内部访问异常对象是安全、有效、可用的;出了 catch 块再拿它的引用,那就是典型悬垂引用,不要再碰。
另一个生命周期相关的坑是:throw 一个局部变量不会废。因为异常对象不是那个局部变量本体,而是在throw时拷贝/移动构造到独立存储区的新对象。它不会因为原局部变量出作用域而失效。但如果你写的是throw &local,那就不一样了——指针指向栈上对象,栈展开后必然悬垂。
2.3 catch(...) 与吞异常的边界
catch(...)捕获任何类型的异常,但拿不到异常对象,只能用来做"最后的兜底"。它的典型用途是:记录日志后重新抛出,或确保某些清理动作一定会执行。
try { process(); } catch (...) { log("unknown error in process"); throw; // 继续向上抛 }但要小心,catch(...)非常容易变成"吞异常"的温床。我看到太多代码是这样:
try { doSomething(); } catch (...) { // 什么都不做,假装一切正常 }这是最危险的写法。异常一旦被吞掉,调用者完全不知道发生了什么,出错状态被隐藏,日志里什么都查不到。如果实在不知道怎么处理,至少记录日志再重新抛出,或者把失败转换成明确的错误码返回。吞异常等于把 bug 埋进深处,迟早以更难看的方式爆出来。
3. 重新抛出不是 throw e; 那么简单
重新抛出这个话题看着小,实际特别容易写错,而且写错的后果很隐蔽。
3.1 空 throw 与会切片的 throw e;
在 catch 块里,直接写throw;会把当前正在处理的异常原样重新抛出,不拷贝,不切片,保持原始类型。这是真正意义上的"重新抛出"。
但不少人会随手写成throw e;,以为效果一样。差别可大了。throw e;会构造一个新的异常对象,而且这个新对象是从 catch 参数e拷贝来的。如果你的 catch 参数是const std::exception& e,而真实异常类型是MyError,那么throw e;抛出去的就是一个std::exception切片后的副本,原始的MyError类型信息和自定义字段全丢了。调用链上层即便写了catch (const MyError& e)也接不到,因为类型已经变了。
try { throw MyError("file not found", 404); } catch (const std::exception& e) { // 错误! throw e; // 切片,抛出去的是 std::exception // 正确 throw; // 抛出去的还是 MyError }如果你在 catch 块里需要对异常做一点"横切处理"再往上抛,比如记录日志、统计指标,那就用throw;。需要包装成新类型时才显式throw WrapperError(...)。这套逻辑我后面会展开。
3.2 典型的"记录后重抛"场景
重新抛出的典型场景是在模块边界统一记录日志,同时保留原始异常给上层:
void runModule() { try { module.execute(); } catch (const std::exception& e) { logger.error("module failed: {}", e.what()); throw; // 保持原始异常,让调用者继续处理 } }这样做的好处是:每一层只做自己能做的事——记日志、释放临时资源、做状态标记——但不剥夺上层处理异常的权利。层级划分清晰,异常信息逐层带上不同上下文,最后到顶层就是一条完整的链路日志。这个模式在大型系统里特别值钱,比每层都 catch 一遍又都不抛要健康得多。
3.3 noexcept:异常承诺的边界
noexcept是 C++11 带来的重要工具,它表示"这个函数不抛异常"。如果声明了noexcept的函数真的抛出异常,程序不会像普通异常那样向上传播,而是直接std::terminate。这就像你跟调用者签了对赌协议:你说不会抛,结果抛了,那大家一起崩。
很多人因此认为 noexcept 是危险的东西,最好别加。恰恰相反,正确的理解是:当你确实有能力保证不抛时,noexcept 带来性能和语义双重收益。
- 析构函数默认 noexcept,这很好,因为栈展开期间第二个异常会 terminate;
- 移动构造函数应当 noexcept。容器需要重新分配内存时,如果元素移动构造不抛,可以放心搬元素;如果可能抛,容器可能回退到拷贝,性能陡降;
- 纯粹 getter 这类简单取值函数,如果实现里没有分配内存、没有可能失败的业务逻辑,可以标 noexcept。
但标 noexcept 前必须把函数体内所有可能抛异常的路径排查干净。例如:
std::string buildMessage(const std::string& prefix) noexcept { return prefix + ":" + data; // 字符串拼接可能抛 bad_alloc }如果内存耗尽,这段直接 terminate。所以我现在的原则是:只对确实无条件不抛的函数标 noexcept,否则宁愿不标。标错 noexcept 的代价,比不标高得多。
顺带提一句,swap函数也值得努力标 noexcept,标准库的一些算法在swap可能抛异常时会切到更保守的实现,性能也会差一截。
4. 自定义异常体系:从 std::exception 派生自己的异常类型
我在代码评审时经常看到项目里到处 throwstd::runtime_error("xxx"),字符串里塞各种信息。偶尔用用还行,工程一大就很难维护,原因在于:哪个模块抛的、错误等级是什么、有没有错误码、有没有上下文,全都淹没在一个字符串里。所以在稍大一点的工程里,自定义异常体系几乎是刚需。
4.1 为什么需要一套自己的异常类型
自带 std::exception 体系的最大问题,是你没法用类型区分错误类别,也就没法精准 catch。如果你抛的都是std::runtime_error,上层想针对"配置解析失败"和"网络请求失败"分别处理,就只能去解析what()字符串——脆弱且恶心。
自定义异常则能实现按类型 catch、按错误码程序化处理,还能带上结构化上下文。比如典型的模块错误体系可以这样设计:
#include <stdexcept> #include <string> class BaseError : public std::runtime_error { public: BaseError(const std::string& msg, int code = 0) : std::runtime_error(msg), code_(code) {} int code() const noexcept { return code_; } protected: int code_; }; class ConfigError : public BaseError { public: explicit ConfigError(const std::string& msg, int code = 0) : BaseError(msg, code) {} }; class NetworkError : public BaseError { public: NetworkError(const std::string& msg, int code = 0) : BaseError(msg, code) {} };调用处就可以根据不同错误类型做差异化处理:
try { parseConfigFile(path); connectServer(); } catch (const ConfigError& e) { handleConfigError(e.code(), path); } catch (const NetworkError& e) { retryOrNotify(e.code()); } catch (const BaseError& e) { // 其他业务错误兜底 } catch (const std::exception& e) { // 未知标准异常兜底 }因为BaseError继承自std::runtime_error,而runtime_error又继承自std::exception,所以所有自定义异常都天然能被catch (const std::exception&)接住,兼容性一下就稳了。
4.2 what() 与 noexcept:一个容易踩的细节
std::exception::what()的签名是virtual const char* what() const noexcept;。很多人在自定义异常时在what()里现场拼字符串,比如:
const char* what() const noexcept override { return ("msg: " + detail).c_str(); // 坏味道,而且可能抛异常 }这样写两个问题:第一,"msg: " + detail会生成临时std::string,函数返回后临时对象析构,c_str()指向悬垂内存,这是未定义行为;第二,字符串拼接可能抛bad_alloc,而what()声明了noexcept,抛异常就 terminate。
正确做法是:异常对象构造时就把最终消息拼好,存成 std::string 成员,what() 里直接返回 c_str()。
class MyError : public std::runtime_error { public: MyError(const std::string& msg, int code) : std::runtime_error(msg), message_("[code " + std::to_string(code) + "] " + msg), code_(code) {} const char* what() const noexcept override { return message_.c_str(); } private: std::string message_; int code_; };这里基类std::runtime_error内部保留原始 msg,我们又额外存一份带错误码的完整消息。what()返回message_.c_str(),简单安全。注意如果构造函数里拼串过程抛异常,异常对象构造失败,最终抛出的会变成bad_alloc,但至少不会 terminate,属于可接受的行为。
4.3 异常类该不该有虚析构,要不要拷贝
作为多态基类,BaseError当然需要虚析构。但这里不需要搞成一套复杂的多态框架,std::runtime_error已经提供了虚析构,继承它并按需重写 what() 就够了。
异常类的拷贝要特别注意:throw 时编译器可能构造异常对象,并进行拷贝。所以:
- 异常类不要放不可拷贝的成员(比如 unique_ptr),否则 throw 编译不过;
- 附加信息少放一点,一个 string 加一个 int 足够,别把大对象塞进异常里;
- 类的层次不要太深。两层到三层足够,太深会让 catch 匹配变得难维护。
4.4 进阶:用 throw_with_nested 保留完整上下文
真实系统里常有这种需求:底层抛了NetworkError,中间层想把"当前处理到哪个请求、哪个阶段"也附加进去,然后继续往上抛。传统做法是丢旧异常、抛新异常,原始细节就丢了。C++11 的std::throw_with_nested允许你抛出嵌套异常,把当前异常和原异常都带上去:
void processRequest(Request req) { try { fetchData(req); } catch (...) { std::throw_with_nested( std::runtime_error("failed to fetch data for request id=" + req.id)); } }上层处理时用std::rethrow_if_nested把内层异常逐层剥出来,就能得到完整调用链。这个工具用在模块边界做异常上下文增强,效果很好,但接口用起来有点绕。我建议封装一个辅助函数,不要让业务代码直接接触这两个接口:
void printNested(const std::exception& e, int depth = 0) { std::cerr << std::string(depth * 2, ' ') << e.what() << "\n"; try { std::rethrow_if_nested(e); } catch (const std::exception& inner) { printNested(inner, depth + 1); } catch (...) { } }有了这套,异常链路的诊断体验会好很多,尤其多层服务追查问题时,比盯着一个扁平字符串强太多。
5. 异常的优缺点:性能账与工程账都要算
异常在 C++ 里是最容易引发争论的话题之一,根本原因就是优缺点都太鲜明。你得两头都看,才能做出正确的工程取舍。
5.1 优点:错误传播的清晰性与 RAII 的完美配合
异常最核心的价值是错误自动沿调用链传播。如果深层函数发生预期之外的错误,你不需要每层都写"检查返回值、返回错误码、上层再检查"的样板代码,错误会自动传到有能力处理的地方。这不仅少写代码,更把正常逻辑和错误处理逻辑分离开来,主流程不被大量错误分支挤满,可读性明显提升。
另一个容易被低估的好处是:RAII 与异常是天生一对。栈展开会让局部对象析构自动执行,mutex lock、socket、文件句柄这些资源在异常传播时都能安全释放。反观错误码模型,你必须在每个 return 之前手动释放资源,漏一处就是泄漏。写错误码代码的人都有那种疲惫感:"每个分支都要记得关文件、解锁、free"。
还有一点很现实:构造函数没有返回值。构造函数失败如果不抛异常,就只能设一个"构造失败"标志,后续每个操作都要检查这个标志,极其别扭。异常是唯一既自然又标准的构造函数失败上报方式。
5.2 缺点:性能和隐式控制流
异常被诟病最多的就是性能。但要分清两种开销。
正常路径上,现代编译器大多采用零成本异常模型,正常运行本身通常没有额外开销,代价是二进制体积变大、编译时间变长、寄存器分配可能受影响。真正昂贵的是抛出异常那一刻:异常对象构造、栈展开遍历、寻找 catch、沿途析构对象、RTTI 匹配,这是一条相当重的路径。实测中,一次抛接的开销能到普通函数调用的几十倍甚至上百倍。
所以工程结论很明确:异常应该代表真的"例外",而不是高频分支。如果一个函数里三成概率会抛异常,性能一定出问题,这种情况应该考虑 optional 或 expected 这类返回式错误。
隐式控制流也是大问题。异常导致调用链上发生了一次"不可见跳转",阅读代码时很难预测路径。尤其第三方库里某个你没想到的异常突然在你的调用栈里炸出来,这正是很多人觉得异常难防、难调的最大原因。
5.3 异常 vs 错误码:各维度对照
| 维度 | 异常 | 错误码 |
|---|---|---|
| 错误传播 | 自动沿调用栈传播 | 必须手动逐层返回 |
| 正常路径开销 | 零成本模型下基本无开销 | 无额外开销 |
| 抛出路径开销 | 较高 | 无 |
| 构造函数失败上报 | 天然支持 | 无法直接返回 |
| 资源安全释放 | 栈展开自动析构 | 需要手写清理 |
| 控制流可读性 | 隐式跳转,较弱 | 显式分支,较清晰 |
| 性能可预测性 | 抛出点不可预测 | 完全可预测 |
| 调试友好度 | 需要依赖调试器异常机制 | 直接看返回值 |
5.4 我的工程取舍
用异常还是错误码,没有一刀切答案。我自己的倾向是分三类场景。
第一,库内部逻辑错误和不可恢复错误,用异常。比如参数校验失败、文件缺失、网络断连,这类问题用异常往上抛,让最外层统一处理。构造函数失败也走异常,没有别的优雅选项。
第二,高频、可预期、必须由调用者立刻处理的错误,用错误码或 optional。比如解析数字失败、查找 key 不存在、计算结果的边界判断,这类错误几乎相当于正常分支,返回错误码或 optional 更省事、更直观。
第三,跨模块边界的错误,用自定义异常体系统一封装上抛。自定义异常在模块边界发挥最大价值:底层异常转换成上层语义异常,比如ConfigError、NetworkError,必要时用throw_with_nested保留原始上下文,上层根据类型做差异化处理,而不是解析字符串。
另外不管选哪条路,团队规范里最好写明几条底线:不允许吞异常;不允许在析构函数里抛异常;不允许 catch 后用错误码伪造成功;不允许每层都 catch 一遍却不做任何信息增强。
写在最后
每次聊异常,我都会想起早期在某个项目里把整个模块的错误处理从错误码改成异常体系的那段经历。改完当天代码量砍掉四成,但紧接着就迎来两周的"异常泄漏"排错期——很多原本被错误码掩盖的问题,都以异常形式浮出水面。那两周很痛苦,但之后系统稳定性肉眼可见地提升了。后来我给自己的补充了三条习惯:一是写可能抛异常的代码时,顺手想想"这个异常让谁接、在哪兜底";二是在模块边界统一做异常增强;三是每写一条 catch 都问自己"我是要处理它,还是只是路过它"。哪怕只做到最后一条,你的异常代码也不会太烂。