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的详细步骤

  1. 异常抛出:当一个异常被抛出时,程序会立即停止当前的执行路径,并开始寻找能够处理该异常的捕获块。
  2. 寻找捕获块:程序会从异常抛出的点开始,向上搜索调用栈,寻找能够处理该异常的捕获块。
  3. 调用析构函数:在搜索捕获块的过程中,程序会依次退出当前作用域,并调用每个作用域中对象的析构函数,以确保资源被正确释放。
  4. 捕获异常:一旦找到合适的捕获块,程序会将控制权转移到该捕获块,并执行其中的代码。
  5. 未找到捕获块:如果在整个调用栈中都没有找到合适的捕获块,程序会调用 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
  1. 资源分配functionAfunctionBfunctionC 分别分配了资源 "A"、"B" 和 "C"。
  2. 异常抛出functionC 抛出了一个 std::runtime_error 异常。
  3. 栈展开
    • functionC 的作用域结束,资源 "C" 被释放。
    • functionB 的作用域结束,资源 "B" 被释放。
    • functionA 的作用域结束,资源 "A" 被释放。
  4. 捕获异常main 函数中的 catch 块捕获了异常,并输出错误信息

 好了,栈展开stack unwinding基本就说明白了,现在我们来看看注意事项

注意事项

  1. 析构函数不应抛出异常:在栈展开过程中,如果析构函数抛出异常,程序会调用 std::terminate,导致程序非正常终止。因此,析构函数应该被声明为 noexcept,确保它们不会抛出异常。
  2. 其实也就是说,在栈展开stack unwinding的过程中,又抛出了一个新的异常,程序就会直接调用 std::terminate(), 导致程序崩溃

好了,现在我们来看一道经典的C++题目

class A 
{ 
    ~A() {throw exception;} 
} 

try 
{ 
    A a; 
a.method();
//throw exception } catch(...) {}

上面代码会有问题么 => 100%有问题,上面代码是C++中一个经典的坑 => 析构函数抛异常 + 正在处理另一个异常 → 程序直接终止

这段代码的执行流程如下

执行流程如下:

  1. a.method() 抛出异常
  2. 进入栈展开过程(准备进入 catch
  3. 局部对象 a 开始析构
  4. ~A() 又抛出异常 ❌❌
  5. 两个异常同时存在 → 直接调用 std::terminate()
  6. catch(...) 根本不会执行
  7. 程序直接崩溃

那么,这段代码应该怎么修改呢,我们来看一个修改版本

#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;
    }
}

这个版本改动点

析构函数不再抛异常

~A() noexcept
  • 明确告诉编译器:这里不会抛异常
  • 即使内部出错,也必须自己处理掉

2. 用 try-catch 包住析构逻辑

try {
cleanup();
} catch (...) {
// 吞掉异常
}

👉 原则是:

析构函数可以“失败”,但不能“抛出去”


3. 把危险逻辑移到普通函数

void cleanup()

 

也就是说 

析构函数内部“可以发生异常”,但绝不能让异常逃出析构函数(escape)

也就是说:

  • ✔ 可以 throw
  • ✔ 但必须在析构函数内部 catch 并处理掉
  • ❌ 不能传播到函数外
~A() noexcept
{
try {
cleanup(); // 这里 throw
} catch (...) {
// 在这里被吃掉
}
}

这段代码 实际行为是:

    1. cleanup() 抛异常 ✅
    2. 被析构函数内部 catch 捕获 ✅
    3. 异常没有逃出析构函数 ✅
    4. 程序安全 ✔

posted on 2026-04-24 12:01  新西兰程序员  阅读(13)  评论(0)    收藏  举报