C++ 异常的隐患与弊端

与几乎所有带来便利的机制一样,异常使用不当亦会暴露若干潜在问题。本文不图面面俱到,仅列出在采用(或决定采用)异常时应当重点权衡的几大风险。

资源清理

初学者最常碰到的难题是:异常发生时如何正确释放资源。考虑如下示例:

#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,控制流将跳转至异常处理器,打印错误后退出。注意:文件尚未关闭!上述代码应改写为:

#include <iostream>

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

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 未被释放。更棘手的是,john 属于 try 块的局部作用域,try 块退出时其生命周期结束,异常处理器已无法访问该指针,因而无法手动释放。

有两种相对简单的修复方式:

  1. john 声明在 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;
  1. 使用“离开作用域即自动清理”的类(常称“智能指针”)。标准库提供 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 拥有 john

    processPerson(john);

    // upJohn 离开作用域时自动释放 john
}
catch (const PersonException& exception)
{
    std::cerr << "Failed to process person: " << exception.what() << '\n';
}

相关内容:详见《std::unique_ptr》。

最佳做法(只要可行)是优先在栈上分配实现了 RAII 的对象(构造时获取资源,析构时自动释放)。这样,无论因何种原因离开作用域,资源都会被自动回收,无需手动干预。

异常与析构函数

与构造函数不同,析构函数绝不应抛出异常。若在栈回卷期间析构函数抛出异常,编译器将陷入两难:继续回卷还是处理新异常?最终程序会被立即终止。

因此,最稳妥的做法是完全避免在析构函数中使用异常,改而将错误写入日志。

规则
若析构函数在栈回卷期间抛出异常,程序将立即终止。

性能考量

异常并非零成本。它们会增加可执行文件体积,并因额外检查而可能降低运行速度。主要开销出现在异常真正抛出时:需回卷栈并匹配处理器,代价相对高昂。

现代部分架构支持“零成本异常”模型;若被支持,则在无异常路径上无额外开销(这正是我们最常关心的场景)。然而,异常触发时代价更大。

何时应当使用异常

异常处理最适用于以下四个条件同时成立之情形:

  1. 该错误极少发生;
  2. 错误严重,无法继续正常执行;
  3. 错误无法在发生点就地解决;
  4. 缺乏向调用者返回错误码的合理替代方案。

举例:编写函数要求用户传入磁盘文件名,函数打开文件、读取数据、关闭文件并返回结果。若用户传入不存在的文件名或空字符串,是否应抛异常?

  • 前两条显然成立:不常发生,且无法计算结果。
  • 第三条亦成立:函数不应负责重新提示用户。
  • 第四条是核心:是否存在返回错误码的合理方式?若可行(如返回空指针或状态码),则优先返回码;否则异常是合理选择。

关注公众号,回复"cpp-tutorial"

可领取价值199元的C++学习资料

公众号二维码

扫描上方二维码或搜索"cpp-tutorial"