More Effective C++ 条款12:理解"抛出一个异常"与"传递一个参数"或"调用一个虚函数"间的差异
核心思想
虽然从语法上看,抛出异常与传递函数参数相似,但它们在实现机制、执行效率和行为特性上存在本质差异。理解这些差异对于编写高效、正确的异常处理代码至关重要。
1. 关键差异分析
1.1 控制流差异
- 函数调用:控制权最终会返回到调用处
- 异常抛出:控制权永远不会回到抛出异常的地方
1.2 对象复制行为对比
特性 | 传递参数 | 抛出异常 |
---|---|---|
复制次数 | 0-1次(可能不复制) | 1-2次(总是至少复制一次) |
复制时机 | 可能延迟或优化掉 | 总是立即复制 |
复制类型 | 动态类型复制 | 静态类型复制 |
避免复制 | 使用引用/指针 | 使用引用捕获(但仍有一次复制) |
cpp
// 示例:异常对象总是被复制
class ExceptionObject {
public:
ExceptionObject() { std::cout << "Constructor\n"; }
ExceptionObject(const ExceptionObject&) { std::cout << "Copy constructor\n"; }
};
void throwException() {
ExceptionObject e; // 输出: Constructor
throw e; // 输出: Copy constructor(创建临时对象)
}
void catchException() {
try {
throwException();
} catch (ExceptionObject e) { // 输出: Copy constructor(复制到catch参数)
// 处理异常
}
}
// 总输出: Constructor → Copy constructor → Copy constructor
1.3 类型转换限制
- 参数传递:允许广泛的隐式类型转换
- 异常抛出:只允许有限的类型转换(继承类到基类、非常量到常量、数组/函数到指针)
cpp
// 有限的类型转换示例
class Base { /*...*/ };
class Derived : public Base { /*...*/ };
void throwDerived() {
Derived d;
throw d; // 允许:Derived到Base的转换
}
void catchBase() {
try {
throwDerived();
} catch (const Base& b) { // 正确捕获
// 处理异常
}
}
1.4 捕获顺序与虚函数调用差异
- 异常捕获:按源代码顺序匹配第一个合适的catch子句
- 虚函数调用:选择与对象动态类型最匹配的函数
cpp
// 异常捕获顺序的重要性
try {
// 可能抛出多种异常的代码
} catch (const std::exception& e) {
// 捕获所有标准异常
} catch (const MySpecialException& e) {
// 永远不会执行:因为std::exception更通用且在前
} catch (...) {
// 捕获所有其他异常
}
// 正确的顺序应该是从具体到通用:
try {
// 可能抛出多种异常的代码
} catch (const MySpecialException& e) {
// 处理特定异常
} catch (const std::exception& e) {
// 处理标准异常
} catch (...) {
// 处理未知异常
}
2. 效率与性能影响
2.1 异常处理的开销来源
- 对象复制:异常对象至少被复制一次
- 栈展开:需要遍历调用栈查找匹配的catch块
- 运行时支持:需要维护类型信息和调用栈元数据
2.2 优化建议
-
使用引用捕获异常:避免额外的对象复制
cpp// ✅ 推荐:使用引用捕获异常 try { // 可能抛出异常的代码 } catch (const MyException& e) { // 只复制一次(抛出时) // 处理异常 } // ❌ 避免:使用值捕获异常 try { // 可能抛出异常的代码 } catch (MyException e) { // 复制两次:抛出时 + 捕获时 // 处理异常 }
-
优先使用noexcept函数:明确标识不会抛出异常的函数
-
避免不必要的异常抛出:在性能关键路径中谨慎使用异常
3. 重新抛出异常的正确方式
cpp
// ✅ 正确:重新抛出当前异常(不创建新副本)
try {
// 可能抛出异常的代码
} catch (const MyException& e) {
// 部分处理...
throw; // 重新抛出原始异常对象
}
// ❌ 错误:创建新的异常副本
try {
// 可能抛出异常的代码
} catch (const MyException& e) {
// 部分处理...
throw e; // 创建新的副本,丢失原始异常信息
}
4. 特殊情况下临时对象的处理
cpp
// 临时对象在异常处理中的行为
void functionThatThrows() {
throw MyException(); // 创建临时对象并复制
}
void testTemporary() {
try {
functionThatThrows();
} catch (const MyException& e) { // 引用绑定到异常对象
// 即使异常对象是临时对象,也允许非const引用捕获
// 这是异常处理与函数参数传递的另一个差异
}
}
5. 实践建议总结
- 总是通过引用捕获异常:避免不必要的对象复制
- 合理安排catch子句顺序:从最具体到最通用
- 使用
throw;
重新抛出异常:保持原始异常信息 - 了解异常处理的开销:在性能敏感代码中谨慎使用
- 利用RAII管理资源:确保异常安全
总结
条款12共同强调了C++异常处理的关键原则:安全性第一,效率第二。条款12则帮助开发者理解异常传递的内部机制以避免常见陷阱。掌握这两个条款对于编写健壮、高效的C++异常安全代码至关重要。
综合建议:
- 在析构函数中使用
noexcept
并彻底处理所有异常- 通过引用捕获异常以减少复制开销
- 使用RAII模式管理资源,确保异常安全
- 了解异常处理的开销,在性能关键代码中谨慎使用异常
- 安排catch子句顺序从具体到通用