C++中异常处理机制中的栈展开stack unwinding
提到C++中的栈展开,我们有必要先了解一下C++中的资源管理模式
RAII --- Resource Acquisition is initialization 资源获取即初始化, 是C++中管理资源生命周期的重要方式. 它是C++语言的一种管理资源、避免资源泄漏的惯用法,利用栈对象自动销毁的特点来实现,因此,我们可以通过构造函数获取资源,通过析构函数释放资源。
核心点: 将资源的获取和释放与对象的生命周期紧密绑定
核心思想
资源获取即初始化(RAII): 在对象的构造函数中完成资源的分配或获取操作,保证资源在对象创建时就处于可用状态
资源释放即清理: 在对象的析构函数中进行资源的释放操作,保证资源在对象生命周期结束时被正确回收,避免引起内存泄漏
作用域控制生命周期:利用对象的作用域来自动管理资源的生命周期,当对象离开其作用域时,析构函数自动调用,从而释放内存资源
好了,了解了RAII,我们现在来看C++异常处理机制中的栈展开 stack unwinding => 栈展开的目的是负责在C++抛出异常时,能够正确地释放资源
我们知道,栈是一种后进先出(LIFO)的数据结构. 在C++中,当调用一个函数时,会在栈上创建一个栈帧,用于存储函数的局部变量和其他信息。当函数返回时,其栈帧会被销毁
但是,在这个过程中,如果函数执行时抛出一个异常,控制流就会立即跳到处理该异常的代码,而不会正常返回. => 这也就是说,函数的栈帧可能没有被正确销毁,从而导致资源泄露. => 为了解决这个问题,C++中引入了栈展开stack unwinding机制 => 它的执行逻辑基本是这样的: 当一个异常被抛出并且没有在当前作用域内被捕获时,程序会去寻找能够处理该异常的捕获块(catch block). 在这个过程中,程序会依次退出当前作用域,并调用每个作用域中对象的析构函数,以确保资源被正确释放 => 这一整个过程就叫做 栈展开 stack unwinding
栈展开stack unwinding的详细步骤
- 异常抛出:当一个异常被抛出时,程序会立即停止当前的执行路径,并开始寻找能够处理该异常的捕获块。
- 寻找捕获块:程序会从异常抛出的点开始,向上搜索调用栈,寻找能够处理该异常的捕获块。
- 调用析构函数:在搜索捕获块的过程中,程序会依次退出当前作用域,并调用每个作用域中对象的析构函数,以确保资源被正确释放。
- 捕获异常:一旦找到合适的捕获块,程序会将控制权转移到该捕获块,并执行其中的代码。
- 未找到捕获块:如果在整个调用栈中都没有找到合适的捕获块,程序会调用
std::terminate,导致程序非正常终止。
我们来看一个例子
#include <iostream> #include <stdexcept> class Book { public: Book(const std::string& name) : name(name) { std::cout << "Acquire resource for Book: " << name << std::endl; } ~Book() { std::cout << "Release resource for Book: " << name << std::endl; } private: std::string name; } void FunctionC() { Book book("C"); throw std::runtime_error("An error occurred in FunctionC"); } void FunctionB() { Book book("B"); FunctionC(); } void FunctionA() { Book book("A"); FunctionB(); } int main() { try { FunctionA(); } catch(const std::exception& e) { std::cerr << "Caught exception: " << e.what() << std::endl; } return 0; }
以上代码的输出为
Acquiring resource for Book: A Acquiring resource for Book: B Acquiring resource for Book: C Releasing resource for Book: C Releasing resource for Book: B Releasing resource for Book: A Caught exception: Exception in functionC
- 资源分配:
functionA、functionB和functionC分别分配了资源 "A"、"B" 和 "C"。 - 异常抛出:
functionC抛出了一个std::runtime_error异常。 - 栈展开:
functionC的作用域结束,资源 "C" 被释放。functionB的作用域结束,资源 "B" 被释放。functionA的作用域结束,资源 "A" 被释放。
- 捕获异常:
main函数中的catch块捕获了异常,并输出错误信息
好了,栈展开stack unwinding基本就说明白了,现在我们来看看注意事项
注意事项
- 析构函数不应抛出异常:在栈展开过程中,如果析构函数抛出异常,程序会调用
std::terminate,导致程序非正常终止。因此,析构函数应该被声明为noexcept,确保它们不会抛出异常。 - 其实也就是说,在栈展开stack unwinding的过程中,又抛出了一个新的异常,程序就会直接调用 std::terminate(), 导致程序崩溃
好了,现在我们来看一道经典的C++题目
class A { ~A() {throw exception;} } try { A a;
a.method(); //throw exception } catch(...) {}
上面代码会有问题么 => 100%有问题,上面代码是C++中一个经典的坑 => 析构函数抛异常 + 正在处理另一个异常 → 程序直接终止
这段代码的执行流程如下
执行流程如下:
a.method()抛出异常- 进入栈展开过程(准备进入
catch) - 局部对象
a开始析构 ~A()又抛出异常 ❌❌- 两个异常同时存在 → 直接调用
std::terminate() catch(...)根本不会执行- 程序直接崩溃
那么,这段代码应该怎么修改呢,我们来看一个修改版本
#include <iostream> #include <exception> class A { public: void method() { throw std::runtime_error("error in method"); } ~A() noexcept // 明确声明不抛异常 { try { cleanup(); // 把可能出错的逻辑放这里 } catch (const std::exception& e) { std::cerr << "Destructor caught exception: " << e.what() << std::endl; } catch (...) { std::cerr << "Destructor caught unknown exception" << std::endl; } } private: void cleanup() { // 模拟可能出错的逻辑 throw std::runtime_error("error in cleanup"); } }; int main() { try { A a; a.method(); } catch (const std::exception& e) { std::cout << "Caught: " << e.what() << std::endl; } }
这个版本改动点
析构函数不再抛异常
- 明确告诉编译器:这里不会抛异常
- 即使内部出错,也必须自己处理掉
2. 用 try-catch 包住析构逻辑
cleanup();
} catch (...) {
// 吞掉异常
}
👉 原则是:
析构函数可以“失败”,但不能“抛出去”
3. 把危险逻辑移到普通函数
也就是说
析构函数内部“可以发生异常”,但绝不能让异常逃出析构函数(escape)
也就是说:
- ✔ 可以
throw - ✔ 但必须在析构函数内部
catch并处理掉 - ❌ 不能传播到函数外
~A() noexcept { try { cleanup(); // 这里 throw } catch (...) { // 在这里被吃掉 } }
这段代码 实际行为是:
cleanup()抛异常 ✅- 被析构函数内部
catch捕获 ✅ - 异常没有逃出析构函数 ✅
- 程序安全 ✔
浙公网安备 33010602011771号