【C++11篇(五)】异常处理完全指南
⭐个人主页:huangjin007_
🔥文章收录专栏:零基础入门C++
能从冰封的土地里
培育出十万朵怒放的蔷薇
C++11篇(五) —— 异常处理详解
本篇文章将由浅入深,带你循序渐进地掌握C++中异常处理的规则,全程干货,坐稳发车~ ദ്ദി˶ー̀֊ー́ )✧
文章目录
- C++11篇(五) —— 异常处理详解
- 1. 为什么需要异常?
- 1.1 传统的错误码方式
- 1.2 异常处理的思想
- 2. 异常的抛出和捕获
- 2.1 基本语法
- 2.2 一个简单的调用链示例
- 3. 栈展开:异常如何寻找它的处理者
- 4. 异常的类型匹配规则
- 5. 自定义异常体系与标准库 `exception`
- 5.1 标准库的异常体系
- 5.2 模拟一个大型项目的异常体系
- 6. 异常的重新抛出
- 7. 异常安全:不要让异常导致资源泄漏
- 7.1 一个典型的内存泄漏场景
- 7.2 析构函数中的异常
- 8. 异常规范:从 `throw()` 到 `noexcept`
- 8.1 C++98 的异常规范(已废弃)
- 8.2 C++11 的 `noexcept`
- 8.3 `noexcept` 运算符
- 结语:
1. 为什么需要异常?
1.1 传统的错误码方式
在 C 语言中,我们习惯用错误码来表示函数执行状态:
intret=some_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 一个简单的调用链示例
看下面这段代码,模拟了一个除法运算,在除数为零时抛出异常:
#include<iostream>#include<string>usingnamespacestd;// 除法函数,当 b == 0 时抛出一个 string 异常doubleDivide(inta,intb){if(b==0){strings("Divide by zero condition!");throws;// 抛出 string 对象}else{return(double)a/(double)b;}}voidFunc(){intlen,time;cin>>len>>time;try{cout<<Divide(len,time)<<endl;}catch(constchar*errmsg)// 捕获 const char* 类型的异常{cout<<errmsg<<endl;}// 注意:如果异常未被当前函数内的 catch 捕获,这行不会被执行cout<<__FUNCTION__<<":"<<__LINE__<<"行执行"<<endl;}intmain(){while(1){try{Func();}catch(conststring&errmsg)// 捕获 string 类型异常的引用{cout<<errmsg<<endl;}}return0;}执行流程分析:
- 用户输入
len=10, time=0。 Func调用Divide(10, 0)。- 在
Divide内部,b == 0成立,于是创建一个string对象s,然后用throw s抛出。 throw后面的return永远不会执行,Divide函数直接退出。- 回到
Func中,检查Divide调用是否在try块内 —— 是的。然后检查catch类型:这里只有catch (const char*),而抛出的是string类型,不匹配。 - 因为当前函数内没有匹配的
catch,Func函数立刻退出,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. 自定义异常体系与标准库exception
5.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基类。
#include<iostream>#include<string>#include<thread>#include<chrono>usingnamespacestd;// 自定义异常基类classException{public:Exception(conststring&errmsg,intid):_errmsg(errmsg),_id(id){}// 虚函数,方便派生类重写,返回错误描述virtualstringwhat()const{return_errmsg;}// 返回错误编号intgetid()const{return_id;}protected:string _errmsg;int_id;};classSqlException:publicException{public:SqlException(conststring&errmsg,intid,conststring&sql):Exception(errmsg,id),_sql(sql){}virtualstringwhat()constoverride{string str="SqlException:";str+=_errmsg;str+=" -> ";str+=_sql;returnstr;}private:conststring _sql;};// 缓存异常classCacheException:publicException{public:CacheException(conststring&errmsg,intid):Exception(errmsg,id){}virtualstringwhat()constoverride{string str="CacheException:";str+=_errmsg;returnstr;}};// HTTP 异常,额外携带请求类型classHttpException:publicException{public:HttpException(conststring&errmsg,intid,conststring&type):Exception(errmsg,id),_type(type){}virtualstringwhat()constoverride{string str="HttpException:";str+=_type;str+=": ";str+=_errmsg;returnstr;}private:conststring _type;};// 模拟各模块voidSQLMgr(){if(rand()%7==0){throwSqlException("权限不足",100,"select * from name = '张三'");}else{cout<<"SQLMgr 调用成功"<<endl;}}voidCacheMgr(){if(rand()%5==0){throwCacheException("权限不足",100);}elseif(rand()%6==0){throwCacheException("数据不存在",101);}else{cout<<"CacheMgr 调用成功"<<endl;}SQLMgr();// 缓存成功后还要访问数据库}voidHttpServer(){if(rand()%3==0){throwHttpException("请求资源不存在",100,"get");}elseif(rand()%4==0){throwHttpException("权限不足",101,"post");}else{cout<<"HttpServer 调用成功"<<endl;}CacheMgr();// HTTP 请求成功后会查缓存}intmain(){srand((unsignedint)time(0));while(1){this_thread::sleep_for(chrono::seconds(1));// 方便观察输出try{HttpServer();}catch(constException&e)// 基类引用捕获所有派生类异常{// 多态调用,实际执行的是派生类的 what()cout<<e.what()<<endl;}catch(...)// 终极兜底{cout<<"Unknown Exception"<<endl;}}return0;}要点解读:
- 我们构建了一个三层异常结构
Exception -> SqlException / CacheException / HttpException,每个派生类都重写了what()方法,提供更丰富的错误上下文。 - 在
main函数中,只需要catch (const Exception& e)就能捕获所有模块的异常,利用多态输出不同的错误信息。 - 这种“抛出派生类,用基类引用捕获”的设计极大降低了耦合,是实际项目中强烈推荐的做法。
6. 异常的重新抛出
有时我们捕获到异常后,并不能完全处理它。比如:对某些特定错误进行局部重试,重试多次仍失败,或者根本无法处理的错误,就需要重新抛出,交给更上层的调用者处理。
下面是一个模拟聊天消息发送的场景:网络不稳定时可以重试,但如果错误是“已被对方删除好友”,则无法通过重试解决,应该直接向上汇报。
// 底层发送消息函数,可能因网络波动或好友关系失效而失败void_SendMsg(conststring&s){if(rand()%2==0){throwHttpException("网络不稳定,发送失败",102,"put");}elseif(rand()%7==0){throwHttpException("你已经不是对方的好友,发送失败",103,"put");}else{cout<<"发送成功"<<endl;}}// 带重试机制的上层发送函数voidSendMsg(conststring&s){// 最多重试 4 次(首次调用 + 3 次重试)for(size_t i=0;i<4;i++){try{_SendMsg(s);break;// 发送成功,退出循环}catch(constException&e){// 错误码 102:网络不稳定,可重试if(e.getid()==102){// 如果已经是第4次尝试(i==3),说明重试机会用完,还是失败if(i==3)throw;// 重新抛出异常,让上层处理cout<<"开始第"<<i+1<<"次重试"<<endl;}else{// 其他错误(如103)无法通过重试解决,直接重新抛出throw;}}}}intmain(){srand((unsignedint)time(0));string str;while(cin>>str){try{SendMsg(str);}catch(constException&e){cout<<e.what()<<endl<<endl;}catch(...){cout<<"Unknown Exception"<<endl;}}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(b==0){throw"Division by zero condition!";// 抛出 const char* 异常}return(double)a/(double)b;}voidFunc(){int*array=newint[10];// 申请动态内存try{intlen,time;cin>>len>>time;cout<<Divide(len,time)<<endl;}catch(...){// 如果 Divide 抛出异常,会跳到这里cout<<"delete[] "<<array<<endl;delete[]array;// 手动释放内存throw;// 重新抛出,让上层知道出错了}// 正常流程下也会释放cout<<"delete[] "<<array<<endl;delete[]array;}intmain(){try{Func();}catch(constchar*errmsg){cout<<errmsg<<endl;}catch(constexception&e)// 可捕获标准库异常{cout<<e.what()<<endl;}catch(...){cout<<"Unknown Exception"<<endl;}return0;}分析:
- 如果
Divide抛出异常,Func中try块后的代码不会执行,但catch(...)会被触发。我们在里面先释放array,再用throw;重新抛出。这样既保证了资源释放,又没有“吞掉”异常。 - 但这只是一种补救式的写法,更安全的方式是使用RAII(Resource Acquisition Is Initialization)。
7.2 析构函数中的异常
另一个容易出问题的场景是析构函数。如果在一个析构函数中抛出了异常,且该异常没有被析构函数自身捕获,那么在栈展开过程中,如果同时存在两个未处理的异常,程序会直接调用terminate终止。
假设析构函数需要释放 10 个资源,释放到第 5 个时发生了异常,如果不加处理,后面 5 个资源就永远泄漏了。《Effective C++》第 8 条条款也强调:别让异常逃离析构函数。应该在析构函数内部try-catch掉所有可能异常,或者确保操作不会抛出异常。
8. 异常规范:从throw()到noexcept
8.1 C++98 的异常规范(已废弃)
C++98 允许在函数声明后加上throw(type list),比如:
void*operatornew(std::size_t size)throw(std::bad_alloc);// 可能抛出 bad_allocvoid*operatordelete(void*ptr)throw();// 不抛出任何异常这种规范过于复杂,而且编译器不会在调用端强制检查,实践中很少被正确使用。C++11 废弃了这种做法(虽然仍支持,但只保留了throw()作为noexcept的等价形式)。
8.2 C++11 的noexcept
C++11 引入了更简洁的关键字noexcept:
void func() noexcept;表示承诺该函数不会抛出异常。- 如果不加
noexcept,表示该函数可能抛出异常。
重要特性:
- 编译器不会在编译期强制检查
noexcept声明的真实性。即便你在noexcept函数里写了throw,大多数编译器只会给出警告,仍然能编译通过。 - 但是,如果一个声明为
noexcept的函数在运行时真的抛出了异常,程序会直接调用std::terminate终止,不会进行正常的栈展开和异常处理。
看下面这个例子:
// 声明为 noexcept,但实际上内部可能抛出异常doubleDivide(inta,intb)noexcept{if(b==0){throw"Division by zero condition!";// 运行时会导致程序终止}return(double)a/(double)b;}intmain(){try{intlen,time;cin>>len>>time;cout<<Divide(len,time)<<endl;}catch(constchar*errmsg)// 这个 catch 不会被触发{cout<<errmsg<<endl;}catch(...){cout<<"Unknown Exception"<<endl;}return0;}如果在运行时输入time = 0,Divide内部抛出异常,但由于noexcept的承诺被打破,程序会立刻终止,不会执行main中的任何catch。
8.3noexcept运算符
noexcept除了作为说明符,还可以作为一个编译期运算符,用来检查一个表达式是否可能抛出异常:
inti=0;cout<<noexcept(Divide(1,2))<<endl;// 可能输出 1(因为 Divide 声明为 noexcept)cout<<noexcept(Divide(1,0))<<endl;// 同样输出 1(声明决定,编译器不分析内部 throw)cout<<noexcept(++i)<<endl;// 典型情况下输出 1,因为自增不抛异常noexcept(expression)返回一个布尔值:
true表示该表达式承诺不抛出异常。false表示可能抛出异常。
它可以用于模板编程中,根据表达式是否抛异常来选择不同的优化路径,比如std::vector的移动构造函数如果能确认移动操作不抛异常,就可以更高效地实现。
结语:
今天的内容到这里就结束了,希望你能有所收获~
干货整理到手抖,觉得有用的话,赏个三连回回血?__(:ᗤ」ㄥ)_ _
