第7章 站在对象模型的尖端2: 异常处理
1.异常处理(Exception Handling)
C++的异常处理由三个主要组成部分构成:throw表达式、catch子句和try块。当异常被抛出时,程序控制权会转移,并且会沿着调用堆栈回溯,直到找到匹配的catch子句。在此过程中,局部对象的析构函数会被调用,称为"堆栈展开"(stack unwinding)的过程。
2.异常处理的支持
当异常发生时,编译系统需要做:
- 检验发生throw操作的函数。
- 决定throw操作是否发生在try区段中:判断异常是否在try块中抛出。
- 如果异常在try块中抛出,编译系统会将异常类型与每一个catch子句进行比较。
- 如果找到匹配的catch子句,控制权转移到该catch子句。
- 如果throw不在try块中,或者没有匹配的catch子句,编译系统会:
(a) 摧毁所有活动的局部对象。
(b) 从堆栈中奖目前的函数"unwind"掉
(c) 继续到堆栈中的下一个函数,并重复上述步骤2-5。
3.异常类型的比较
对于每个抛出的异常,编译器会生成一个类型描述符,其中包含异常类型的编码信息。如果异常类型是派生类型,描述符中还会包含所有基类的类型信息,包括私有和保护基类。编译器还为每个catch子句生成一个类型描述符。运行时异常处理器会将抛出对象的类型描述符与每个catch子句的类型描述符进行比较,直到找到匹配的catch子句或堆栈被完全展开。
4.异常对象的生命周期
当异常对象在运行时被抛出时,会发生以下过程:
异常对象被创建并通常放在一个特殊的异常数据堆栈中。传递给catch子句的是异常对象的地址、类型描述符(或一个返回类型描述符的函数指针)以及可能的异常对象描述符。
catch子句中的异常对象
考虑以下catch子句
catch(exPoint p) {
// do something
throw;
}
如果异常对象的类型是exVertex,派生自exPoint,那么这个catch子句会匹配。在这种情况下,p会发生以下变化:p会被初始化为异常对象的副本,就像函数参数一样。如果exPoint有复制构造函数和析构函数,它们会应用于p。由于p是一个对象而不是引用,复制时会进行切片(slicing),即只复制exPoint部分,丢弃exVertex特有的部分。此外,p的虚函数表指针(vptr)会被设置为exPoint的虚函数表。当再次抛出异常时,会创建一个新的临时对象,而不是使用原来的exVertex对象。原来的异常对象会被再次抛出,对p的修改会被丢弃。
如果catch子句是引用类型:
catch(exPoint &p) {
// do something
throw;
}
在这种情况下,p是原始异常对象的引用,任何虚函数调用都会基于exVertex的实际类型,对p的修改会传递到下一个catch子句。
异常处理机制要求编译器跟踪每个函数的作用域,以便在抛出异常时能够找到匹配的catch子句来处理异常。编译器还必须提供查询异常对象类型的方法(RTTI),并且管理被抛出的对象,包括其创建、销毁和清理。假设有一个基类 BaseException 和一个派生类 DerivedException,并且我们希望在抛出DerivedException 时,能够用 BaseException 类型的 catch 子句来捕获它。
5.异常对象的类型识别(RTTI)
#include <iostream>
#include <typeinfo>
class BaseException {
public:
virtual ~BaseException() {}
};
class DerivedException : public BaseException {
public:
~DerivedException() override {}
};
void someFunction() {
try {
// 抛出一个 DerivedException 对象
throw DerivedException();
} catch (const BaseException& e) {
// 使用 typeid 来检查异常的实际类型
if (typeid(e) == typeid(DerivedException)) {
std::cout << "Caught a DerivedException" << std::endl;
} else {
std::cout << "Caught a BaseException" << std::endl;
}
}
}
int main() {
someFunction();
return 0;
}
抛出一个 DerivedException 对象。由于 BaseException 是 DerivedException 的基类,我们可以使用 BaseException 类型的引用作为 catch 子句的参数来捕获这个异常。然后,我们使用 typeid 操作符来检查捕获到的异常对象的实际类型。如果异常对象是 DerivedException 类型,程序会输出 "Caught a DerivedException";否则,输出 "Caught a BaseException"。
当执行 throw DerivedException(); 语句时,一个 DerivedException 对象被创建。这个对象是一个临时对象,它被创建在堆栈上或者堆上,具体取决于编译器的实现。抛出的 DerivedException 对象需要被存储起来,直到找到合适的 catch 子句。在存储期间,对象的生命周期由异常处理机制来维护。
当异常对象被传递给 catch (const BaseException& e) 时,它并没有立即被销毁。只有当 catch 块执行完毕时,异常对象才会被销毁。如果在 catch 块内部再次抛出异常(例如 throw或者 throw e),那么原始的异常对象会被复制,并且新的副本会被传递给下一个匹配的 catch 子句。原始对象仍然会在当前 catch 块结束时被销毁。如果 DerivedException 对象包含需要特别清理的资源(比如文件句柄或网络连接),这些资源会在对象的析构函数中被释放。例如,如果我们给 DerivedException 添加一个析构函数来关闭文件句柄,那么当 DerivedException 对象被销毁时,文件句柄就会被关闭。
当一个异常被抛出时,异常处理机制需要确定异常对象的实际类型,这样才能找到匹配的catch子句。C++提供了typeid操作符和dynamic_cast,这些都是基于RTTI的特性。通过RTTI,编译器能够在运行时检查异常对象的类型,从而决定应该执行哪个catch块。
6.异常对象的生命周期管理
异常对象在抛出时被创建,在找到匹配的catch子句后被销毁。编译器需要负责确保异常对象在整个异常处理过程中得到正确的管理。
当throw表达式被执行时,一个新的异常对象会被创建,这个对象通常是一个临时对象。异常对象需要被存储,直到找到合适的catch子句。这个存储位置可能是堆栈或堆,取决于编译器的实现。一旦异常对象被传递给catch子句,它将在catch块结束时被销毁。如果catch子句中有throw语句来重新抛出异常,那么原来的异常对象会被复制,新的副本将被传递给下一个catch子句。如果异常对象包含了需要特殊清理的资源(如文件句柄或网络连接),那么在对象被销毁时,这些资源需要被正确地释放。这通常是通过对象的析构函数来完成的。
异常处理可能对程序大小和执行速度产生影响。编译器可以选择在编译时或运行时构建支持异常处理所需的数据结构。编译时构建可以提高执行速度但可能增加程序大小,运行时构建则相反。
-
在编译时构建的情况下,编译器会在编译阶段生成一个异常处理表,这个表包含了关于每个try-catch块的信息,比如它们在代码中的位置、哪些异常类型可以被捕获等。这样,当异常发生时,编译器可以非常快速地查找这个表来决定如何处理异常。
-
编译器会动态地构建异常处理信息,它可能会创建一个临时的数据结构,记录当前的调用堆栈和异常类型。然后,这个数据结构会被用来搜索匹配的catch块。如果找到了匹配的catch块,异常对象会被传递给它;如果没有找到匹配的catch块,则会继续向上搜索调用堆栈,直到找到一个匹配的catch块或到达顶层调用。
对于一些看似简单的函数,异常处理也带来了额外的复杂性。
当一个异常抛出时,函数可能需要确保某些资源被正确释放,如锁定的内存等。文章还提供了几种编写异常安全代码的技术,比如使用智能指针auto_ptr来自动管理动态分配的内存,或者封装资源获取和释放逻辑到类的构造函数和析构函数中。
// 原始的mumble函数,没有异常处理
Point* mumble() {
Point *pt1, *pt2;
pt1 = foo();
if (!pt1) return 0;
Point p; // 局部对象
pt2 = foo();
if (!pt2) return pt1;
// ... 其他代码
return pt2;
}
// 添加了异常处理的mumble函数
void mumble(void *arena) {
Point *p = new Point;
smLock(arena); // 锁定内存
// ... 其他代码
smUnlock(arena); // 解锁内存
delete p;
}
// 使用try-catch块使mumble函数异常安全
void mumble(void *arena) {
Point *p;
p = new Point;
try {
smLock(arena);
// ... 其他代码
} catch (...) {
smUnlock(arena);
delete p;
throw; // 重新抛出异常
}
smUnlock(arena);
delete p;
}
//使用智能指针和封装资源管理的mumble函数
void mumble(void *arena) {
std::auto_ptr<Point> p(new Point);
SMLock sm(arena); // 封装了锁的类
// 不需要显式的解锁和删除
}
原始的 mumble 函数,没有异常处理。如果 foo() 抛出异常,那么 mumble 函数将直接终止,而不会释放任何已分配的资源。此外局部对象 p 会在函数退出时自动销毁,但如果在 p 的构造过程中抛出异常,函数会直接退出,不会执行后续的代码。
添加异常处理的mumble函数
void mumble(void *arena) {
Point *p = new Point;
smLock(arena); // 锁定内存
// ... 其他代码
smUnlock(arena); // 解锁内存
delete p;
}
该函数创建了一个 Point 对象,锁定了一块内存(arena),执行一些其他代码,然后解锁内存并删除 Point 对象。
使用 try-catch 块来确保在抛出异常时,smUnlock 和 delete p 仍然会被执行。如果在 try 块中的代码抛出异常,catch 块会解锁内存并删除 Point 对象,然后重新抛出异常。如果 try 块中的代码没有抛出异常,smUnlock 和 delete p 会在 try 块结束后正常执行。
使用智能指针和封装资源管理的 mumble 函数
void mumble(void *arena) {
std::auto_ptr<Point> p(new Point);
SMLock sm(arena); // 封装了锁的类
// 不需要显式的解锁和删除
}
auto_ptr 会在离开作用域时自动删除 Point 对象,即使在构造 Point 对象或执行其他代码时抛出异常。SMLock 类会在构造函数中锁定内存,在析构函数中解锁内存,这样即使在 try 块中的代码抛出异常,锁也会被正确地解锁。方法利用了RAII原则,确保资源在对象生命周期内得到正确管理。
原文链接:https://blog.csdn.net/m0_52043808/article/details/143589604