C++进阶(8)——异常
目录
基本概念
异常的抛出和捕获
栈展开
查找匹配的处理代码
重新抛异常
异常安全问题
异常规范
标准库中的异常
基本概念
我们在编写复杂的C++程序的时候,不可避免地会遇到意料之外的错误、资源分配失败或是运行时故障。异常处理(Exception Handing)是C++提供的一种优雅并且强大的机制,这主要是用于将我们的错误检测(问题的发生地)和我们的错误处理(问题解决地)分离开来。
我们使用异常主要是为了让我们的代码更加清晰、更加健壮,并且可以更加高效地管理程序运行中的“意外”。
我们在C语言中使用的主要是通过我的错误码的形式处理错误,错误码本质就是提前对错误信息进行的编号分类,拿到错误码后还要去查询错误信息,可以说是相当的麻烦。
异常处理的核心三剑客
我们的C++异常处理主要是围绕着这三个关键字展开的:try、catch和throw。
try(尝试):监测区域,用于包裹你预期可能发生异常的代码段。
throw(抛出):发出警报,当我们的程序监测到一个无法在当前环境中处理的错误的时候,它会使用throw关键字抛出一个异常。
catch(捕获):善后处理,紧跟在我的try后面的是一个或是多个catch块,它们负责捕获或是处理被抛出的异常。
异常的抛出和捕获
异常的抛出和捕获的匹配规则:
1、程序出现问题的时候,我们通过抛出(throw)一个对象来引发一个异常,该对象的类型以及当前的调用链决定了应该由哪一个catch的处理代码来处理该异常。
2、被选中的处理代码是调用链中和该对象类型匹配且离异常位置最近的那一个,根据抛出对象的内容和类型,程序的抛出异常部分告知异常处理部分到底发生了什么错误。
3、当throw执行的时候,我们的throw后面语句将不再被执行,程序的执行从throw的位置跳到与之匹配的catch模块,catch可能是同一函数中的局部的一个catch,也可能是调用链中另一个函数的catch,控制权就从throw位置转到了catch位置。
这里有两个重点的含义:
- 1、沿着调用链的函数可能提早退出。
- 2、一旦程序开始执行异常处理程序,沿着调用链创建的对象都将会被销毁。
4、抛出异常对象后,会生成一个异常对象的拷贝,因为抛出的异常对象可能是一个局部对象,所以会生成一个拷贝对象,这个拷贝的对象会在catch子句后销毁掉。
栈展开
栈展开的规则如下:
1、抛出异常之后,我们的程序会暂停当前函数的执行,开始寻找我们与之匹配的catch子句,首先检查throw本省是否是在try块的内部,如果在就查找匹配的catch语句,如果有匹配的,就跳到catch的地方上进行相应的处理。
2、如果当前函数中没有了try或是catch子句,或者是try/catch子句有但是类型不相匹配,就退出当前函数,继续在外层函数链中查找,上面查找catch过程就是栈展开。
3、如果到了main函数还是没有找到我们匹配的catch子句,那么程序会自动调用标准库中terminate函数终止程序的。找到了匹配的catch子句处理完了之后,catch子句代码会继续执行。
示例代码:
#include <iostream>
using namespace std;
void func1() {throw string("异常");
}
void func2() {func1();
}
void func3() {func2();
}
int main() {try {func3();} catch(const string& s) {cout << "错误是:" << s << endl;} catch(...) {cout << "未知异常" << endl;}return 0;
}
测试效果:
这里我们重点看一看这个异常抛出后栈展开的过程:
图示:
说明:
- 我们这里首先会检查一下throw本身是不是在try块的内部,这里发现不在,所以我们会退出func1所在的函数栈,继续向上一个函数栈中进行查找,也就是我们的func2所在的函数栈。
- 到了func2发现还是没有匹配的catch,所以会继续向上一个调用函数栈中进行查找,也就是我们的func3所在的函数栈。
- 到了func3发现还是没有匹配的catch,所以这个时候就会在main所在的函数栈中进行查找,最终我们在main函数栈中找到了与之匹配的catch。
- 这个时候就会跳到main函数中对应的catch块中执行相对应的代码块,指向完后继续执行这个代码块的后续代码。
我们这里给出一个抛异常的实际使用样例:
示例代码:
#include <iostream>
using namespace std;
double Divide(int a, int b) {try {if(b == 0) {string s("除零异常!");throw s;}else {return ((double)a / (double)b);}}catch (int errid) {cout << errid << endl;}return 0;
}void Func() {int len, time;cin >> len >> time;try {cout << Divide(len, time) << endl;}catch (const char* errmsg){cout << errmsg << endl;}}
int main() {while(1) {try {Func();} catch (const string& errmsg) {cout << errmsg << endl;cout << "hello world" << endl;}}return 0;
}
测试效果:
说明:
我们这个代码实现的是“除零异常的判断”,它会在调用链中寻找到匹配的处理异常的catch,最终会找到我们的main函数中的catch并进行处理,然后执行后续的代码。
查找匹配的处理代码
我们在一般情况下抛出对象和catch中的类型是完全匹配的,如果是多个类型匹配的话,我们就会选择哪个离它位置最近的那一个。
但是我们这里还是有一些例外的,我们这里允许我们的非常量向我们的常量进行类型的转换,也就是我们前面所说的权限缩小,我们这里还允许数组转换指向数组元素类型的指针,函数允许被转换成指向函数的指针,允许从派生类向我们的基类进行类型的转换,这一点还是非常实用的。
重点说明一下:
我们这里如果是到了main函数还是没有与之匹配的catch就会终止程序,不是发生严重错误的情况下,我们是不期望程序终止的,所以一般main函数最后会是用catch(...)来捕获异常,因为这个可以捕获任意类型的异常。
我们这里模拟了一个服务器服务中的三个模块(HttpServer->CacheMgr->SQLMgr),使用继承自基类Exception的自定义异常来报告不同层级的错误,并在main函数中通过捕获基类来进行统一的处理。
示例代码:
#include <iostream>
#include <string>
#include <cstdlib> // For rand() and srand()
#include <ctime> // For time()
#include <thread> // For this_thread::sleep_for
#include <chrono> // For chrono::secondsusing namespace std;// =========================================================
// 异常基类和派生类定义
// =========================================================// 一般大型项目程序才会使用异常,下面我们模拟设计一个服务的几个模块
// 每个模块的继承都是Exception的派生类,每个模块可以添加自己的数据
// 最后捕获时,我们捕获基类就可以
class Exception
{
public:Exception(const string& errmsg, int id):_errmsg(errmsg), _id(id){}// 虚函数 what() 允许通过基类指针调用派生类特定的错误信息 virtual string what() const{return _errmsg;}int getid() const{return _id;}
protected:string _errmsg;int _id;
};// SQL 异常,添加了 SQL 语句信息
class SqlException : public Exception
{
public:SqlException(const string& errmsg, int id, const string& sql):Exception(errmsg, id), _sql(sql){}virtual string what() const{string str = "SqlException:";str += _errmsg;str += "->";str += _sql;return str;}
private:const string _sql;
};// 缓存异常
class CacheException : public Exception
{
public:CacheException(const string& errmsg, int id):Exception(errmsg, id){}virtual string what() const{string str = "CacheException:";str += _errmsg;return str;}
};// HTTP 异常,添加了请求类型信息
class HttpException : public Exception
{
public:HttpException(const string& errmsg, int id, const string& type):Exception(errmsg, id), _type(type){}virtual string what() const{string str = "HttpException:";str += _type;str += ":";str += _errmsg;return str;}
private:const string _type;
};// =========================================================
// 模拟服务模块函数 (可能抛出异常)
// =========================================================void SQLMgr()
{// 大约 1/7 的概率抛出 SqlExceptionif (rand() % 7 == 0){throw SqlException("权限不足", 100, "select * from name = '张三'");}else{cout << "SQLMgr 调用成功" << endl;}
}void CacheMgr()
{// 大约 1/5 或 1/6 的概率抛出 CacheExceptionif (rand() % 5 == 0){throw CacheException("权限不足", 100);}else if (rand() % 6 == 0){throw CacheException("数据不存在", 101);}else{cout << "CacheMgr 调用成功" << endl;}// 调用下一层函数,可能会抛出 SqlExceptionSQLMgr();
}void HttpServer()
{// 大约 1/3 或 1/4 的概率抛出 HttpExceptionif (rand() % 3 == 0){throw HttpException("请求资源不存在", 100, "get");}else if (rand() % 4 == 0){throw HttpException("权限不足", 101, "post");}else{cout << "HttpServer调用成功" << endl;}// 调用下一层函数,可能会抛出 CacheException 或 SqlExceptionCacheMgr();
}// =========================================================
// 主函数 (TRY-CATCH 统一处理)
// =========================================================int main()
{srand(time(0)); // 初始化随机数种子while (1){std::this_thread::sleep_for(std::chrono::seconds(1)); // 每次循环暂停 1 秒try{HttpServer(); // 调用最上层函数}// 捕获基类 Exception,基类对象和派生类对象都可以被捕获 [cite: 382, 383]catch (const Exception& e){// 通过虚函数 what() 打印出派生类特有的错误信息cout << e.what() << endl;}// 捕获所有其他未知类型的异常 catch (...){cout << "Unkown Exception" << endl;}cout << "------------------------------------------" << endl;}return 0;
}
测试效果:
敲黑板:
我们这里的基类实现了签名(what()虚函数)和获取异常错误ID(getid())的函数。
重新抛异常
有的时候我们catch一个异常对象之后,我们需要对这个错误进行分类,其中某种异常需要进行一些特殊处理,其他错误则重新抛出异常给外层调用链进行处理(比如我们catch一些对象进行了一些局部处理(例如记录日志、清理资源等)之后,将我们捕获的异常再次抛出);我们捕获异常后需要重新抛出,直接使用throw;即可将我们捕获的对象直接抛出了。
我们这里模拟实现了我们聊天过程中可能出现的发送消息的异常机制,多次尝试了发送不出去,就需要重新抛出,如果是未知好友出的错也是需要重新抛出的:
示例代码:
#include <iostream>
#include <string>
#include <cstdlib> // For rand() and srand()
#include <ctime> // For time()
#include <thread> // For this_thread::sleep_for
#include <chrono> // For chrono::secondsusing namespace std;// =========================================================
// 异常基类和派生类定义 (为了让代码能够编译,这里重新定义)
// =========================================================class Exception
{
public:Exception(const string& errmsg, int id):_errmsg(errmsg), _id(id){}// 虚函数 what() virtual string what() const{return _errmsg;}int getid() const{return _id;}
protected:string _errmsg;int _id;
};// HTTP 异常,添加了请求类型信息
class HttpException : public Exception
{
public:HttpException(const string& errmsg, int id, const string& type):Exception(errmsg, id), _type(type){}// 重写 what(),增加 HTTP 请求类型信息virtual string what() const{string str = "HttpException:";str += _type;str += ":";str += _errmsg;return str;}
private:const string _type;
};// =========================================================
// 模拟消息发送函数
// =========================================================/*** @brief 模拟底层消息发送,可能抛出 HttpException* * @param s 待发送消息*/
void _SeedMsg(const string& s)
{// 1. 模拟网络不稳定错误 (错误ID: 102),大约 50% 概率if (rand() % 2 == 0){throw HttpException("网络不稳定,发送失败", 102, "put");}// 2. 模拟好友关系错误 (错误ID: 103),大约 1/7 概率else if (rand() % 7 == 0){throw HttpException("你已经不是对象的好友,发送失败", 103, "put");}// 3. 成功发送else{cout << "【_SeedMsg】消息:\"" << s << "\" -> 发送成功" << endl;}
}/*** @brief 高层消息发送逻辑,包含重试机制* * @param s 待发送消息*/
void SendMsg(const string& s)
{// 尝试次数:1 次初始尝试 + 3 次重试 = 共 4 次const int MAX_ATTEMPTS = 4;for (int i = 0; i < MAX_ATTEMPTS; i++){try{_SeedMsg(s);// 成功发送则跳出循环return; }catch (const Exception& e){// 捕获所有自定义异常// 检查错误 ID 是否为 102 (网络不稳定)if (e.getid() == 102){// 如果是最后一次尝试 (i == MAX_ATTEMPTS - 1),则认为网络环境不可恢复,重新抛出异常if (i == MAX_ATTEMPTS - 1){cout << "重试" << i + 1 << "次失败,网络环境太差。" << endl;throw; // 重新抛出原始异常,交由 main 函数处理}// 否则,进行下一次重试cout << "【SendMsg】网络不稳定,开始第" << i + 2 << "次尝试..." << endl;// 可以加入短暂的等待,模拟实际延迟std::this_thread::sleep_for(std::chrono::milliseconds(200)); }else{// 如果不是 102 (例如:不是好友 103),说明是非网络错误,不需要重试,直接重新抛出cout << "【SendMsg】捕获到非重试错误,直接抛出。" << endl;throw; }}}
}// =========================================================
// 主函数 (TRY-CATCH 统一处理)
// =========================================================int main()
{srand(time(0)); // 初始化随机数种子string str = "Hello C++";cout << "请输入消息内容 (回车发送):" << endl;while (cin >> str){cout << "\n-------------------- 开始发送消息: \"" << str << "\" --------------------" << endl;try{SendMsg(str); // 调用包含重试逻辑的函数}// 捕获由 SendMsg 重新抛出或未捕获的自定义异常catch (const Exception& e){// 打印出派生类特有的、包含上下文的错误信息cout << "【MAIN CATCH】发送失败,最终原因: " << e.what() << endl;}// 捕获所有其他未知类型的异常 catch (...){cout << "【MAIN CATCH】Unkown Exception" << endl;}cout << "---------------------------------------------------------------------" << endl;}return 0;
}
测试效果:
异常安全问题
我们异常抛出了之后,我们的后面的代码就不再执行了,假如我们前面申请的资源(内存、锁等),我们后面释放,但是中间可能会出现异常然后我们抛异常导致我们的资源没有被正确地释放掉,这里就产生了安全性的问题了。
其次我们的析构函数里面如果抛出异常也要谨慎处理,比如析构函数要释放掉10个资源,释放到了第五个的时候抛出了异常,这个时候也需要进行捕获处理了,否则后面的就都没释放,资源就泄露了。
我们这里给个和处理上面情况的示例:
示例代码如下:
#include <iostream>
#include <exception>
#include <string>using namespace std;double Divide(int a, int b)
{if (b == 0){throw "Division by zero condition!";}return (double)a / (double)b;
}void Func()
{int* array = new int[10];try {int len, time;cout << "请输入两个整数(被除数和除数):" << endl;if (!(cin >> len >> time)) {throw string("Input reading failed!");}cout << Divide(len, time) << endl;}catch (...) {// 捕获异常后的首要任务:释放内存,防止内存泄漏cout << "Func: 捕获异常,释放堆内存 " << array << endl;delete[] array;// 异常重新抛出,交给外层(main函数)处理// 捕获到什么类型的异常就重新抛出什么类型throw; }// 正常执行流程:如果 try 块没有抛出异常,执行到这里释放内存cout << "Func: 正常执行结束,释放堆内存 " << array << endl;delete[] array;
}int main()
{try{Func();}catch (const char* errmsg) // 捕获 Divide 抛出的 C 风格字符串异常{cout << "main: 捕获到 const char* 异常: " << errmsg << endl;}catch (const exception& e) // 捕获标准库异常{cout << "main: 捕获到标准库异常: " << e.what() << endl;}catch (...) // 捕获 Func 中重新抛出的所有其它类型异常(例如本例中新增的 string 异常){cout << "main: 捕获到 Unkown Exception (非 const char* 或标准库异常)" << endl;}return 0;
}
测试效果:
异常规范
对于我们用户和编译器来说,我们预先知道某个程序会不会抛异常是大有裨益的,我们知道了某个函数是否会抛异常有助于我们简化我们调用的代码。
我们C++也有了相关规定:
1、在函数参数列表后面加上throw()(C++98)表示我们的函数不抛异常,C++11中进一步简化成了在函数参数列表后面加上noexcept表示不会抛异常。
2、在函数参数列表后面接上throw(类型1,类型2...)表示的就是可能会抛出多种类型的异常,可能会抛出的类型我们用逗号分隔开。
3、我们的noexcept(expression)还可以作为我们的一个运算符取检测我们的表达式是不是会抛出了异常,是就返回false,不是就返回true。
这里我们给出代码示例:
// C++98
// 这里表示我们的这个函数只会抛出bad_alloc的异常
void* operator new(std::size_t size) throw(std::bad_alloc)
// 这里表示这个函数不会抛异常
void* operator delete (std::size_t size, void* ptr) throw()// C++11
size_type size() const noexcpet;
标准库中的异常
C++标准库中也定义了自己的一套比较完整的异常继承体系库,基类就是std::exception,所以我们日常自己写程序的时候,只需要在主函数中捕获std::exception即可,要获取相对应的异常信息就需要调用我们的what函数(标签)。继承关系如图:
具体说明如下表:
异常类型 | 说明 |
---|---|
std::exception | 所有标准异常类的基类。所有标准异常都直接或间接地继承自该类。 |
std::bad_alloc | 在动态内存分配失败时抛出,通常是 new 操作符无法分配足够内存时。 |
std::domain_error | 用于表示域错误,通常表示无效的数学操作,例如对负数取平方根。 |
std::invalid_argument | 表示传递给函数的参数无效,例如函数期望的参数范围外的值。 |
std::bad_cast | 用于不合法的类型转换(如 dynamic_cast 失败时)。 |
std::bad_typeid | 在对空指针使用 typeid 运算符时抛出,表示无效的类型信息。 |
std::length_error | 用于表示容器长度不符合要求,通常在超出容器容量时抛出。 |
std::bad_exception | 在异常处理过程中发生不被标准库支持的异常时抛出。 |
std::out_of_range | 表示访问容器时超出有效范围(如访问越界的数组元素)。 |
std::logic_error | 表示逻辑错误,通常是由程序设计缺陷引起的错误(如非法操作)。 |
std::overflow_error | 用于表示整数溢出等运算结果超出了其类型的最大表示范围。 |
std::runtime_error | 用于表示运行时错误,通常与程序的执行有关,如文件操作失败等。 |
std::range_error | 表示超出函数接受范围的错误。通常用于数学计算或索引错误。 |
std::underflow_error | 表示数值下溢错误,通常发生在浮点运算中。 |