深入淺出話異常-(1) (轉)

worldblog發表於2007-12-15
深入淺出話異常-(1) (轉)[@more@]

深入淺出話異常-(1)

Robert Schmidt

May 10, 1999

本期討論要點: 標準C的異常處理機制。

前言

標準C提供了幾種異常管理機制,這些機制在標準C++裡也可用,但是相關的頭名稱作了改變:舊的標準C標頭檔案名會從對映到新的標準C++裡的標頭檔案名。(標頭檔案名的字首C是為了記憶,指明它們是標準C的庫檔案)

雖然在C++的向後相容裡保留了C的標頭檔案,但我勸告你在任何可能的地方使用新的標頭檔案。對於許多實際使用中,最大的改變是在新的標頭檔案與namespace std內進行宣告。請看以下三種不同型別的示例:

//舊的使用使用方法,在標準C++裡被替換成#include

#include  

FILE *f = fopen("blarney.txt", "r");

//現在的用法,與舊方法很相似

std::FILE *f = std::fopen("blarney.txt", "r");

//混合使用,Visual C++支援這種用法?????

#include  
using namespace std;

FILE *f = fopen("blarney.txt", "r");

不幸的是,'s Visual C++不能在新的標頭檔案與namespace std同時具備的條件下進行宣告,即使這種行為是標準C必需的。除非等到Visual C++支援這種行為,我將在本行使用舊的C風格名字。

(對於像Microt這樣的庫供應商來說,實現這些C庫標頭檔案的正確性需要維護與測試兩套不同的程式碼,這是一項艱鉅的任務,且不帶來任何商業價值)

一、絕對終止:

這是一種徹底忽略異常的方法,大概這種簡單的響應是一種的退出方法。在一些情形裡,這是最正確的方法。

C庫標頭檔案提供了兩個不是相當完美的:abortexit,兩者都不返回它的者,並且結束執行。

雖然兩者在概念上是相同的,但使用它們的結果是不同的:

  • abort: 粗魯地結束程式。這是預設的,在執行時診斷裡呼叫abort來安全結束程式。這種結束方式可能會或可能不會重新整理與關閉開啟的檔案或刪除臨時檔案。
  • exit:文明地結束程式。它附加了關閉開啟的檔案與返回狀態碼給環境,exit還呼叫你用atexit註冊的回撥函式。

你通常是在發生嚴重異常的情況下呼叫abort,由於abort預設行為是立即結束程式,你需要在呼叫abort之前儲存你的資料。(在討論裡會再提到)

對於兩者的差異,exit執行客戶用atexit註冊的清除程式碼,它們的呼叫順序是按它們被註冊的相反順序來的。示例:

#include #include static void atexit_handler_1(void) { printf("within 'atexit_handler_1'n"); } static void atexit_handler_2(void) { printf("within 'atexit_handler_2'n"); } int main(void) { atexit(atexit_handler_1); atexit(atexit_handler_2); exit(EXIT_SUCCESS); printf("this line should never appearn"); return 0; } /* 執行後的結果: within 'atexit_handler_2' within 'atexit_handler_1' 並返回退出碼給呼叫環境. */


(注意:如果你的程式在main函式結束時沒有顯式呼叫exit,那麼你用atexit註冊的處理函式也會被呼叫)。

二、條件結束:

abortexit無條件終止你的程式。你也可以有條件地結束你的程式,這種機制是每一個程式設計師喜受的診斷工具:assert宏定義在,如下相似程式碼:

#if defined NDE #define assert(condition) ((void) 0) #else #define assert(condition) _assert((condition), #condition, __FILE__, __LINE__) #endif


//譯註:各家產品提供的assert的實現並不一樣,比如:

Visual C++ 6.0的實現是:#define assert(exp) (void)((exp)||(_assert(#exp, __FILE__, __LINE__), 0));

Borland C++ 5.5的實現是:#define assert(exp) ((exp) ? (void)0 : _assert(#exp, __FILE__, __LINE__))

至於函式_assert(在gcc的庫中_assert是一個宏)是各家的內部實現,不一定得非要_assert這個名字,其內容一般是利用printf函式(在WIN平臺上往往是呼叫MessageBox)輸出出錯資訊(檔名及行號)並呼叫abort終止程式。//end 譯註

在這個定義裡,當定義了預處理符號NDEBUG的時候,斷言是無效的,這意味著assert斷言宏只在你的Debug版本中有效。在Release版本里,assert斷言宏不進行任何計算。由於這個而會引起一些側面效應,比如:

/* 版本 */ #undef NDEBUG #include #include int main(void) { int i = 0; assert(++i != 0); printf("i is %dn", i); return 0; } /* 當執行後輸出: i is 1 */


那麼現在改變程式碼版本到release版本,定義NDEBUG:

/* release版本*/ #defing NDEBUG #include #include int main(void) { int i = 0; assert(++i != 0); printf("i is %dn", i); return 0; } /* 當執行後輸出: i is 0 */


所以在assert中只能是比較而不能有實質性的動作,否則除錯和釋出版的結果可能會大相徑庭。

因此,為了避免這種差異,確保在assert不能包含有側面影響的程式碼。

只在Debug版本里,assert會呼叫_assert函式。以下是相似程式碼:

void _assert(int test, char const *test_image, char const *file, int line) { if (!test) { printf("Assertion failed: %s, file %s, line %dn", test_image, file, line); abort(); } }


在斷言失敗將產生出詳細的診斷資訊,包含源程式檔名與行號,之後呼叫abort,我給這種機制的示例是相當的粗糙;你的庫實現者可能更復雜。

assert典型是用在除錯邏輯錯誤,它永遠不會存在於release程式裡。

static void f(int *p) { assert(p != NULL);//這兒! /* ... */ }


在使用assert中要注意邏輯錯誤與執行時錯誤的區別:

/* ...讓輸入檔名... */ FILE *file = fopen(name, mode); assert(file != NULL); /* 相當可疑的用法??? */


這種錯誤出現在assert表示式裡,但它不是BUG,它是執行時異常,assert可能會不正確地響應,你應該使用其它機制,我在下面介紹。

三、非區域性goto:

對比於abortexitgoto 讓你有更多地管理異常的方法,不幸的是gotos是區域性的,goto只能在它們函式的內部跳轉,因此不能在程式的任意地方控制它。

為了克服這種限制,標準C提供了setjmplongjmp函式,它可以goto到任何地方。標頭檔案 定義了這些函式,包括間接的jmp_buf,這種機制簡單直接:

  • setjmp(j)設定goto指標,jmp_buf用當前程式上下文資訊來初始j。這種上下文資訊典型包括程式位置指標、堆疊與指標,還有其暫存器與值。當初始化上下文資訊後,setjmp返回0.
  • 稍後呼叫longjmp(j, r)來goto到物件j指定的地方(之前呼叫setjmp進行初始化j),當呼叫的目標非區域性goto,setjmp返回r,如果r是0返回1.(記住:setjmp在這個上下文中不能返回0)

這裡有兩種型別的返回值,setjmp讓你來如何使用它。當設定j的時候,setjmp工作在正常預期的行為,但當目標是long jump, setjmp "wakes up" from outs its normal context.

如果使用longjmp來引發終止異常,setjmp可以標記相應的異常處理過程。

#include #include jmp_buf j; void raise_exception(void) { printf("exception raisedn"); longjmp(j, 1); /* jump到異常處理過程 */ printf("this line should never appearn"); } int main(void) { if (setjmp(j) == 0) { printf("'setjmp' is initializing 'j'n"); raise_exception();//恢復上下文 printf("this line should never appearn"); } else { printf("'setjmp' was just jumped inton"); /* 異常處理過程 */ } return 0; } /* 執行結果: 'setjmp' is initializing 'j' exception raised 'setjmp' was just jumped into */


注意:用jmp_buf來恢復其它上下文是無效的,請看以下示例:

jmp_buf j; void f(void) { setjmp(j); } int main(void) { f(); longjmp(j, 1); /* 邏輯錯誤 */ return 0; }


你必須在當前呼叫上下文中只認為setjmp是非區域性goto

四、訊號(Signals):

標準C也標準化事件(event)管理包(雖然較原始)。這個管理包定義了設定事件與訊號,連同標準的引發與處理方法。那些訊號可在異常表示式或不同的擴充套件事件裡引發它們。這也是要討論的目的。我只集中在異常訊號. 

對於使用這些管理包,應該包含標準標頭檔案,這個標頭檔案定義了raisesignal函式,sig_atomic_t型別與開始執行訊號事件的宏SIG。在標準要求裡有6個訊號宏,但你的庫實現者可以增加其它。但設定訊號的函式定義固定在裡,你不能擴充套件你自已的訊號設定函式。呼叫raise來引發訊號,並進入到相應的處理過程。執行時提供了預設的處理方法,但你可以你自已的訊號處理行為。處理方法透過sig_atomic_t來與外部程式進行通訊.對於型別名字的建議,分配給每一物件是原子方式或中斷安全(interrupt-safe)。

當你註冊訊號處理過程的時候,一般你要提供處理函式地址。每一個函式必需接受int值,且返回void。在這種方法,訊號處理方法象setjmp;只有異常上下文能接收單個整數:

void handler(int signal_value); void f(void) { signal(SIGFPE, handler); /* 註冊處理過程*/ /* ... */ raise(SIGFPE); /* 透過 'SIGFPE'來呼叫處理過程 */ }


有兩種安裝指定處理方法可供選擇:

  • signal(SIGxxx, SIG_DFL),//使用系統預設的處理方法.
  • signal(SIGxxx, SIG_IGN), //告訴系統忽略訊號。

在所有情形裡,訊號返回指向先前的處理過程的指標或SIG_ERR(意味著註冊失敗)

當處理方法被呼叫的時候,這意味訊號開始進行異常處理。你可以在處理方法裡自由呼叫abort,exit或longjmp來效地結束異常。一些有趣的地方:實際上,abort自已在內部也呼叫raise(SIGABRT),預設的SIGABRT異常處理方法顯示診斷資訊與結束程式。但你可以安裝你自已的SIGABRT異常處理方法來改變這種行為:

但你不能改變abort的終止程式的行為,以下是abort的相似程式碼:

void abort(void) { raise(SIGABRT); exit(EXIT_FAILURE); }


這兒,如果你SIGABRT異常處理方法返回後,abort也結束程式。

在標準C庫裡,在訊號異常處理方法行為也是有限制的。請看標準7.7.1.1的細節。

(譯者注:以下是標準C的草案檔案:裡的n843.pdf)

五、公共變數:

正常用於檢測到異常後進行通知處理過程:當得到異常事件的通知的時候,異常處理過程將被喚醒。如果你更喜歡檢查錯誤碼的方法,那麼標準庫提供了這種行為,包含在標頭檔案裡。這個標頭檔案定義了errno,再加上errno一些常用到的值。標準庫要求三個這樣的值:E, ERANGE,EILSEQ ,它們分別是domain,range與multibyte-sequence error,但提供商可能增加其它。

errno,包含設定與獲取:當程式碼產生異常物件(單個整數)時,複製異常物件的值給予errno,然後在使用者中檢測異常。

主要使用errno的庫函式集中在。在程式開始時errno被設定為0,而且沒有任何庫程式碼會自動再一次設定errno為0(也就是說當你處理了錯誤之後,一定要將errno設定為0才能再呼叫標準庫程式碼)。因此,對於檢測錯誤,你必須設定0,然後繼續呼叫標準庫程式。以下是示例:

#include #include #include int main(void) { double x, y, result; /* ... somehow set 'x' and 'y' ... */ errno = 0; result = pow(x, y); if (errno == EDOM) printf("domain error on x/y pairn"); else if (errno == ERANGE) printf("range error on resultn"); else printf("x to the y = %dn", (int) result); return 0; }


說明:errno不需要引用到物件:

int *_errno_function() { static int real_errno = 0; return &real_errno;//不需要這樣做 } #define errno (*_errno_function()) int main(void) { errno = 0; /* ... */ if (errno == EDOM) /* ... */ }


六、返回值與引數:

errno-像異常物件但沒有限制:

  • 所有相關部分必須集中在一起,允許設定與檢測相同物件.
  • 隨時可以改變物件.
  • 如果在呼叫其它程式之前你沒有重置物件或檢測它們,那麼你將錯過異常.
  • 宏與內部物件名會隱藏異常物件。
  • 靜態物件天生不具執行緒全安。

總結:每一個物件都是脆弱的:你太容易濫用它們,在你的編譯器沒有警告資訊裡,你的程式可能出現不可預測的行為。

去掉這些缺陷,你需要的物件應該是:

  • 由兩部分組成:一部分產生異常,另一部分檢測異常。
  • 取得正確的值.
  • 不要隱藏它們.
  • 是執行緒安全.

函式的返回值應該符合這些標準,因為它們是呼叫函式里建立未命名的臨時物件,且只能被呼叫者理解。當一個呼叫完成,呼叫者可能檢測或複製返回物件的值;之後,返回的原始物件消失了,因此不能在使用這個物件了。由於物件是未命名的物件,它是不能被隱藏的。

(在C++裡,我假定在函式呼叫表示式只返回左值,意味著呼叫者不能返回引用,我的這種限制只在我討論的C相容技術這部分裡,而且C沒有引用(C標準-C98也加入支援引用),所以我的這個假設是合理的)

int f() { int error; /* ... */ if (error) /* 存在錯誤 */ return -1; /* 產生異常物件 */ /* ... */ } int main(void) { if (f() != 0) /* 檢測異常 */ { /* 處理異常 */ } /* 再次執行 */ }


返回值是標準C庫用來傳播異常的較好的方法,請思考以下示例:

if ((p = malloc(n)) == NULL) /* ... */ if ((c = getchar()) == EOF) /* ... */ if ((ticks = clock()) < 0) /* ... */


說明:這種在一個語句裡進行捕捉返回值與測試異常的方法是較典型的慣用法。它有兩個不同的含義:合法的資料值與異常值。程式碼必須解釋這兩種計算路徑在哪兒知道它是正確的。

函式返回值的方法被運用於許多公共語言,Microsoft運用在它的COM模型。COM方法透過返回HRESULT來通報異常物件,Microsoft對這個值使用32位無符號整數。不像當才的例子只是討論。COM的返回值只返回狀態與異常資訊,其它資訊透過指標指向引數。

外部指標與C++引用引數是變種的函式返回值,但它們有以下幾點不同:

  • 你可以忽略或丟棄返回值。可是,外部引數繫結到相應的資訊,你不能完全忽略它們,與返回值對比,函式與呼叫者把引數緊緊耦合著。
  • 任何數值都可以經過外部引數返回,雖然函式返值只能傳送一個值,但外部引數可以提供多個邏輯返回值。
  • 返回值是臨時物件:在呼叫函式之前它們是不存在的,它們在呼叫者結束後消失。異常物件的生命期比被呼叫函式更長。

結尾

本期圍繞著介紹標準C支援的一般異常的處理方法。在第二期,我將介紹Microsoft擴充套件了這些標準C的方法:專用的異常處理宏與結構化異常處理(SEH)。

(譯註:說來慚愧,本人對C是一知半解,對於在C++裡,這些方法都不被推薦,以至於沒有深入過,這篇文章我翻譯得挺吃力的,如果有錯誤,請大家指點。)


來自 “ ITPUB部落格 ” ,連結:http://blog.itpub.net/10752043/viewspace-993608/,如需轉載,請註明出處,否則將追究法律責任。

相關文章