1. 异常处理到底在解决什么问题
先聊点实际的。你写 C++ 的时候,有没有遇到过这种情况:程序跑着跑着突然崩溃,弹出一个类似“xxx.exe 已停止工作”的窗口,你一脸懵,根本不知道是哪行代码出了问题。或者更隐蔽的,函数返回了一个错误码,你忘了检查,结果后续逻辑拿着这个错误码继续往下算,最后算出一堆莫名其妙的结果。
这两个场景,就是 C++ 异常处理要解决的核心问题。C++ 的异常处理机制,本质上是一套错误传播与处理机制,它允许程序在运行时检测到错误条件后,主动抛出一个异常对象,然后由调用链上某个合适的地方捕获并处理这个异常,从而避免程序直接崩溃,或者带着错误状态继续运行。
很多人一提到“异常”,第一反应是“是不是就是 try-catch 一下”。这么理解没错,但远远不够。异常处理在 C++ 里的分量,比表面看起来要重得多。它涉及异常安全、资源管理、栈展开、对象生命周期、性能开销、设计模式……如果你只是把 try-catch 当成“出错就抓一下”,那很多坑你迟早会踩一遍。
举个最简单的例子。
void func1() { std::vector<int> v; v.push_back(1); int* p = new int(42); // 模拟某个操作抛出异常 throw std::runtime_error("something went wrong"); delete p; }这段代码有问题。如果 throw 执行了,delete p永远不会执行,p指向的内存就泄漏了。即使你在外层 catch 住了异常,内存已经漏了,程序继续跑,内存占用越来越大,最终可能被系统杀掉。这就是异常安全问题的雏形。
所以,真正理解异常处理,你得先明白它在整个 C++ 语言体系中的定位,然后把栈展开、资源管理、异常安全等级这些概念串起来。这篇内容就是想带着你把这条线理清楚。
无论你是刚学 C++ 的新手,还是写了两三年 C++ 但一直对异常处理“感觉会又说不清”的开发者,这篇文章都适合你。我会从底层机制讲起,再到实际工程中的用法、常见坑、性能考量,最后给出一套我用了很久的异常处理规范建议。
2. 从错误码到异常:为什么 C++ 要搞这么一套机制
2.1 错误码方案的老大难问题
在 C++ 出现异常机制之前,C 语言时代的错误处理基本靠错误码和全局变量(比如errno)。函数调用失败了,返回一个负数或者 NULL,调用者自己判断。
这种做法在简单场景下没问题,但一旦工程规模上来,问题就暴露得非常明显。
问题一:错误信息太少。错误码通常只是一个 int 数字,你得去查表才知道这个数字代表什么。如果错误发生在深层嵌套的函数里,外层拿到错误码,往往只能知道“里面出错了”,但具体是哪一层出错、出错现场是什么样的,信息几乎为零。
问题二:容易漏检查。任何一个函数都可能失败,如果你每次调用后都写一段 if 判断,代码会变得非常啰嗦。更常见的情况是,你心里想着“这个函数应该不会失败吧”,然后就跳过了检查。等到线上真的出了问题,你根本不知道是哪个调用点出了问题。
问题三:错误处理路径和正常路径混杂。用错误码方案时,函数内部往往要先检查各种失败条件,然后提前 return;正常逻辑反而被一堆分支判断挤成了“夹心层”。代码阅读体验极差。
问题四:析构函数和中间资源释放问题。这个最伤。如果函数在多个地方都可能失败返回,你必须在每个 return 之前手动释放已经获取的资源。一旦中间加了一行新的失败检查而忘记释放资源,内存泄漏就出现了。
2.2 异常机制的核心思路
C++ 的异常机制换了一个思路:不强制在每一层都处理错误,而是允许错误沿着调用栈向上传播,直到找到一个愿意处理它的地方。
这个设计有个天然的好处:中间层函数可以完全不关心错误处理,只管做自己的事。如果底层出错了,异常会自动往上抛,自动跳过中间层剩余代码,直接到 catch 的地方。
void level3() { throw std::runtime_error("level3 error"); } void level2() { level3(); std::cout << "这行不会执行" << std::endl; } void level1() { level2(); std::cout << "这行也不会执行" << std::endl; } int main() { try { level1(); } catch (const std::exception& e) { std::cout << "捕获: " << e.what() << std::endl; } }level3 抛出异常后,level2 和 level1 中那些还没执行的代码全部跳过,异常直接跳到 main 的 catch 块。这段过程中,中间层函数不需要写任何错误处理代码。
这里有个关键点:异常在向上传播的过程中,会触发栈展开(stack unwinding)。所谓栈展开,就是程序会自动销毁所有从 try 块开始到异常抛出点之间创建的局部对象,逐一调用它们的析构函数。
class Logger { public: ~Logger() { std::cout << "Logger destroyed" << std::endl; } }; void test() { Logger log; throw std::runtime_error("boom"); } int main() { try { test(); } catch (...) { std::cout << "caught" << std::endl; } }输出结果会是:
Logger destroyed caughtLogger对象是在栈展开时被自动析构的。这就是异常机制和错误码机制的一个本质差异:异常机制可以自动管理栈上对象的生命周期,而错误码机制中,你一旦提前 return,就必须手动清理栈上对象持有的资源。
2.3 土办法类比:你是一个传话的员工
想理解异常传播,可以想象一个场景。
你是公司里一个普通员工,工作中发现一个自己解决不了的问题。你可以选择写一份报告塞到格式固定的表格里往上递交,领导再往上传,一层一层,直到有人能处理——这就是错误码方案。每个层级都得拆开报告看一眼,判断要不要继续传。
异常机制则像是你在公司内部系统里直接发起了一个“紧急求助工单”,系统自动绕过所有中间管理层,直接推送给你那个能解决问题的部门。中间被绕过的管理层根本不需要知道这件事。对于那个专门的部门来说,他只需要关心“收到求助工单了,里面写清了问题过程”。
对应到 C++,工单就是异常对象,系统就是运行时环境,绕过中间层的过程就是栈展开。
这个类比能帮你理解为什么异常机制比错误码“省心”——中间层代码不需要夹带错误处理逻辑,职责更纯粹。
3. 核心细节拆解:try/catch/throw 的正确打开方式
3.1 抛出的东西是什么
在 C++ 里,你可以 throw 几乎任何类型的对象。可以是一个 int,一个字符串,一个自定义类对象,甚至是一个指针。
throw 42; // 可以 throw "hello"; // 可以,但这是个 const char* throw std::runtime_error("x"); // 常规做法但实际工程中,我强烈建议你不要 throw 裸的 int 或字符串,而是抛出一个派生自std::exception的异常对象。原因很实在:
- 统一接口:
std::exception提供了what()方法,你可以通过基类指针捕获所有标准异常。 - 语义清晰:你能通过异常类型区分逻辑错误、运行时错误、越界访问等。
- 兼容性:第三方库多在捕获
std::exception,你抛出的东西能被它们顺利接住。
3.2 catch 的匹配规则
catch 块在匹配异常类型时,不是按“最近抛出的类型”精确匹配,而是遵循一套规则:
- 允许派生类到基类的转换。你抛出
std::runtime_error,用catch (const std::exception&)能接住。 - 不允许做标准算术转换,
catch (int)接不住你抛出的long。 - 指针类型的匹配类似,但注意:不要抛出指针。抛指针容易在
catch (const std::exception&)路径上漏接,而且管理异常对象的生命周期很麻烦。
try { throw std::runtime_error("oops"); } catch (const std::logic_error& e) { // 接不到,因为 runtime_error 不是 logic_error 的派生类 } catch (const std::exception& e) { // 在这里接住 }还有一个值得注意的细节:如果你用多个 catch 块,匹配顺序是从上到下,先到先得。所以派生类的 catch 必须放在基类 catch 前面。
try { // ... } catch (const std::runtime_error& e) { // 先匹配这个 } catch (const std::exception& e) { // 基类兜底 }如果你把catch (const std::exception&)写在前面,所有标准异常都会被它接住,后面的catch (const std::runtime_error&)就成了永远执行不到的代码。编译器通常会对此给出警告。
3.3 catch(...) 的兜底用法
catch(...)可以捕获任意类型的异常。这个语法在特定场景下非常有价值。比如你不想让任何异常逃出某个函数,就可以兜底抓一下,记录日志后重新抛出。
void safeInvoke() { try { doSomething(); } catch (...) { std::cerr << "unknown exception caught" << std::endl; throw; // 重新抛出 } }注意这里的关键字:throw;单独出现时是“重新抛出当前异常”,而不是抛出一个新的空异常。这个操作会保留原始异常的类型和栈信息,方便外层继续处理。
这里也引出一个重要技巧:如果你不确定当前函数可能会遇到什么异常类型,但确实需要在此刻记录现场信息,那么catch(...)+throw;是标准做法。
但在实际工程中我通常不建议catch(...)轻易出场。因为你一旦用catch(...)接住所有异常,却又不重新抛出,那异常就被无声吞掉了。程序后续可能会进入一个完全不可预料的错误状态——比崩溃更可怕。
3.4 异常类型设计:别只用一个类型打天下
如果你要在工程中定义自己的异常体系,我的建议是遵循以下设计模式:
先定义一个自己的异常基类,继承自std::runtime_error(或者std::exception,看你的需求),再定义几个具体的子类代表不同错误类型。
class BaseException : public std::runtime_error { public: explicit BaseException(const std::string& msg) : std::runtime_error(msg) {} }; class NetworkException : public BaseException { public: explicit NetworkException(const std::string& msg) : BaseException(msg) {} }; class ConfigException : public BaseException { public: explicit ConfigException(const std::string& msg) : BaseException(msg) {} };这样一来,调用方既可以分别捕获NetworkException和ConfigException做差异化处理,也可以统一捕获BaseException做兜底处理,非常灵活。
在实际项目中,我还建议在自定义异常里加入文件名、行号、函数名等上下文信息。不过 C++20 的std::source_location已经能帮你自动拿这些信息,比手动塞宏更优雅。
#include <source_location> class DetailedException : public std::runtime_error { public: DetailedException(const std::string& msg, std::source_location loc = std::source_location::current()) : std::runtime_error(msg + " at " + loc.file_name() + ":" + std::to_string(loc.line())) {} };这个用法效率高,而且不会暴露内部实现细节。
3.5 构造函数里的异常
构造函数里抛出异常,有一个非常特殊的语义:对象的析构函数不会被调用,但类成员对象的析构函数会被调用,且已分配的内存会被自动回收。
class Resource { public: Resource() { std::cout << "Resource created" << std::endl; } ~Resource() { std::cout << "Resource destroyed" << std::endl; } }; class Wrapper { public: Wrapper() : res() { throw std::runtime_error("constructor failure"); } private: Resource res; }; int main() { try { Wrapper w; } catch (...) { std::cout << "caught" << std::endl; } }输出:
Resource created Resource destroyed caughtWrapper自身构造失败后,Wrapper的析构函数不会执行——这是一个你必须牢记的特性。如果你在构造函数里手动分配了裸资源(比如 new 出来的内存),然后构造函数中途抛异常,这些资源会全部泄漏。这也是为什么构造函数中建议优先使用 RAII 而不是手动管理资源的原因。
如果构造函数里必须做一些可能失败的初始化操作,可以考虑用两阶段构造(先构造一个无资源对象,再调用 init 方法初始化),或者直接让那些可能失败的操作发生在对象构造完成之后的其它方法里。
4. 异常安全与 RAII,这才是 C++ 异常处理的灵魂
4.1 什么是异常安全
异常安全(exception safety)是 C++ 异常处理中绕不开的概念。它描述的是:当异常发生时,程序状态是否仍然保持一致、资源是否不会泄漏、对象是否处于可析构状态。
通常分四个等级:
| 等级 | 含义 | 示例 |
|---|---|---|
| 无保证 | 异常可能泄漏资源、破坏状态 | 手工管理裸指针,抛出异常时 delete 被跳过 |
| 基本保证 | 不泄漏资源,对象状态一致但可能被修改 | 操作失败后对象状态已变,但仍是合法状态 |
| 强保证 | 操作要么成功,要么程序状态完全回到操作前 | 比如用 copy-and-swap 模式实现赋值操作 |
| 不抛异常保证 | 操作一定不会抛出异常 | 析构函数、swap操作 |
在写代码时,我建议你尽量让自己的核心接口达到“基本保证”或“强保证”级别。
有个记忆技巧:析构函数永远不要抛出异常。这是 C++ 中最接近“铁律”的一条规则。析构函数抛异常会导致严重问题:如果在栈展开过程中(已经因为别的异常)析构函数再次抛异常,程序会直接调用std::terminate,连 catch 的机会都没有。
class Bad { public: ~Bad() { throw std::runtime_error("dtor throws"); } }; int main() { try { Bad b; throw std::runtime_error("outer"); } catch (...) { // 永远不会执行到这里 } }程序会直接终止。
所以,如果你在析构函数里做了一些可能产生异常的操作(比如关闭文件、释放网络连接),最好把它们包在 try-catch 里,吞掉异常或记录日志,但绝不能让异常继续往外抛。
4.2 RAII:异常安全的基石
RAII(Resource Acquisition Is Initialization)的中文名是“资源获取即初始化”,名字有点绕,但你完全可以把它理解成“用对象的生命周期管理资源”。
你需要在构造函数里获取资源,在析构函数里释放资源。当对象生命周期结束时(无论是正常结束还是被栈展开强制结束),析构函数必定会被调用,资源就安全释放了。
回到本文开头的例子:
void func1() { std::vector<int> v; v.push_back(1); int* p = new int(42); throw std::runtime_error("something went wrong"); delete p; }换成 RAII 风格:
void func1() { std::vector<int> v; v.push_back(1); std::unique_ptr<int> p(new int(42)); throw std::runtime_error("something went wrong"); }std::unique_ptr是栈上对象,无论异常何时抛出,它的析构函数都会运行,自动删除持有的内存。这就是 RAII 带来的异常安全。
这种模式可以推广到一切资源:文件句柄、数据库连接、线程锁、互斥量、套接字……
举一个实际点的例子。如果你在多线程代码里手动 lock 和 unlock:
std::mutex m; void bad() { m.lock(); // 这里抛异常 doTask(); m.unlock(); }异常抛出后,m.unlock()永远不执行,互斥锁一直被占用,其他线程会永久阻塞。而用std::lock_guard:
std::mutex m; void good() { std::lock_guard<std::mutex> lock(m); doTask(); }无论doTask()是否抛异常,lock_guard 析构时都会自动解锁。这就是 RAII 在并发场景中的价值。
4.3 copy-and-swap 与强异常安全保证
有经验的 C++ 工程师在实现重载赋值运算符时,喜欢用 copy-and-swap 模式。它天然提供强异常安全保证。
class MyVector { public: void swap(MyVector& other) noexcept { std::swap(size_, other.size_); std::swap(data_, other.data_); } MyVector& operator=(const MyVector& other) { MyVector temp(other); // 先拷贝一份 swap(temp); // 然后交换 return *this; } private: size_t size_; int* data_; };这段代码的逻辑:先用other拷贝构造一个临时对象temp。如果拷贝过程中抛异常,temp构造失败,当前对象毫发无损。如果拷贝成功,再通过交换把数据换过来。交换操作本身不抛异常,所以整个赋值操作要么完全成功,要么对当前对象没有任何影响。
这不只是理论上的优雅,在实际系统里非常管用。比如一个服务配置更新接口,你希望更新失败时配置保持原样,而不是变成“改了一半”的脏状态。copy-and-swap 天然帮你实现了这个需求。
4.4 noexcept:告诉编译器我保证不抛异常
在函数声明后加上noexcept,表示这个函数承诺不会抛出异常。这不仅仅是个文档性质的标记,它对编译器优化和标准库行为都有影响。
void safeFunc() noexcept { // 如果这里实际上抛出了异常,程序会终止 }这里必须警告:如果你标记了noexcept,但函数内部确实抛出了异常,程序不会 去找 catch,而是直接调用std::terminate。所以不要在noexcept函数里偷偷调用可能抛异常的函数,除非你自己在函数内部做了 catch。
哪些函数应该标记noexcept?我的经验是:
- 移动构造函数和移动赋值运算符,能
noexcept就noexcept。因为容器在扩容时,如果移动构造被标记为noexcept,标准库会优先用移动而不是拷贝,性能差别明显。 swap函数,应该标记noexcept,因为它承担着异常安全的重要职责。- 析构函数,默认就是
noexcept的(除非成员析构函数可能抛出),不需要特别写。
一个经典的性能场景:
std::vector<std::string> v; v.reserve(100);当 vector 扩容时,需要把旧内存中的元素搬到新内存。如果你的类型移动构造是noexcept的,vector 会大胆地直接搬元素;如果移动构造可能抛异常,vector 出于安全考虑可能选择拷贝元素,而拷贝的开销远高于移动。
所以,你在自定义类型时,如果能保证移动操作不会失败,一定要显式加上noexcept。
5. 实操过程:从零搭一个带异常处理的 C++ 示例项目
5.1 环境准备
现在很多文章教你用 VS Code 配 C++ 环境,这没问题,但我会直接用最传统的命令行工具来演示,因为这个方式对你理解编译和运行过程更有帮助。
你需要准备:
- 一个支持 C++11 或更高版本的编译器(GCC、Clang、MSVC 都行)
- 一个文本编辑器
以下例子我在 Linux 上用 GCC 编译验证过,在 Windows 上用 MSVC 也完全可以,语法都是标准 C++。
创建一个文件exception_demo.cpp,内容如下:
#include <iostream> #include <stdexcept> #include <vector> #include <memory> #include <string> class DatabaseError : public std::runtime_error { public: explicit DatabaseError(const std::string& msg) : std::runtime_error(msg) {} }; class DatabaseConnection { public: explicit DatabaseConnection(const std::string& connStr) : connected_(true) { if (connStr.empty()) { throw DatabaseError("empty connection string"); } std::cout << "Connecting to " << connStr << std::endl; } void query(const std::string& sql) { if (!connected_) { throw DatabaseError("not connected"); } if (sql.find("SELECT") == std::string::npos) { throw DatabaseError("unsupported query: " + sql); } std::cout << "Executing: " << sql << std::endl; } ~DatabaseConnection() { std::cout << "Closing connection" << std::endl; connected_ = false; } private: bool connected_; }; void runQueries(const std::string& connStr) { // 这里故意不使用堆上的对象,直接用栈对象演示 RAII DatabaseConnection conn(connStr); conn.query("SELECT * FROM users"); conn.query("DELETE FROM users"); // 这条会抛异常 std::cout << "query done" << std::endl; } int main() { try { runQueries("tcp://localhost:3306"); } catch (const DatabaseError& e) { std::cerr << "Database error: " << e.what() << std::endl; } catch (const std::exception& e) { std::cerr << "Generic error: " << e.what() << std::endl; } std::cout << "Program continues..." << std::endl; return 0; }编译运行:
g++ -std=c++17 -o exception_demo exception_demo.cpp ./exception_demo输出:
Connecting to tcp://localhost:3306 Executing: SELECT * FROM users Closing connection Database error: unsupported query: DELETE FROM users Program continues...你注意几个细节:
DatabaseConnection对象是在栈上创建的,在异常抛出时自动析构,“Closing connection”被打印出来,说明析构函数被执行了。conn.query("DELETE FROM users")之后那行std::cout << "query done"没执行,因为被跳过了。- 异常被
catch (const DatabaseError& e)精确捕获,没有漏到外层。 - 程序在 catch 之后继续执行,没有崩溃。
这个例子能完整展示异常处理的三个核心能力:错误传播、栈展开自动析构、可控的恢复路径。
5.2 如果不用异常,代码会长什么样
我们把上面同样的逻辑改成错误码风格,看看对比效果:
struct Result { bool ok; std::string error; }; Result connect(const std::string& connStr) { if (connStr.empty()) { return {false, "empty connection string"}; } return {true, ""}; } Result query(bool connected, const std::string& sql) { if (!connected) { return {false, "not connected"}; } if (sql.find("SELECT") == std::string::npos) { return {false, "unsupported query: " + sql}; } return {true, ""}; } void runQueries(const std::string& connStr) { auto r1 = connect(connStr); if (!r1.ok) { std::cerr << "connect failed: " << r1.error << std::endl; return; } auto r2 = query(true, "SELECT * FROM users"); if (!r2.ok) { std::cerr << "query failed: " << r2.error << std::endl; return; } auto r3 = query(true, "DELETE FROM users"); if (!r3.ok) { std::cerr << "query failed: " << r3.error << std::endl; return; } std::cout << "query done" << std::endl; }代码变长了,每个调用后面都跟着一个 if 判断,错误处理逻辑穿插在正常流程中。如果函数嵌套层次更深,这种模式的维护成本会成倍上升。而且,错误码方案没办法自动执行析构函数,你必须手动处理连接关闭之类的事情,很容易漏。
这不是说错误码方案一无是处。在性能敏感、异常必须禁止或历史代码大量使用错误码的场景中,错误码方案仍然有它的位置。但在大多数业务逻辑层,异常机制配合 RAII 是更省心的选择。
6. 异常处理的常见问题与排查技巧实录
6.1 “明明 catch 了,程序还是崩了”
很多人遇到的第一个诡异问题:代码里明明写了 catch,程序还是崩溃。这时候常见原因有以下几种。
第一个原因是抛出点在noexcept函数或析构函数中,异常没有机会被 catch,直接terminate。
第二个原因是跨模块异常。如果你用不同编译器或不同标准库编译了两个动态库,一个库抛异常,另一个库 catch,异常类型可能对不上。原因在于异常类型在 ABI 层面未必兼容。解决方法是:在模块边界统一捕获异常,转换为错误码或统一的错误信息传递。
第三个原因最经典:在构造函数里抛异常,且对象是栈对象,但外层没有 catch。栈展开会把栈上的所有对象正确析构,但这个过程本身不在 catch 保护范围内的话,异常会继续向上传播到main之外,最终调用std::terminate。
排查时,先用调试器看栈回溯。如果你看到__cxa_throw或std::terminate,基本可以判定异常没有找到匹配的 catch。
6.2 异常被吞掉了,程序状态变得不可预测
有一种更隐蔽的问题:异常被某个catch(...)或者不合适的catch捕获后,没有重新抛出,也没有做任何记录,程序继续往下走,但状态已经被破坏了。
我见过一个项目,某次升级后偶发数据错乱。查了很久才发现,一个底层线程池在任务执行时用了catch (...)吞掉了所有异常。底层的std::bad_alloc或者逻辑错误全被吞掉,线程池还继续接下一个任务,最终导致状态混乱。
这里我的建议是:
- 如果只是暂时恢复现场,
catch (...)必须配合日志和重新抛出。 - 如果你确定要在这个函数里吞掉异常,请在日志中记录完整上下文,包括异常类型、what() 内容、调用栈。没有记录的吞异常,相当于把缺陷隐藏起来了。
6.3 “为什么我的 catch 顺序不对,编译却不报错”
这个有意思。C++ 允许编译器对不可达的 catch 块发出警告,但默认不一定报错。如果你把catch (const std::exception&)放在前面,编译器通常只是给 warning,而不是 error。
所以你需要自己检查 catch 顺序,或者开启编译器的严格警告。GCC/Clang 下推荐:
g++ -Wall -Wextra -Werror -o app app.cpp-Werror会把警告升级为错误,帮助你提前发现这种问题。
6.4 异常安全排查清单
排查代码中的异常安全问题时,可以按这个清单自查:
- 构造函数中是否使用了裸指针、裸文件句柄、未包装的互斥锁等资源?
- 析构函数是否会抛出异常?
- 函数调用了可能抛异常的操作,但声明了
noexcept? - 容器扩容时,你的类型移动构造是否
noexcept? - 动态分配的资源是否有 RAII 对象管理?
- 赋值操作是否使用 copy-and-swap 模式?
这个清单我在 code review 时基本每轮都会检查一遍,很多线上事故的根因都能从这里找到。
6.5 性能问题:异常真的慢吗
关于 C++ 异常性能,有一个广泛流传的误解:异常很慢,千万别用。
早期 C++ 编译器确实用了一套低效的异常实现,但随着 Itanium ABI 的成熟,现代编译器的异常处理采用“零代价异常”模型:在程序正常运行路径上,异常处理几乎不产生额外开销,代价只在异常真正抛出时才体现。异常抛出时,需要做栈展开,查找 handler,调用析构函数,这个过程确实比单纯的 if 判断慢一些,但它发生在错误路径上,频率很低。
真正需要注意性能的情形是:在极端性能敏感的内层循环中反复抛出和捕获异常。比如每秒百万次调用的解析器里,不要用异常做正常控制流,改用返回值或状态码更合适。但在多数业务代码中,异常的性能开销是可接受的,千万不要因噎废食,为了“性能”把所有函数改成错误码模式,结果代码可维护性急剧下降。
注意:如果你在 Google 或微软的一些性能敏感代码库里看到禁止异常的规定,那是特定场景的策略选择,不代表大众项目都应该这么做。判断标准很简单:你的代码是库还是应用?内层循环是否可能大量抛异常?编译器和 ABI 是否统一?
7. 我的异常处理工程实践建议
最后一部分,我把自己多年写 C++ 总结出的一套异常处理“内功心法”整理出来。这些不是教科书上的标准答案,是真实项目中踩出来的经验。
建议一:对用户输入和外部依赖使用异常,对内部逻辑错误用断言。
如果输入来自用户或者外部系统,格式不对、网络中断、数据库连接失败,这些是“预期中可能的失败”,用异常处理。如果代码内部逻辑出现了不可能发生的情况,比如一个变量本应在 0~100 之间却等于 101,这是程序员写错了,用assert或者直接日志崩溃,而不要用异常去“纠正”程序员自己犯下的错。
建议二:异常类型应该准确,但不要为了“精确”弄出几十个异常类。
NetworkException、ParseException、ConfigException、DatabaseException这几个大方向就够用了。如果你需要更细的错误信息,放在 what() 字符串里,不要把类型体系设计得过度膨胀,否则调用方捕获几个分支就会觉得很累。
建议三:函数内部不要无脑 try-catch 所有异常。
有些新手每写一个函数就 try-catch 一次,觉得这样就安全了。其实这会让代码变得难以阅读,而且错误被吞掉后,上层根本不知道发生了什么。正确的做法是:只在你能真正处理错误的层去捕获异常。如果这层只能把错误包一层再往上抛,那就要用异常链或包装异常的手法,把上下文信息附加进去,而不是直接丢给底层原始的 what()。
建议四:定义模块边界时,考虑异常与错误码的转换。
如果你的 C++ 模块被 C 语言接口调用,或者被其他语言通过 FFI 调用,异常不能直接跨语言传递。这种情况下,要在模块边界捕获所有异常,转换成错误码返回。这个转换点要写得严谨,别把异常信息丢掉。
extern "C" int c_api_function(char* errBuf, size_t errBufSize) { try { cppFunction(); return 0; } catch (const std::exception& e) { snprintf(errBuf, errBufSize, "%s", e.what()); return -1; } catch (...) { snprintf(errBuf, errBufSize, "unknown error"); return -2; } }建议五:写好日志。异常不是用来“遮盖”错误的,是用来“暴露”错误的。
每次 catch 到异常后,在合适的日志级别记录异常类型、what() 内容、当前上下文。实际调试时,你八成要依赖这些日志定位问题。我见过很多项目,catch 之后什么都不写,出错时只有一句 “Error occurred”,排起查来痛不欲生。
建议六:不要在异常对象里持有大对象或复杂的资源。
异常对象在栈展开过程中会被复制或移动,如果里面塞了一个巨大的容器,会拖慢异常抛出过程。尽量只携带错误消息文本和少量上下文数据。这个点很容易被忽略,但真的会影响实际异常路径的性能。
最后说一点实际的体会。C++ 异常处理不是一门“学会了语法就会用”的技术,它是一种贯穿整个工程的设计思路。真正上手写一个中型项目之后,你才会发现异常安全问题和 RAII 的关系有多紧密,也才会理解为什么现代 C++ 讲究“资源管理交给对象生命周期、错误处理交给异常传播”。
我个人在实际项目中踩过几次坑之后,现在写代码前会先问自己一个问题:如果这段代码在运行时出错了,谁会知道?谁会处理?如果答案模糊不清,那就是异常设计还没到位,需要把接口的异常契约理清楚再动手写实现。
建议你拿到这篇文章后,先照着那个DatabaseConnection的例子动手跑一遍,然后把异常安全四个等级和 RAII 的概念刻在脑子里,再回到自己项目里检查一遍异常使用。这个“打通任督二脉”的过程,不是看一遍文章就能完成的,而是要在真实代码里反复体验“异常从哪来、到哪去、资源怎么活怎么死”。慢慢来,等你哪天在 code review 中一眼看到异常的坑,你就算是真的出师了。