
1. 异常处理到底在解决什么问题先聊点实际的。你写 C 的时候有没有遇到过这种情况程序跑着跑着突然崩溃弹出一个类似“xxx.exe 已停止工作”的窗口你一脸懵根本不知道是哪行代码出了问题。或者更隐蔽的函数返回了一个错误码你忘了检查结果后续逻辑拿着这个错误码继续往下算最后算出一堆莫名其妙的结果。这两个场景就是 C 异常处理要解决的核心问题。C 的异常处理机制本质上是一套错误传播与处理机制它允许程序在运行时检测到错误条件后主动抛出一个异常对象然后由调用链上某个合适的地方捕获并处理这个异常从而避免程序直接崩溃或者带着错误状态继续运行。很多人一提到“异常”第一反应是“是不是就是 try-catch 一下”。这么理解没错但远远不够。异常处理在 C 里的分量比表面看起来要重得多。它涉及异常安全、资源管理、栈展开、对象生命周期、性能开销、设计模式……如果你只是把 try-catch 当成“出错就抓一下”那很多坑你迟早会踩一遍。举个最简单的例子。void func1() { std::vectorint 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做兜底处理非常灵活。在实际项目中我还建议在自定义异常里加入文件名、行号、函数名等上下文信息。不过 C20 的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异常安全的基石RAIIResource Acquisition Is Initialization的中文名是“资源获取即初始化”名字有点绕但你完全可以把它理解成“用对象的生命周期管理资源”。你需要在构造函数里获取资源在析构函数里释放资源。当对象生命周期结束时无论是正常结束还是被栈展开强制结束析构函数必定会被调用资源就安全释放了。回到本文开头的例子void func1() { std::vectorint v; v.push_back(1); int* p new int(42); throw std::runtime_error(something went wrong); delete p; }换成 RAII 风格void func1() { std::vectorint v; v.push_back(1); std::unique_ptrint p(new int(42)); throw std::runtime_error(something went wrong); }std::unique_ptr是栈上对象无论异常何时抛出它的析构函数都会运行自动删除持有的内存。这就是 RAII 带来的异常安全。这种模式可以推广到一切资源文件句柄、数据库连接、线程锁、互斥量、套接字……举一个实际点的例子。如果你在多线程代码里手动 lock 和 unlockstd::mutex m; void bad() { m.lock(); // 这里抛异常 doTask(); m.unlock(); }异常抛出后m.unlock()永远不执行互斥锁一直被占用其他线程会永久阻塞。而用std::lock_guardstd::mutex m; void good() { std::lock_guardstd::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::vectorstd::string v; v.reserve(100);当 vector 扩容时需要把旧内存中的元素搬到新内存。如果你的类型移动构造是noexcept的vector 会大胆地直接搬元素如果移动构造可能抛异常vector 出于安全考虑可能选择拷贝元素而拷贝的开销远高于移动。所以你在自定义类型时如果能保证移动操作不会失败一定要显式加上noexcept。5. 实操过程从零搭一个带异常处理的 C 示例项目5.1 环境准备现在很多文章教你用 VS Code 配 C 环境这没问题但我会直接用最传统的命令行工具来演示因为这个方式对你理解编译和运行过程更有帮助。你需要准备一个支持 C11 或更高版本的编译器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 -stdc17 -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 中一眼看到异常的坑你就算是真的出师了。