“析”有此理:解构 C++ 中的异常谜团

183 阅读5分钟

以下内容为本人的学习笔记,如需要转载,请声明原文链接 微信公众号「ENG八戒」mp.weixin.qq.com/s/UEXQEXED3…

_d17df2de-2485-48e0-be19-4c3e07ae6553.jpeg

大家好,欢迎来到《掌握 C++ 异常艺术:构建健壮程序的秘诀与实战策略》系列文章的第七篇!

如果你已经熬过了前几篇文章,恭喜你,你的 C++ 技能树已经点亮了相当一部份,想想就开心了。

但别高兴得太早,因为今天我们要探讨的是一个足以让你怀疑人生的主题——析构函数中的异常。

你知道吗?C++ 中的异常处理就像是一场危险的高空马戏表演,稍有不慎就会摔得粉身碎骨。

而析构函数,就像是在表演结束时,老板突然要求你加演的高难度动作。这时如果你处理不当,别说收场了,连命都保不住!

所以今天,让我们一起探讨这个险象环生的场景,并将深入挖掘那些你可能未曾思索过的陷阱,和处理秘诀。

系好安全带,接下来这趟旅程绝对不会让你失望,当然,轻松也是不可能的。

析构发生异常

一般编码实践中,都不鼓励在析构函数中抛出异常,因为会有可能形成异常的嵌套抛出。

比如:

class A {
public:
    ~A() {
        throw std::runtime_error("A throw exception");
    }
};

class B {
public:
    B() {
        A a;
        throw std::runtime_error("B throw exception");
    }
};

int main() {
    try {
        B b;
    } catch (const std::exception& e) {
        std::cerr << "Caught an exception: "
            << e.what() << std::endl;
    }
}

这段代码里,在构造 B 类型对象时抛出异常信号,然后在调用栈内已完成初始化的局部对象 a 的析构函数释放对象 a 时再次抛出异常。

在前一个异常信号未被处理完成之前,再次叠加抛出其它异常信号,这会导致栈展开(stack unwinding)过程中异常链的断裂,进而引起程序行为不可预测。

不可预测指的是,此时系统会变得左右为难,如果系统有两个选择可以取,那么,其一是按照最早的异常处理路径执行,其二是按照最新的异常处理路径执行,这是一种无法确定的状态,最终导致系统执行 terminate()。

我们可以试着运行一下上面的代码:

terminate called after throwing an instance of 'std::runtime_error'
  what():  A throw exception
Aborted (core dumped)

果然,程序在抛出异常信息 “A throw exception” 后被终止了。

虽然在析构函数中不是绝对不能抛出异常,但有的编译器实现会为了简化处理逻辑,一旦检测到析构函数抛出异常就直接终止程序执行。

所以建议尽可能不在析构函数中抛出异常,依据经验,这是一种「魔咒」,除非你有魔法,否则不要轻易模仿。

那么为什么会出现在析构函数抛出异常的情况?如果真发生了,又该怎么正确处理?

答案是,天有不测之风云,各种库或者你的所忽都会是原因。另外,秉持内部矛盾不扩散的原则,截住它,不要让异常信号从析构函数跑了。

中国有句古话叫,家丑不外扬,这是一种智慧。

下面以操作文件读句柄为例,创建文件句柄类 FileHandle,实例化该类可以获取文件流指针,通过该类可以读取文件。

class FileHandle {
public:
    FileHandle(const std::string& filename) :
        file_(nullptr),
        filename_(filename) {
        file_ = new std::fstream(filename_,
                                std::ios::in);
        if (!file_->is_open()) {
            std::cerr << "Error opening file: "
                << filename_ << std::endl;
        }
    }

    ~FileHandle() {
        if (file_) {
            try {
                if (!file_->is_open()) {
                    throw std::runtime_error("file has not been open");
                }
                file_->close();
            } catch (const std::runtime_error& e) {
                std::cerr << "Error closing file: "
                    << filename_ << ". Reason: "
                    << e.what() << std::endl;
            } 
            delete file_;
            file_ = nullptr;
        }
    }

    void DoSomeThing() {
        if (!file_->is_open()) {
            throw std::runtime_error("when do some thing with file...");
        }
    }

private:
    std::fstream* file_;
    std::string filename_;
};

int main() {
    try {
        FileHandle file("test.txt");
        file.DoSomeThing();
    } catch (const std::exception& e) {
        std::cerr << "Caught an exception: "
            << e.what() << std::endl;
    }

    return 0;
}

实例化类 FileHandle 时,传入一个不存在的文件名作为初始化参数。构造对象时,打开文件失败,返回的文件流状态无效。在这里没有抛出异常,而是记录错误信息,方便后边操作时引发异常。

实例化类 FileHandle 后,由于文件流无效,如果调用对象的方法操作文件,则会失败并抛出异常。在匹配对应异常处理代码之前,会把当前执行栈的内存空间清理,而 FileHandle 对象就分配在当前栈内,所以调用 FileHandle 对象析构函数释放资源。

调用类 FileHandle 的析构函数时,需要关闭文件,如果此时判断文件流无效,在某些业务场景下可以选择抛出异常。为了演示析构触发异常的情形,这里直接抛出异常。

为避免触发 terminate() 的执行,在析构函数中抛出的异常应该在析构函数退出之前就被处理,而且不能再次抛出。

程序的输出:

Error opening file: test.txt
Error closing file: test.txt. Reason: 
file has not been open
Caught an exception: when do some thing with file...

在析构函数中选择抛出异常的做法需要小心谨慎,最好避免,毕竟考虑的流程越复杂就越是容易出问题,测试的同学也少挨罪。

要是真的在析构函数中又触发了异常抛出,避免异常被抛出到上一层调用栈也是亡羊补牢。