More Effective C++之异常Exceptions
- 条款12:了解“抛出一个exception”与“传递一个参数 ”或“调用一个虚函数”之间的差异
- 条款13:以by reference方式捕捉exceptions
- 条款14:明智运用exception specifications
- 条款15:了解异常处理(exception handling)的成本
条款12:了解“抛出一个exception”与“传递一个参数 ”或“调用一个虚函数”之间的差异
函数参数的声明语法,和catch子句的声明语法,简直如出一辙:
class Widget { ... }; //某个class
void f1(Widget w); // 参数Widget
void f2(Widget& w); // 参数Widget&
void f3(const Widget& w); // 参数const Widget&
void f4(Widget* pw); // 参数Widget *
void f5(const Widget* pw); // 参数const Widget *catch (Widget w); ... // 捕捉类型Widget
catch (Widget& w); ... // 捕捉类型Widget&
catch (const Widget& w); ... // 捕捉类型const Widget&
catch (Widget* pw); ... // 捕捉类型Widget *
catch (const Widget* pw); ... // 捕捉类型const Widget *
我们可能因而假设“从抛出端传出一个exception到catch子句”,基本上和“从函数调用端传递一个自变量到函数参数”是一样的。其实有相同之处,但是也有重大的不同点。
让我们从相同点开始谈起。函数参数和exception的传递方式有3种:by value,by reference,by pointer。然而视所传递的参数或exceptions,发生的事情可能完全不同。原因是当调用一个函数,控制权最终会回到调用端(除非函数失败以至于无法返回),但是当抛出一个exception,控制权不会再回到抛出端。试看以下函数,不但传递一个Widget作为参数,也抛出一个Widget exception:
// 此函数从一个stream中读取一个Widget。
istream operator >> (istream& s, Widget &w);
void passAndThrowWidget() {Widget localWidget;cin >> localWidget; // 将localWidget以Widget&传给operator>>throw localWidget; // 将localWidget抛出成为一个exception
}
当localWidget被交到operator>>函数手中,并没有发生复制(copying)行为。而是operator>>内的reference w被绑定与localWidget身上。此时,对w所做的任何事情,其实是施加于localWidget身上的。这和localWidget当做一个exception的情况不同。不论被捕捉的exception是以by value或by reference方式传递(此处不可能以by pointer方式传递——那将造成类型不吻合),都会发生localWidget的复制行为,而交到catch子句手上的正是那个副本。一定是这样,因为此情况下一旦控制权离开passAndThrowWidget,localWidget便离开了其生存空间(scope),于是localWidget destructor会被调用。如果此时是以localWidget本身传递给一个catch子句的,此子句收到的将是一个被析构的Widget,一个已经仙逝的Widget。这具尸体曾经负载一个Widget,但现在已经不是了,没有作用了。这便是为什么C++特别要声明,一个对象被抛出作为exception时,总是会发生复制(copying)。
即使被抛出的对象没有瓦解的危险,复制行为还是会发生。例如,假设passAndThrowWidget将localWidget声明为static:
void passAndThrowWidget() {static Widget localWidget;cin >> localWidget; // 将localWidget以Widget&传给operator>>throw localWidget; // 将localWidget抛出成为一个exception
}
当上述函数抛出exception,还是会产生一个localWidget副本。意味着即使此exception以by reference方式被捕捉,catch端还是不可能修改localWidget,只能修改localWidget的副本。“exception objects必定会造成复制行为”这一事实也解释了“传递参数”和“抛出exception”之间的另一个不同:后者常常比前者慢。
当对象被复制当做一个exception,复制行为是由对象的copy constructor执行的。这个copy constructor相应于该对象的“静态类型”而非“动态类型”。例如,考虑下面这个稍加修改的passAndThrowWidget函数:
class Widget { ... };
class SpecialWidget : public Widget { .. };
void passAndThrowWidget() {SpecialWidget localSpecialWidget;...Widget &rw = localSpecialWidget; // rw代表一个SpecialWidgetthrow rw; // 抛出一个类型为Widget的exception
}
这里抛出的是一个Widget exception——虽然rw实际代表的是一个SpecialWidget。这是因为rw的静态类型是Widget而非SpecialWidget.rw虽然实际上代表一个此行为模式可能不是我们想要的,但它和其他所有“C++复制对象”的情况一致,复制动作永远是以对象的静态类型为本。(后续条款25会展示一种以对象的动态类型为本进行复制的技术)
“exception对象是其他对象的副本”这个事实,会对我们“如何在catch语句块内传播exceptions”带来冲击。考虑以下两个catch语句块,乍见之下似乎做了相同的事情:
catch (Widget &w) { // 捕捉Widget exception... // 处理exceptionthrow; // 重新抛出exception,使它能继续传播
}
catch (Widge &w) {...throw w; // 传播被捕捉的exception的一个副本
}
这两个catch语句块之间唯一的差异就是,前者重新抛出当前的exception,后者抛出的是当前exception的副本。如果暂且排除“额外的复制行为所带来的性能成本”因素,这两种做法有差别吗?
有!第一语句块重新抛出当前的exception,不论其类型为何。更明确地说如果最初抛出的exception的类型是SpecialWidget,第一语句块会传播一个SpecialWidget exception——甚至虽然w的静态类型是Widget。这是因为当此exception被重新抛出时,并没有发生复制行为。第二catch语句块则抛出一个新的exception,其类型总是Widget,因为那是w的静态类型。一般而言,我们必须使用以下语句
throw;
才能重新抛出当前的exception,其间没有机会让我们改变被传播的exception的类型。此外,它也比较有效率,因为不需要产生新的exception object。
(附带一提,为exception所做的复制动作,其结果是个临时对象。如条款19所言,这给予编译器进行优化的权利。然后我们不能期望编译器在这上面有什么好表现,Exception毕竟是比较罕见的,所以即使编译器厂商在其优化上面灌注的心力不大,也在意料之中。)
让我们检验3种catch子句,它们都有能力捕捉“被passAndThrowWidget抛出”的Widget exception:
catch (Widget w) ... // 以by value的方式捕捉
catch (Widget& w) ... // 以by reference的方式捕捉
catch (const Widget w) ... // 以by reference-to-const的方式捕捉
我们立刻就注意到了“参数传递”和“exception传播”之间的另一个区别,一个被抛出的对象(如先前所解释,必为临时对象)可以简单地用by reference的方式捕捉,不需要以by reference-to-const的方式捕捉。函数调用过程中将一个临时对象传递给一个non-const reference参数是不允许的,但对exceptions则属合法。
然而让我们忽略这个差异,回到“复制exception objects”的主题。我们知道,如果以by value方式传递函数自变量,便是对被传递对象做一个副本,此副本存储于对应的函数参数中。如果by value方式传递exception,亦发生相同的事情。因此,当我们声明一个catch子句如下:
catch (Widget w) ... // 以by value的方式捕捉。
预期得付出“被抛出物”的“两个副本”的构造代价,其中一个构造动作用于“任何exception都会产生的临时对象”身上,另一个构造动作用于“将临时对象复制到w”。类似道理,当我们以by reference方式捕捉一个exception:
catch (Widget& w) ... // 以by reference的方式捕捉
catch (const Widget w) ... // 以by reference-to-const的方式捕捉
预期得付出“被抛出物”的“单一副本”的构造代价。这里的副本便是指临时对象。由于以by reference方式传递函数参数时并不发生复制行为,所以“抛出exception”和“传递函数参数”相比,前者会多构造一个“被抛出物”的副本(并于稍后析构)。
我们尚未讨论以by pointer方式抛出exceptions,但throw by pointer事实上相当于pass by pointer,两者都传递指针副本。必须特别注意的是,千万不要抛出一个指向局部对象的指针,因为该局部对象会在exception传离其scope(译注:控制权同事也离开scope)时被销毁,因此catch子句会获得一个指向“已被销毁的对象”的指针上。这正是“义务性复制(copy)规则”的设计要避免的情况。
“自变量传递”与“exception传播”俩动作有着互异的做法,其中一个不同就是对象从“调用端或抛出端”被搬移到“参数或catch子句”时的做法(如上所述);第二个不同则是“调用者或抛出者”和“被调用者或捕捉者”之间所存在的类型吻合(type match)规则。试考虑标准程序库的数学函数sqrt:
double sqrt(double); // from <cmath> or <match.h>
int i;
double sqrtOfi = sqrt(i);
其中没有什么值得大惊小怪的。C++允许隐式转换,将int转换为double,所以在调用sqrt的过程中,i会被默默地转换为一个double,而sqrt的结果将应该是double。一般而言,如此的转换并不发生于“exceptions与catch子句相匹配”的过程中。下面这段代码:
void f(int value) {try {if (someFuction()) {throw value; // 抛出一个int }}catch (double d) { // 在这里处理类型为double的exception...}
}
try语句块中抛出的int exception绝不会被“用来捕捉double exception”的catch子句捕捉到。后者只能捕捉类型确确实实为double的exceptions,其间不会有类型转换的行为发生。所以,如果int exception被捕捉,它一定是被某些其他(也许是外围)catch子句捕捉的(它们的捕捉类型一定是int或int&,或许再加上const或volatile之类的限定词)。
“exceptions与catch子句相匹配”的过程中,仅有两种转换可以发生。第一种是“继承架构中的类转换(inheritance-based conversions)”。是的,一个针对base class exceptions而编写的catch子句,可以处理类型为derived class的exceptions。例如,C++标准程序库中定义有exceptions集成体系,其中的诊断(diagnostics)相关类如下:
一个针对runtime_error而编写的catch子句,可以捕捉类型为range_error、overflow_error及underflow_error的exceptions。一个可接受最根源类(exception)的catch子句,可以捕捉此继承体系下的所有exceptions。
此所谓的“集成架构中的exception转换”规则可适用于by value,by reference及by pointer3种形式:
catch (runtime_error) ... // 可捕捉类型为runtime_error、overflow_error及underflow_error的错误
catch (runtime_error&) ... // 同上
catch (const runtime_error&) ... // 同上
catch (runtime_error*) ... // 可捕捉类型为runtime_error*、overflow_error*及underflow_error*的错误
catch (const runtime_error*) ... // 同上
第二个允许发生的转换是从一个“有型指针”转换为“无型指针”,所以一个针对const void*指针而设计的catch子句,可捕捉任何指针类型的exception:
catch (const void *) ... // 可捕捉任何指针类型的exception
“传递参数”和“传递exception”的最后一个不同是,catch子句总是依出现顺序做匹配尝试。因此,当try语句块分别有针对base class而设计和针对derived class设计的catch子句,一个derived class exception仍有可能被“针对base class 而设计的catch子句”处理掉。例如:
try {...
}
catch (logic_error& ex) { // 此语句块捕捉所有logic_error exceptions,同时包括domain_error,invalid_argument,length_error,out_of_range exceptions...
}
catch (invalid_argument& ex { // 此语句绝不会执行起来,因为所有的invalid_argument exceptions都会被上述子句捕捉...
}
将此行为拿来和“调用虚函数时所发生的事情”比对。当我们调用虚函数,被调用的函数是“被调用(某个对象)的动态类型”中的函数。可以说,虚函数采用所谓的“best fit”(最佳吻合)策略,而exception处理机制遵循所谓的“first fit”(最先吻合)策略。如果“针对derived class而设计的catch子句”出现在“针对base case而设计的catch子句”之后,编译器可能会给出一个警告——有些更严厉的编译器甚至会发出错误信息,因为这样的代码在C++中通常是不正确的。但是我们的最佳行动纲领就是先发制人:绝不要将“针对base class而设计的catch子句”放在“针对derived class而设计的catch子句”之前。上述代码应该重新安排如下:
try {...
}
catch (invalid_argument& ex { // 处理invalid_argument exceptions...
}
catch (logic_error& ex) { // 这里用来处理其他所有logic_error exceptions...
}
因此我们可以说,“传递对象到函数去,或是以对象调用虚函数”和“将对象抛出成为一个exception”之间,有3个主要差异。第一,exception objects总是被复制,如果by value方式捕捉,它们甚至被复制两次。至于传递给函数参数的对象则不一定得复制。第二,“被抛出成为exceptions”的对象,其被允许的类型转换动作,比“被传递到函数去”的对象少。第三,catch子句一起“出现源代码的顺序”被编译器依次检验比对。其中第一个匹配成功便执行;而当我们以某对象调用一个虚函数,被选中执行的是那个“与对象类型最佳吻合”的函数,不论它是不是源代码所列的第一个。
学习心得
exception在被抛出时必然会产生临时副本对象,为了在传播过程中保有exception的原有类型(不被复制为basic exception )使用throw语句(不要使用throw ex语句);catch子句的处理采用“first fit”处理方式,所以如果有多个catch存在的情况下,基类总是放在代码的后面。存在继承关系的两个Exception对象,永远保持继承类(derived class)出现在基类(base class)的前面。
条款13:以by reference方式捕捉exceptions
写一个catch子句时,我们必须指明exception objects如何被传递到这个子句来。就像我们可以选择参数如何被传递到函数来一样,现在我们也有3种选择:by pointer,by value或是by reference。
首先让我们考虑by pointer。理论上,将一个exception从抛出端搬移到捕捉端,必然是个缓慢的过程,而by pointer应该是最有效率的一种做法,因为by pointer是唯一在搬移“异常相关信息”是不需要复制对象的一种做法(只需要复制一个指针)。例如:
catch exception { ... };
void someFunctiion() {static exception ex:...throw &ex;...
}
void doSomething() {try {someFunction(); // 可能抛出exception *,只复制exception*}catch (exceptiion *ex) { // 捕捉到exception*,没有exception对象被复制...}
}
这看起来十分优雅整齐,但是它并不像我们所看到的那么好。为了让这段代码能够运行,程序员必须有办法让exception objects在控制权离开那个“抛出指针”的函数之后依然存在。Global对象及static对象都没问题,但程序员很容易忘记这项约束。于是,常常写出这样的代码:
void someFunctiion() {exception ex: // 局部的exception object,将在此函数结束时销毁...throw &ex; // 抛出一个指针,指向即将被销毁的对象 ...
}
这是很糟糕的情况,因为catch子句所收到的指针,指向不复存在的对象。
另一种做法是抛出一个指针,指向一个新的heap object:
void someFunctiion() {...throw new exception; // 抛出一个指针,指向一个新的heap-based object ...
}
这避免了“捕捉到一个指针,它却指向一个已不存在的对象”的问题。但是现在catch子句的作者遭遇了一个更难缠的问题:应该删除获得的指针吗?如果exception objec被分配于heap,必须删除。否则便会泄露资源。如果exception object不是被分配于heap,就不必删除之,否则便会招致未受定义的程序行为。该怎么做才好呢?
没有人知道该怎么做才好。某些人可能会把一个global或static对象的地址传出去,另一些人可能会把一个位于heap中的exception object的地址传出去。Catch by pointer于是有了哈姆雷特的难题:to delete or not to delete?这个问题没有答案。
此外,catch-by-pointer和语言本身建立起来的惯例有所矛盾。4个标准的exception——bad_alloc(当operator new无法满足内存需求时抛出)、bad_cast(当对一个reference施行dynamic_cast失败时抛出)、bad_typeid(当dynamic_cast被施行于一个null指针时抛出)、bad_exception(适用于未预期的异常情况)——统统都是对象,不是对象指针。所以我们无论如何必须以by value或by reference的方式捕捉它们。
Catch-by-value可以消除上述“exception是否需要删除”及“与标准exception不一致”等问题。然而在此情况下,每当exception objects被抛出,就得复制两次【见条款12】。此外它也会引起切割(slicing)问题,因为derived class exception objects被捕捉被视为base class exception者,将失去其派生成分。如此被切割过的对象其实就是base class objects:它们缺少derived class data members,当虚函数在其上被调用时,会被解析为base class的虚函数(这和对象以by
value方式传递给函数时所发生的事情一样)。例如,考虑一个应用程序,采用exception class标准继承体系的一个扩充版本:
class exception {
public :virtual const char* what() throw();
};
class runtime_error : public exception { ... };
class Validation_error : public runtime_error {
public:virtual const char * what() throw();...
};
void someFunction() {...if (a validation test fails) {throw Validation_error();}...
}
void doSomething() {try {someFunction(); // 可能会抛出一个Validation_error object}catch (exception ex) { // 使用基类进行捕获cerr << ex.what() ; // 调用的是exception::what()而不是Validation_error::what()...}
}
被调用的what函数时base class版——即使被抛出的exception属于Validation_error类型,而Validation_error重新定义了虚函数what。这种切割(slicing)行为几乎不是我们所要的。
剩下的就是catch-by-reference喽,Catch-by-referece不需要蒙受我们所讨论的任何问题。它不像catch-by-pointer,不会发生对象删除问题,因此也就不难捕捉标准的exception。它和catch-by-value也不同,所以没有切割(slicing)问题,而且exception objects只会被复制一次。
如果我们使用catch-by-reference重写上一个例子,结果如下:
void someFunction() {...if (a validation test fails) {throw Validation_error();}...
}
void doSomething() {try {someFunction(); // 可能会抛出一个Validation_error object}catch (exception& ex) { // 这里改用catch by reference取代catch by valuecerr << ex.what() ; // 调用的是Validation_error::what()而非exception::what()...}
}
抛出端没有任何改变,catch子句内的唯一改变是增加了一个&符号。然后这个微小的修改成就了极大的不同,catch语句块内所调用的虚函数,调用的是我们所期望的:Validation_error中的函数会被调用——如果他们重新定义了exception内的虚函数的话。
多么令人开心的结果呀!如果catch by reference,我们就可以避开对象被删除的问题(相对于catch by pointer),我们也可以避开exception objects的切割(slicing)问题(相对于catch by value);我们可以保留捕捉标准exception的能力;我们也约束了exception objects需被复制的次数。
学习心得
异常捕获的三种方式catch-by-pointer,catch-by-value,catch-by-reference。尽管catch-by-pointer是抛出捕获方式性能最高的,但是由于指针是否释放,以及与标准exception抛出方式不匹配所以不推荐使用。catch-by-value有性能不佳(至少被拷贝两次),同时存在切割(slicing)丢失了derived class信息的缺点,也不推荐使用。剩下catch-by-reference, 性能相对可接受(只拷贝一次),与标准exception兼容,同时保留了derived class的信息(可实现多态polymorphism),所以catch-by-reference成为了最被推荐的捕获方式。
条款14:明智运用exception specifications
exception specification还是有吸引力的。它让代码更容易被理解,因为它明确指出一个函数可以抛出什么样的exceptions,但它不只是一个漂亮的注释而已。编译器有时候能够在编译期间侦测到与exception specifications不一致的行为。如果函数抛出了一个并未列于其exception specification的exception,这个错误会在运行期被检查出来,于是特殊函数unexpected会被自动调用。可以说,exception specifications不但是一种文档式的辅助,也是一种实践式的机制,用来规范exception的运用。它确实满吸引人的。
然而,就像日常所见,美貌只是一种肤浅的表现。unexpected的默认行为是调用terminate,而terminate的默认行为是调用abort,所以程序如果违反exception specification,默认结果就是程序中止。是的,局部变量不会获得销毁的机会,因为abort会使程序停摆,没机会执行此类清理工作。一个未获得尊重的exception specification就像大洪水一般,会带来毁灭。最好永远不要发生这种事情。
不幸的是,很容易就能写出一个函数让可怕的事情发生。因为编译器只会对exception specifications做局部性检验。它们所没有检验的——同时也是C++明定不准拒绝的——是调用某个函数而该函数可能违反调用端函数本省的exception specification(有些比较严谨的编译器会对此发生警告)。
考虑以下的f1函数声明,它没有exception specification。此函数可以抛出任何一种exception:
extern void f1(); // 可以抛出任何东西
在考虑函数f2,它通过exception specification声称,只抛出类型为int的exceptions:
void f2() throw(int);
在C++中,f2调用f1绝对合法,即使f1可能抛出一个exception而该exception违反了f2的exception specification:
void f2() throw(int) {...f1(); // 合法,甚至即使f1可能抛出int以外的exceptions。...
}
这种弹性是必要的。如果带有exception specification的新代码和缺乏exception specification的旧代码要整合在一起的话,这种弹性就有必要。
由于编译器心甘情愿地让我们调用“可能违反当前函数本身的exception specification”的函数,并且由于如此的调用行为可能导致程序被迫中止,所以如何才能将这种不一致性降到最低,是很重要的思考。一个好办法就是避免将exception specification放在“需要类型自变量”的template身上。考虑下面这个template,它看起来好像绝不会抛出任何exceptions:
// 一个不良的template设计,因为它带有exception specifications。
template <class T>
bool operator == (const T& lhs, const T& rhs) throw() {return &lhs == &rhs;
}
这个template为所有类型定义一个operator==函数,针对同型的两个对象,如果其地址相同,便返回true,否则返回false。
上述template有一个exception specification。指明被此template所产生出来的函数不会抛出任何exceptions。但其实那并非绝对为真,因为有可能operator&(取址操作符)已经被某些类型重载了。如果真是这样,operator==内部调用operator&时便有可能由operator&抛出一个exception。果真如此,上述的exception specification便遭违反,导致迈向unexpected之路。
这只是个特殊的例子。更一般化的问题是,没有任何方法可以知道一个template的类型参数可能抛出什么exceptions。所以千万不要为template提供意味深长的exception specification,因为templates几乎必然会以某种方式使用其类型参数。结论是:不应该将templates和exception specification混合使用。
避免踏上unexpected之路的第二个技术是:如果A函数内调用了B函数,而B函数无exception specification,那么A函数本身也不要设定exception specification。这是很简单的常识,但是有一种情况很容易被忘记,就是当允许用户注册所谓的callback(回调)函数时:
// 函数指针类型,用于窗口系统的callback(回调)函数——当窗口系统发出一个event。
typedef void (*CallBackPtr)(int eventXLocation, int eventYLocation, void *dataToPassBack);
// 窗口系统内的class,用来放置用户注册的callback函数。
class CallBack {
public:CallBack(CallBack fPtr, void *dataToPassBack) :func(fPtr), data(dataToPassBack) {}void makeCallBack(int eventXLocation, int eventYLoaction) const throw();
private:CallBackPtr func; // callback发生时所要调用的函数。void * data; // 用来传给callback函数的数据。
};
// 为了实现callback,我们调用已经给您注册的函数,
// 并以“event的发生坐标”和“经过注册的数据”作为自变量
void CallBack::makeCallBack(int eventXLocation, int eventYLocation) const throw() {func(eventXLocation, eventYLocation, data);
}
此处makeCallBack函数内部对func的调用,有可能违反exception specification,因为没有任何方法可以知道func可能抛出什么样的exception。
如果CallBackPtr typedef中加上exception specification,就可以消除这个问题1:
typedef void (*CallBackPtr)(int eventXLocation, int eventYLocation, void *dataToPassBack) throw();
有了这个typedef,现在如果注册一个callback函数而后者无法保证不抛出任何exception,便会出现错误:
// 一个不带有exception specification的callback函数。
void callBackFcn1(int eventXLocation, int eventYLocation, void* dataToPassBack);
void *callBackData;
...
CallBack c1(callBackFcn1, callBackData); // 错误!因为callBackFcn1可能会抛出exception。
// 一个带有exception specification的callback函数。
void callBackFcn2(int eventXLocation, int eventYLocation, void* dataToPassBack) throw;
CallBack c2(callBackFcn2, callBackData); // 没问题!因为callBackFcn2满足exception specification。
避免踏上unexpected之路的第三个技术是:处理“系统”可能抛出的exceptions。其中最常见的就是bad_alloc,那是在内存分配失败时由operator new和operator new[]抛出的。如果我们在函数内使用new operator,我们必须有心理准备:这个函数可能会遭遇bad_alloc exceptions.
虽说防微杜渐,预防胜于治疗,但有时候预防很困难,治疗反倒简单。也就是,有时候,直接处理非预期的exceptions,反而比事先预防来得简单得多。举个例子,如果我们写的软件大量使用exception specifications,但被迫调用程序库提供的函数,而后者没有使用exception specification,那么想要阻止非预期的exception发生,就是件不切实际的事,因为那非得改变程序库源代码不可。
如果“阻止非预期的exception发生”是件不切实际的事,我们可以利用一个事实:C++允许以不同类型的exception取代非预期的exception。举个例子,假设我们希望所有的非预期的exception都以UnexpectedException objects取而代之,可以这么做:
class UnexpectedExpection {}; // 所有非预期的exception objects,都将被取代为此类objects
void convertUnexpected() {throw UnexpectedExpection();
}
// 并以convertUnexpected取代默认unexpected函数
set_unexpected(convertUnexpected);
一旦完成这些布置,任何非预期的exception便会导致convertUnexpected被调用,于是非预期的exception被一个新的,类型为UnexpectedException的exception取而代之。那么,只要被违反的exception specification内含有UnexpectedException,exception的传播便会继续下去,好似exception specification获得满足似的。但如果exception specification未包含UnexpectedException,terminate会被调用,犹如从未取代unexpected一样。
“将非预期的exception转换为一个已知类型”的另一个做法就是,依赖以下事实:如果非预期函数的替代者重新抛出当前的exception,该exception会被标准类型bad_exception取而代之。
以下代码就会发生这样的事情:
// 如果非预期的exception被抛出,此函数便被调用,它只是重新抛出当前的exception
void convertUnexpected() {throw ;
}
// 设置convertUnexpected,作为unexpected函数的替代品
set_unexpected(convertUnexpected);
如果做了上述安排,并且每一个exception specification都含有bad_exception(或其基类,也就是标准类exception),我们就再也不必担心程序会遇上非预期的exception时中止执行。任何非预期的exception都会被一个bad_exception取代,而该exception会取代原来的exception继续传播下去。
现在我们了解到了,exception specifications可能带来许多麻烦。编译器只为它执行局部性检查,在templates中使用它会有问题,它们很容易被不经意地违反,而且当他们被违反,默认情况下会导致程序草草中止。Exception specifications还有另一个缺点,那就是它们会造成“当一个较高层次的调用者已经准备好要处理发生的exception时,unexpected函数却被调用”的现象。例如考虑以下这段几乎从条款11抄录过来的代码
class Session {
public:Session();~Session();...
private:static void logDestruction(Session* objAddr) throw();
};
Session::~Session() {try {logDestruction(this);}catch (...) { }
}
其中的Session destructor调用logDestruction以记录“有个Session object正被销毁”的事实,并以catch ( … ) 明确指出它要捕获任何可能被logDestruction抛出的exception。然后logDestruction带有一个exception specification,保证不抛出任何exceptions。现在假设某些被logDestruction调用函数抛出一个exception,而logDestruction没有拦下它。当这个非预期的exception传播到达logDestruction,函数unexpected会被调用,默认情况下会导致程序的中止。这是正确的行为,毫无疑问,但这是Session destructor的作者真正想要的行为吗?那位作者费尽苦心地处理所有可能的exceptions,所以如果在中止程序之前没有给Session destructor内的catch语句块一个机会,似乎并不公平。如果logDestruction没有设定exception specification,则catch (…)子句会发挥效果。阻止它的办法之一就是,将unexpected函数重新进行设定。重新生成新的Exception,or通过throw将当前exception转换为bad_exception。
对exception specification保有持平的观点,至为重要。它们对于“函数希望抛出什么样的exception”提供了卓越的说明。而在“违反exception specification的下场十分悲惨,以至于需要立刻结束程序”的形势下,它们提供了默认行为。但是虽然有这些好处,它们相对有一些缺点,包括编译器只对它们做局部性检验、很容易被不经意地违反等。此外它们可能妨碍更上层的exception处理函数处理未预期的exceptions——即使更上层的处理函数已经知道该怎么做。exception specification是一把双刃剑,在将它加入函数之前,请考虑它所带来的程序行为是否真是我们想要的。
学习心得
本条款告诉我们当针对函数声明的exception specification与函数实际抛出的exception不一致,可能导致不符合预期的行为;比如程序中止、上层代码无法捕获异常等问题。鉴于这个问题本条款建议我们:1:在不清楚函数实际会抛出什么异常的情况下,宁可不使用exception specification(特别是不要与template class混用)2:通过使用接口set_unexcepted,重新配置抛出异常的行为。
条款15:了解异常处理(exception handling)的成本
为了能够在运行时处理exception,程序必须做大量的簿记工作。在每一个执行点,它们必须确认“如果发生exception,哪些对象需要析构”,它们必须在每个try语句块的进入点和离开店做记号,针对每个try语句块它们必须记录对应的catch子句及能够处理的exception类型。这些簿记工作必须付出代价。运行时期的比对工作(以确保符合exception specification)不是免费的,exception被抛出时销毁适当对象并找出正确的catch子句也不是免费的。exception的处理需要成本,即使我们从未使用关键字try,throw或catch,我们也必须付出某些成本。
让我们从“即使从未使用任何exception处理机制,也必须付出”的最低消费额谈起。我们必须付出一些空间,放置某些数据结构(记录着哪些对象已被完全构造妥当,见条款10);我们必须付出一些时间,随时保持那些数据结构的正确性。这些成本通常相当适当。尽管如此,编译过程中如果没有加上对exception的支持,程序通常比较小,执行时也比较快。如果编译过程中加上对exceptions的支持,程序就比较大,执行时也比较慢。
理论上,面对这些成本我们别无选择:exceptions是C++的一部分,编译器必须支持它们,就是这么回事 。即使我们未使用exception机制,我们也不能期望编译器厂商消除这些成本,因为程序通常是由多个独立完成的object files构成,其中一个不做任何与exception相关的事,并不表示其他也都如此。此外,即使程序赖以构成的object files中没有任何一个运用了exceptions,它们所链接(link)的程序库又如何?只要程序的任何一部分运用了exceptions,整个程序就必须支持它,否则就不可能再运行时期提供正确的exception处理行为。
这只是理论。事实上,大部分对exception处理机制有所支持的厂商,允许我们自行决定是否要在他们所产生的代码上放置“exception支持能力”。如果我们知道程序没有任何一处使用try,throw或catch,而且也知道所链接的程序库没有一个用到try,throw或catch,我们可以在编译过程中放弃支持exception,并因而免除了大小和速度的成本:否则我们会获得一个其实并未使用的性质。随着时间过去,程序库对exception的运用普及度愈来愈高,这个策略会变得比较无处着力,但是目前C++软件开发的现况是,如果我们决定不使用exceptions,并让编译器知道,编译器可以适度完成某种性能优化。对于避免exception的程序库而言,这也是一个诱人的优化机会,前提是必须保证“client端抛出的exceptions绝不会传入程序库”。这是一个困难的保证,因为它会对“client重新定义程序库内的虚函数”带来妨碍,也会对“client定制callback函数”带来排挤影响。
Exception处理机制带来的第二种成本来自try语句块。只要我们用上那么一个,也就是说一旦决定捕捉exceptions,就得付出那样的成本。不同编译器以不同的方法实现try语句块,所付出的代价各不相同。粗略估计,如果使用try语句块,代码大约整体膨胀5%-10%,执行速度亦大约下降这个数。这是在假设没有任何exceptions被抛出的情况下。此处我们所讨论的知识“代码中出现try语句块”的成本而已。为了将此成本最小化,我们应该比避免必要的try语句块。
面对exception specifications,编译器产出的代码倾向类似于面对try语句块的作为,所以exception specification通常会招致与try语句块相同的成本。
这把我们带到一个中心思维:抛出一个exception,成本几何?事实上这不应该成为关心的焦点,因为exception应该是罕见的,毕竟它们是用来表现异常的发生。80-20法则告诉我们,如此的时间应该不会对一个程序的整体性能有太巨大的冲击才是。尽管如此,如果抛出一个exception,会带来多大的冲击?答案是:可能十分巨大。和正常的函数返回动作比较,由于抛出exception而导致的函数返回,器速度可能比正常情况慢3个数量级。这可是大冲击。但是只有在抛出exception时才需要承受这样的冲击。而exception的出现应该是罕见的。然后如果我们视exceptions为一种用来表现“相对平常”的状态的工具,例如,用来表现一个数据结构的遍历完成,或是一个循环的结束,那么现在正是好好反省的时机。
读者可能对抛出异常的成本这么大存疑,可以通过使用自己的编译器进行测试验证;
深思明辨的做法就是,了解本条款所描述的成本,但是不要对以上数据过度敏感。不论exception处理过程需要多少成本,我们都不应该付出比该付出的部分更多。为了让exception的相关成本最小化,只要能够不支持exceptions,编译器便不支持;请将对try语句块和exception specifications的使用限制于非用不可的地点,并且在真正异常的情况下才抛出exceptions。如果还是有性能上的问题,请利用分析工具(profile)分析你的程序,以决定“对exception的支持”是否是一个影响因素。如果是,请考虑改用不同的编译器——改用一个能够以较高效率提供“C++ exception 处理机制”的编译器。
学习心得
本条款告诉我们异常处理机制通常会带来额外的成本,只要使用try、catch、exception specification等语句则会带来5%-10%的目标代码膨胀以及对应的运行效率下降;同时如果真的抛出了exception其影响将是巨大的,可能导致响应时间增大3个数量级。所以,非必要情况不要使用try-catch语句,以及针对函数进行exception specification声明。同时如果发现exception在非用不可的情况下,发现效率影响还很大,考虑换个编译器试试。
事实上它不能解决(至少不能广具移植性地解决)。虽然许多编译器接受本页呈现的代码,但标准委员会宣称“typedef内不可出现exception specification”,而且没有任何解释。 ↩︎