C++ 异常
一.异常的概念与使用
1.异常的概念
• 异常处理机制允许程序中独⽴开发的部分能够在运⾏时就出现的问题进⾏通信并做出相应的处理,异常使得我们能够将问题的检测与解决问题的过程分开,程序的⼀部分负责检测问题的出现,然后解决问题的任务传递给程序的另⼀部分,检测环节⽆须知道问题的处理模块的所有细节。
• C语⾔主要通过错误码的形式处理错误,错误码本质就是对错误信息进⾏分类编号,拿到错误码以后还要去查询错误信息,⽐较⿇烦。异常时抛出⼀个对象,这个对象可以函数更全⾯的各种信息。
2.异常的抛出和捕获
• 程序出现问题时,我们通过抛出(throw)⼀个对象来引发⼀个异常,该对象的类型以及当前的调⽤
链决定了应该由哪个catch的处理代码来处理该异常。
• 被选中的处理代码是调⽤链中与该对象类型匹配且离抛出异常位置最近的那⼀个。根据抛出对象的类型和内容,程序的抛出异常部分告知异常处理部分到底发⽣了什么错误。
• 当throw执⾏时,throw后⾯的语句将不再被执⾏。程序的执⾏从throw位置跳到与之匹配的catch
模块,catch可能是同⼀函数中的⼀个局部的catch,也可能是调⽤链中另⼀个函数中的catch,控
制权从throw位置转移到了catch位置。这⾥还有两个重要的含义:1、沿着调⽤链的函数可能提早退出。2、⼀旦程序开始执⾏异常处理程序,沿着调⽤链创建的对象都将销毁。
• 抛出异常对象后,会⽣成⼀个异常对象的拷⻉,因为抛出的异常对象可能是⼀个局部对象,所以会⽣成⼀个拷⻉对象,这个拷⻉的对象会在catch⼦句后销毁。(这⾥的处理类似于函数的传值返回)
#define _CRT_SECURE_NO_WARNINGS 1#include<iostream>
#include<string>
#include<exception>
using namespace std;double Divide(int a, int b)
{try{// 当b == 0时抛出异常if (b == 0){string s("Divide by zero condition!");throw s;cout<< "发生异常被跳过"<< endl;}else{return ((double)a / (double)b);}}catch (int errid){cout << errid << endl;}cout << __FUNCTION__ << ":" << __LINE__ << "行执行" << endl;return 0;
}void Func()
{int len, time;cin >> len >> time;try{cout << Divide(len, time) << endl;}catch (const char* errmsg){cout << errmsg << endl;}cout << __FUNCTION__ << ":" << __LINE__ << "行执行" << endl;
}int main()
{while (1){try{Func();}catch (const char* errmsg){cout << errmsg << endl;}catch (...) // 任意类型的异常{cout << "未知异常" << endl;}}return 0;
}
运行程序后,输入10 0,抛出异常,throw后面的cout语句未被执行,直接跳转到catch语句处理异常
异常处理流程的重要特征:
- 控制权转移:从throw点直接跳转到匹配的catch块
- 对象销毁:栈展开过程中会析构局部对象
- 不可逆性:一旦进入异常处理,无法返回原执行点
3.栈展开
若一个程序中存在多个函数调用和异常处理块,就会出现栈展开的现象。
• 抛出异常后,程序暂停当前函数的执⾏,开始寻找与之匹配的catch⼦句,⾸先检查throw本⾝是否在try块内部,如果在则查找匹配的catch语句,如果有匹配的,则跳到catch的地⽅进⾏处理。
• 如果当前函数中没有try/catch⼦句,或者有try/catch⼦句但是类型不匹配,则退出当前函数,继续在外层调⽤函数链中查找,上述查找的catch过程被称为栈展开。
• 如果到达main函数,依旧没有找到匹配的catch⼦句,程序会调⽤标准库的 terminate 函数终⽌程序。
• 如果找到匹配的catch⼦句处理后,catch⼦句代码会继续执⾏。
我们用上面的Divide函数调用作为例子来解释栈展开,当除数为0时:
栈展开的核心流程:
1. 局部查找阶段
-
检查throw语句位置:系统首先检查
throw
语句是否位于try
块内部 -
顺序匹配catch子句:如果是,则按声明顺序逐个检查后面的
catch
子句 -
类型匹配判断:将异常对象类型与每个
catch
子句声明的类型进行匹配 -
成功匹配处理:如果找到匹配的
catch
子句,则执行该处理器中的代码 -
继续查找条件:如果当前
try
块中没有匹配的处理器,则进入下一阶段
2. 函数退出阶段
-
无匹配catch时的处理:如果当前函数没有能处理异常的
catch
块 -
局部对象析构:所有局部对象按照构造的逆序进行析构(栈展开的核心)
-
函数立即返回:函数提前终止,不再执行
throw
语句后的任何代码 -
向上层传播:异常传播到调用当前函数的上一层函数中
-
调用者中继续查找:在调用者的上下文中继续查找匹配的
catch
处理器
3. 终止条件
-
main函数无匹配catch:如果异常一直传播到
main
函数仍无匹配的catch
处理器 -
调用std::terminate():C++运行时调用
std::terminate()
函数 -
程序异常终止:通常会导致程序非正常终止,可能不会执行正常的清理工作
4.异常匹配处理机制
• ⼀般情况下抛出对象和catch是类型完全匹配的,如果有多个类型匹配的,就选择离他位置更近的那个。
try {throw std::runtime_error("error");
}
catch(const std::runtime_error& e) { // 精确匹配// 处理代码
}
• 但是也有⼀些例外,允许从⾮常量向常量的类型转换,也就是权限缩⼩;允许数组转换成指向数组元素类型的指针,函数被转换成指向函数的指针;允许从派⽣类向基类类型的转换,这个点⾮常实⽤,实际中继承体系基本都是⽤这个⽅式设计的。
//权限缩小
try {throw int(10);
}
catch(const int& e) { // 允许从int到const int的转换// 处理代码
}//数组退化为数组指针
try {char arr[10];throw arr;
}
catch(char* e) { // 数组退化为指针// 处理代码
}//函数指针转换
try {throw someFunction; // 函数名
}
catch(void (*funcPtr)()) { // 转换为函数指针// 处理代码
}//支持派生类向基类类型的向上转型,这是面向对象异常处理的重要特性
//利用多态特性,用基类捕捉,实际类型按照捕捉到的对象决定
class Base {};
class Derived : public Base {};try {throw Derived();
}
catch(Base& b) { // 派生类到基类的转换// 处理代码
}
• 如果到main函数,异常仍旧没有被匹配就会终⽌程序,不是发⽣严重错误的情况下,我们是不期望程序终⽌的,所以⼀般main函数中最后都会使⽤catch(...),它可以捕获任意类型的异常,但是不知道异常错误是什么。
int main() {try {// 程序主体}catch(...) { // 捕获所有未处理的异常std::cerr << "Unknown exception caught" << std::endl;// 可以在此进行资源清理return EXIT_FAILURE;}return EXIT_SUCCESS;
}
我们这里用随机数模拟项目中的异常捕获情况:
#define _CRT_SECURE_NO_WARNINGS 1#include<iostream>
#include<string>
#include<exception>
using namespace std;#include<thread>// 一般大型项目程序才会使用异常,下面我们模拟设计一个服务的几个模块
// 每个模块的继承都是Exception的派生类,每个模块可以添加自己的数据
// 最后捕获时,我们捕获基类就可以
class Exception
{
public:Exception(const string& errmsg, int id):_errmsg(errmsg), _id(id){}virtual string what() const{return _errmsg;}int getid() const{return _id;}
protected:string _errmsg;int _id;
};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;}
};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()
{if (rand() % 7 == 0){throw SqlException("权限不足", 100, "select * from name = '张三'");}else{cout << "SQLMgr 调用成功" << endl;}
}void CacheMgr()
{if (rand() % 5 == 0){throw CacheException("权限不足", 100);}else if (rand() % 6 == 0){throw CacheException("数据不存在", 101);}else{cout << "CacheMgr 调用成功" << endl;}SQLMgr();
}void HttpServer()
{if (rand() % 3 == 0){throw HttpException("请求资源不存在", 100, "get");}else if (rand() % 4 == 0){throw HttpException("权限不足", 101, "post");}else{cout << "HttpServer调用成功" << endl;}CacheMgr();
}int main()
{srand(time(0));while (1){this_thread::sleep_for(chrono::seconds(1));try{HttpServer();}catch (const Exception& e) // 这里捕获基类,基类对象和派生类对象都可以捕获{// 多态调用cout << e.what() << endl;}catch (...){cout << "Unkown Exception" << endl;}}return 0;
}
运行结果:
5.异常重新抛出
异常重新抛出(Exception Rethrowing)是指在一个catch
块中捕获异常后,经过一些处理,再次抛出同一个异常,让更上层的调用者继续处理这个异常的机制。
1.基本语法
try {// 可能抛出异常的代码
} catch (const Exception& e) {// 进行一些处理logError(e); // 例如记录错误日志throw; // 重新抛出同一个异常
}
2.工作原理
-
保持异常对象不变:重新抛出的是原始的异常对象,不是它的拷贝
-
类型信息保留:异常的类型信息保持不变
-
传播链继续:异常继续沿着调用栈向上传播,寻找匹配的处理器
3.典例分析
现在用一个模拟消息发送来解释异常重新抛出机制。
// 下面程序模拟展示了聊天时发送消息,发送失败捕获异常,但是可能在
// 电梯地下室等场景手机信号不好,则需要多次尝试,如果多次尝试都发
// 送不出去,则就需要捕获异常再重新抛出,其次如果不是网络差导致的
// 错误,捕获后也要重新抛出。// 假设的异常类定义(代码中未给出,这里补充以便理解)
class Exception {
public:virtual const char* what() const = 0;virtual int getid() const = 0;
};class HttpException : public Exception {
private:std::string message;int id;std::string method;public:HttpException(const std::string& msg, int errId, const std::string& mtd) : message(msg), id(errId), method(mtd) {}const char* what() const override { return message.c_str(); }int getid() const override { return id; }
};// 模拟发送消息的函数,可能抛出异常
void _SendMsg(const string& s)
{if (rand() % 2 == 0){throw HttpException("网络不稳定,发送失败", 102, "put");}else if (rand() % 7 == 0){throw HttpException("你已经不是对象的好友,发送失败", 103, "put");}else{cout << "发送成功" << endl;}
}// 包装函数,实现重试和异常重新抛出逻辑
void SendMsg(const string& s)
{// 发送消息失败,则再重试3次for (size_t i = 0; i < 4; i++){try{_SendMsg(s);break; // 发送成功,跳出循环}catch (const Exception& e){// 捕获异常,if中是102号错误,网络不稳定,则重新发送// 捕获异常,else中不是102号错误,则将异常重新抛出if (e.getid() == 102){// 重试三次以后都失败了,则说明网络太差了,重新抛出异常if (i == 3)throw; // 重新抛出异常cout << "开始第" << i + 1 << "重试" << endl;}else{// 重新抛出throw; // 重新抛出异常}}}
}int main()
{srand(time(0));string str;while (cin >> str){try{SendMsg(str);}catch (const Exception& e){cout << e.what() << endl << endl;}catch (...){cout << "Unkown Exception" << endl;}}return 0;
}
场景一:网络问题重试后仍失败
-
当检测到网络不稳定错误(102)时,函数会尝试重试最多3次
-
如果3次重试后仍然失败,则重新抛出异常,让调用者(
main
函数)处理
if (e.getid() == 102) // 网络不稳定错误
{if (i == 3) // 已经重试了3次throw; // 重新抛出异常,让上层处理cout << "开始第" << i + 1 << "重试" << endl;
}
场景二:非网络问题立即重新抛出
-
当检测到其他类型的错误(如103,表示不是好友)时,立即重新抛出
-
因为这些错误不是暂时的网络问题,重试没有意义
else // 不是网络不稳定错误
{throw; // 重新抛出异常
}
异常传播路径
-
_SendMsg()
抛出HttpException
-
SendMsg()
的catch
块捕获异常 -
根据错误类型决定是重试还是重新抛出
-
如果重新抛出,异常传播到
main()
函数的catch
块 -
main()
函数处理异常(打印错误信息)
4.注意事项
应用场景
中间层处理:在多层嵌套的try-catch结构中,中间层可以筛选出自己能处理的异常,将其余异常传递给外层
异常分类:根据异常的类型或内容决定不同的处理方式
资源清理:在完成必要的资源清理工作后重新抛出异常
注意事项
使用throw;时必须在catch块中,否则会导致程序终止
重新抛出的异常会保留原始的异常类型和堆栈信息
与throw e;不同,throw;不会对异常对象进行切片(slice)
重新抛出的异常可以被更外层的catch块捕获
重新抛出的优势
-
局部处理与全局处理的结合:
-
在
SendMsg()
中处理可重试的错误(网络不稳定) -
在
main()
中统一处理所有最终错误
-
-
保持异常上下文:
-
重新抛出保持原始异常的所有信息
-
上层处理器可以获取完整的错误详情
-
-
代码结构清晰:
-
错误处理逻辑分层清晰
-
每层只处理自己能处理的错误类型
-
6.异常安全机制
• 异常抛出后,后⾯的代码就不再执⾏,前⾯申请了资源(内存、锁等),后⾯进⾏释放,但是中间可能会抛异常就会导致资源没有释放,这⾥由于异常就引发了资源泄漏,产⽣安全性的问题。中间我们需要捕获异常,释放资源后⾯再重新抛出:
void saferFunction() {char* buffer = new char[1024];mutex.lock();try {processData(buffer);} catch (...) {mutex.unlock();delete[] buffer;throw; // 重新抛出}mutex.unlock();delete[] buffer;
}
当然后⾯智能指针讲的RAII⽅式解决这种问题是更好的
double Divide(int a, int b)
{// 当b == 0时抛出异常if (b == 0){throw "Division by zero condition!";} return(double)a / (double)b;
}
void Func()
{// 这⾥可以看到如果发⽣除0错误抛出异常,另外下⾯的array没有得到释放。// 所以这⾥捕获异常后并不处理异常,异常还是交给外层处理,这⾥捕获了再// 重新抛出去。int* array = new int[10];try{int len, time;cin >> len >> time;cout << Divide(len, time) << endl;} catch(...){// 捕获异常释放内存cout << "delete []" << array << endl;delete[] array;throw; // 异常重新抛出,捕获到什么抛出什么} cout << "delete []" << array << endl;delete[] array;
}
int main()
{try{Func();} catch(const char* errmsg){cout << errmsg << endl;} catch(const exception & e){cout << e.what() << endl;}catch(...){cout << "Unkown Exception" << endl;} return 0;
}
• 其次析构函数中,如果抛出异常也要谨慎处理,⽐如析构函数要释放10个资源,释放到第5个时抛出异常,则也需要捕获处理,否则后⾯的5个资源就没释放,也资源泄漏了。《Effctive C++》第8个条款也专⻔讲了这个问题,别让异常逃离析构函数。
class ResourceHolder {
public:~ResourceHolder() {releaseResource1(); // 可能抛出异常releaseResource2();// ...其他资源释放}
};
更安全的做法是为每一个析构资源进行异常保护
~ResourceHolder() {try { releaseResource1(); } catch (...) { /*记录日志*/ }try { releaseResource2(); } catch (...) { /*记录日志*/ }// ...
}
或者为客户端提供专门的资源处理方法
void safeCleanup() {// 显式清理逻辑
}~ResourceHolder() {try {safeCleanup();} catch (...) {// 基本保障处理}
}
7.异常规范
1. C++98中的异常规范(动态异常规范)
在C++98中,我们可以使用动态异常规范来指定函数可能抛出的异常类型。
void func() throw(int, double); // 只能抛出int或double异常
void func() throw(); // 不抛出任何异常
void func(); // 可能抛出任何异常(默认)
-
如果函数抛出了不在规范中的异常,会调用
std::unexpected()
,默认终止程序。 -
运行时检查,性能开销。
-
难以维护,特别是在大型项目中。
2.C++11中的异常规范(noexcept规范)
C++11引入了新的异常规范,主要使用noexcept
关键字,它表示函数是否可能抛出异常。
void func() noexcept; // 不会抛出异常
void func() noexcept(true); // 同上,不会抛出异常
void func() noexcept(false); // 可能抛出异常
void func(); // 可能抛出异常(默认)
特点:
-
noexcept
是编译期检查,不会在运行时带来开销。 -
如果
noexcept
函数抛出了异常,程序会调用std::terminate()
终止。 -
noexcept
可以带一个常量表达式参数,根据条件决定是否抛出异常。
3. noexcept的优势
-
性能优化:编译器可以对
noexcept
函数进行更好的优化。 -
移动语义:标准库容器(如
std::vector
)在重新分配内存时,如果元素的移动构造函数是noexcept
的,则会使用移动而不是拷贝,从而提高性能。 -
可读性:明确表示函数不会抛出异常,使代码更易于理解。
4. 异常规范的使用建议
-
对于不会抛出异常的函数,使用
noexcept
进行标记。 -
析构函数通常应该标记为
noexcept
,因为在析构过程中抛出异常可能导致程序终止。 -
移动操作(移动构造函数和移动赋值运算符)应该尽量标记为
noexcept
,以允许标准库容器使用移动而非拷贝。 -
不要使用动态异常规范(C++98风格),因为它们已被C++11弃用,并在C++17中移除。
double Divide(int a, int b)
{// 当b == 0时抛出异常if (b == 0){throw "Division by zero condition!";}return (double)a / (double)b;
}#include<list>int main()
{/*try{int len, time;cin >> len >> time;cout << Divide(len, time) << endl;}catch (const char* errmsg){cout << errmsg << endl;}catch (...){cout << "Unkown Exception" << endl;}*/int i = 0;// 检查表达式是否是noexceptcout << noexcept(Divide(1, 2)) << endl;cout << noexcept(Divide(1, 0)) << endl;cout << noexcept(++i) << endl;list<int> lt;cout << noexcept(lt.begin()) << endl;return 0;
}
5.noexcept的使用
1.标准库中的应用
// std::vector在重新分配时使用移动而非拷贝(如果移动操作是noexcept的)
template<class T>
void vector<T>::resize(size_type n) {if (n > capacity()) {// 如果T的移动构造函数是noexcept,使用移动// 否则使用拷贝(避免异常安全问题)}
}
2.移动操作与noexcept:移动操作应标为noexcept
class MyClass {
public:// noexcept移动构造函数MyClass(MyClass&& other) noexcept : data(std::move(other.data)) {}// noexcept移动赋值运算符MyClass& operator=(MyClass&& other) noexcept {data = std::move(other.data);return *this;}private:std::vector<int> data;
};
3.noexcept运算符:用于检查在编译阶段是否会抛出异常
// 检查表达式是否会抛出异常
bool will_throw = noexcept(may_throw_function());// 常用于模板编程中
template<typename T>
void process(T&& obj) {if constexpr (noexcept(obj.process())) {// 使用更高效的实现obj.process();} else {// 使用更安全的实现try {obj.process();} catch (...) {// 异常处理}}
}
二.标准库异常
C++标准库也定义了⼀套⾃⼰的⼀套异常继承体系库,基类是exception。具体文档可以查看exception - C++ Reference
关键特性
• 所有标准库异常都继承自std::exception基类
• 基类定义了虚函数what(),用于返回异常描述信息
• 实际使用时应捕获std::exception及其派生类
• 常见派生类包括:
std::runtime_error(运行时错误)
std::logic_error(逻辑错误)
std::bad_alloc(内存分配失败)
std::out_of_range(越界访问)
使用案例:
try {// 可能抛出异常的代码throw std::runtime_error("manmba wrong");
}
catch (const std::exception& e) {// 捕获所有标准库异常std::cerr << "Error: " << e.what() << std::endl;
}
•最佳实践
在main函数中捕获std::exception作为最后防线
可以通过继承std::exception创建自定义异常类
重写what()函数时应返回有意义的错误描述
对于标准库操作,建议优先使用其提供的异常类型
注意事项
what()返回的是const char*,需注意字符串生命周期
异常处理会对性能有影响,不应用于常规控制流
现代C++推荐使用noexcept标识不抛出的函数