C++程序员应了解的那些事(98)关于C++异常
本文是作者翻译过C++之父Bjarne Stroustrup的技术文章C++核心准则中有关C++中异常的文章之后的总结:
异常处理机制希望解决的问题:
为了使用错误处理系统化,健壮和不繁琐。例如下面的代码:
void f2(int i) // Clumsy and error-prone: explicit release
{
int* p = new int[12];
// ...
if (i < 17) {
delete[] p;
throw Bad{"in f()", i};
}
// ...
}
代码需要针对每种错误进行处理,更为复杂的是当程序的规模达到一定程度之后,在各个模块之间和调用的各个层级之间传递错误信息也会变成一个巨大的负担。异常就是为了解决这个问题而出现的。
推荐使用异常的情况:
一般情况下会认为异常意味着重大的例外事件和错误。例如下面的情况:
-
一个前提条件没有满足
-
构造函数无法构造对象(无法建立类的不变式)
-
越界错误(例如 v[v.size()]=7)
-
无法获取资源(例如:网络断)
通过抛出异常来向调用者表明函数无法执行指定的任务。
不应该使用异常的情况
循环的正常终止,处理的正常结束都是正常和期待的动作,不应该被视为异常。这种做法可以保证错误处理和“普通的代码”分离。C++编译器会很罕见的以异常处理为前提进行代码优化。不要使用将抛出异常作为从函数中返回结果的另一种方式使用。
使用异常时应防止资源泄露
资源泄露通常都是不可接受的。如果只是简单的去掉原有的错误处理代码并增加异常抛出和处理代码,通常会发生资源泄露。例如下面的代码:
void leak(int x) // don't: may leak
{
auto p = new int{7};
if (x < 0) throw Get_me_out_of_here{}; // may leak *p
// ...
delete p; // we may never get here
}
手动释放资源虽然不是完全做不到,但是工作量巨大且容易引发错误。
void f2(int i) // Clumsy and error-prone: explicit release
{
int* p = new int[12];
// ...
if (i < 17) {
delete[] p;
throw Bad{"in f()", i};
}
// ...
}
这样的代码过于冗长,甚至比不用异常的代码更加冗长。在更大规模的,存在更多的抛出异常的可能性的代码中,显式释放资源会更加繁复和易错。解决这个问题的方法是RAII(“资源请求即初始化”),它是防止泄露最简单,更加系统化的方式。
void f3(int i) // OK: resource management done by a handle (but see below)
{
auto p = make_unique<int[]>(12);
// ...
if (i < 17) throw Bad{"in f()", i};
// ...
}
另外一个解决方案(通常更好)是用局部变量来避免使用指针!
void no_leak_simplified(int x)
{
vector<int> v(7);
// ...
}
定义和使用自己的异常类型
使用用户定义类型的好处是避免和其他人的异常发生冲突。这种问题在代码规模变大之后会在不知不觉中出现。继承自exception的标准库类应该只用于基类或只要求“通常”处理的异常。和内置类型相似,对它们的使用也有可能和其他人的使用发生冲突。
使用常量引用形式捕捉继承体系中的异常
为了避免数据截断。大多数处理程序不会改变异常的内容,因此通常我们同时推荐使用常量形式。
正确排列catch子句
catch子句按照它们表示的次序执行,一个子句处理之后,其他子句不再执行。
void f()
{
// ...
try {
// ...
}
catch (Base& b) { /* ... */ }
catch (Derived& d) { /* ... */ }
catch (...) { /* ... */ }
catch (std::exception& e) { /* ... */ }
}
如果Deriveds是Base的派生类,捕捉派生类的处理永远不会执行。捕捉所有异常的处理会导致捕捉std::exception的处理程序永远不会执行。
重新抛出异常
重新抛出已经捕获的异常时一定要使用throw;而不是throw e; 使用后者会抛出一个e的新拷贝(静态类型std::exception的截断结果)而不是重新抛出原始异常。
关于noexcept
为了让错误处理更系统化,健壮和高效可以为函数定义noexcept。因为某段代码由不会抛出异常的操作构成,所以我们知道某函数不会抛出异常。通过将函数定义为noexcept,我向编译器和代码的读者传递了可以让它们更容易理解和维护的信息。很多标准库函数被定义为noexcept,包含所有从C标准库继承的标准库函数。
但应该注意的是,一旦定义了noexcept,C++编译器就会放弃为函数生成接受、转发异常的处理。如果实际发生了异常,结果是毁灭性的。一定要慎重定义noexcept。
析构函数,内存释放和swap操作永远不能失败!
如果析构函数、swap操作或者内存释放失败了,我们不知道如何编写可信赖的处理程序;标准库假设析构函数,内存释放函数(例如delete运算符),swap都不会抛出异常。如果它们异常,标准库的前提条件就被破坏了。
不要试图在所有函数中捕捉所有异常
在一个无法提供有意义的恢复操作的函数中捕捉错误会导致代码复杂化和冗余。让异常向外传播直到到达一个可以处理它的函数。让RAII处理调用路径上的清理动作。
try/catch结构冗长,非平凡的用法容易出错。try/catch可以看作是非系统化和低层次资源管理或错误处理的信号。
最小限度显式使用try/catch。
无法使用异常的情况
有些系统,例如硬实时系统要求保证一个动作在开始执行之前就能确定其执行时间小于某个固定值(通常很小)。这样的系统只有在存在某种可以准确预测系统从抛出异常过程中恢复的最大时间的工具时才可以使用异常。如果没有适当的时间评价工具,异常处理机制很难满足这个要求。这样的系统(例如飞行控制系统)通常也会禁止使用动态(堆)内存。
不要使用抛异常声明
抛异常声明本来的目的是明确表明某个函数可能抛出的异常。
int use(int arg) throw(X, Y)
{
// ...
auto x = f(arg);
// ...
}
但是异常声明让错误处理更脆弱,并强制产生运行时成本,已经从C++标准中被移除了。在不会抛出任何异常时,使用noexcept或者和它等价的throw()是才更加正确的做法。
关于异常代价和性能
很多关于异常的大量恐惧都是被误导的。当在没有被指针或复杂的控制结构搞乱的代码环境中使用异常时,异常处理几乎总是可以接受的(无论是时间还是空间维度),几乎总是可以带来更好的代码。
在谴责异常或抱怨异常的成本过高之前,考虑使用错误代码时的成本和复杂度。如果你担心性能,进行测量(而不是无根据的怀疑,译者注)。
参考资料:
C++核心准则英文原文(C++之父Bjarne Stroustrup的技术文章):
https://github.com/isocpp/CppCoreGuidelines/blob/master/CppCoreGuidelines.md
推荐阅读
-
程序员应了解的那些事(16)C语言中利用setjmp和longjmp做异常处理 / 不要在C++中使用setjmp和longjmp
-
C++程序员应了解的那些事(64)~ 指向 Data Member 的指针 <成员指针>
-
C++程序员应了解的那些事(68)非类型模板参数
-
C++程序员应了解的那些事(47)函数之 传入传出参数 / 默认参数
-
C++程序员应了解的那些事(36)Effective STL第6条:当心C++编译器中最烦人的分析机制 --- 调用构造函数被误认为是函数声明的问题
-
C++程序员应了解的那些事(63)STL内建函数对象、仿函数
-
C++程序员应了解的那些事(62)~ list::splice()函数详解
-
C++程序员应了解的那些事(94)之STL容器内存释放问题
-
C++程序员应了解的那些事(99)之 C++中的ODR法则
-
C++程序员应了解的那些事(18)C++11 通过key访问map容器:下标访问、at()、find、lower_bound&upper_bound、equal_range