在日常程序处理中,很可能发生一些我们意想不到的情况。最典型的情况是,在一次除法或求模运算中,除数是0——这种情况将触发一个典型的除零异常。异常有两个来源,一个来自于CPU,另外一种是来自于程序——我们可以抛出一个异常。

在Delphi中,支持两种处理异常的结构:try...finally...end和try...except...end。许多初学者搞不太清它们的涵义与用法,这里对它们进行一个基本的介绍。

当编译器遇到try时,会生成一些用于处理异常的指令。首先,要确定指定当异常发生后,将应用程序的执行位置转到进行下一步处理的代码,也就是finally段或except段的代码。接下来,将该地址压入用于异常处理的栈结构中,以便正确进出try结构。

先说相对简单一些的except,当异常没发生时,该段中的内容不会得到执行,而是直接退出该异常结构,继续执行end后面的代码。当异常发生时,会根据该段中on...do(如果有的话)来匹配是否属于要处理该异常。如果不处理的话,该段及后面的代码都不会得到执行,而是跳到异常栈中下一个指定的代码位置。如果处理该异常的话,则会执行on...do对应的语句,并且退出异常块,继续执行end后的代码,除非使用raise要求继续由下一个异常处理代码进行处理。未指定on...do的情况,则会被编译器视同对所有的异常进行都处理。

而finally段中的代码,一定会得到执行,不论异常是否发生。当异常没有发生时,执行完finally段后退出该异常,end之后的代码也会得到继续执行。而当异常发生时,首先会执行finally段的代码,然后和except不处理异常的情况一样,跳到异常栈中下一个指定的代码位置继续对异常进行处理。这里需要强调的是,finally段的代码“一定会得到执行”,跟异常是完全无关的——甚至当在try当中使用Exit提前退出该函数时,也会保证在执行finally段中的代码之后,才会退出该函数。

换种形像的方式来说,except会呑掉指定的异常,利用该段中的相应内容对异常进行相应的处理,以使后面的代码能够正确执行。而finally不会呑掉异常,只保证finally段中的内容在任何情况下都会得到正确执行。也许你会产生疑问,为什么finally不呑掉异常呢?因为在实际使用中,即使异常未得到处理,我们仍要保证一些代码能够执行;而如果呑掉直接异常,编译无法判断end后面的内容是否仍然能够正确运行,因而交给下一个异常处理的代码进行处理是更加安全的做法。遗憾的是,Delphi并未支持如C#那样的语法,形成try...except...finally...end的结构,导致许多情况下,仍要进行try结构的嵌套。

正是由于try结构进行了许多对要执行的代码位置的处理,必须小心处理控制流,以保证用于进行异常处理的代码能够匹配执行,因而编译器对控制和跳转语句进行了严格的限制。在try段和except段中,goto的位置必须也处理该段中;而finally段的限制更多,用于跳出该try...finally...end结构的Break、Continue、Exit都不能使用。

正是因为有了这两个结构,Delphi有了强大的语法支持,可以安全完成许多功能。前面提到的finally的典型情况是用于清理try前分配的资源,例如申请了一块内存,为保证不出现内存泄露,把相应的释放代码写在finally段中。当在函数中使用局部变量用于临时分配资源后,使用try...finally...end结构用于执行后续操作和释放是非常好的编程习惯,建议你在阅读本文后将所有这种情况的代码都改成这样。而except用于从异常中恢复,使程序得到正确的执行,例如处理整数运算的溢出(Overflow)。整数类型的数据类型是有固定的宽度的,例如Byte值的范围是0~255,当一个Byte类型的整数进行200+200运算时,运算结果超出了Byte能够容纳的范围,这种情况就是溢出。这种情况下,CPU会在得到运算结果144(400-256)以外,设置标志寄存器的溢出位。遗憾的是,Delphi并没有直接获得该标志位姿态的语法支持,但可以通过另外一种方式检测到:在工程(Project)选项中打开Runtime Errors->Overflow Checking,或在代码中使用编译器开关$Q+或$OVERFLOWCHECKS ON打开溢出异常。当发生整数运算溢出后,RTL会抛出一个溢出异常,这样就可以发现溢出的发生了。假如我们的程序只能对一定范围内的整数进行处理,正常情况下不会出现溢出。但用户也可能输入一个非常大的数字(比如多输了一个0),导致错误的结果。这种情况下,需要检测到溢出,并且提示用户输入的数字过大。这时就可以使用一个try...except...end结构,对溢出异常进行处理,提示用户发生了溢出,原因是输入的数字过大。

这里需要补充一下,计算机对异常的处理是非常复杂的,因而速度也非常慢。例如前面提到的除零异常,它是由CPU触发的,而除法指令(尤其是整数除法指令)本身的处理速度不是一个确定的值,除零运算是其中最慢的情况之一。这里要再补充一些关于CPU与操作系统的知识。以前提到过,x86保护模式支持从ring 0到ring 3一共4个级别的特权模式,数字越低特权级别越高。操作系统工作在级别0中,特权级别最高,一般称为内核态;而我们为一般的应用程序编写的代码,最终工作在级别3中,被称为用户态,只有通过特殊的操作才能进入内核态。如果我没记错的话,只有三种方式由用户态切换到内核态:异常(Exception),中断(Interrupt)和系统调用。其中,异常是我们正在讲的东西;中断是由硬件触发的,例如主板上的其它硬件设备;系统调用是前些年才出现的CPU的特殊指令,Intel使用SYSENTER,AMD使用SYSCALL。当异常产生时,CPU会挂起正在执行的线程,保存相关的寄存器等等,然后切换特权级别,进入内核态将异常交给操作系统用于处理异常的代码进行处理。操作系统会根据异常的情况进行相应的处理,当该异常不属于操作系统要处理的异常时,会将异常的交给要处理异常的应用程序(例如调试器),或者将异常返回给原来的应用程序。因此,上面提到的溢出处理在实际中一般并不使用,而是采用其它的方法进行变通(例如在加法后,运算结果比任何一个加数更小就足以判断了),也就是说这个例子实际上不太恰当(但我一时也想不出更好的例子了)。

而try...finally在一些实际上跟异常无关的应用中也有应用。例如在使用WinAPI要完成一个比较复杂的功能时,要调用多个API,它们都返回一个需要在使用后释放的值,其中任何一个返回非法值时都要提前退出,并且清理前面那些返回值。如果在每一个判断返回值非法的代码中,都手写一堆释放代码再Exit的话,不仅非常累,还很可能多一个或者少一个处理,以后如果要修改代码结构时,工作量也异常巨大。但如果使用了一系列try...finally结构的话,只要一个Exit就可以了,非常方便。