C++异常处理详解
概述
这篇博客将深入探讨 C++异常处理的工作原理, 最佳实践以及如何编写异常安全的代码, 配有代码示例和详细说明.
1. 异常的挑战
-
性能开销: 异常在失败情况下会带来显著的运行时成本.
图片来自: Introduction to proposed std::expected - Niall Douglas - Meeting C++ 2017
-
推理复杂性: 异常的使用增加了代码分析和调试的难度.
-
依赖动态内存: 异常处理往往需要堆内存分配.
比如在抛出异常时, 分配std::string
对象存储错误信息. -
二进制大小增加: 异常机制会增加编译后的二进制文件体积.
2. 异常的工作原理
C++异常机制通过以下几个步骤处理异常:
2.1 关键关键词
-
throw
: 用于抛出异常.- 语法:
throw expression;
- 抛出后, 程序会立即停止当前代码的执行, 进入异常处理流程.
- 语法:
-
try
: 定义一个监控异常的代码块.- 语法:
try { // 可能抛出异常的代码 } catch (const std::exception& e) { // 异常处理代码 }
- 语法:
-
catch
: 用于捕获和处理异常. 匹配到类型兼容的异常后执行.try { exampleFunction(1); // Try changing this to 2 or another value } catch (const std::runtime_error& re) { std::cerr << "Caught a runtime error: " << re.what() << std::endl; } catch (const std::logic_error& le) { std::cerr << "Caught a logic error: " << le.what() << std::endl; } catch (...) { std::cerr << "Caught an unknown exception." << std::endl; }
2.2 堆栈展开(Stack Unwinding)
当异常抛出时:
- 逆序销毁对象:
- 堆栈上的所有本地对象会按照构造的逆序依次调用其析构函数.
- 异常传播:
- 如果当前函数中没有匹配的
catch
块, 异常会继续向调用链的上一层传播, 直到找到匹配的处理器. - 如果最终未捕获异常, 则会调用
std::terminate()
.
- 如果当前函数中没有匹配的
示例: 堆栈展开
输出结果:
Constructing A
Constructing B
Constructing C
Destructing C
Destructing B
Caught exception: Error in C
Destructing A
2.3 未处理的异常
未捕获的异常会导致以下行为:
- 调用
std::terminate()
终止程序. - 堆栈展开不会完成, 导致可能的资源泄漏.
- 没有调用任何尚未执行的析构函数.
3. 异常处理的最佳实践
3.1 何时使用异常(何时不使用)
- 针对预计很少发生的错误
- 针对无法在本地处理的"异常情况"(I/O 错误)
- 未找到文件
- 无法在映射中找到键针对运算符和构造函数(即很少有其他机制可以工作的情况)
何时不用异常
-
对于预期会频繁发生的错误. 因为异常的代价很大, 频繁使用会使程序运行的很慢.
-
对于预期会失败的函数. 比如下面这个将
string
转为int
的函数, 很明显并非所有的字符串都可用转为int
, 此时用诸如std::optional
和std::expected
更合适(不熟悉的读者可以参考我的博客: 解读 C++23 std::expected 函数式写法, 现代 C++ 必备知识: 解锁std::optional
,std::variant
和std::any
).std::optional<int> str2num(std::string const&s) std::expected<int> str2num(std::string const&s)
-
对响应时间有严格要求
-
对于不应该发生的事情, 不要用异常来做兜底. 比如一些编程错误:
- 解引用空指针
- 超出范围的访问
- 释放后使用
3.2 如何使用异常
3.2.1 建立在 std::exception
层次结构上
参考: Back to Basics: Exceptions - Klaus Iglberger - CppCon 2020
3.2.2 通过右值抛出
右值抛出是现代 C++ 推荐的异常处理方式, 因为它在语义上更加清晰, 并且在性能上具有优势.
-
推荐右值抛出原因:
- 抛出右值会通过值传递, 避免异常对象在调用链中意外被修改.
- 避免了捕获指针时的内存管理问题, 例如需要手动释放内存.
-
性能上的优势:
- 右值对象在现代编译器中可以通过优化减少拷贝.
- 使用右值抛出的异常对象生命周期由系统自动管理, 无需开发者额外处理.
-
示例:
#include <stdexcept>
void f(int a, int b) {
if (b == 0) {
throw std::runtime_error("Division by zero"); // 推荐的右值抛出
}
}
- 对比:
以下是不推荐的指针抛出方式, 因为它引入了额外的内存管理复杂性:
void g(int a, int b) {
if (b == 0) {
std::runtime_error* error = new std::runtime_error("Error via pointer");
throw error; // 不推荐
}
}
void h() {
try {
g(10, 0);
} catch (std::runtime_error* e) {
std::cerr << "Caught exception: " << e->what() << std::endl;
delete e; // 确保释放内存
}
}
通过右值抛出可以避免如上示例中手动释放内存的风险, 同时提高代码的可维护性.
3.2.3 通过引用捕获
不要按值捕获, 因为这样会:
- 创建不必要的副本
- 可能会切分异常
#include <exception>
#include <iostream>
void cf() {
try {
throw std::runtime_error("An error occurred");
} catch (std::exception ex) { // 😱 按值捕获会创建副本,
// 并且可能会出现类切分(class slicing)
std::cerr << "Caught exception by value: " << ex.what() << std::endl;
}
}
void cg() {
try {
throw std::runtime_error("An error occurred");
} catch (const std::exception& ex) { // 推荐的引用捕获方式
std::cerr << "Caught exception by reference: " << ex.what() << std::endl;
}
}
int main() {
cf();
cg();
return 0;
}
输出:
Caught exception by value: std::exception
Caught exception by reference: An error occurred
通过引用捕获(const
引用)不仅能避免创建不必要的副本, 还能确保异常对象的完整性, 防止切分异常.
3.3 异常安全保证
- 基本异常安全保证
- 保存不变量
- 不泄漏任何资源
- 强异常安全保证
- 不变量被保留
- 不泄漏任何资源
- 无状态改变(提交或回滚)
- 并非总是可能(例如套接字, 流等)
- 无抛出保证
- 操作不会失败
- 在代码中用
noexcept
表示
3.4 如何编写异常安全代码
在编写异常安全代码时, 确保某些关键函数绝不能失败至关重要. 这些函数包括析构函数, 移动操作和交换操作. 以下是具体的说明和示例:
不允许失败的函数
-
析构函数:
- 析构函数在堆栈展开期间调用. 如果在此过程中抛出异常, 程序将调用
std::terminate()
. - 自 C++11 起, 析构函数隐式标记为
noexcept
.
示例:
class Example { public: // 确保析构函数不抛出异常 ~Example() noexcept { // 清理资源 } };
- 析构函数在堆栈展开期间调用. 如果在此过程中抛出异常, 程序将调用
-
移动操作(
std::move
):- 移动构造函数和移动赋值操作应通过
noexcept
明确声明为不会抛出异常.
示例:
class Movable { public: Movable(Movable&&) noexcept = default; // 移动构造 Movable& operator=(Movable&&) noexcept = default; // 移动赋值 };
- 移动构造函数和移动赋值操作应通过
-
交换操作(
std::swap
):- 自定义的交换函数也应保证不会抛出异常.
示例:
void swap(Movable& a, Movable& b) noexcept { using std::swap; swap(a.data, b.data); }
noexcept 的好处
noexcept
使代码中可见的永不抛出的承诺noexcept
可以使代码(稍微)更快- 如果异常导致标有
noexcept
的函数, 则会调用terminate()
- 编译器不会检查此承诺
noexcept
承诺无法收回- 只有少数函数应标有
noexcept
- 析构函数隐式标有
noexcept
C++ Core Guideline 的指导意见
- RAII 是 C++ 编程语言中最重要的习惯用法. 使用它!
- 所有函数都应至少提供基本的异常安全保证, 如果可能且合理, 则提供强保证.
- 考虑不抛出保证, 但只有当您能够保证它甚至可能针对未来可能发生的变化时才提供它
参考资料
- Back to Basics: Exceptions - Klaus Iglberger - CppCon 2020
源码链接
源码链接