第4章 异常与断言

程序中会发生三种错误:用户错误、运行时错误和异常。用户错误是预期会发生的,因为错误的用户输入就可能会导致用户错误。此类错误的例子,包括命名不存在的文件、在电子表格中指定格式错误的数字以及向编译器提交语法错误的源程序等。程序必须预计到这种错误并妥善处理。通常,必须处理用户错误的函数会返回错误码,这种错误是计算过程的一个普通组成部分。

前几章中描述的已检查的运行时错误,与用户错误相比,实在是相隔参商。已检查的运行时错误不是用户错误。它们从来都是非预期的,总是表明程序出现了bug。因而,应用程序无法从这种错误恢复,而必须优雅地结束。本书中的实现使用断言(assertion)来捕获这种错误。断言的处理在4.3节描述。断言总是导致程序结束,具体的结束方式或许取决于机器或应用程序。

异常(exception)介乎用户错误和程序bug之间。异常是可能比较罕见的错误,或许是非预期的,但从异常恢复也许是可能的。一些异常反映了机器的能力,如算术运算上溢和下溢以及栈溢出。其他异常表明操作系统检测到的状况,这些状况可能是由用户发起的,如按下一个“中断”键或写文件时遇到写入错误。在UNIX系统中,此类异常通常由信号传送,由信号处理程序处理。当有限的资源用尽时也可能发生异常,如应用程序内存不足时,或用户指定了过大的电子表格文件时。

异常不会频繁发生,因此发生异常的函数通常不返回错误码,那样做将因为对罕见情况的处理弄乱代码,并模糊对常见情形的处理。异常由应用程序引发,由恢复代码处理(如果能恢复的话)。异常的作用域是动态的:当一个异常被引发时,它由最近实例化的处理程序处理。将控制权转移到处理程序,类似于非局部的goto,实例化处理程序的例程可能与引发异常的例程相距颇远。

一些语言对实例化处理程序和引发异常,提供了内建的设施。在C语言中,标准库函数setjmp和longjmp是建立结构化的异常处理设施的基础。简言之,setjmp实例化一个处理程序,而longjmp引发一个异常。

详细说明则需要举个例子。假定函数allocate调用malloc分配n字节,并返回由malloc返回的指针。但如果malloc返回NULL指针,这表示无法分配请求的空间,allocate会引发Allocate_Failed异常。异常本身声明为一个jmp_buf,在标准头文件setjmp.h中:

#include <setjmp.h>  
 
int Allocation_handled = 0;  
jmp_buf Allocate_Failed;  

除非已经实例化一个处理程序,否则Allocation_handled为零,allocate在引发异常之前会检查Allocation_handled:

void *allocate(unsigned n) {  
    void *new = malloc(n);  
 
    if (new)  
        return new;  
    if (Allocation_handled)  
        longjmp(Allocate_Failed, 1);  
    assert(0);  
} 

在分配失败且没有已经实例化的处理程序时,allocate使用断言来实现已检查的运行时错误。

处理程序通过调用setjmp(Allocate_Failed)实例化,该调用返回一个整数。setjmp的一个有趣特性是,它可能返回两次。对setjmp的调用返回零。allocate中对longjmp的调用导致setjmp第二次返回,这次的返回值是longjmp的第二个参数,在上述的例子中是1。因而,客户程序可通过测试setjmp的返回值处理异常:

char *buf;  
Allocation_handled = 1;  
if (setjmp(Allocate_Failed)) {  
    fprintf(stderr, "couldn't allocate the buffer\n");  
    exit(EXIT_FAILURE);  
}  
buf = allocate(4096);  
Allocation_handled = 0;  

在setjmp返回0时,代码将继续执行,调用allocate。如果分配失败,allocate中的longjmp将导致setjmp再次返回,这一次返回值为1,执行将进入另一个分支,调用fprintf和exit。

这个例子没有处理嵌套的处理程序,如果上述的代码调用了makebuffer(假定),而makebuffer本身又实例化了一个处理程序并调用了allocate,就会出现嵌套的处理程序。嵌套的处理程序机制是必须提供的,因为客户程序无法得知实现因自身的目的而实例化的那些处理程序。此外,Allocation_handled标志也颇为别扭,未能在适当的时候设置或清除它将导致混乱。下一节描述的Except接口会处理这些遗漏。

4.1 接口

Except接口将setjmp/longjmp设施封装在一组宏和函数中,这些宏和函数相互协作,提供了一个结构化的异常处理设施。它并不完善,但避免了上文所述的错误,而其中的宏很清楚地标识出了使用异常的位置。

异常是Except_T类型的全局或静态变量:

except.h

〉≡  
  #ifndef EXCEPT_INCLUDED 
  #define EXCEPT_INCLUDED  
  #include <setjmp.h>  
 
  #define T Except_T  
  typedef struct T {  
      const char *reason;  
  } T;  
 
 〈exported types

 39〉  
 〈exported variables

 39〉  
 〈exported functions

 35〉  
 〈exported macros

 35〉  
 
  #undef T  
  #endif  

Except_T结构只有一个字段,可以初始化为一个描述异常信息的字符串。在发生未处理的异常时,将输出该字符串。

异常处理程序需要操作异常的地址。因而异常必须是全局或静态变量,使得其地址可以唯一地标识某个异常。将异常声明为局部变量或作为参数是未检查的运行时错误。

异常e通过RAISE宏或Except_raise函数引发

exported macros

 35〉≡  
  #define RAISE(e) Except_raise(&(e), __FILE__, __LINE__)  
 
〈exported functions

 35〉≡  
  void Except_raise(const T *e, const char *file,int line);  

向Except_raise传递的e值为NULL指针,是已检查的运行时错误。

处理程序通过TRY-EXCEPT和TRY-FINALLY语句实例化,这些语句用宏实现。这些语句处理嵌套异常并管理异常状态数据。TRY-EXCEPT语句的语法如下:

TRY  
    S

  
EXCEPT( e

1

 )  
    S

1

  
EXCEPT( e

2

 ) 
    S

2

  
...  
EXCEPT( e

n

 ) 
    S

n

 
ELSE  
    S

0

  
END_TRY  

TRY-EXCEPT语句为e1 、e2 、…、en 等异常确定处理程序,并执行语句S。如果S没有引发异常,将卸载处理程序并继续执行END_TRY之后的语句。如果S引发了一个异常e,e是e1 -en 之一,那么S的执行将中断,控制立即转移到e对应的EXCEPT子句后的语句。各个处理程序将卸载,而e对应的EXCEPT子句中的处理程序语句Si 将会执行,接下来将继续执行END_TRY之后的代码。

如果S引发的异常并非e1 -en 其中之一,那么各处理程序将被卸载,ELSE后的语句将执行,而后将继续执行END_TRY之后的代码。ELSE子句是可选的。

如果S引发的异常不能被某个Si 处理,那么各处理程序将卸载,该异常将传递到此前执行的TRY-EXCEPT或TRY-FINALLY语句建立的处理程序。

TRY-END_TRY在语法上与单个语句是等效的。TRY引入一个新的作用域,该作用域在对应的END_TRY处结束。

重写前一节末尾的例子,即可说明这些宏的用法。Allocate_Failed变为一个异常,如果malloc返回NULL指针,allocate将引发该异常:

Except_T Allocate_Failed = { "Allocation failed" };  
     
void *allocate(unsigned n) {  
    void *new = malloc(n);  
 
    if (new)  
        return new;  
    RAISE(Allocate_Failed);  
    assert(0);  
}  

如果客户程序代码想要处理该异常,则需在TRY-EXCEPT语句内部调用allocate:

extern Except_T Allocate_Failed;  
char *buf;  
TRY  
    buf = allocate(4096);  
EXCEPT(Allocate_Failed)  
    fprintf(stderr, "couldn't allocate the buffer\n");  
    exit(EXIT_FAILURE);  
END_TRY;  

TRY-EXCEPT语句是用setjmp和longjmp实现的,因此标准C语言有关这些函数用法的警告也适用于TRY-EXCEPT语句。特别地,如果S改变了某个自动变量,如果异常导致执行转向某个处理程序语句Si 或END_TRY之后的代码,那么该修改可能是无效的。例如,下述代码片段

static Except_T e;  
int i = 0;  
TRY  
    i++;  
    RAISE(e);  
EXCEPT(e) 
    ;  
END_TRY;  
printf("%d\n", i);  

可能输出0或1,这取决于setjmp和longjmp的实现相关的细节。S中改变的局部变量必须声明为volatile,例如,将i的声明改为

volatile int i = 0;  

将导致上述的例子输出1。

TRY-FINALLY语句的语法如下:

TRY  
    S

  
FINALLY  
    S

1

 
END_TRY  

如果S没有引发异常,将执行S1 并继续执行END_TRY之后的语句。如果S引发了异常,将中断S的执行,控制立即转移到S1 。在S1 执行之后,导致S1 执行的异常将被再次引发 (re-raised),使之可以被此前实例化的处理程序处理。请注意,在这两种情况下S1 都会执行。处理程序可以用RERAISE宏明确地再次引发异常:

exported macros

 35〉+≡  
  #define RERAISE Except_raise(Except_frame.exception, \ 
      Except_frame.file, Except_frame.line)  

TRY-FINALLY语句等效于:

TRY 
    S

  
ELSE  
     S

1

  
    RERAISE;  
END_TRY;  
S

1

请注意,无论S是否引发了异常,都会执行S1

TRY-FINALLY语句的一个目的是,在发生异常时给客户程序一个机会进行“清理”。例如,

FILE *fp = fopen(...);  
char *buf; 
TRY 
    buf = allocate(4096);  
    ... 
FINALLY  
    fclose(fp); 
END_TRY;  

无论分配失败还是成功,上述代码都会关闭打开的文件fp。如果分配确实失败了,那么必须有另一个处理程序来处理Allocate_Failed。

如果TRY-FINALLY语句中的S1 或TRY-EXCEPT语句中的处理程序引发了一个异常,该异常将由此前实例化的处理程序处理。

下述的退化语句

TRY  
    S

  
END_TRY  

等效于

TRY  
    S

  
FINALLY  
    ;  
END_TRY  

该接口中最后一个宏是

exported macros

 35〉+≡  
  #define RETURN switch (〈pop

 41〉,0) default: return  

在TRY语句内部需要使用RETURN宏,而不是return语句。在TRY-EXCEPT或TRY-FINALLY语句内部执行C语言的return语句是一个未检查的运行时错误。如果TRY-EXCEPT或TRY-FINALLY中的任何语句必须执行返回,可以用RETURN宏来代替通常的return语句。RETURN宏中使用了switch语句,使得RETURN和RETURN e 都能够扩展为语法正确的C语句。<pop 41>的细节在下一节描述。

显然,Except接口中的宏比较粗糙且有些脆弱。其中的未检查的运行时错误特别麻烦,可能成为特别难发现的bug。对大多数应用程序来说,这些宏是足够的,因为异常应当保守地使用,在大型应用程序中也只应该有少量异常。如果异常的数量迅速扩大,这通常标志着更严重的设计错误。

4.2 实现

Except接口中的宏和函数相互协作,维护了一个结构栈,栈中的各个结构实例记录了异常状态和实例化的处理程序。该结构的env字段是一个jmp_buf,由setjmp和longjmp使用,因而该栈能够处理嵌套异常。

exported types

 39〉≡  
  typedef struct Except_Frame Except_Frame;  
  struct Except_Frame {  
      Except_Frame *prev;  
      jmp_buf env;  
      const char *file;  
      int line;  
      const T *exception;  
  };  
 
〈exported variables

 39〉≡  
  extern Except_Frame *Except_stack;  

Except_stack指向异常栈顶端的异常帧,每个帧的prev字段指向前一个帧。如前一节中RERAISE的定义所示,引发异常会将异常的地址存储在exception字段中,并将异常的“坐标”(即引发异常的文件和行号)存储到file和line字段中。

TRY子句将一个新的Except_Frame压入异常栈并调用setjmp。Except_raise由RAISE和RERAISE调用,该函数会在栈顶的异常帧中填写exception、file和line字段,将栈顶的Except_Frame弹出栈,并调用longjmp。EXCEPT子句测试该帧的exception字段来确定应用哪个处理程序。FINALLY子句执行其清理代码并再次引发弹出的异常帧中存储的异常。

如果发生异常后,控制转移到END_TRY子句时异常尚未被处理,则再次引发该异常。

TRY、EXCEPT、ELSE、FINALLY和END_TRY几个宏相互协作,将TRY-EXCEPT语句转译为下述形式的语句:

do {  
    创建Except_Frame并压栈 
    if (从setjmp第一次返回) {  
        S

  
    } else if (异常为 e

1

 ) {  
        S

1

  
    ...  
    } else if (异常为 e

n

) { 
        S

n

 
    } else {  
        S

0

   
    }  
    if (发生异常但没有处理)  
        RERAISE;  
} while (0)  

do-while语句使得TRY-EXCEPT在语法上与普通的C语句等效,这样它可以像任何其他C语句一样使用。例如,它可以用作if语句的后项。图4-1给出了一般的TRY-EXCEPT语句生成的代码。阴影方框标明了TRY和END_TRY宏展开得到的代码,方框标记了EXCEPT宏展开得到的代码,而双线框标记了ELSE展开生成的代码。图4-2给出了TRY-FINALLY语句展开生成的代码。方框标记了FINALLY展开得到的代码。

052

图4-1 TRY-EXCEPT语句的展开

Except_Frame的空间是在栈上分配的,只需在由TRY开始的do-while内部的复合语句中声明一个该类型的局部变量即可:

exported macros

 35〉+≡  
  #define TRY do { \  
      volatile int Except_flag; \  
      Except_Frame Except_frame; \  
     〈push

 41〉 \  
      Except_flag = setjmp(Except_frame.env); \  
      if (Except_flag == Except_entered) {  
053

图4-2 TRY-FINALLY语句的展开

一个TRY语句内有4种状态,如以下的枚举标识符所示。

exported types

 39〉+≡  
  enum { Except_entered=0, Except_raised,  
         Except_handled,  Except_finalized };  

setjmp的第一次返回将Except_flag设置为Except_entered,表示已经进入TRY语句并将一个异常帧压入异常栈。Except_entered必须为零,因为第一次调用setjmp返回零,此后从setjmp返回时会将该标志设置为Except_raised,这表示发生了异常。处理程序将Except_flag设置为Except_handled,表示它们已经处理了该异常。

Except_Frame压入异常栈时,只需将其添加到Except_stack指向的Except_Frame结构链表的头部,而从链表头部删除异常帧,即表示将栈顶的异常帧出栈。

push

 41〉≡  
  Except_frame.prev = Except_stack; \ 
  Except_stack = &Except_frame;  
 
〈pop

 41〉≡  
  Except_stack = Except_stack->prev  

EXCEPT子句将变为图4-1中给出的else-if语句。

exported macros

 35〉+≡  
  #define EXCEPT(e) \  
             〈pop if this chunk follows S

 42〉 \  
          } else if (Except_frame.exception == &(e)) { \  
              Except_flag = Except_handled;  
 
〈pop if this chunk follows S

 42〉≡  
  if (Except_flag == Except_entered)〈pop

 41〉;  

使用宏来实现异常将导致一些扭曲的代码,如代码块<pop if this chunk follows S 42>所示。该代码块出现在上述EXCEPT定义中的else-if之前,仅当处于第一个EXCEPT子句中,才会弹出异常栈顶部的异常帧。如果在执行S时没有发生异常,Except_flag的值仍然是Except_entered,那么在控制到达if语句时,将弹出异常栈顶部的异常帧。而第二个和后面的EXCEPT子句则跟随在处理程序之后,此时Except_flag已经变为Except_handled。对于这些子句来说,异常栈顶部的异常帧已经弹出,代码块<pop if this chunk follows S 42>中的if语句防止了再次弹出。

ELSE子句与EXCEPT子句类似,但将else-if改为else:

exported macros

 35〉+≡  
  #define ELSE \  
         〈pop if this chunk follows S

 42〉 \  
      } else { \  
          Except_flag = Except_handled;  

同样,FINALLY子句也类似于ELSE子句,只是没有else语句而已:控制直接进入到清理代码。

exported macros

 35〉+≡  
  #define FINALLY \  
         〈pop if this chunk follows S

 42〉 \  
      } { \  
          if (Except_flag == Except_entered) \  
              Except_flag = Except_finalized;  

这里将Except_flag从Except_entered改变为Except_finalized,表示没有发生异常,但进入到了FINALLY子句。如果发生了异常,那么Except_flag仍然保持Except_raised的值不变,这样在清理代码执行之后可以再次引发异常。在END_TRY中,会判断Except_flag是否等于Except_raised,如果是的话,则再次引发异常。如果没有发生异常,Except_flag将是Except_entered或Except_finalized:

exported macros

 35〉+≡  
  #define END_TRY \  
         〈pop if this chunk follows S

 42〉 \  
          } if (Except_flag == Except_raised) RERAISE; \  
  } while (0)  

except.c中Except_raise的实现,是拼图的最后一片:

except.c

〉≡  
  #include <stdlib.h>  
  #include <stdio.h>  
  #include "assert.h"  
  #include "except.h"  
  #define T Except_T  
 
  Except_Frame *Except_stack = NULL;  
 
  void Except_raise(const T *e, const char *file, 
      int line) {  
      Except_Frame *p = Except_stack;  
 
      assert(e);  
      if (p == NULL) {  
         〈announce an uncaught exception

 43〉  
      }  
      p->exception = e;  
      p->file = file;  
      p->line = line;  
     〈pop

 41〉;  
      longjmp(p->env, Except_raised);  
  }  

如果异常栈顶部有一个Except_Frame,则Except_raise填写其exception、file和line字段,从栈中弹出该异常帧,并调用longjmp。与之对应的setjmp的调用将返回Except_raised,在TRY-EXCEPT或TRY-FINALLY语句中,setjmp返回的Except_raised接下来会赋值给Except_flag,然后执行适当的处理程序。Except_raise会从异常栈栈顶弹出一个异常帧,这样,如果某个处理程序中发生了异常,该异常将由当前异常帧顶部的异常帧所对应的TRY-EXCEPT语句处理。

如果异常栈是空的,即将引发的异常不会有处理程序,因此Except_raise别无选择,只能宣布一个未处理的异常并停止程序的执行:

announce an uncaught exception

 43〉≡  
  fprintf(stderr, "Uncaught exception");  
  if (e->reason)  
      fprintf(stderr, " %s", e->reason);  
  else  
      fprintf(stderr, " at 0x%p", e);  
  if (file && line > 0)  
      fprintf(stderr, " raised at %s:%d\n", file, line);  
  fprintf(stderr, "aborting...\n");  
  fflush(stderr);  
  abort();  

abort是标准C库函数,用于放弃程序的执行,有时会有一些与机器相关的副效应。例如,它可能启动一个调试器或只是进行内存转储。

4.3 断言

C语言标准要求头文件assert.h将assert(e)定义为宏,来提供诊断信息。assert(e)会计算表达式e的值,如果e为0,则向标准错误输出(stderr)写出诊断信息,并调用标准库函数abort放弃程序的执行。诊断信息包含失败的断言(即表达式e的文本)和断言(e)出现的坐标(文件和行号)。该信息的格式是由具体实现定义的。assert(0)是一个很好的方法,用于指明“不可能发生”的情况。当然,也可以使用如下的断言:

assert(!"ptr==NULL -- can't happen")  

这显示了更有意义的诊断信息。

assert.h也使用NDEBUG宏,但并未定义。如果定义了NDEBUG,那么assert(e)必须等效于空表达式((void)0)。这样,程序员可以通过定义NDEBUG并重新编译来关闭断言。由于e可能不被执行,很重要的一点是,e绝不应该成为有副效应的计算过程(如赋值)的一个必要部分。

assert(e)是一个表达式,因此assert.h的大多数版本在逻辑上都等效于

#undef assert  
#ifdef NDEBUG  
#define assert(e) ((void)0)  
#else  
extern void assert(int e);  
#define assert(e) ((void)((e)|| \  
    (fprintf(stderr, "%s:%d: Assertion failed: %s\n", \  
    __FILE__, (int)__LINE__, #e), abort(), 0)))  
#endif  

(assert.h的“真实”版本与上述代码不同,因为使用fprintf和stderr需要包含stdio.h,这是不允许的。)类似e1 ||e2 的表达式通常出现在条件判断中,如if语句,但它也可以作为单独的语句出现。作为单独的语句,该表达式的效果等效于下述语句:

if (!(e

1

)) e

2

;  

assert的定义使用了e1 ||e2 ,这是因为assert(e)必须扩展为表达式,而不是语句。e2 是一个逗号表达式,其结果是一个值,这是||运算符的要求,整个表达式最终转换为void,是因为C语言标准规定assert(e)没有返回值。在标准的C预处理器中,#e将转换为一个字符串常量,字符串的内容是表达式e在源代码中的文本。

Assert接口按标准的规定定义了assert(e),但在断言失败时将引发Assert_Failed异常,而不是放弃执行,另外也没有提供表达式e的文本:

assert.h

〉≡  
  #undef assert  
  #ifdef NDEBUG  
  #define assert(e) ((void)0)  
  #else  
  #include "except.h"  
  extern void assert(int e);  
  #define assert(e) ((void)((e)||(RAISE(Assert_Failed),0)))  
  #endif  
 
〈exported variables

 39〉+≡  
  extern const Except_T Assert_Failed;  

Assert模仿了标准的定义,这样Assert和标准提供的两个assert.h头文件是可互换的,这也是Assert_Failed出现在except.h中的原因。该接口的实现很简单:

assert.c

〉≡  
  #include "assert.h"  
 
  const Except_T Assert_Failed = { "Assertion failed" };  
 
  void (assert)(int e) { 
      assert(e);  
  }  

在函数定义中,围绕函数名assert的括号防止宏assert在此展开,因而按接口的规定定义了该函数。

如果客户程序没有处理Assert_Failed,那么断言失败将导致程序放弃执行,并输出一条信息,如下所示:

Uncaught exception Assertion failed raised at stmt.c:201  
aborting...  

这在功能上与assert.h特定于机器的版本所输出的诊断信息是等效的。

将断言打包起来,使之在失败时引发异常,这种做法有助于解决在产品程序中处理断言面临的两难处境。一些程序员建议不要将断言留在产品程序中,assert.h中对NDEBUG的标准用法支持了该建议。关于删除断言的原因,最常提到的两个原因是效率和含义模糊的诊断信息。

断言确实要花费时间,因此删除断言只会使程序更快。可以测量有无断言情况下执行时间的差别,但差别通常很小。因为效率原因而删除断言,与改进执行时间的其他任何改变都是类似的:仅在得到客观测量结果的支持时,才应该进行改变。

在测量表明断言开销太高时,有时可以移动断言的位置,在不失去断言好处的情况下降低其开销。例如,假定h包含了一个开销过高的断言,f和g都调用了h,测量表明大多数时间开销是因为来自g的调用造成的,g在一个循环中调用了h。谨慎的分析可能会揭示这样的可能性,即h中的断言可以移到f和g,在g中置于循环之前。

断言的更严重的问题在于,它们会导致输出诊断信息,如上文的断言失败诊断,这将迷惑用户。但删除断言,无疑是用更严重的问题代替了诊断信息。在断言失败时,程序就是错误的。如果程序继续执行,其结果是不可预测的,很可能崩溃。如下的信息:

General protection fault at 3F60:40EA  

Segmentation fault -- core dumped  

与上文显示的断言失败诊断信息没多大差别。更糟糕的是,在断言失败之后继续执行(而不停止)的程序可能会破坏用户的数据,例如,编辑器如果在断言失败后继续执行,就可能破坏用户的文件。这种行为是不可原谅的。

断言失败时,诊断信息含义模糊的问题可以这样解决:在程序的产品版本顶层代码中放一个TRY-EXCEPT语句,捕获所有的未捕获异常,并输出更有帮助的诊断信息。例如:

#include <stdlib.h>  
#include <stdio.h>  
#include "except.h"  
 
int main(int argc, char *argv[]) {  
    TRY  
        edit(argc, argv);  
    ELSE  
        fprintf(stderr, 
    "An internal error has occurred from which there is " 
    "no recovery.\nPlease report this error to "  
    "Technical Support at 800-777-1234.\nNote the " 
    "following message, which will help our support " 
    "staff\nfind the cause of this error.\n\n")  
        RERAISE; 
    END_TRY; 
    return EXIT_SUCCESS;  
}  

在出现未捕获的异常时,将由该处理程序接手,指导用户报告bug,然后再输出含义模糊的异常诊断信息。对于断言失败,它会输出

An internal error has occurred from which there is no recovery. 
Please report this error to Technical Support at 800-777-1234. 
Note the following message, which will help our support staff 
find the cause of this error.  
 
Uncaught exception Assertion failed raised at stmt.c:201 
aborting...  

4.4 扩展阅读

有几种语言内建了异常机制,例子包括Ada、Modula-3 [Nelson,1991]、Eiffel [Meyer,1992]、和C++[Ellis and Stroustrup,1990]。Except接口的TRY-EXCEPT语句模仿了Modula-3的TRY-EXCEPT语句。

对C语言,已经提议了几种异常机制,它们都提供了类似TRY-EXCEPT语句的功能,语法和语义方面间或稍有变化。[Roberts,1989]一书描述了一种用于异常设施的接口,与Except提供的接口是等效的。他的实现也与本书类似,但在引发异常时更为高效。Except_raise调用longjmp将控制转移到处理程序。如果处理程序没有处理该异常,会再次调用Except_raise,进而调用longjmp。如果该异常的处理程序位于异常栈顶部之下第N帧,那么需要调用Except_raise和longjmp函数N次。Roberts的实现只需一次调用,即可找到适当的处理程序,或跳转到第一个FINALLY子句。为做到这一点,需要对TRY-EXCEPT语句中异常处理程序的数目设置一个上限。一些C语言编译器(如微软公司提供的),提供了结构化异常设施作为语言扩展。

一些语言有内建的断言机制,Eiffel就是一个例子。大多数语言使用与C语言的assert宏类似的机制,或用其他编译器指令来指定断言。例如,Digital的Modula-3编译器可以识别形如<*ASSERT expression*>的注释,将其作为指定断言的编译指示。[Maguire,1993]一书用一整章的篇幅讨论C程序中断言的使用。

4.5 习题

4.1 一个语句同时包含EXCEPT和FINALLY子句,该语句会有何种效果?以下是这种形式的语句:

TRY  
    S

  
EXCEPT(e

1

)  
    S

1

  
...  
EXCEPT(e

n

) 
    S

n

 
FINALLY  
    S

0

  
END_TRY  

4.2 修改Except的接口和实现,使得只调用一次longjmp,即可到达适当的处理程序或FINALLY子句,如上文所述,[Roberts,1989]一书就实现了这种处理方式。

4.3 UNIX系统使用信号来通知一些异常情况,如浮点上溢和用户敲击“中断”键。请研究UNIX信号指令系统,并对信号处理程序设计实现一种接口,将信号转换为异常。

4.4 一些系统在程序异常结束时输出调用栈回溯。这给出了程序异常结束时过程调用栈的状态,它可能包括过程名和参数。改变Except_raise,使之在通知未捕获的异常时输出调用栈回溯。读者也许能够输出调用的过程名和行号,这取决于读者计算机上的调用约定。例如,调用栈回溯信息可能如下所示:

Uncaught exception Assertion failed 
raised in whilestmt() at stmt.c:201 
called from statement() at stmt.c:63 
called from compound() at decl.c:122 
called from funcdefn() at decl.c:890 
called from decl() at decl.c:95 
called from program() at decl.c:788 
called from main() at main.c:34 
aborting...  

4.5 在一些系统上,程序在检测到错误时可以对本身调用调试器。这种设施在开发期间特别有用,这期间断言失败的情况很常见。如果你的系统支持这种设施,可修改Except_raise,使之在通知未捕获的异常后不再调用abort,而是启动调试器。设法使你的实现能够在产品程序中工作,即,使之能够在运行时判断是否调用调试器。

4.6 如果你可以接触到C编译器的源代码如lcc [Fraser and Hanson,1995],请修改该编译器,使之支持异常、TRY语句、RAISE以及RERAISE表达式,语法和语义如本章所述,但不能使用setjmp和longjmp。你需要实现一种类似setjmp和longjmp的机制,只是该机制专用于异常处理。例如,通常可以只用几个指令来实例化处理程序。提醒读者:这个习题是一个较大的项目。