异常:throw、try、catch
理解 C++ 异常机制──栈展开、标准异常层次结构、自定义异常,以及何时优先使用异常而非错误码。
为什么需要异常
在异常出现之前,C 语言通过返回特殊值(如 -1 或 NULL)来表示错误。这存在一个严重缺陷:调用者可以默默地忽略错误。异常通过强制程序要么处理错误要么终止来解决这个问题。它们还将错误检测位置(在库代码深处)与错误处理位置(在应用代码中)分离开来,两者之间可以相隔许多调用帧。
C++ 异常在大多数现代编译器上遵循「成功路径零开销」模型(使用基于表的展开)。当没有异常被抛出时,没有运行时开销──没有隐藏的 if 检查,没有分支预测失误。只有在异常实际被抛出时才会产生开销,这使得异常非常适合异常(罕见)的错误情况。
throw、try 和 catch
核心机制很简单:throw 一个对象来表示错误,将有风险的代码包裹在 try 块中,然后 catch 匹配的异常类型。
#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 为根的层次结构。了解它有助于你抛出正确的类型并在正确的层级捕获。
// 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() 以提供有意义的消息。
#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. 在栈展开过程中,每个栈帧中的局部变量会发生什么?