C++异常机制深度解析:从栈展开到异常安全的工程实践
写这篇文章的起因比较实际。前阵子有个用UG/NX做二次开发的朋友跟我吐槽说程序跑着跑着弹出一句“捕获到标准C异常。有关详细信息请参见系统日志文件”联调了三天的功能说崩就崩连个堆栈信息都没留下。我一看就明白这是典型的C异常没被妥善处理的表现。其实不只是工业软件很多刚转C的开发者写出来的代码里错误处理基本靠return -1加全局变量项目一大了错误信息满天飞根本不知道哪条路径出了问题。C的异常机制本质上是给程序提供了一条独立的错误上报通道它不像返回码那样需要每一层函数都手动检查、逐级传递而是可以在任意深度的调用栈中直接跳转到能处理这个错误的地方。这个特性让错误处理逻辑和业务逻辑解耦让代码的健壮性上了一个台阶但也引入了一整套需要重新理解的概念栈展开、析构顺序、异常安全、noexcept语义、性能开销等等。这篇文章我就围绕着异常机制本身把这套东西掰开揉碎了讲清楚。从为什么需要它、怎么写出正确的抛出和捕获代码到继承体系、栈展开原理、异常安全的三个级别、性能影响再到工程实践里那些文档中不会细说的坑最后给出一份可以直接照着排查问题的速查表。适合刚接触C异常的新手也适合写了几年业务代码但没系统梳理过异常机制的开发者。1. 为什么需要异常机制返回码方案到底差在哪1.1 传统错误处理的困境在没有异常机制之前C语言时代的错误处理方式非常朴素函数返回一个特殊值调用方检查这个值然后决定下一步做什么。这在简单的程序里完全够用因为调用层次浅路径少出错的概率低。但一旦程序规模上去这套方案就会暴露出两个非常致命的问题。第一个问题是错误信息无法携带上下文。一个int返回值承载的信息量极其有限调用方只能知道“出错了”至于为什么出错、问题出在哪一层、当时的状态是什么全都不清楚。很多项目为了弥补这个缺陷会搞一个全局错误码变量比如errno但全局变量的坏处是线程不安全、容易被打断覆盖而且代码一旦写成多线程这个方案基本等于报废。第二个问题是每一层都必须手动传递错误。假设调用链是A - B - C - DD里面出了一个错D要把错误码返回给CC要检查、判断要不要返回给BB还要再检查、再决定最后回到A手里可能已经不是原来的错误了很多中间层只是简单地把非零值原样往上抛把真正有用的上下文全部丢掉了。这种代码写起来极其啰嗦而且很容易漏掉检查一旦漏掉一个返回值判断后面的代码就会拿一个错误的中间结果继续执行产生的错误往往比原来的错误更难排查。相比之下C的异常机制让错误信息可以从最底层的函数直接穿越所有中间层跳转到设计好的catch块。中间层的代码可以完全不关心错误处理只需要保证自己申请的资源被正确释放剩下的交给上层的异常处理逻辑去统一处理。1.2 异常机制的核心思想错误上报与业务分离异常机制的本质是把“检测到错误”和“处理错误”这两件事彻底拆开。抛出异常的那一方不需要知道谁会处理这个异常、在处理之前经历了多少层调用只需要把错误信息打包成一个异常对象往外抛。捕获异常的那一方不需要关心这个异常是从哪个函数、哪个模块抛出来的只需要按类型匹配就能接收到。这个设计的直接好处是中间层的代码写起来非常干净——不用在每一个函数里写一堆错误分支判断只需要保证资源安全然后让异常自然地穿过自己。等到真正的顶层业务逻辑里再统一捕获、统一处理。比如你写一个读取配置文件的模块底层文件不存在、格式不对、内容为空可以分别抛出不同异常上层调用的时候只需要一个try包住每个catch对应一种情况。而不是像C语言那样每个函数返回值都要判断一遍返回值被中间的调试代码打断之后就什么都找不回来了。当然异常机制在C里的定位一直是“处理不可预测的运行时错误”它不适合用来做正常的流程控制。这个边界在后面的内容里我会再详细解释。1.3 C异常与错误码的对比我整理了一张对比表可以比较直观地看出两者的差异维度错误码/返回值异常机制错误信息量只有简短的编码上下文需额外维护异常对象可以携带任意信息、自定义类型传递路径逐层手动传递容易丢信息自动穿越调用栈直达能处理的catch块漏检风险忘记检查返回值时变成静默错误未捕获异常直接终止程序错误无处藏身代码侵入性每层函数都要写判断业务逻辑被搅混正常代码和错误处理分离性能特征几乎零开销正常路径接近零开销抛出时开销大适用场景常见状态、可预见错误异常状态、不可预测的运行时错误我在实践中一般会两条路一起走对于常见的、可恢复的错误状态——比如查找不到某个元素、计数器越界——优先用返回值或者std::optional解决这种错误发生的频率高每次都要接受异常机制的开销不划算对于真正异常的、不可恢复的状态——比如内存分配失败、文件读取I/O错误、数据一致性校验失败——才抛出异常。这样既能保证效率又能保证程序的健壮性。2. 异常机制的核心语法与执行流程2.1 从一次完整的抛出-捕获流程说起要理解C异常机制先看一个最基础的实例。假设我们要写一个计算两个整数相除的函数要求除数为0时给上层报错。用异常来写是这样#include iostream #include stdexcept double divide(double a, double b) { if (b 0.0) { throw std::runtime_error(division by zero, divisor cannot be zero); } return a / b; } int main() { try { double result divide(10.0, 0.0); std::cout result result std::endl; } catch (const std::runtime_error e) { std::cerr Caught exception: e.what() std::endl; return -1; } return 0; }这里发生了一连串的事情值得逐个拆解。throw语句创建了一个std::runtime_error对象然后把这个对象以某种方式传递出去。throw之后的代码不再执行——这是异常机制的一个关键行为它是非局部跳转。紧接着运行时系统开始在当前调用栈中寻找匹配的catch块寻找的顺序是从抛出点所在的函数开始逐层向外展开。在当前这个例子里divide函数内部没有try/catch所以直接跳到外层main函数的try块里匹配。std::runtime_error匹配const std::runtime_error进入catch分支执行然后程序继续往下走。注意这里的几个细节throw抛出的是一个对象catch捕获的是一个引用。捕获引用的原因我后面会细说但最简单直接的原因就是——避免对象拷贝同时支持多态行为。2.2 throw、try、catch三者的配合关系把这三个关键字拆开来看。throw是异常的起点。它可以抛出任意类型——基础类型、字符串、标准异常类、自定义类都行。但实际工程中强烈建议只抛出从std::exception派生出来的对象原因很简单如果你抛出一个int或者一个字符串字面量调用方在catch时必须精确匹配这个类型而所有的标准库组件比如std::vector、std::string抛出的都是标准的异常类。如果你的代码和自己的第三方库混用统一从std::exception派生能让所有异常都能按一个共同的基类被捕获处理逻辑会干净非常多。// 不推荐 throw -1; throw file not found; // 推荐 throw std::runtime_error(file not found);try块划定了一个保护区域。把可能抛出异常的代码放进try里它后面紧跟一个或多个catch块。编译器生成代码时会维护这张异常处理表当异常抛出来的时候运行时系统靠这张表来决定跳到哪个catch块去执行。catch块负责匹配并处理异常。它有三种典型写法// 按引用捕获推荐 catch (const std::exception e) { ... } // 按值捕获不推荐有切片问题且有拷贝开销 catch (std::runtime_error e) { ... } // 捕获所有异常用于兜底不推荐用于常规逻辑 catch (...) { ... }按引用捕获这条规则是每一个C开发者踩过坑之后都该记住的。你想想throw std::runtime_error(...)抛出来一个runtime_error对象如果在catch那里按值捕获std::exception e会发生对象切片——派生类对象的派生部分被切掉只剩基类部分你拿到的就是一个残缺的异常对象。按引用捕获则不会const std::exception e可以绑定到任何派生类异常对象上多态机制正常工作。2.3 标准异常类体系介绍C标准库提供了一套完整的异常类继承体系根节点是std::exception。我列一下常用的子类异常类含义典型场景std::runtime_error运行时错误数值错误、状态错误std::logic_error逻辑错误违反前置条件、非法参数std::out_of_range越界访问vector::at()越界std::invalid_argument非法参数传入无效值std::length_error长度错误超出最大长度限制std::bad_alloc内存分配失败new分配失败std::bad_cast类型转换失败dynamic_cast失败std::bad_function_call函数对象调用失败空std::function被调用它们的构造函数基本都接受一个std::string参数用来存放错误描述信息。what()成员函数可以取出这条信息。派生层次上runtime_error和logic_error各自下面还有一层更具体的子类比如out_of_range是logic_error的派生类。这就意味着你可以按具体类型精确捕获也可以用catch一句const std::exception兜底所有标准异常。工程实践里我更推荐的做法是自定义项目级的异常基类。比如在项目里定义class ProjectException : public std::runtime_error { ... }然后所有业务异常都从它派生。这样既能保留标准异常体系的统一接口——what()、runtime_error的语义——又能为项目增加额外的属性字段比如错误码、模块名、时间戳等。错误信息不再是一句孤零零的字符串而是一个可以定位到模块和业务场景的结构化对象。3. 栈展开与RAII异常机制中最核心的原理3.1 从抛出点到catch块的栈展开栈展开是理解C异常机制绕不开的概念。它的含义是当异常被抛出控制权从抛出点转移到匹配的catch块时所有在抛出点与catch块之间的栈帧都会被销毁这个销毁过程就是从栈上逐层弹出函数调用帧。这个过程中的每一步都不是白做的每一个栈帧销毁时该函数内所有局部对象都会调用析构函数自动释放资源。也就是说只要你的资源是用对象管理的——文件句柄、锁、堆内存——它们在栈展开时都会被正确释放。举个例子来加深理解#include iostream #include stdexcept class Logger { public: ~Logger() { std::cout Logger destroyed std::endl; } }; void inner() { Logger log; throw std::runtime_error(something bad happened); } void outer() { Logger log; inner(); } int main() { try { outer(); } catch (const std::exception e) { std::cerr e.what() std::endl; } return 0; }这段代码的运行顺序是outer()创建了一个Logger对象然后调用inner()inner()里也创建了一个Logger对象然后抛出异常。异常穿过inner()、outer()两个栈帧这两个栈帧内的局部对象析构函数依次执行输出两行Logger destroyed最后异常到达main的catch块被捕获。你看到没有即使你没有在inner和outer里写任何错误处理分支互斥锁、malloc出来的内存、打开的文件描述符只要被封装成了对象都会在栈展开时被自动清理。这就是C异常机制和C语言错误处理之间最根本的差别——它不是把资源清理的责任推给每一个中间函数而是从语言层面保证了这个过程一定会发生。3.2 RAII的语义在异常中的体现RAII全称Resource Acquisition Is Initialization中文一般叫资源获取即初始化。它是一种C特有的资源管理方式把资源的生命周期绑定到对象的生命周期上。资源在构造函数中获取在析构函数中释放。正常返回时局部对象析构函数执行异常栈展开时局部对象析构函数同样执行——两条路径都走析构不存在中间漏掉的情况。C标准库里的几乎所有容器和智能指针都是RAII的实现。std::vector在栈展开时析构内部堆上的内存被自动释放std::unique_ptr在栈展开时析构指向的对象被deletestd::lock_guard在栈展开时析构互斥锁被自动解锁。有了它们你写异常机制代码时基本不需要在catch里手动release资源。反过来如果你还保持着C语言时代的思维在函数里裸用new和delete那栈展开时就会两级反转new出来的内存不会因为栈帧销毁而自动释放它会变成内存泄漏。正确做法是优先使用智能指针和容器尽量避免裸指针和手动资源管理。还有一个很容易被忽略的点异常在抛出的过程中如果构造函数或析构函数中出了问题整个对象的生命周期会被打乱。严格来说构造函数中如果抛出异常该对象的析构函数不会被执行——因为对象还没有构造完成。因此构造函数中已经获取了部分资源时再抛出异常需要格外小心这部分资源要在此之前就封住通常靠把资源持有者作为成员对象来解决。保证析构函数不抛出异常则是一条铁律我后面会专门讲。4. 异常安全与性能开销两个绕不开的话题4.1 异常安全的四个级别认识异常机制之后写代码时就跑不掉一个概念——异常安全。它衡量的是一个函数在抛出异常之后程序状态是否仍然正确。业内把异常安全分为四个级别按强度从低到高排列异常安全级别含义判定标准无保证异常发生后状态不确定可能资源泄漏、数据损坏不能保证任何东西基本保证资源不泄漏所有对象处于有效状态但数据内容可能不一致不崩溃、可析构、可继续使用强保证操作要么完全成功要么完全失败且状态不变类似数据库事务的原子性不抛异常保证函数内部不抛出任何异常析构函数、移动构造、swap操作实际开发中我给自己定的标准是析构函数、swap、移动操作必须是不抛异常的修改多个数据成员的操作至少做到基本保证能做成强保证的尽量做。这样设计的原因很实际如果你改了一个链表改到一半抛了异常链表处于什么状态节点是不是丢了数据是不是错乱了调用方能做什么这些都是异常安全的范畴。做到强保证有一个很实用的技巧——copy-and-swap惯用法。大致思路是先对临时副本做所有操作所有步骤都成功之后再一次性与当前对象交换数据。如果中途异常当前对象完全没有被改动始终保持着原来的状态。这种操作模式在实现重载赋值操作符的时候特别常用。4.2 异常机制的性能代价正常路径与异常路径关于异常机制的性能开销业界有一个流传很广的误解——“异常慢”。这个说法只对了一半关键要看是在什么路径上。在正常执行路径上现代编译器GCC、Clang、MSVC对异常处理都采用了零成本模型。什么意思就是说在没有异常抛出时代码运行不需要额外判断、不需要检查标志位性能几乎和不启用异常机制时一样。编译器会在代码段之外生成一张异常处理表通常放在.gcc_except_table这个section里这张表描述的是“哪个地址范围内如果抛出异常应该跳到哪个catch块”。正常运行的时候这张表根本不会被读取CPU不会去碰它。在抛出异常的路径上代价就完全不一样了。抛出异常需要做这几件事构造异常对象、进入运行时库的抛出分支、遍历异常处理表匹配catch块、执行栈展开并调用析构函数、最后跳转到catch块继续执行。整个流程涉及多次内存分配栈展开时每一层都要做表查询和类型匹配开销大概在微秒级。对于一个频繁进出、每秒调用上万次的热点函数来说每次用异常做流程控制性能会很难看。这也就延伸出了我之前提过的原则异常机制是为“异常状态”准备的不是为“常见分支”准备的。如果一个条件分支在正常业务中几乎有一半的概率触发比如“搜索结果不存在”“缓存未命中”那它就不适合用异常来表达用返回值和std::optional更合适。反过来如果它是真正的异常场景——文件打不开、网络连接中断、数据校验失败——那异常带来的可读性和健壮性提升远超那点微秒级的开销。4.3 noexcept的语义和正确用法noexcept是C11引入的关键字用来说明一个函数不会抛出异常。它有两个作用一是给编译器优化提供依据——编译器可以跳过为这个函数生成异常处理表的代码生成更紧凑的代码二是给调用方提供保证——调用这个函数不需要准备捕获异常的代码。看起来只是一个声明但它牵动着一个性能关键点移动构造函数的noexcept标注直接影响容器的性能。举例来说std::vector扩容的时候需要把旧内存中的元素搬到新内存中如果元素的移动构造函数声明了noexceptvector就可以直接移动如果没声明vector为了保证强异常安全万一移动过程中抛出异常必须能恢复到原来的状态只能退化为拷贝构造。拷贝一个int数组和移动一个int数组开销差距在几十倍以上这就是为什么std::vectorstd::string扩容性能尚可而std::vectorstd::vectorint扩容慢到让人怀疑人生的原因之一——内存重分配时每一层都要拷贝整个子数组。关于noexcept还有一个关键点如果一个函数被声明为noexcept但运行时却抛出了异常程序会直接调用std::terminate终止运行。也就是说声明noexcept的时候要注意函数内部不要调用可能抛异常的函数或者在处理逻辑上保证不会抛异常。标准库容器默认情况下析构函数是noexcept的前提是你的元素析构函数也不抛这就是我那句“析构函数一定不抛异常”的底层来源。你一旦在一个noexcept函数里逞强抛异常整个程序就没了连捕获的机会都没有。5. 编写健壮异常代码的实操要点5.1 抛出什么、怎么抛、抛之前要想清楚什么写throw语句的时候我建议想清楚三件事类型合适吗信息够吗时机对吗类型方面前面已经强调过——优先使用标准异常类或从它们派生的自定义异常类。抛int、抛const char*这些野路子写demo可以进项目就是给自己挖坑。你想想看如果所有模块都是各抛各的类型调用方要写多少个catch分支才能保证不遗漏信息方面what()字符串里建议尽量包含上下文的可操作信息。比如抛std::runtime_error的时候写出Failed to open file: path , error code: std::to_string(errno)比单独写file open error有用得多。别人排查问题的时候一行what()要给足线索而不是让他看完报错再去翻代码猜。时机方面唯一的判断标准是——当前函数对这个异常能做什么有意义的事情。如果做不了就别catch住了再重新throw让它自己往上走。滥用catch然后重新抛是一种比较恶心的写法它既打断了异常的原始信息又增加了代码量还容易在中间过程把异常对象切坏。真实工程里我见过太多这种代码——每个函数都try { ... } catch (...) { throw; }——等于什么都没干但每层都在制造栈展开的开销和代码噪声。5.2 自定义异常类型的最佳实践当标准的异常类型不够用需要自定义时这里是我的推荐模板#include stdexcept #include string class NetworkException : public std::runtime_error { public: NetworkException(const std::string message, int errorCode) : std::runtime_error(message), errorCode_(errorCode) {} int errorCode() const noexcept { return errorCode_; } private: int errorCode_; };几个关键点继承自std::runtime_error是最常见的选择。相比直接继承std::exceptionruntime_error已经提供了接收字符串的构造函数省去你自己管理字符串生命周期的问题。直接继承std::exception你还得自己实现构造函数和what()逻辑字符串管理就是个大坑。构造函数里把错误码、模块ID这些额外信息作为成员保存。传入的message和保存的errorCode分别归位存放互不干扰。推荐在自定义异常类中也给what()做有意义的拼接操作。比如构造时就把错误码转成字符串拼进message里这样捕获方直接调用e.what()就能拿到所有关键信息不需要再额外检查你自己的接口。5.3 析构函数绝不抛异常这是底线这句话值得再加粗重复一遍析构函数绝不允许抛出异常。原因我在前面提过这里把技术细节讲全。当异常触发栈展开时所有局部对象会依次调用析构函数。如果在栈展开期间析构函数又抛出一个异常那么两个异常同时存在C运行时无法处理这种情况只能调用std::terminate()直接终止程序。这不是你的try/catch能兜住的是整个进程的终结。即使不是在栈展开期间析构函数抛出异常也极其危险。因为析构函数经常在对象生命期结束时被自动调用调用方没有准备好catch异常会直接穿过不该穿过的边界导致程序行为变得完全不可预测。解决办法其实很简单析构函数里不要做可能抛异常的操作。如果非要做——比如关闭文件时写回数据失败——那就在析构函数内部自己消化掉或者记录日志但绝不能让它抛出去~FileHandler() { try { flushAndClose(); } catch (const std::exception e) { // 记录日志或静默处理绝不向外抛 } }实际上C11开始析构函数默认带有noexcept(true)的语义——也就是说只要析构函数里有任何异常逃逸出去程序就会立即调用std::terminate。标准库容器、智能指针都依赖这个保证来做资源释放。5.4 小心构造函数中的异常构造失败与析构顺序的博弈构造函数抛出异常是一个比较隐蔽的场景但也值得单独提一下。首先明确一点类中成员对象的析构顺序与构造顺序相反。如果一个类的构造函数由多个成员对象组成比如class Config { std::vectorint values_; std::mutex mutex_; public: Config(size_t n) : values_(n) { // ... } };如果values_在构造过程中抛出异常比如分配大量内存失败那么values_自身因为构造尚未完成其析构函数不会执行但它的所有已经构造成功的内部成员比如分配的内存块会通过成员指针的RAII得到释放会被清理而该对象的析构函数~Config()也不会被执行——因为对象本身没有构造完成。这个设计其实是为了保证资源安全做的一种保护机制对象没有构造完成析构函数本来也无法正确释放资源所以编译器干脆让它不执行转而逐层析构已经构造完成的成员对象。因此构造函数中的资源获取必须做到每获取一个资源要么已经绑定到一个RAII包装对象上要么能够自己处理后续清理。最安全的做法依然是——把所有动态资源都封装到智能指针或容器中让RAII的准则替你把每个中间状态管好。6. 常见错误用法与排查技巧6.1 容易踩到的坑catch(...)滥用、异常与线程、反复抛出先说说几个我反复见到的误用模式。第一catch(...)滥用。有些人图省事直接在顶层所有代码外面包一个try { ... } catch (...) { }以为这样就万事大吉了。后果是所有的异常都被吞掉程序没有任何日志输出出了问题根本找不到源头。catch(...)的正确用途只有两个一是作为最终兜底catch住之后记录日志然后继续抛出用throw;重新抛出或者做必要的清理后再终止二是在析构函数里确保不向外抛异常。它不应该成为日常错误处理的常规手段。如果你只是捕获但不知道具体是什么异常那你就是在掩盖问题而非处理问题。第二异常与多线程的配合。一个线程里抛出的异常不能直接跑到另一个线程的catch块中被捕获。这是很多刚接触多线程开发的人容易踩的坑。比如你在一个std::thread里执行任务任务里抛了异常如果你没有在该线程内捕获异常会直接导致这个线程终止。C标准说线程函数的异常如果未被捕获会调用std::terminate整个进程退出。你需要做的是把每个线程的执行体内部包好try/catch把异常对象吊到一个传输通道里交给主线程统一处理。C11引入的std::exception_ptr就是为此设计的——可以把它当作“异常的指针”在线程间传递。第三反复抛出同一异常导致的怪异行为。有的代码在catch块里没有使用throw;重新抛出而是重新构造异常对象再throw一个。这种做法如果在循环里会不断创建新异常对象开销倍增而且很容易在层层包装中丢失原始异常的上下文。正确的做法是如果只记录日志不需要修改异常就原样throw;如果需要向外传播新的语义再用异常链的方式把原始异常包装进去。6.2 调试技巧如何定位未捕获异常和异常源头异常机制给调试带来一个麻烦异常是在栈展开的过程中传播的中间层的堆栈信息已经丢失你在catch块里只能看到抛出点所在的地方却没有完整的调用栈。拿到一个what()报错信息要反推出是哪条调用路径触发的在高并发场景下尤其痛苦。几个实战有效的定位手段第一利用调试器的异常断点功能。在Visual Studio中打开Debug窗口的Exception Settings勾选C Exceptions选择“Thrown”状态。这样每个throw语句执行时调试器都会自动断下堆栈就停在那里可以轻松看到抛出异常的时刻、线程栈、变量状态。GDB里对应的是catch throw命令一样的效果。这个方式最直接强烈推荐在调试阶段开启。第二使用std::current_exception和std::exception_ptr将异常对象传递到日志系统。当你需要在多个模块间传递异常时不要手动重新throw一个新对象而是用std::exception_ptr保存原始异常对象。这样日志系统可以跨模块、跨线程拿到同一个异常对象并通过std::rethrow_exception在任意上下文重新抛出处理。第三对于一些神秘的系统级异常——像文章开头提到的UG/NX那种“捕获到标准C异常。有关详细信息,请参见系统日志文件”——优先去Windows事件查看器或对应产品的日志文件里找线索。这类异常多半是跨语言、跨模块边界时C异常被外层包装后二次抛出的原始信息往往已经被吞掉或改写直接从代码内层开始排查很难找到根因。6.3 常见问题速查表现象可能原因排查方法程序直接消失/终止没有输出未捕获异常在栈顶逃逸触发std::terminate在main外层加兜底catch输出e.what()开启异常断点析构函数导致程序崩溃析构函数中抛异常栈展开期间双重异常检查析构函数是否有抛异常路径改成内部吞掉捕获到的异常信息丢失了上下文中间层重新构造异常对象改成throw;原样重新抛出或使用exception_ptr传递vector扩容时性能极差移动构造函数没有标noexcept给移动构造函数、移动赋值运算符加noexcept多线程代码中异常导致整个进程退出线程函数内部未捕获异常逃逸在线程入口函数中包try/catch传exception_ptr回主线程new分配大内存时抛出bad_alloc内存不足合理catchstd::bad_alloc或调整分配策略使用nothrow版本7. 异常机制的工程实践建议7.1 异常规范项目中的统一约定异常机制用得好不好很大程度上取决于团队是否有统一的规范。我参与的项目里一般会定这几条规矩第一异常类层次统一。项目所有自定义异常必须继承自std::runtime_error或一个统一的项目基类。禁止抛出基础类型和字符串字面量。第二边界上捕获内部传递。模块内部的异常允许任意抛出和捕获但模块对外的接口层要统一兜底转换成符合对外协议的错误结构——比如给某个上层框架的错误码或错误对象。这样外部使用方只需要面对一套错误表达方式不被内部异常的真实类型牵着走。第三禁止吞异常。除了极少数刻意设计的场景如析构函数不允许catch住异常后不做任何处理直接放行。至少要输出日志。第四构造函数中可以抛异常只要在抛出前自行释放已获取的资源或用RAII对象自动释放。构造函数中用异常表达构造失败比构造一半返回一个半成品对象然后靠某种标志位检查要清晰得多这也是C社区的主流观点。7.2 异常机制在大型项目中的适用性和边界异常机制在很多C项目里是个有争议的话题。一些高性能项目比如某些游戏引擎、嵌入式系统会直接编译期关闭异常-fno-exceptions以换取更小的二进制体积和更可预测的栈空间占用。但绝大多数常规业务系统、后端服务、桌面开发工具异常机制都是标配它的收益远大于成本。我这里给出一个比较实用的决策框架如果你的项目以业务逻辑为主、调用链较深、需要清晰的多错误类型处理——用异常收益极大。如果你的项目收益与性能强相关、需要严格控制堆栈分配、又跑在RTOS这类环境上——可以考虑关闭异常但要做好错误处理的整体设计。千万不要滥用异常来做流程控制——为了跳过一层判断抛个异常是我见过最糟糕的代码之一它既慢又难读还会让编译器优化失效。从C语言项目迁移到C时异常机制不必一步到位。可以先从最内层的资源管理用RAII开始改造再逐步把错误处理切换成异常降低整体迁移风险。7.3 如何在遗留代码库中渐进式引入异常如果你面对的是一套已经写了好几年的C代码库全部用错误码方式处理错误直接一刀切全改成异常几乎不现实。我给出一个渐进式引入的路线参考第一步先在所有函数的对外边界上加统一的顶层try/catch兜底保证程序不至于因为未捕获异常直接崩溃。这一步改动极小但能很大程度提升稳定性。第二步新写的模块和函数统一使用异常方式严格遵守异常安全规范。同时要求资源管理全部换成RAII智能指针、容器的std::管理类。第三步针对老的错误码接口写适配层。适配层内部捕获异常对外仍返回错误码。这样下游调用方不用改代码。第四步当核心模块已经稳定切换后再逐级push异常边界向调用方扩展最终让异常机制覆盖到多数业务路径。渐进式的最大好处是每一步都有明确的验证边界不会因为大面积改动导致问题无法定位。我见过太多团队试图用一个大分支把整个代码库从错误码改成异常结果改到一半合并冲突爆表、运行崩溃无从着手最后回滚了事。这个内容后续还能继续扩展比如异常与协程、异常与模块边界、异常序列化都是很大的话题。不过路子已经铺开了先把基础的异常机制和工程习惯打好后面踩坑的时候自然就知道该怎么应对了。