异常之殇

辗转开解

辗转开解(stack unwinding)说的其实是这么一个现象。当执行流从深层向浅层转移时,深层调用所产生的栈上对象(stack object)需要销毁,资源需要释放。对于面对对象语言而言,往往就会执行到析构函数。

辗转开解中的异常

辗转开解真正令人迷惑之处在于,如果在析构函数中发生错误怎么办?在异常处理中发生异常,我们可以继续向上抛出。但是在辗转开解代码中出现异常,上层应当收到两个异常呢?还是一个?

无论是哪种可能,都没有完美自恰的符合直觉,因此这一般是一个未定义的行为。在C++中,进程会整个彻底崩溃掉的。因此,千万不要在析构函数内抛出(或者可能抛出)异常

如果分离析构和资源销毁

一种做法是,在析构时不做资源销毁,转而提供专门的函数来执行资源销毁过程。析构只处理简单的delete等操作。然而这种做法的杯具在于,你在任何时候,一旦使用对象,都必须使用finally来保证销毁函数的调用。在发生异常时,栈上对象的辗转开解是自动的,析构函数的调用也是自动的,但是销毁函数的调用就是手工的了。

拷贝构造和隐式转换

和构造相反,对于构造函数,我们不能限制异常使用。你必须捕获构造函数的异常。

假如构造函数出了错

普通函数出错,你有两种选择。1. 异常。2. 返回值。构造函数出错,是没有选项2的。因此构造函数凡是出错必定异常。

而如果构造函数可能出错,而你期望捕获他,你就不能栈上构造一个对象出来。因为这会导致栈上对象的作用域被限定在捕获他所用的try块之内。

分离构造的尝试

和析构函数类似,我们可以尝试在构造函数外,提供一个构造函数,来替代构造的初始化过程。这样可以很大程度上保证构造函数不出错。

然而,首先,这样的代码就会变的复杂。每次构造函数完成调用后,都必须调用初始化函数。而且,有两种特殊的构造函数你不可能使用这种方法来解决。

拷贝构造和隐式转换

是的,这两种构造函数分别叫做拷贝构造(copy construct)和隐式转换(implicit casting)。我们举例来说。如果你在函数内建立了一个对象,你希望返回这个对象,怎么做呢?第一个思路是引用返回。不幸的是,要做引用返回,这个对象必须是堆上对象,而非栈上对象。因为栈上对象在返回后会销毁掉。如果要返回栈上对象,唯一靠谱的方案是先将对象复制到堆上,然后再复制到调用者的栈里。

C++中有一类特殊的优化,叫做对象返回优化。当编译器察觉到你需要返回栈上对象时,那么编译器会直接获得调用者栈里的对象地址。这样可以避免两次的拷贝过程。然而,如果没有对象返回优化(或者没有识别出来),那么就需要两次复制以保证正确性。而C++里,默认的复制过程是内存拷贝。

对于很多对象,内存拷贝是错误的行为。例如字符串,一种字符串的加速方法叫做共享内存字符串。两个字符串对象会共享一个内存块,以避免重复内容的开销。直到其中一块需要修改时,复制才真的继续。对于这种情况,直接拷贝会明显的导致错误。因此C++有一种特殊的构造函数,叫做拷贝构造。

在拷贝构造的时候,调用是由C++隐式发生的,你根本没有先构造,再调用的机会和权力。因此,试图分离构造在技术上不可行。

隐式转换是另一种情况。当你传递的参数和实际被赋值对象的类型不一致时(例如调用了某个函数,其参数类型不一致),C++会试图将你的对象转换为目标对象。如果是内部类型,这个被称为内部隐式转换。unsigned char可以被无错的转换为unsigned long,这个大家都知道。但是如果是对象,转换行为就需要由构造函数定义,这个叫做隐式转换构造函数。

另外,隐式转换也是OO中的一大问题。我强烈建议你用explicit禁用所有隐式转换,改为显式转换。这会费一点事,但是却可以避免很多问题。

分离构造/析构的邪恶之处

ZMQ的作者曾经吐槽过这种在构造/析构之外再定义初始化/清除代码的努力。他的观点是,如果万一在构造函数中加入了代码,会引起半构造现象。为了解决这个问题,会使得整个类带上状态。我在上面已经假定这件事情不会发生了,否则代码会更加复杂,问题也更加严重。

二次异常

是的,你不应当在异常处理代码中抛出异常。当然,这里的异常指的是你的异常处理代码不应当发生异常。经过逻辑判定,当前的异常应当由更上层处理的情况不在此列。

如果在异常处理中抛出异常,很可能导致的结果就是异常处理没有完成。而未完成的异常处理会发生什么问题,那只有天晓得。这个在任何带有异常系统的语言中都是成立的。