27-8 异常的风险和弊端

几乎所有事物都有其益处,例外情况也不例外,它也存在一些潜在的弊端。本文并非旨在面面俱到,而只是指出在使用例外情况(或决定是否使用例外情况)时应考虑的一些主要问题。

清理资源

新手程序员在使用异常处理时遇到的最大问题之一,就是异常发生后如何清理资源。请看以下示例:

#include <iostream>

try
{
    openFile(filename);
    writeFile(filename, data);
    closeFile(filename);
}
catch (const FileException& exception)
{
    std::cerr << "Failed to write to file: " << exception.what() << '\n';
}

如果 WriteFile() 函数失败并抛出 FileException 异常会发生什么?此时,我们已经打开了文件,控制流会跳转到 FileException 处理程序,该程序会打印错误信息并退出。请注意,文件从未被关闭!此示例应重写如下:

#include <iostream>

try
{
    openFile(filename);
    writeFile(filename, data);
}
catch (const FileException& exception)
{
    std::cerr << "Failed to write to file: " << exception.what() << '\n';
}

// Make sure file is closed
closeFile(filename);

当处理动态分配的内存时,这类错误通常会以另一种形式出现:

#include <iostream>

try
{
    auto* john { new Person{ "John", 18, PERSON_MALE } };
    processPerson(john);
    delete john;
}
catch (const PersonException& exception)
{
    std::cerr << "Failed to process person: " << exception.what() << '\n';
}

如果 processPerson() 抛出异常,控制流会跳转到 catch 处理程序。因此,john 永远不会被释放!这个例子比之前的例子稍微复杂一些——因为 johntry 代码块的局部变量,所以当 try 代码块退出时,它会超出作用域。这意味着异常处理程序根本无法访问 john(它已经被销毁了),因此它无法释放这部分内存。

不过,有两种相对简单的方法可以解决这个问题。首先,将 john 声明在 try 代码块之外,这样当 try 代码块退出时,它就不会超出作用域:

#include <iostream>

Person* john{ nullptr };

try
{
    john = new Person("John", 18, PERSON_MALE);
    processPerson(john);
}
catch (const PersonException& exception)
{
    std::cerr << "Failed to process person: " << exception.what() << '\n';
}

delete john;

由于 john 是在 try 代码块之外声明的,因此它既可以在 try 代码块内部访问,也可以在 catch 处理程序中访问。这意味着 catch 处理程序可以正确地执行清理操作。

第二种方法是使用一个类的局部变量,该变量能够在超出作用域时自动释放自身资源(通常称为“智能指针”)。标准库提供了一个名为 std::unique_ptr 的类,可用于此目的。std ::unique_ptr是一个模板类,它保存一个指针,并在超出作用域时释放该指针。

#include <iostream>
#include <memory> // for std::unique_ptr

try
{
    auto* john { new Person("John", 18, PERSON_MALE) };
    std::unique_ptr<Person> upJohn { john }; // upJohn now owns john

    ProcessPerson(john);

    // when upJohn goes out of scope, it will delete john
}
catch (const PersonException& exception)
{
    std::cerr << "Failed to process person: " << exception.what() << '\n';
}

相关内容:
std::unique_ptr我们将在第 22.5 课中介绍-- std::unique_ptr。

最佳方案(只要有可能)是优先使用实现了 RAII 协议(构造时自动分配资源,销毁时自动释放资源)的栈分配对象。这样,当管理资源的对象由于任何原因超出作用域时,它都会自动释放资源,我们就无需担心此类问题了!

异常和析构函数

与构造函数不同,构造函数中抛出异常可以有效地表明对象创建不成功,但在析构函数中绝对不应该抛出异常。

当析构函数在栈展开过程中抛出异常时,就会出现问题。如果发生这种情况,编译器将无法确定是继续栈展开还是处理新抛出的异常。最终结果是程序会立即终止。

因此,最好的做法就是完全避免在析构函数中使用异常。而是将消息写入日志文件。

规则:
如果在堆栈展开期间析构函数抛出异常,程序将停止运行。

性能问题

异常处理确实会带来一些性能损失。它们会增加可执行文件的大小,并且由于需要执行额外的检查,也可能导致运行速度变慢。然而,异常处理的主要性能损失发生在异常实际抛出时。在这种情况下,必须展开堆栈并找到合适的异常处理程序,这是一个相对耗时的操作。

需要注意的是,一些现代计算机架构支持一种称为零成本异常的异常模型。如果支持零成本异常,则在非错误情况下(也就是我们最关心性能的情况)不会产生额外的运行时开销。但是,一旦发生异常,则会产生更大的性能损失。

那么我应该在什么情况下使用异常呢?

当以下所有条件都成立时,异常处理最为有效:

  • 正在处理的错误很可能不会经常发生。
  • 错误非常严重,否则程序无法继续执行。
  • 错误发生的地方无法处理该错误。
  • 目前还没有更好的方法将错误代码返回给调用者。

举个例子,假设你编写了一个函数,该函数需要用户传入磁盘上的文件名。你的函数会打开这个文件,读取一些数据,关闭文件,并将结果返回给调用者。现在,假设用户传入的文件名不存在,或者是一个空字符串。这种情况是否应该抛出异常呢?

在这种情况下,上述前两点显然都满足——这种情况并不常见,而且你的函数在没有数据的情况下无法计算结果。该函数也无法处理错误——重新提示用户输入新文件名并非函数的职责,而且根据程序的设计,这样做可能也不合适。第四点才是关键——是否有更好的方法将错误代码返回给调用者?这取决于你的程序细节。如果有(例如,你可以返回空指针或状态码来指示失败),这可能是更好的选择。如果没有,那么抛出异常也是合理的。

posted @ 2025-12-05 09:15  游翔  阅读(8)  评论(0)    收藏  举报