实现C语言的异常处理机制 Implementing Exceptions in C

网上冲浪时发现一个很有意思的文献——《Implementing Exceptions in C》,虽然十分古老(1989),但对C语言这种几乎不变的语言来说不存在知识过时的问题。文中讲了怎么在纯C语言中实现类似C++异常处理机制的方法,并提供了库源码,让人眼前一亮,于是翻译一番,作为自己的库的一部分。

译者注:译者博客(http://blog.csdn.net/lin_strong),转载请保留这条,仅供学习交流使用,请勿用于商业用途。

另,感谢作者Eric S. Roberts及Systems Research Center of Digital Equipment Corporation in Palo Alto, California。

Implementing Exceptions in C

by Eric S. Roberts

摘要:传统地,C程序员使用异常返回码的方式来告知程序执行中发生的异常。更高级的语言提供了另一种异常处理机制,这种机制把异常处理整合进了控制结构语句中。这种新方法相较于异常码有许多优势:它增加了程序错误被探测到的可能性;使得结构化一个抽象的规范(specification of an abstraction)变得简单;并通过提供更好的语法以拆分正常和异常情况的处理,提高了代码的可读性。这篇文章描述了一个支持C语言异常处理的语言扩展集(a set of language extensions)和一个基于预处理器的实现以阐述这个方法的可行性和移植性。

介绍

在设计一个抽象(abstraction)时,重要的是定义它的行为,不只是在“正常”状况下的行为,还包括不正常以及异常状况下的。比如,对于一个可能实现了函数如openreadwrite的文件处理包,它必须定义这些例程的语义,不仅要考虑所有都完美的情况,还要考虑如没找到文件或数据出错时怎么办。一些状况说明出错了;而另一些则是预期会发生,但不是“主线”的行为,比如read中读到了文件结尾符(end-of-file,EOF)。总的来说,上面的两种情况都叫做异常。

历史上,C程序员遵从Unix的传统,通过特别设计的返回码来通知异常。比如,标准I/O库中的fopen函数在请求的文件无法打开时返回NULL。相似地,getc函数通过返回特别的值 — EOF,来通知文件结束。然而这种方法有许多缺陷,并被描述为“可能是异常处理机制最原始的形式”。许多学者描述了更高级的报告及处理异常的结构来解决这些缺点。

这篇文章描述了用在C语言中的一个通用异常处理方法(facility)。这个方法实现了重大的功能提升,使得我们能从语义和逻辑上拆分一个抽象的正常行为和可能发生的异常状况。它基于Modula-2+和Modula-3的异常处理机制,并与Ada和CLU中的相似机制有着历史相关性。范例本身并不是新的,这篇文章的主要贡献在与阐述这个机制可以在不改变语言或牺牲移植性的前提下在C中实现。

尽管这个工作是独立完成的,它与Eric Allman和David Been在1985 USENIX会议上报告的一个更早的机制很相似。目标都是提供一个C语言的可移植的异常处理实现;另外,两个包都使用了C预处理器来实现可移植性,但之前的那个包使用了与系统相关的汇编代码。这篇文章中描述的工作在四个方面做出了额外的贡献:(1)不需要汇编代码;(2)语法上更强调了异常处理代码与被其捕获异常的程序区域的联系;(3)异常处理块(exception handlers)可以访问异常体(exception body)作用域内的本地变量;(4)这个方法还包含可以指定“最终(finalization)”行为的机制。

这篇文章中,第二章简述了C语言中传统使用的返回码机制的不足,第三章介绍了一个备选方案。第4章提供了这个机制的语义定义。第5章讨论了这个异常处理方法的实现。为了实现可移植性,这个实现基于C预处理器,并对运行环境做了最小程度的假设。在特定环境中,可以通过整合这个语法到编译器中以获得重大的性能提升,章节5.2中进行了讨论。只要这些扩展的编译器仍与基于预处理器的实现兼容,程序员可以依赖于基于预处理器的实现以保留可移植性。

返回码机制的缺陷

作为一个普通的技术,返回码有许多不足。首先,当函数同时返回数据时,常常难以找到合适的返回码来通知异常。一些情况下,找到这么一个值会造成类型系统(type system)违反直觉的削弱。比如,你会觉得一个叫做getc的函数应该返回一个类型为char的值;然而为了包含EOFgetc被定义为返回一个int

其次,使用单个返回码来通知异常经常也意味着如果想提供额外的数据以说明造成异常的细节的话,这些数据必须在异常码机制之外传递。在标准的Unix库中,这是通过特殊变量errno完成的。不幸地是,这个策略是不可重复的(reentrant),并且使得支持在单个地址空间使用多线程的库的设计变得更加复杂。

最后,最重要的是,通过返回码来通知异常的方式使得程序员很容易忽视它们。在开发分层软件包的时候这个问题特别明显,每个层都必须明确地检测返回值,决定是要内部处理它还是将其传递给调用者。如果抽象分层中的某一层没有检查返回值,那么异常追踪就丢失了。这个问题是许多传统C代码中难以调试的bug的元凶。

异常捕获的控制结构

备选方案是将异常处理作为控制结构的一部分。当探测到了一个异常状况,程序通过转移控制权给一个动态的专门处理这个状况的代码块来通知这个事件。控制权转移被称作 抛出一个异常,探测并响应异常状况的代码被称为一个 异常处理块(exception handler)。

具体来说,假设我们设计了一个新的文件管理包,它使用这个基于控制权的异常处理方案。客户端可能使用以下代码打开文件 test.dat:

TRY
    file = newopen(”test.dat", ”r” );
EXCEPT(OpenError)
    printf(”Cannot open the file test.dat\n”);
ENDTRY;

语句格式在下一个章节进行描述,但是上例阐述了这个机制。如果文件test.dat存在并且可读,newopen会正常返回并传递回一个handle然后赋值给file。如果,探测到了问题,newopen的实现可以抛出OpenError异常,这会导致控制权直接移交给EXCEPT中的printf。

注意,抛出OpenError异常的语句可以放在文件管理包的实现的任意深度。当异常被抛出,控制权栈(control stack)会弹出异常处理块的控制权,然后控制权被移交给异常处理块。如果没有发现合适的异常处理块,抛出的异常会导致致命错误。这意味着可以被安全地忽视的状况必须在代码中明确地指出,因此减少了无心之失的可能性。

如下所述,这个包提供的了比上面那个简单的例子多的多的功能。比如,TRY-EXCEPT语句可以指定多个独立的异常,不限于一个。每个异常有自己的处理块,这样,代码中就清晰地写出了对每种状况的响应。当一个异常被抛出,是可以传递额外的数据给处理块的。因此,在上例中,文件处理包可以传递错误类型的指示符,这样,客户端就可以区分是不存在文件还是保护冲突导致的异常。 这些是以可重入的方式实现的,因此不需要使用像errno这样的全局变量。包还提供了指定“最终”行为的机制,因为有些情况下,中间的软件层需要确保,即使异常导致控制权传递到这个层之外,有些事情也必须做。在描述TRY-FINALLY语句时会讨论更多细节。

语法形式

这个章节描述了为了实现C中异常处理而定义的新的“语句形式”,它们被定义为预处理宏。为了使用这个包,需要通过包含以下行来读取异常处理头文件:

#include ”exception.h”

声明异常

在异常包中,通过使用类型exception来声明一个新的异常:

exception name;

就像C中的其他变量声明一样,可以通过关键词static来限制一个异常的作用域,或者使用extern来引入另一个模块中的异常。一个异常通过它的地址唯一标识;它的值以及使用的实际类型与包的运作无关。在典型的实现中,一个异常使用的实际类型是一个结构体,以确保lint能在使用异常时探测到类型错误。

TRY-EXCEPT语句

一旦探测到异常,TRY-EXCEPT语句用于联系一个或更多个异常处理块到一个语句序列。TRY-EXCEPT语句的格式如下:

TRY
    statements
EXCEPT(name-J)
    statements
EXCEPT(name-2)
    statements
  ……
ENDTRY

可以有任意多个EXCEPT分支(多达由实现决定的最大值)。

TRY-EXCEPT语句的语法如下。首先,TRY语句块中的语句是被评估的。如果语句序列运行完成前没有遇到异常,异常作用域就退出了,然后控制权就传递到了整个块的后面。如果任意语句(或函数中封装的语句)抛出了一个异常,控制权立即传递给与TRY-EXCEPT中异常名匹配的异常处理块。

通过调用

RAISE(name, value);

来抛出异常。

其中,name是一个声明过的异常,而value是一个整型值,它会被传递给处理块的作用域。当遇到了RAISE语句,TRY作用域的动态栈会被用于搜索最内层的声明处理这个异常或处理预声明异常ANY的处理块,ANY会匹配所有异常。如果没有发现异常处理块,就会发生异常退出。如果发现了合适的处理块,控制权会返回到这个栈上下文,执行这个处理块中的语句。这些语句是在本地异常作用域外执行的,所以在处理块中的RAISE语句会传递异常回更高层。

在处理块中,传递给RAISE的value值可以通过使用参数名 exception_ value 取回,它是在异常处理块作用域中自动声明为一个int的。大部分情况下,不需要这个值。即使这样,还是需要在RAISE中指定value参数,因为RAISE是作为宏而不是一个例程实现的。

TRY-FINALLY语句

TRY的第二个用法是将“最终(finalization)”代码联系到一个语句序列以确保这段代码即使因为异常而非正常结束也会执行。这是通过使用TRY-FINALLY语句实现的,形式如下:

TRY
    statements
FINALLY
    statements
ENDTRY

在这种形式中,标准流程是执行TRY语句体后执行FINALLY分支中的语句。如果TRY语句体中的语句生成了一个异常,这会导致控制权传递出这个作用域,FINALLY语句体也会执行,然后异常会被重新抛出,这样它最终会被合适的处理块捕获。

比如,假设acquire(res) 和 release(res)分别请求和释放一些必须独占式访问的资源。代码片段

acquire(res);
TRY
    … 访问资源的代码  ...
FINALLY
    release(res);
ENDTRY

确保资源会被释放,即使访问资源的代码抛出了异常。确保这不会破坏管理资源的数据结构的完整性是程序员的责任。

注意,在不改变编译器的情况下,如果在TRY-FINALLY语句中明确地要将控制权转移出去的话,是无法拦截的,只有能异常被正确地处理。比如,这样写起来很方便:

acquire(res);
TRY
    return (res->data);
FINALLY
    release(res);
ENDTRY

你可能希望在TRY-FINALLY语句体中return的时候能够调用FINALLY分支(在Modula-2+和Modula-3中确实是这样的),但是在可移植的实现中,这是不可行的。因此,只能把结果赋值给一个临时变量,并将return语句写在TRY语句体之外。

异常处理的实现

考虑下面简单的TRY-EXCEPT示例:

#include "exception.h"
exception e;
Test(){
    TRY
        Body();
    EXCEPT(e)
        Handler();
    ENDTRY
}

其扩展形式如下:

Test(){
    {
        context_block _ctx;
        int _es = ES_Initialize;
        _ctx.nx = 0;
        _ctx.link= NULL;
        _ctx.finally = 0;
        _ctx.link = exceptionStack;
        exceptionStack = &_ctx;
        if (setjmp(_ctx.jmp) != 0) _es = ES_Exception;
        while (1) {
            if (_es == ES_EvalBody) {
                Body();
                if(_es == ES_EvalBody)  exceptionStack = _ctx.link;
                break;
            }
            if (_es == ES_Initialize) {
                if (_ctx.nx >= MaxExceptionsPerScope)
                    exit(ETooManyExceptClauses);
                _ctx.array[_ctx.nx++] = &e;
            } else if (_ctx.id == &e || &e == &ANY) {
                int exception_value = _ctx.value;
                exceptionStack = _ctx.link;
                Handler();
                if (_ctx.finally && _es == ES_Exception)
                    _RaiseException(_ctx.id, _ctx. value);
                break;
            }
            _es = ES_EvalBody;
        }
    }
}

扩展的TRY语句体设计为最开始在例程的栈帧上声明一个本地上下文块_ctx,初始化合适的字段,然后链接这个块到活跃异常的链表中。在循环的第一遍,变量_es被设为ES_Initialize。这个变量会被设为另外两个值:在while循环的第二遍迭代时设为ES_EvalBody,如果经由调用RAISE导致控制权返回setjmp时设为ES_Exception。

TRY的语句体在循环的第一遍迭代时并没有执行,第一遍只是简单地初始化了由这个TRY-EXCEPT处理的异常的数组。第二遍则执行主语句体,如果调用了RAISE,其会被翻译为调用_Raise_Exception,这个函数会搜索异常栈以找到合适的处理块,然后使用longjmp来跳转到这个上下文。当这发生时,_es被设置为ES_Exception,EXCEPT语句的扩展中的条件分支会选择正确的处理块。

基于预处理器的实现 vs. 编译器支持

上述基于预处理器的实现并没有生成特别高效的代码,主要是因为实现基于宏扩展,没有办法实现上下文敏感的扩展或重排代码。编译器的话能做的更好。

当然可以选择直接在编译器中实现这个异常处理机制。编译器可以识别这个语法并生成效率高得多的代码。特别地,编译器可以通过将大部分工作移到抛出异常的代码中来大大减少进入异常作用域的开销;因为大部分应用使用异常的情景并没那么频繁,这个权衡能提升整体性能。但是,需要改变编译器的方案牺牲了可移植性。然而,只要存在一个基于预处理器的实现,有理由扩展特定编译器以提供更低的开销。

我们使用上面给出Test例程的扩展形式来阐述基于预处理器的实现导致的额外开销。大部分开销是由于预处理器无法考虑上下文信息。比如,对于一个带有两个EXCEPT分支的TRY语句,由于TRY语句体末尾所需要操作与首个EXCEPT语句末尾所需的不同,编译器会在每个位置生成不同的代码。不幸地是,预处理器却做不到。预处理器唯一可做的是用上下文无关的方式扩展EXCEPT宏。这导致了冗余的测试,比如主语句体后的

if (_es == ES_EvaIBody)

条件分支。如果代码运行到了这,条件肯定为真,然而当同个宏在一个EXCEPT分支后面扩展开时,它会是false。将这个语法整合进编译器就可以消除这样的冗余。

基于编译器的实现的另一个优点是可以提供更好的语法错误检查。如大部分基于宏的扩展,依赖C预处理器意味着一些语法错误能够通过检测,而那些被探测到的错误会以扩展的形式被报告。

依赖

这个实现依赖于库例程setjmp和longjmp来实现实际的控制权传输。许多非Unix的C实现上都支持这两个例程,所以这个依赖不会大大降低这个包的可移植性。特别地,包没有对定义在头文件setjmp.h中的jmp_buf的内部结构做出任何假设。

即使这样,也一定要意识到,一些系统在使用setjmp和longjmp时有着和实现相关的限制,特别是当编译器试着过于聪明时。如果存在这些限制,异常处理包可能无法使用。我们觉得这样的setjmp和longjmp实现真是脑子有毛病,我们并不认为应该改变编译器和语言以兼容这种环境。

多处理器上的实现

这篇文章中的实现使用了全局变量exceptionStack作为指向活跃的异常块的链表的指针。这在传统的Unix环境中是合适的,但是在一个并发(concurrent)环境中会出问题,在并发环境中,多个独立的轻量过程(线程)共享同个地址空间。如果操作系统支持线程的话,得要为每个线程分别维护一个这个指针的拷贝。如果并发的实现提供了线程独立的数据空间,链表指针应该会被放在那里。否则,需要通过其他方式进行模拟,比如对线程的ID进行哈希。

其他权衡考虑

在这个实现中,context_block结构实现了一个异常的数组,而不是一个链表,这样避免了注册异常时动态分配内存的开销。但是副作用就是会在每一个作用域放一个固定上界的数组,但在实践中这不太可能是个大问题。

在_Raise_Exception的代码中,执行了两个循环:一个用于确定是否存在任意异常处理块,另一个用于执行FINALLY分支。这里可以使用一个循环,但是两个循环的优点是EUnhandledException错误发生在最初的栈上下文,这样在调试器中更容易发现未处理的异常。

结论

这个包说明,不用扩展编译器,也不用非标准地假定运行环境,就能在C中实现异常处理机制。这就是说,使用异常机制写出来的程序可以移植到许许多多架构、操作系统和编译器。如果需要更高的效率,这个语法可以整合到编译器中,并且还保留对基于预处理器实现的可移植性。

鸣谢

感谢Garret Swart通读了这篇文章的多个草稿,并催促我写下这个报告。

参考文献


源码

译者注:下面的源码基本遵循原版,但是因原版语法过于老旧,对函数声明部分略有修改。同时由于用到了exit函数,在头文件中加入了#include <stdlib.h>,原版并没有。

/* Copyright 1989 Digital Equipment Corporation.                          */
/* Distributed only by permission.                                        */
/**************************************************************************/
/* File: exception.h                                                      */
/* Last modified on Wed Mar 15 16:40:41 PST 1989 by roberts               */
/*                                                                        */
/* The exception package provides a general exception handling mechanism  */
/* for use with C that is portable across a variety of compilers and      */
/* operating systems. The design of this facility is based on the         */
/* exception handling mechanism used in the Modula-2+ language at DEC/SRC */
/* and is described in detail in the paper in the documents directory.    */
/* For more background on the underlying motivation for this design, see  */
/* SRC Research Report #3.                                                */
/**************************************************************************/

#include <setjmp.h>
#include <stdlib.h>

#define MaxExceptionsPerScope 10
#define ETooManyExceptClauses 101
#define EUnhandledException   102

#define ES_Initialize 0
#define ES_EvalBody   1
#define ES_Exception  2

typedef struct { char *name ;} exception;

typedef struct _ctx_block {
    jmp_buf jmp;
    int nx;
    exception *array[MaxExceptionsPerScope];
    exception *id;
    int value;
    int finally;
    struct _ctx_block *link;
} context_block;

extern exception ANY;
extern context_block *exceptionStack;
extern void _RaiseException(exception *e, int v);

#define RAISE(e, v) _RaiseException(&e, v)

#define TRY \
    {\
        context_block _ctx;\
        int _es = ES_Initialize;\
        _ctx.nx = 0;\
        _ctx.link= NULL;\
        _ctx.finally = 0;\
        _ctx.link = exceptionStack;\
        exceptionStack = &_ctx;\
        if (setjmp(_ctx.jmp) != 0) _es = ES_Exception;\
        while (1) {\
            if (_es == ES_EvalBody) {
              
#define EXCEPT(e) \
                if(_es == ES_EvalBody)  exceptionStack = _ctx.link;\
                break;\
            }\
            if (_es == ES_Initialize) {\
                if (_ctx.nx >= MaxExceptionsPerScope)\
                    exit(ETooManyExceptClauses);\
                _ctx.array[_ctx.nx++] = &e;\
            } else if (_ctx.id == &e || &e == &ANY) {\
                int exception_value = _ctx.value;\
                exceptionStack = _ctx.link;

#define FINALLY \
            }\
            if (_es == ES_Initialize) {\
                if (_ctx.nx >= MaxExceptionsPerScope)\
                    exit(ETooManyExceptClauses);\
                _ctx.finally = 1;\
            } else {\
                exceptionStack = _ctx.link;
                
#define ENDTRY \
                if (_ctx.finally && _es == ES_Exception)\
                    _RaiseException(_ctx.id, _ctx.value);\
                break;\
            }\
            _es = ES_EvalBody;\
        }\
    }
/* Copyright 1989 Digital Equipment Corporation.                          */
/* Distributed only by permission.                                        */
/**************************************************************************/
/* File: exception.h                                                      */
/* Last modified on Wed Mar 15 16:40:42 PST 1989 by roberts               */
/*                                                                        */
/* Implementation of the C exception handler. Much of the real work is    */
/* done in the exception.h header file.                                   */
/**************************************************************************/

#include <stdio.h>
#include "exception.h"

context_block *exceptionStack = NULL;

exception ANY;

void _RaiseException(exception *e, int v){
    context_block *cb, *xb;
    exception *t;
    int i, found;

    found = 0;
    for (xb = exceptionStack; xb != NULL; xb = xb->link) {
        for (i = 0; i < xb->nx; i++) {
            t = xb->array[i];
            if (t == e || t == &ANY) {
                found = 1;
                break;
            }
        }
        if (found) break;
    }
    if (xb == NULL) exit(EUnhandledException);
    for (cb = exceptionStack; cb != xb && !cb->finally; cb = cb->link);
    exceptionStack = cb;
    cb->id = e;
    cb->value = v;
    longjmp(cb->jmp, ES_Exception);
}

测试

这个不是文献内容,是译者自己进行的简单测试。
测试发现EXCEPT和FINALLY语句块不能跟在同一个TRY后面,如果要同时用到两个特性,则要写两个TRY语句进行嵌套,用宏的方法还是有很多局限的。

#include "exception.h"
#include <stdio.h>

exception e = { "假装是个很严重的错误"};
void raiseE(exception *toRaise){
  RAISE(*toRaise,100);
}

void main(){
  int i = 0;
  TRY
    TRY
      ++i;
      raiseE(&e);
      --i;
    FINALLY
      printf("FINAL\n");
    ENDTRY
  EXCEPT(e)
    printf("捕获到异常e:%s;异常码:%d\n",e.name,exception_value);
  ENDTRY
    printf("i = %d\n",i);
    system("pause");
}

运行结果如下:

从结果可以看出,异常确实可以从函数内部的抛出,并且跳过了–i这一句,所以最终i的值为1,并且运行了FINALLY语句块后继续抛出,并被EXCEPT(e)语句块捕获,然后成功获得了异常信息。

不得不叹服作者构思之精妙。

还有好多要学的。。。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章