深入剖析 C++ 异常机制:异常对象、栈展开与 noexcept

目录
1.1 异常的概念
异常处理机制允许程序在运行过程中,将“正常逻辑”和“错误处理逻辑”分离开来。当程序运行出现问题时,可以通过抛出异常的方式,将错误信息从当前执行流中传递出去,由更高层统一处理,而无需在每一层函数中手动检查错误。
这种机制使得程序的结构更加清晰:正常代码专注于业务逻辑,而异常处理代码专门负责处理运行时错误,从而提高代码的可读性和可维护性。
在传统的 C 语言中,通常通过返回错误码的方式来表示函数执行是否成功,调用者需要逐层检查返回值才能判断是否发生错误。这种方式虽然简单,但容易导致错误遗漏,并且使正常逻辑被大量错误判断代码干扰。
相比之下,C++ 引入异常机制。当程序发生错误时,会抛出一个异常对象,该对象携带更完整的错误信息,并自动跳转到匹配的处理代码(catch 块)。这种方式避免了层层返回错误码的繁琐过程,使错误处理与正常逻辑解耦。
因此,从本质上看,异常机制是对传统错误处理方式的一种升级,它将“手动检查错误状态”转变为“自动化的控制流转移机制”。
补充:异常并不是用来替代所有错误处理的,例如逻辑错误仍然应该用返回值处理。
1.2 异常处理的基本语法
在C++中,这三个关键字(try、throw、catch)共同构成了 C++ 异常处理体系。
1.2.1 throw —— 抛出异常
当程序检测到无法继续正常执行的错误时,可以通过throw关键字抛出一个异常对象。
例如:
int Divide(int a, int b)
{
if (b == 0)
{
throw "除数不能为0";
}
return a / b;
}
当执行到throw语句时,当前函数将立即终止后续代码的执行,并将异常对象向外抛出,等待异常处理器进行捕获。
需要注意的是,throw 后面可以抛出任意类型的对象。
例如:
throw 10; // int
throw 3.14; // double
throw "error"; // 字符串常量
throw std::string("error");// string对象
不过在实际开发中,更推荐抛出自定义异常对象或标准库异常对象,以变携带更丰富的错误信息。
1.2.2 try —— 监控异常
可能发生异常的代码需要放在 try 代码块中。
try
{
Divide(10, 0);
}
当 try 代码块中的代码正常执行时,程序不会受到任何影响。如果某条语句抛出了异常,则程序会立即停止当前执行流程,并开始寻找与之匹配的异常处理器。
1.2.3 catch —— 捕获异常
异常处理器通过 catch 关键字定义。
catch(const char* msg)
{
cout << msg << endl;
}
异常处理的完整示例
int Divide(int a, int b)
{
if (b == 0)
{
throw "除数不能为0";
}
return a / b;
}
int main()
{
try
{
cout << Divide(10, 0) << endl;
}
catch(const char* msg)
{
cout << msg << endl;
}
return 0;
}
运行结果:
除数不能为0
1.3 异常对象与异常匹配规则
1.3.1 什么是异常对象
在 C++ 中,异常并不是一个特殊的语法结构,而是一个普通对象。
当程序执行到 throw 语句时,会创建一个异常对象,并将该对象抛出,随后匹配的 catch 语句进行接受和处理。
例如:
throw 10;
throw 3.14;
throw "error";
上述代码分别抛出了: int对象 double对象 const char*对象。因此,从本质上来说:throw抛出的不是错误,而是一个对象。
1.3.2 异常对象的传递过程
例如:
try
{
throw 10;
}
catch(int x)
{
cout << x << endl;
}
执行流程: throw 10 -> 创建异常对象 -> 存储到异常缓冲区 -> 查找匹配catch -> catch接收异常对象
可以简单理解为:普通对象 -> throw -> 异常对象 -> catch接收
1.3.3 异常匹配原则
异常处理时,系统会寻找参数匹配的 catch。
try
{
throw 10;
}
catch(int)
{
cout << "int" << endl;
}
输出:int
try
{
throw 10;
}
catch(double)
{
cout << "double" << endl;
}
不会输出double,原因:throw 的是int,catch接收的是double,类型不匹配。
在异常处理机制中,异常匹配要求严格匹配,不会进行隐式类型转换。
但是也有一些例外,允许从非常量向常量的类型转换,也就是权限缩小;允许数组转换成指向数组元素类型的指针,函数被转换成指向函数的指针;允许从派生类向基类类型的转换,这个点非常实用,实际中继承体系基本都是用这个方式设计的。
多个catch时,按照代码执行顺序依次匹配。如果有多个类型匹配的,就选择离他位置更近的
那个。
try
{
throw 10;
}
catch(double s)
{
}
catch(char s)
{
}
catch(int s)
{
cout << "int 1" << endl;
}
catch(int t)
{
cout << "int 2" << endl;
}
输出:int 1
系统找到第一个匹配项后立即停止查找,跳转到匹配的catch代码块中执行代码。
1.3.4 万能捕获 catch(...)
如果到main函数,异常仍旧没有被匹配,程序就会调用标准库的terminate函数终止程序。所以不是发生严重错误的情况下,我们是不期望程序终止的,所以一般main函数中最后都会使catch(...),它可以捕获任意类型的异常,但是不知道异常错误是什么。
catch(...)
{
cout << "未知异常" << endl;
}
catch(...) 可以捕获任意类型的异常。因此它通常放在最后作为兜底处理。如果写在其他catch的前面,后面的catch将永远无法执行。
1.3.5 为什么推荐引用捕获
很多初学者会这样写:
catch(string s)
{}
实际上更推荐:
catch(const string& s)
{}
原因有两个:
1. 避免额外拷贝,提高效率
异常对象在抛出时已经构造完成,如果使用值接收的方式捕获异常,会额外产生一次对象拷贝;而使用引用接收则不会发生拷贝,因此效率更高。
2. 保留多态特性
在实际开发中,异常体系通常采用继承结构设计。不同模块可能对应不同类型的异常,因此往往会定义一个异常基类,并派生出多个具体异常类,通过重写统一的错误信息接口来描述不同错误。
如果采用值接收:
catch(Exception e)会发生对象切片(Object Slicing),派生类对象中的派生部分被截断,异常对象退化为基类对象,导致动态绑定失效,只能调用基类版本的虚函数,而无法调用派生类重写后的虚函数。
如果采用引用接收:
catch(const Exception& e)则不会发生对象切片,能够保留异常对象的真实动态类型,从而正确地发挥多态机制的作用。
因此,在实际开发中,异常对象通常都采用 const 引用 的方式进行捕获。
1.4 异常栈展开
1.4.1 什么是栈展开
当程序执行过程中发生异常,并且当前作用域内没有找到匹配的 catch 时,异常会沿着函数调用链逐层向上传播。在向上传播的过程中,系统会自动销毁当前作用域内已经构造完成的局部对象,并调用它们的析构函数。这个过程称为:栈展开
栈展开过程:
异常发生
↓
退出当前函数
↓
析构局部对象
↓
返回上一层函数
↓
继续寻找catch
↓
直到找到匹配处理器
示例:
#include <iostream>
using namespace std;
void Func3()
{
throw "发生异常";
}
void Func2()
{
Func3();
}
void Func1()
{
Func2();
}
int main()
{
try
{
Func1();
}
catch(const char* msg)
{
cout << msg << endl;
}
return 0;
}
调用关系:main -> Func1 -> Func2 -> Func3
当执行到 throw "发生异常";程序会首先检查Func3中是否存在类型匹配的 catch 处理器,没有找到,Func3 栈帧销毁,继续检查Func2中是否存在类型匹配的 catch 处理器,没有找到,Func2 栈帧销毁,依次类推,到main函数内部找到类型匹配的catch,此时代码的执行流到达类型匹配的catch处。其中,在回退过程中会析构局部对象。
如果到main函数中依旧没有找到类型匹配的catch,那么程序会调用 std::terminate() 函数,程序直接终止。
1.4.2 栈展开过程中对象析构
栈展开最重要的特点就是:栈销毁时,已经构造完成的局部对象一定会被自动析构
为什么会自动析构?
因为局部对象存储在函数栈帧中,函数退出时,局部对象生命周期结束,自动调用析构函数,然后栈帧销毁。
异常导致的函数退出本质上仍然属于:局部对象生命周期结束,因此析构函数依然会执行。
对于栈上一些局部对象管理着堆内存、文件句柄、网络连接、互斥锁等资源,由于异常直接退出,还没有来得及释放它们拥有的资源,那么就会导致资源丢失、内存泄漏等问题。
解决这一问题的核心思想就是将它们封装成一个对象,通过栈展开自动调用析构函数这一特性来完成资源的管理。这一章节我们将在智能指针中详细理解。
1.5 异常重新抛出
有些情况下,catch捕获到一个异常对象后,需要对错误进行分类,其中的某种异常错误需要进行特殊处理,其他错误则重新抛出异常对象给外层调用链处理。这种异常处理机制就称为异常重新抛出。
典型场景:当前层知道发生了异常,但不知道如何处理,于是记录日志、清理资源后,继续把异常交给上层处理。
#include <iostream>
using namespace std;
void Func()
{
try
{
throw "数据库连接失败";
}
catch(const char* err)
{
cout << "Func记录日志:" << err << endl;
throw; // 重新抛出当前异常
}
}
int main()
{
try
{
Func();
}
catch(const char* err)
{
cout << "main处理异常:" << err << endl;
}
}
throw; 和 throw err; 的区别
正确做法 throw; 错误做法 throw err;
对于throw err; 这里会重新创建一个新的异常对象,如果存在继承关系,创建的这个新对象由于发生对象切片会使得异常对象变成基类,多态信息丢失。
一句话总结:
当当前层无法真正处理异常时,可以在 catch 中执行必要的清理或日志记录工作,然后通过 、throw;将异常继续向上传播。需要注意,应使用 throw; 而不是 throw err; ,前者会保留原始异常对象,而后者可能导致对象切片和类型信息丢失。
对于很多初学者,可能会存在一下这个问题
假如是三层调用关系,第一层抛出一个异常对象,第二层用引用接收能理解,因为异常对象被创建在第二层函数栈帧内部,但第二层捕获后重新抛出,这个异常对象依旧在第二层函数栈帧内部,如果throw; 不创建一个新的异常对象,这不就导致野引用了吗。
真相:异常对象不在函数栈帧里,异常对象会被放到一个运行时维护的异常缓冲区中,因此throw;不会创建一个新的对象,而是重新抛出异常对象,所以不会出现野引用的问题,该异常对象的生命周期会持续到异常处理结束。
1.6 异常安全问题
异常抛出后,后面的代码就不再执行,产生的问题:前面申请了资源(内存、锁等),后面进行释放,但是中间可能会抛异常就会导致资源没有释放,这里由于异常就引发了资源泄漏,产生安全性的问题。对于没有智能指针的情况,就需要我们捕获异常,释放资源后再重新抛出,这种处理方式会导致代码冗余、可读性差。于是C++11智能指针的RAII思想就是解决这种问题的最佳方式。
对于析构函数,如果需要抛出异常也要谨慎处理,比如析构需要释放10个资源,释放到第5个资源时抛出异常,则也需要捕获处理,否则后面的5个资源就没释放,就会导致资源泄漏。
1.7 异常规范
对于用户和编译器而言,预先知道某个程序会不会抛出异常意义很大,知道某个函数是否抛出异常有助于简化调用函数的代码。
C++98中参数列表的后面接throw(),表示函数不抛异常,函数参数列表的后面throw(类型 1,类型 2 ...) 表示可能抛出多种类型的异常,可能会抛出异常对象的类型用逗号分隔。
// 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++98中的这种方式非常复杂,在实践中并不好用,于是C++11对其进行了简化,函数参数列表后面加noexcept表示不会抛出异常,不加 noexcept 表示可能抛出异常。
// C++11
size_type size() const noexcept;
iterator begin() noexcept;
const_iterator begin() const noexcept;
编译器并不会在编译时检查noexcept,也就是说如果一个函数被noexcept修饰,但是又包含了throw语句或者调用时可能会抛出异常,编译器在编译时会通过(有些编译器会报个警告)。但是一个被noexcept修饰的函数如果抛出了异常,程序会调用 std::terminate() 终止程序。
double Divide(int a, int b) noexcept
{
// 当b == 0时抛出异常
if (b == 0)
{
throw "Division by zero condition!";
}
return (double)a / (double)b;
}

noexcept(expression)还可以作为一个运算符去检测一个表达式是否会抛出异常,可能会则返回false,不会就返回true。对于被noexcept 修饰的函数,无论函数内部是否抛异常,都返回true。
double Divide(int a, int b)
{
// 当b == 0时抛出异常
if (b == 0)
{
throw "Division by zero condition!";
}
return (double)a / (double)b;
}
int main()
{
try
{
int len = 2, time = 3;
Divide(len, time);
}
catch (const char *errmsg)
{
cout << errmsg << endl;
}
catch (...)
{
cout << "Unkown Exception" << endl;
}
int i = 0;
cout << noexcept(Divide(1, 2)) << endl;
cout << noexcept(Divide(1, 0)) << endl;
cout << noexcept(++i) << endl;
return 0;
}
运行结果:
0
0
1
对于noexpect作为运算符时,它只判断表达式函数内部是否可能抛异常,不去运行,所以 noexcept(Divide(1, 2)) 返回值也为false。
1.8 标准库的异常
exception - C++ Reference
https://legacy.cplusplus.com/reference/exception/exception/
C++标准库也定义了一套自己的一套异常继承体系库,基类是exception,所以我们日常写程序,需要在主函数捕获exception即可,要获取异常信息,调用what函数,what是一个虚函数,派生类可以重写。


更多推荐


所有评论(0)