【c++面向对象编程】第36篇:析构函数应永远不抛出异常——原因与最佳实践

目录

一、一个崩溃的程序

二、为什么析构函数不能抛出异常?

核心原因:同时存在两个异常

标准的规定

三、正确的做法:吞掉所有异常

通用模板

四、标准库中的例子

[std::vector 的析构函数](#std::vector 的析构函数)

[std::unique_ptr 的自定义删除器](#std::unique_ptr 的自定义删除器)

[五、noexcept 关键字的作用](#五、noexcept 关键字的作用)

验证默认行为

六、完整例子:安全的资源管理类

七、常见误区

误区1:认为可以在析构函数中抛异常然后让调用者处理

误区2:认为只有在栈展开时抛异常才危险

[误区3:认为可以用 noexcept(false) 绕开规则](#误区3:认为可以用 noexcept(false) 绕开规则)

八、最佳实践总结

九、这一篇的收获


一、一个崩溃的程序

先看这段代码,猜猜会发生什么:

cpp

复制代码
#include <iostream>
#include <stdexcept>
using namespace std;

class Dangerous {
public:
    ~Dangerous() {
        cout << "析构函数开始" << endl;
        throw runtime_error("析构函数异常");   // ❌ 析构函数抛异常
        cout << "析构函数结束" << endl;
    }
};

int main() {
    try {
        Dangerous d;
        throw runtime_error("main 中的异常");
    }
    catch (const exception& e) {
        cout << "捕获到: " << e.what() << endl;
    }
    return 0;
}

运行结果(典型输出):

text

复制代码
析构函数开始
terminate called after throwing an instance of 'std::runtime_error'
  what(): 析构函数异常
Aborted (core dumped)

程序直接崩溃,catch 块根本没有机会执行。

发生了什么?

  1. main 中抛出第一个异常

  2. 栈展开开始,d 被析构

  3. 析构函数中抛出第二个异常

  4. 两个异常同时存在 → C++ 调用 std::terminate()


二、为什么析构函数不能抛出异常?

核心原因:同时存在两个异常

C++ 异常处理机制不支持同时处理两个活跃异常。如果栈展开过程中析构函数又抛出异常,程序无法决定该处理哪个,唯一的选择就是终止。

cpp

复制代码
// 场景1:栈展开中抛异常 → 崩溃
try {
    throw A();      // 异常1
} catch(...) {
    // 析构局部对象时,如果某个析构函数抛异常 B
    // 程序 terminate
}

// 场景2:析构函数在非栈展开时抛异常
// 理论上可以捕获,但风险极大,仍然不推荐

标准的规定

C++ 标准明确:如果栈展开过程中析构函数抛出了异常,程序会调用 std::terminate()

这意味着:即使你写了 catch(...),也救不了。

cpp

复制代码
int main() {
    try {
        Dangerous d;   // 析构函数会抛异常
    }
    catch (...) {      // 这个 catch 无法捕获析构函数在栈展开时抛的异常
        cout << "不会执行到这里" << endl;
    }
}

三、正确的做法:吞掉所有异常

如果析构函数中的操作可能失败(比如关闭文件、释放网络连接、写日志),正确的做法是:

  1. 在析构函数内部用 try-catch 捕获所有异常

  2. 记录日志(或采取其他补救措施)

  3. 绝不重新抛出

cpp

复制代码
class FileCloser {
    FILE* file;
public:
    FileCloser(FILE* f) : file(f) {}
    
    ~FileCloser() {
        try {
            if (file && fclose(file) != 0) {
                // fclose 可能失败,但无法向调用者报告
                // 只能记录日志
                cerr << "警告: 关闭文件失败" << endl;
            }
        }
        catch (...) {
            // 捕获任何异常,确保不向外传播
            cerr << "警告: 关闭文件时发生未知异常" << endl;
        }
    }
};

通用模板

cpp

复制代码
class ResourceGuard {
    // 资源句柄
public:
    ~ResourceGuard() {
        try {
            // 可能失败的清理操作
            doCleanup();
        }
        catch (const std::exception& e) {
            // 记录异常信息,但不抛出
            std::cerr << "析构函数异常: " << e.what() << std::endl;
        }
        catch (...) {
            std::cerr << "析构函数未知异常" << std::endl;
        }
        // 函数正常结束,不向外传播异常
    }
};

四、标准库中的例子

std::vector 的析构函数

std::vector 的析构函数会调用每个元素的析构函数。如果某个元素的析构函数抛异常,标准库的选择是:立即 terminate

cpp

复制代码
struct Bad {
    ~Bad() {
        throw std::runtime_error("Bad 析构异常");
    }
};

int main() {
    std::vector<Bad> v(10);
    // v 析构时 → terminate,程序崩溃
}

这就是为什么自定义类型的析构函数必须遵守"不抛异常"规则。

std::unique_ptr 的自定义删除器

unique_ptr 允许自定义删除器,但删除器也应该不抛异常:

cpp

复制代码
auto deleter = [](FILE* f) {
    if (f) {
        try {
            fclose(f);
        }
        catch (...) {
            // 吞掉异常,不传播
        }
    }
};
unique_ptr<FILE, decltype(deleter)> file(fopen("test.txt", "r"), deleter);

五、noexcept 关键字的作用

C++11 引入了 noexcept,可以明确声明一个函数不会抛出异常。

cpp

复制代码
class Safe {
public:
    ~Safe() noexcept {
        // 如果这里抛异常,会直接 terminate
        // 所以必须确保内部不会抛出
    }
};

如果析构函数被标记为 noexcept(默认就是),抛出异常就会调用 terminate 这更加强化了规则。

验证默认行为

cpp

复制代码
class Test {
public:
    ~Test() {
        throw 42;   // 默认是 noexcept(true) 吗?
    }
};

// C++11 起,析构函数默认是 noexcept
static_assert(noexcept(std::declval<Test>().~Test()), "析构函数应该是 noexcept");

C++11 开始,析构函数隐式noexcept(除非基类或成员析构函数是 noexcept(false))。


六、完整例子:安全的资源管理类

cpp

复制代码
#include <iostream>
#include <stdexcept>
#include <cstdio>
#include <memory>
using namespace std;

class DatabaseConnection {
    int conn_id;
    bool is_open;
    
    void doDisconnect() {
        // 模拟断开连接,可能失败
        if (conn_id == 0) {
            throw runtime_error("连接句柄无效");
        }
        cout << "断开连接: " << conn_id << endl;
        is_open = false;
    }
    
public:
    DatabaseConnection(int id) : conn_id(id), is_open(true) {
        if (id == -1) {
            throw runtime_error("无效的连接ID");
        }
        cout << "建立连接: " << conn_id << endl;
    }
    
    // 主动关闭(可能抛异常)
    void close() {
        if (is_open) {
            doDisconnect();
        }
    }
    
    // 析构函数:保证不抛异常
    ~DatabaseConnection() {
        try {
            if (is_open) {
                doDisconnect();
            }
        }
        catch (const exception& e) {
            // 记录日志,但不向外传播
            cerr << "析构函数警告: 关闭连接 " << conn_id 
                 << " 时发生异常: " << e.what() << endl;
        }
        catch (...) {
            cerr << "析构函数警告: 关闭连接 " << conn_id 
                 << " 时发生未知异常" << endl;
        }
    }
    
    void query(const string& sql) {
        if (!is_open) {
            throw runtime_error("连接已关闭");
        }
        cout << "执行查询: " << sql << endl;
    }
};

int main() {
    cout << "=== 正常场景 ===" << endl;
    {
        DatabaseConnection db(1);
        db.query("SELECT * FROM users");
        db.close();   // 主动关闭,可以捕获异常
    }
    
    cout << "\n=== 异常场景:连接无效 ===" << endl;
    try {
        DatabaseConnection db(-1);   // 构造函数抛异常
    }
    catch (const exception& e) {
        cout << "捕获: " << e.what() << endl;
    }
    
    cout << "\n=== 析构函数中的异常被吞掉 ===" << endl;
    {
        DatabaseConnection db(2);
        db.query("SELECT * FROM orders");
        // 不调用 close,由析构函数关闭
        // 即使 doDisconnect 抛异常,也会被捕获并记录,程序正常运行
    }
    
    cout << "\n程序正常结束" << endl;
    return 0;
}

输出:

text

复制代码
=== 正常场景 ===
建立连接: 1
执行查询: SELECT * FROM users
断开连接: 1

=== 异常场景:连接无效 ===
捕获: 无效的连接ID

=== 析构函数中的异常被吞掉 ===
建立连接: 2
执行查询: SELECT * FROM orders
断开连接: 2

程序正常结束

七、常见误区

误区1:认为可以在析构函数中抛异常然后让调用者处理

cpp

复制代码
// ❌ 错误
~MyClass() {
    if (error) throw MyError();
}

无法保证调用者能捕获到,特别是在栈展开过程中。

误区2:认为只有在栈展开时抛异常才危险

即使不在栈展开过程中,析构函数抛异常也会导致:

cpp

复制代码
int main() {
    MyClass* p = new MyClass();
    delete p;   // 如果 ~MyClass() 抛异常,程序可能终止或资源泄漏
}

误区3:认为可以用 noexcept(false) 绕开规则

cpp

复制代码
class Bad {
public:
    ~Bad() noexcept(false) {
        throw 42;   // 理论上可以,但实践中是灾难
    }
};

即使这样声明,栈展开时抛异常仍然会 terminate


八、最佳实践总结

规则 说明
永远不要让异常离开析构函数 try-catch 捕获所有异常
记录失败信息 写入日志或 cerr,便于调试
不要重新抛出 即使想重新抛出,也做不到安全传播
主动提供 close() 方法 给调用者一个可以处理错误的途径
使用 RAII 管理资源 让智能指针、容器管理资源,避免手写析构函数
标记析构函数为 noexcept 让编译器帮你检查

九、这一篇的收获

你现在应该理解:

  • 析构函数抛异常是危险的 :栈展开过程中会导致 terminate

  • 核心原因:无法同时处理两个活跃异常

  • 正确做法 :在析构函数内用 try-catch 捕获所有异常,记录日志,但不向外传播

  • 主动提供 close() 方法:让需要处理失败的调用者有机会捕获异常

  • C++11 起析构函数默认 noexcept:强化了这条规则

💡 小作业:写一个 ScopedFile 类,在析构函数中关闭文件。故意让 fclose 失败(如传入无效指针),确保析构函数不会崩溃。同时提供一个 close() 方法,让调用者可以主动关闭并处理错误。


下一篇预告:第37篇《面向对象设计原则(一):单一职责与开闭原则》------进入设计模式与设计原则章节。单一职责:一个类只做一件事;开闭原则:对扩展开放,对修改关闭。这些原则是写出可维护 OOP 代码的基石。

相关推荐
我命由我1234513 小时前
Android 开发问题:TextView 内容超过宽度时,默认不会换行
android·开发语言·java-ee·android studio·android jetpack·android-studio·android runtime
一条泥憨鱼13 小时前
【Java 进阶】LinkedHashMap 与 TreeMap
java·开发语言·数据结构·笔记·后端·学习
ゆづき13 小时前
假如编程语言们有外号
java·c语言·c++·python·学习·c#·生活
凤山老林13 小时前
63-Java LinkedList(链表)
java·开发语言·链表
恣艺13 小时前
用Go从零实现一个高性能KV存储引擎:B+Tree索引、WAL持久化、LRU缓存的工程实践
开发语言·数据库·redis·缓存·golang
kkeeper~1 天前
0基础C语言积跬步之深入理解指针(5下)
c语言·开发语言
一直不明飞行1 天前
Java的equals(),hashCode()应该在什么时候重写
java·开发语言·jvm
REDcker1 天前
有限状态机与状态模式详解 FSM建模Java状态模式与C++表驱动模板实践
java·c++·状态模式
盲敲代码的阿豪1 天前
Python 入门基础教程(爬虫前置版)
开发语言·爬虫·python