1.10 使用异常造成的开销
当异常被抛出时,将造成相当多的运行时开销(但是,这是有益的开销,因为对象被自动清理了!)。由于这种原因,不要将异常作为正常控制流的一部分使用,无论这种想法看起来多么精巧诱人。异常应该很少发生,所以开销主要是由异常造成的而不是由正常执行的代码造成的。异常处理机制的重要设计目标之一是,当异常没有发生时,它不应该影响系统的运行速度;也就是说,如果不抛出异常,那么代码的运行速度就像没有使用异常处理机制时一样快。这么说是否正确,依赖于用户所使用的特定编译器的实现方式。(参考这一节的后面部分对“零代价模型(zero-cost model)”的描述。)
可以这样认为,一个throw表达式就像是一个特殊的系统函数调用,它接收异常对象作为参数并且沿着执行调用链向上回溯。为了完成这项工作,编译器需要在栈上放置额外的信息,来辅助栈反解过程。为了理解这些内容,用户需要了解有关运行栈(runtime stack)的知识。
每当函数被调用的时候,有关这个函数的信息被压到运行栈顶部的活动记录实例(activation record instance, ARI)中,也叫栈结构(stack frame)。典型的栈结构包含调用函数的指令所在的地址(这样,程序的执行流程可以返回到这个地址),指向这个函数静态父对象的ARI(某个作用域,它在词法上包含被调用函数,这样这个函数就可以访问全局变量了)的指针,和指向调用函数的指针(它的动态父函数)。沿着动态父函数链反复追踪所得到的逻辑结果路径就是动态链,或称其为调用链,读者在这一章的前面见到过它。这就是为什么当异常抛出时执行流程能够回溯,这种机制使得在彼此缺乏了解的情况下开发出来的程序的各个部分,能够在运行时互相传递出错信息。
对于异常处理机制系统允许栈反解,每个函数额外的异常相关信息,必须对每一个栈结构来说都是可用的。这些信息描述了哪个析构函数应该被调用(因此,局部对象可以被清理),这些信息显示了当前函数是否有try块,而且这些信息列出了与try块相关的catch子句能够捕获哪些异常。这些额外信息会造成存储空间的消耗,所以支持异常处理机制的程序要比不支持异常处理机制的程序大[1]。因为在运行期间生成扩展栈结构的逻辑必须由编译器生成,所以使用异常处理的程序在编译时也较大。
为了演示这一点,在这里使用Borland C++Builder和Microsoft Visual C++[2]:分别在支持异常处理机制和不支持异常处理机制的模式下编译下面的程序:
如果允许异常处理,编译器必须为f()保存有关析构函数~HasDestructor()在运行时的大量信息到ARI(活动记录实例)中(这样即使g()抛出异常,f()也能正确地销毁对象h)。下表总结了编译结果文件(.obj)的大小(单位:字节)。
不要把两种模式之间文件大小的百分比看得太重。请记住,典型情况下异常(应该)只构成程序的很小一部分,其空间开销是相当小的(通常只占总开销的5%~15%)。
额外的管理工作会降低执行速度,但是聪明的编译器会避免这种情况。由于与异常处理代码和局部对象偏移量有关的信息只在编译时刻计算一次,这些信息可以保存在与每个函数相关的单独位置中,而不是保存在每个ARI中。我们基本上已经从每个活动记录实例中消除了异常的空间开销,因此也避免了压栈操作造成的附加时间开销。这种方法称为异常处理的零代价(zero-cost)模型[3],早先提及的优化存储被认为是影子栈(shadow stack)[4]。
[1]这取决于在不使用异常的情况下用户必须插入多少代码来检查返回值。
[2]Borland在默认情况下允许异常处理;使用-x编译器选项来禁止异常处理。Microsoft在默认情况下不允许异常处理;使用-GX选项开启异常处理。两种编译器都使用-c选项作为只执行编译过程的选项。
[3]GNU C++编译器默认使用零代价模型。Metrowerks Code W arrior for C++也有一个选项,能够选择使用零代价模型。
[4]感谢Scott Meyers和Josee Lajoie在零代价模型上的洞察力。读者可以在Josee的精彩文章“Exception Handling:Behind the Scenes,”C++Gems, SIGS,1996中找到有关异常如何工作的更多信息。