網上衝浪時發現一個很有意思的文獻——《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)時,重要的是定義它的行爲,不只是在“正常”狀況下的行爲,還包括不正常以及異常狀況下的。比如,對於一個可能實現了函數如open、read和write的文件處理包,它必須定義這些例程的語義,不僅要考慮所有都完美的情況,還要考慮如沒找到文件或數據出錯時怎麼辦。一些狀況說明出錯了;而另一些則是預期會發生,但不是“主線”的行爲,比如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的值;然而爲了包含EOF,getc被定義爲返回一個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)語句塊捕獲,然後成功獲得了異常信息。
不得不歎服作者構思之精妙。
還有好多要學的。。。