【C++11篇(五)】异常处理完全指南

发布时间:2026/7/10 7:12:52
【C++11篇(五)】异常处理完全指南 大家好欢迎来到 huangjin007_ 的博客⭐个人主页huangjin007_文章收录专栏零基础入门C总会有一些坚持能从冰封的土地里培育出十万朵怒放的蔷薇C11篇(五) —— 异常处理详解本篇文章将由浅入深带你循序渐进地掌握C中异常处理的规则全程干货坐稳发车~ ദ്ദി˶̀֊́ )✧文章目录C11篇(五) —— 异常处理详解1. 为什么需要异常1.1 传统的错误码方式1.2 异常处理的思想2. 异常的抛出和捕获2.1 基本语法2.2 一个简单的调用链示例3. 栈展开异常如何寻找它的处理者4. 异常的类型匹配规则5. 自定义异常体系与标准库 exception5.1 标准库的异常体系5.2 模拟一个大型项目的异常体系6. 异常的重新抛出7. 异常安全不要让异常导致资源泄漏7.1 一个典型的内存泄漏场景7.2 析构函数中的异常8. 异常规范从 throw() 到 noexcept8.1 C98 的异常规范已废弃8.2 C11 的 noexcept8.3 noexcept 运算符结语1. 为什么需要异常1.1 传统的错误码方式在 C 语言中我们习惯用错误码来表示函数执行状态intretsome_function();if(ret-1){// 通过错误码判断错误类型需要查文档才知道 -1 代表什么}这种方式有几个明显的问题错误码只是一个整数必须额外查询才能知道具体错误信息。错误码容易和正常返回值混淆调用者可能忘记检查。错误信息的传递能力很弱无法携带上下文比如哪个文件出错、哪条 SQL 语句有问题。1.2 异常处理的思想C 的异常处理机制带来了一个全新的思路将问题的检测和问题的处理分离开来。检测模块只负责发现错误并“抛出”一个对象来描述这个错误。处理模块在上层调用链的某个位置“捕获”这个对象做出相应的处理。这样做的好处是检测错误的代码不需要知道错误最终会如何被处理而处理错误的代码也能拿到丰富的错误信息这大大增强了程序的模块化和可维护性。2. 异常的抛出和捕获2.1 基本语法C 中三个关键字throw抛出异常。try包裹可能抛出异常的代码。catch捕获并处理异常。当程序出现问题时我们通过throw关键字抛出一个对象来引发异常。该对象的类型以及当前的函数调用链决定了由哪个catch块来处理这个异常。被选中的catch块是调用链中与抛出对象类型匹配且离抛出位置最近的那一个。程序通过抛出的异常对象把错误信息传递给异常处理部分。当执行到throw语句时throw后面的代码将不再执行程序控制流立刻跳转到匹配的catch块。这个catch可能在当前函数中也可能在调用链的上层函数中。这带来两个重要影响1. 沿着调用链的函数可能提前退出函数中throw之后的代码被跳过。2. 一旦开始执行异常处理程序沿着调用链创建的局部对象都将被销毁自动调用析构函数保证资源释放。另外抛出异常对象后会生成一个异常对象的拷贝类似于函数按值返回时的拷贝因为抛出的异常对象通常是局部对象它的生命周期即将结束。这个拷贝对象会在catch子句处理完毕后销毁。2.2 一个简单的调用链示例看下面这段代码模拟了一个除法运算在除数为零时抛出异常#includeiostream#includestringusingnamespacestd;// 除法函数当 b 0 时抛出一个 string 异常doubleDivide(inta,intb){if(b0){strings(Divide by zero condition!);throws;// 抛出 string 对象}else{return(double)a/(double)b;}}voidFunc(){intlen,time;cinlentime;try{coutDivide(len,time)endl;}catch(constchar*errmsg)// 捕获 const char* 类型的异常{couterrmsgendl;}// 注意如果异常未被当前函数内的 catch 捕获这行不会被执行cout__FUNCTION__:__LINE__行执行endl;}intmain(){while(1){try{Func();}catch(conststringerrmsg)// 捕获 string 类型异常的引用{couterrmsgendl;}}return0;}执行流程分析用户输入len10, time0。Func调用Divide(10, 0)。在Divide内部b 0成立于是创建一个string对象s然后用throw s抛出。throw后面的return永远不会执行Divide函数直接退出。回到Func中检查Divide调用是否在try块内 —— 是的。然后检查catch类型这里只有catch (const char*)而抛出的是string类型不匹配。因为当前函数内没有匹配的catchFunc函数立刻退出try块后面的cout语句不会执行。程序沿着调用链继续向上进入main函数中的try块寻找匹配的catch。发现catch (const string)可以匹配于是进入这个catch块输出错误信息Divide by zero condition!。处理完毕后程序从catch块之后继续执行这里进入while的下一次循环。关键点异常对象s是一个局部变量在抛出时会产生一个拷贝类似于函数的值返回。catch(const string errmsg)捕获的是这个拷贝对象的引用该拷贝对象会在catch子句执行完毕后销毁。3. 栈展开异常如何寻找它的处理者上面的例子已经展示了“栈展开”的过程我们再把它抽象出来抛出异常后程序暂停当前函数的执行。首先检查当前函数的throw是否在try内部如果在则查找匹配的catch。如果当前函数内没有匹配的catch退出当前函数继续在外层调用函数中查找。这个不断向外层查找匹配catch的过程称为栈展开stack unwinding。在栈展开过程中从throw到catch之间所有在调用链上的函数都可能提前退出。一旦程序进入了某个catch处理代码沿着调用链创建的所有局部对象都会被自动销毁析构函数被调用。这保证了资源的正确释放前提是你使用了 RAII(下篇的智能指针会详细讲解)。如果栈展开一直进行到main函数依然没有找到匹配的catch程序就会调用标准库函数terminate直接终止程序。因此通常建议在main函数中至少使用一个catch(...)来兜底防止程序莫名其妙地崩溃。4. 异常的类型匹配规则异常捕获的匹配并不是要求完全严格一致C 允许以下几种“合理”的隐式转换非const到const的转换例如抛出string对象可以用const string捕获。数组到指针的转换抛出数组类型实际会退化为指针可以用对应指针类型捕获。函数到函数指针的转换。派生类向基类的转换非常重要可以抛出派生类对象用基类引用或指针来捕获。这在实际项目的异常体系设计中是最常用的手段。此外如果有多个catch都与异常类型匹配会选择离抛出位置最近的那个即先出现的那个。如果既想捕获所有异常又不想关心具体类型可以使用catch(...)它能够匹配任意类型的异常但缺点是你无法在块内直接访问异常对象。5. 自定义异常体系与标准库exception5.1 标准库的异常体系C 标准库提供了一套以std::exception为基类的异常类层次定义在exception头文件中。std::exception有一个虚函数what()返回一个const char*描述错误信息。常见派生类如std::runtime_error、std::logic_error等。如果我们自定义的异常类继承自std::exception那么可以在外层用catch(const std::exception e)统一捕获并调用e.what()获取信息。不过即使不继承标准库异常我们也可以完全自己造轮子。5.2 模拟一个大型项目的异常体系假设我们有一个包含 HTTP 服务、缓存、数据库操作的服务程序。每个模块都可能出错我们可以为每个模块设计一个异常类它们都继承自一个公共的Exception基类。#includeiostream#includestring#includethread#includechronousingnamespacestd;// 自定义异常基类classException{public:Exception(conststringerrmsg,intid):_errmsg(errmsg),_id(id){}// 虚函数方便派生类重写返回错误描述virtualstringwhat()const{return_errmsg;}// 返回错误编号intgetid()const{return_id;}protected:string _errmsg;int_id;};classSqlException:publicException{public:SqlException(conststringerrmsg,intid,conststringsql):Exception(errmsg,id),_sql(sql){}virtualstringwhat()constoverride{string strSqlException:;str_errmsg;str - ;str_sql;returnstr;}private:conststring _sql;};// 缓存异常classCacheException:publicException{public:CacheException(conststringerrmsg,intid):Exception(errmsg,id){}virtualstringwhat()constoverride{string strCacheException:;str_errmsg;returnstr;}};// HTTP 异常额外携带请求类型classHttpException:publicException{public:HttpException(conststringerrmsg,intid,conststringtype):Exception(errmsg,id),_type(type){}virtualstringwhat()constoverride{string strHttpException:;str_type;str: ;str_errmsg;returnstr;}private:conststring _type;};// 模拟各模块voidSQLMgr(){if(rand()%70){throwSqlException(权限不足,100,select * from name 张三);}else{coutSQLMgr 调用成功endl;}}voidCacheMgr(){if(rand()%50){throwCacheException(权限不足,100);}elseif(rand()%60){throwCacheException(数据不存在,101);}else{coutCacheMgr 调用成功endl;}SQLMgr();// 缓存成功后还要访问数据库}voidHttpServer(){if(rand()%30){throwHttpException(请求资源不存在,100,get);}elseif(rand()%40){throwHttpException(权限不足,101,post);}else{coutHttpServer 调用成功endl;}CacheMgr();// HTTP 请求成功后会查缓存}intmain(){srand((unsignedint)time(0));while(1){this_thread::sleep_for(chrono::seconds(1));// 方便观察输出try{HttpServer();}catch(constExceptione)// 基类引用捕获所有派生类异常{// 多态调用实际执行的是派生类的 what()coute.what()endl;}catch(...)// 终极兜底{coutUnknown Exceptionendl;}}return0;}要点解读我们构建了一个三层异常结构Exception - SqlException / CacheException / HttpException每个派生类都重写了what()方法提供更丰富的错误上下文。在main函数中只需要catch (const Exception e)就能捕获所有模块的异常利用多态输出不同的错误信息。这种“抛出派生类用基类引用捕获”的设计极大降低了耦合是实际项目中强烈推荐的做法。6. 异常的重新抛出有时我们捕获到异常后并不能完全处理它。比如对某些特定错误进行局部重试重试多次仍失败或者根本无法处理的错误就需要重新抛出交给更上层的调用者处理。下面是一个模拟聊天消息发送的场景网络不稳定时可以重试但如果错误是“已被对方删除好友”则无法通过重试解决应该直接向上汇报。// 底层发送消息函数可能因网络波动或好友关系失效而失败void_SendMsg(conststrings){if(rand()%20){throwHttpException(网络不稳定发送失败,102,put);}elseif(rand()%70){throwHttpException(你已经不是对方的好友发送失败,103,put);}else{cout发送成功endl;}}// 带重试机制的上层发送函数voidSendMsg(conststrings){// 最多重试 4 次首次调用 3 次重试for(size_t i0;i4;i){try{_SendMsg(s);break;// 发送成功退出循环}catch(constExceptione){// 错误码 102网络不稳定可重试if(e.getid()102){// 如果已经是第4次尝试i3说明重试机会用完还是失败if(i3)throw;// 重新抛出异常让上层处理cout开始第i1次重试endl;}else{// 其他错误如103无法通过重试解决直接重新抛出throw;}}}}intmain(){srand((unsignedint)time(0));string str;while(cinstr){try{SendMsg(str);}catch(constExceptione){coute.what()endlendl;}catch(...){coutUnknown Exceptionendl;}}return0;}关于throw;的重要细节throw;不带参数会将当前捕获到的同一个异常对象重新抛出不会发生额外的拷贝也不会丢失异常的类型信息多态性保持不变。千万不要写成throw e;因为e是const Exception类型throw e会以Exception的静态类型重新抛出一个基类对象的拷贝这会导致派生类特有的信息被切掉对象切片而且后续的catch也只能捕获到Exception类型无法再区分原始异常是SqlException还是HttpException。重新抛出请直接用throw;。7. 异常安全不要让异常导致资源泄漏7.1 一个典型的内存泄漏场景异常的出现会让函数提前退出这就带来一个严重问题如果我们在抛出异常之前申请了资源如动态内存等而释放资源的代码在抛出点之后那么异常会导致释放代码被跳过从而发生资源泄漏。例如我们在new一块内存后、delete之前可能抛出异常导致delete没有执行内存泄漏。doubleDivide(inta,intb){if(b0){throwDivision by zero condition!;// 抛出 const char* 异常}return(double)a/(double)b;}voidFunc(){int*arraynewint[10];// 申请动态内存try{intlen,time;cinlentime;coutDivide(len,time)endl;}catch(...){// 如果 Divide 抛出异常会跳到这里coutdelete[] arrayendl;delete[]array;// 手动释放内存throw;// 重新抛出让上层知道出错了}// 正常流程下也会释放coutdelete[] arrayendl;delete[]array;}intmain(){try{Func();}catch(constchar*errmsg){couterrmsgendl;}catch(constexceptione)// 可捕获标准库异常{coute.what()endl;}catch(...){coutUnknown Exceptionendl;}return0;}分析如果Divide抛出异常Func中try块后的代码不会执行但catch(...)会被触发。我们在里面先释放array再用throw;重新抛出。这样既保证了资源释放又没有“吞掉”异常。但这只是一种补救式的写法更安全的方式是使用RAIIResource Acquisition Is Initialization。7.2 析构函数中的异常另一个容易出问题的场景是析构函数。如果在一个析构函数中抛出了异常且该异常没有被析构函数自身捕获那么在栈展开过程中如果同时存在两个未处理的异常程序会直接调用terminate终止。假设析构函数需要释放 10 个资源释放到第 5 个时发生了异常如果不加处理后面 5 个资源就永远泄漏了。《Effective C》第 8 条条款也强调别让异常逃离析构函数。应该在析构函数内部try-catch掉所有可能异常或者确保操作不会抛出异常。8. 异常规范从throw()到noexcept8.1 C98 的异常规范已废弃C98 允许在函数声明后加上throw(type list)比如void*operatornew(std::size_t size)throw(std::bad_alloc);// 可能抛出 bad_allocvoid*operatordelete(void*ptr)throw();// 不抛出任何异常这种规范过于复杂而且编译器不会在调用端强制检查实践中很少被正确使用。C11 废弃了这种做法虽然仍支持但只保留了throw()作为noexcept的等价形式。8.2 C11 的noexceptC11 引入了更简洁的关键字noexceptvoid func() noexcept;表示承诺该函数不会抛出异常。如果不加noexcept表示该函数可能抛出异常。重要特性编译器不会在编译期强制检查noexcept声明的真实性。即便你在noexcept函数里写了throw大多数编译器只会给出警告仍然能编译通过。但是如果一个声明为noexcept的函数在运行时真的抛出了异常程序会直接调用std::terminate终止不会进行正常的栈展开和异常处理。看下面这个例子// 声明为 noexcept但实际上内部可能抛出异常doubleDivide(inta,intb)noexcept{if(b0){throwDivision by zero condition!;// 运行时会导致程序终止}return(double)a/(double)b;}intmain(){try{intlen,time;cinlentime;coutDivide(len,time)endl;}catch(constchar*errmsg)// 这个 catch 不会被触发{couterrmsgendl;}catch(...){coutUnknown Exceptionendl;}return0;}如果在运行时输入time 0Divide内部抛出异常但由于noexcept的承诺被打破程序会立刻终止不会执行main中的任何catch。8.3noexcept运算符noexcept除了作为说明符还可以作为一个编译期运算符用来检查一个表达式是否可能抛出异常inti0;coutnoexcept(Divide(1,2))endl;// 可能输出 1因为 Divide 声明为 noexceptcoutnoexcept(Divide(1,0))endl;// 同样输出 1声明决定编译器不分析内部 throwcoutnoexcept(i)endl;// 典型情况下输出 1因为自增不抛异常noexcept(expression)返回一个布尔值true表示该表达式承诺不抛出异常。false表示可能抛出异常。它可以用于模板编程中根据表达式是否抛异常来选择不同的优化路径比如std::vector的移动构造函数如果能确认移动操作不抛异常就可以更高效地实现。结语今天的内容到这里就结束了希望你能有所收获~干货整理到手抖觉得有用的话赏个三连回回血__(:ᗤ」ㄥ)_ _