跳至内容

异常:throw、try、catch

理解 C++ 异常机制──栈展开、标准异常层次结构、自定义异常,以及何时优先使用异常而非错误码。

为什么需要异常

在异常出现之前,C 语言通过返回特殊值(如 -1 或 NULL)来表示错误。这存在一个严重缺陷:调用者可以默默地忽略错误。异常通过强制程序要么处理错误要么终止来解决这个问题。它们还将错误检测位置(在库代码深处)与错误处理位置(在应用代码中)分离开来,两者之间可以相隔许多调用帧。

C++ 异常在大多数现代编译器上遵循「成功路径零开销」模型(使用基于表的展开)。当没有异常被抛出时,没有运行时开销──没有隐藏的 if 检查,没有分支预测失误。只有在异常实际被抛出时才会产生开销,这使得异常非常适合异常(罕见)的错误情况。

throw、try 和 catch

核心机制很简单:throw 一个对象来表示错误,将有风险的代码包裹在 try 块中,然后 catch 匹配的异常类型。

divide.cpp
#include <iostream>
#include <stdexcept>
#include <string>

double divide(double numerator, double denominator) {
    if (denominator == 0.0) {
        throw std::invalid_argument("denominator cannot be zero");
    }
    return numerator / denominator;
}

int main() {
    try {
        double result = divide(10.0, 0.0);
        std::cout << "Result: " << result << '\n';
    } catch (const std::invalid_argument& e) {
        std::cerr << "Invalid argument: " << e.what() << '\n';
    } catch (const std::exception& e) {
        std::cerr << "Error: " << e.what() << '\n';
    } catch (...) {
        std::cerr << "Unknown error occurred\n";
    }
}

栈展开详解

当异常被抛出时,运行时执行栈展开:它沿着调用栈向上回溯,通过调用析构函数来销毁每一帧中的所有局部变量。这就是 RAII(资源获取即初始化)如此关键的原因──如果你将资源持有在 RAII 包装器中,它们会在展开过程中被自动释放。

展开过程如下:

1. throw 表达式创建异常对象(通常在特殊的内存区域,而不是栈上)。
2. 运行时在当前函数中搜索匹配的 catch 处理程序。
3. 如果没有找到,它退出当前函数,按构造的逆序销毁所有局部对象。
4. 它在调用栈上重复步骤 2-3。
5. 如果在任何帧中都没有找到处理程序,则调用 std::terminate()。

关键规则:按值抛出,按 const 引用捕获。 按值抛出避免悬空指针。按 const 引用捕获避免切片(即派生异常被复制为基类对象,丢失派生数据)和不必要的复制。

标准异常层次结构

标准库提供了以 std::exception 为根的层次结构。了解它有助于你抛出正确的类型并在正确的层级捕获。

hierarchy.cpp
// Standard exception hierarchy (simplified)
//
// std::exception
// ├── std::logic_error          (programmer mistakes - precondition violations)
// │   ├── std::invalid_argument
// │   ├── std::domain_error
// │   ├── std::length_error
// │   └── std::out_of_range
// ├── std::runtime_error        (errors detectable only at runtime)
// │   ├── std::range_error
// │   ├── std::overflow_error
// │   ├── std::underflow_error
// │   └── std::system_error     (OS-level errors with error_code)
// └── std::bad_alloc            (memory allocation failure)
//     std::bad_cast              (failed dynamic_cast)
//     std::bad_typeid            (typeid on null pointer)

#include <stdexcept>
#include <vector>
#include <iostream>

int main() {
    std::vector<int> v = {1, 2, 3};
    try {
        // .at() throws std::out_of_range, unlike operator[] which is UB
        int val = v.at(10);
    } catch (const std::out_of_range& e) {
        std::cerr << "Out of range: " << e.what() << '\n';
    }
}

自定义异常类

从 std::exception 或其子类派生以创建领域特定的异常。始终重写 what() 以提供有意义的消息。

custom_exception.cpp
#include <stdexcept>
#include <string>
#include <iostream>

class FileParseError : public std::runtime_error {
public:
    FileParseError(const std::string& filename, int line, const std::string& msg)
        : std::runtime_error(
              "Parse error in '" + filename + "' at line " +
              std::to_string(line) + ": " + msg),
          filename_(filename),
          line_(line) {}

    const std::string& filename() const noexcept { return filename_; }
    int line() const noexcept { return line_; }

private:
    std::string filename_;
    int line_;
};

void parse_config(const std::string& filename) {
    // Simulate a parse failure at line 42
    throw FileParseError(filename, 42, "unexpected token '@@'");
}

int main() {
    try {
        parse_config("app.conf");
    } catch (const FileParseError& e) {
        std::cerr << e.what() << '\n';
        std::cerr << "  File: " << e.filename()
                  << ", Line: " << e.line() << '\n';
    }
}

noexcept 和异常规范

noexcept 说明符告诉编译器(和人类读者)一个函数永远不会抛出异常。如果一个 noexcept 函数确实抛出了异常,std::terminate() 会被立即调用──不会进行栈展开。

为什么 noexcept 重要?

- 性能:编译器可以省略 noexcept 函数的展开表,生成更紧凑的代码。
- STL 优化:像 std::vector 这样的容器在重新分配时只有在移动构造函数是 noexcept 时才会移动元素。否则,它们会回退到复制以维持强异常保证。这可能导致巨大的性能差异。
- 文档化:它是与调用者的契约,表明该函数不会失败。

在以下情况使用 noexcept:移动构造函数、移动赋值运算符、swap 函数、析构函数(隐式 noexcept)、简单的 getter 以及比较运算符。

旧的 throw() 规范(和 throw(type-list))在 C++11 中被弃用,在 C++20 中被移除。始终使用 noexcept 代替。

常见陷阱

永远不要在析构函数中抛出异常。在另一个异常的栈展开过程中,抛出第二个异常会调用 std::terminate()。如果析构函数必须执行可能失败的操作,请在析构函数内部捕获异常并在那里处理(例如,记录日志并抑制)。

  • 按值捕获会切片派生异常──始终按 const& 捕获
  • 抛出指针(例如 throw new std::runtime_error(...))会导致内存泄漏──按值抛出
  • 先捕获 ... 会隐藏其下方的所有特定处理程序──把它放在最后
  • 栈展开期间在析构函数中抛出异常会导致 std::terminate()
  • 将异常用于控制流(非错误路径)会破坏零开销模型并损害性能
关键要点
  • 按值抛出,按 const 引用捕获,以避免切片和泄漏
  • 栈展开按构造的逆序销毁局部对象──RAII 确保资源清理
  • C++ 异常在成功路径上是零开销的(基于表的展开),只在抛出时才有代价
  • noexcept 启用编译器优化,对 STL 使用的移动操作至关重要
  • 从 std::runtime_error 或 std::logic_error 派生自定义异常并重写 what()

测验 — 检验你的知识

(15 经验值)

1. 为什么应该用 `const&` 而不是按值来捕获异常?

2. 如果一个标记为 `noexcept` 的函数抛出了异常,会发生什么?

3. 在栈展开过程中,每个栈帧中的局部变量会发生什么?