前言
即使程式本身沒有錯誤,不代表程式運行時不會發生錯誤;程式要面對許多外部錯誤,像是權限不足、檔案內容錯誤、網路無法連線等。一開始學程式設計時,我們會先忽略錯誤處理的部分,這是為了簡化範例程式碼,讓程式碼更易於學習。但我們在撰寫程式時,不能一廂情願地認為錯誤不會發生;應該要考慮可能的錯誤,撰寫相對應的程式碼。
我們在本文中介紹 C 語言的錯誤處理模式。
常見的錯誤處理模式
一般來說,錯誤處理有兩種模式,一種是用 try ... catch ...
或同義的特化控制結構來處理錯誤。像是以下假想的 Python 程式碼:
try:
do_something()
except:
sys.stderr.write("something wrong\n")
exit(1)
在 try:
區塊中,如果捕捉到錯誤時,會中止其程式的執行,並跳到 except:
區塊中。所以,try ... catch ...
其實是一種 goto
的變形。
另外一種是用內建的控制結構來處理錯誤。像是以下假想的 Go 程式碼:
val, err := doSomething()
// // Check possible error.
if err != nil {
panic("Something wrong")
}
在此例中,檢查 err
是否為 nil
(空值),當 err
不為空值時,進行相對應的動作。
在 C 語言中,沒有內建的 try ... catch ...
控制結構,使用一般的控制結構來處理錯誤,類似第二種方式。但 C 語言本身沒有錯誤物件 (error object) 的概念,通常是藉由回傳常數來代表錯誤的狀態。
使用控制結構處理運行期錯誤
C 語言使用一般的控制結構來處理錯誤。像是以下建立堆疊物件的程式:
stack_t *s = (stack_t *) malloc(sizeof(stack_t));
// Check possible error.
if (!s) {
// Handle the error here.
}
配置記憶體實際上是有可能失敗的動作,所以我們要在配置記憶體後檢查物件 s
是否為空。在此處,我們使用一般的 if
敘述來處理錯誤。
goto
很適合用在錯誤處理,因為 goto
和例外很像,都會中斷目前程式執行的流程,直接跳到錯誤處理的程式碼區塊。以下是一個假想的例子:
#include <stdio.h> /* 1 */
#include <stdlib.h> /* 2 */
int main(void) /* 3 */
{ /* 4 */
int *i_p = (int *) malloc(sizeof(int)); /* 5 */
if (!i_p) { /* 6 */
perror("Failed to allocate int\n"); /* 7 */
goto ERROR; /* 8 */
} /* 9 */
/* Do more things here. */ /* 10 */
free(i_p); /* 11 */
return 0; /* 12 */
ERROR: /* 13 */
if (i_p) /* 14 */
free(i_p); /* 15 */
return 1; /* 16 */
} /* 17 */
在這個範例中,我們為 int *
型態的指標 i_p
配置記憶體。由於配置記憶體是有可能失敗的動作,我們在第 6 行至第 9 行進行檢查。
當 i_p
未成功建立時,代表程式發生錯誤。我在在第 8 行中斷目前的程式,跳到第 13 行,即 ERROR
標籤所在的位置。在錯誤處理流程中,我們會釋放系統資源,並在最後回傳代表程式異常結束的 1
。
在這個假想範例中,goto
敘述類似於拋出例外。
使用 exit()
函式或 abort()
函式中止程式
exit()
函式和 abort()
函式的用途皆為提早結束程式。兩者的差別在於 exit()
會完成清理的動作後再結束程式,而 abort()
則會立即結束程式。這個和拋出例外 (exception) 意義不同,因為呼叫這兩個函式後程式會中止,我們無法接住這個事件,所以除了嚴重的錯誤外,不應隨意呼叫這兩個函式。
利用 assert
檢查程式錯誤
assert
巨集的用途是在開發過程中確認程式是否有誤,如下例:
int stack_pop(stack_t *self)
{
assert(!stack_is_empty(self));
Node *temp = self->top;
int popped = temp->data;
self->top = temp->next;
free(temp);
return popped;
}
assert
巨集會直接中止程式,其用途是在開發時間幫程式設計者防呆。對於要實際上線的程式來說,還是得在程式中檢查外部資料是否正確。
setjmp
和 longjmp
其實,C 語言也有類似例外 (exception) 的語法特性,就是透過 setjmp.h 函式庫的 setjmp()
函式和 longjmp()
函式。實例如下:
#include <stdio.h>
#include <setjmp.h>
int main(void) {
jmp_buf buf;
if (!setjmp(buf)) {
printf("Something wrong");
} else {
longjmp(buf, 1); // Jump to `setjmp` with new `buf`
printf("Hello World!\n");
}
return 0;
}
我們先用 setjmp()
函式設置接收 jump 的點,在後續的程式中用 longjmp()
觸發 jump,程式就會跳回 jump 所設的位置。以本程式來說,該程式會印出 "Something wrong"
而不會印出 "Hello World"
。
國外已有聰明的開發者利用這項特性模擬出 try ... catch ...
區塊了,詳見下一節。
模擬 try ... catch ...
區塊
這個程式的原始出處在這裡,有興趣的讀者可以看一看,本節展示其用法。
先建立以下的巨集:
#ifndef _TRY_THROW_CATCH_H_
#define _TRY_THROW_CATCH_H_
#include <stdio.h>
#include <setjmp.h>
#define TRY do { jmp_buf ex_buf__; switch( setjmp(ex_buf__) ) { case 0: while(1) {
#define CATCH(x) break; case x:
#define FINALLY break; } default: {
#define ETRY break; } } }while(0)
#define THROW(x) longjmp(ex_buf__, x)
#endif /*!_TRY_THROW_CATCH_H_*/
實際套用該巨集的程式如下:
#include <stdio.h>
// Include the above library.
#include "try_catch.h"
int main(void)
{
TRY
THROW(2);
printf("Hello World\n");
CATCH(1)
printf("Something wrong\n");
CATCH(2)
printf("More thing wrong\n");
CATCH(3)
printf("Yet another thing wrong\n");
FINALLY
printf("Clean resources\n");
ETRY
return 0;
}
實際執行程式的效果如下:
$ gcc -o file file.c
$ ./file
More thing wrong
Clean resources
讀者可能會覺得很神奇,不知如何做到的。由於該函式庫本質上是巨集,我們利用 GCC 將前置處理器處理後的結果展開如下:
int main(void)
{
do {
jmp_buf ex_buf__;
switch (setjmp(ex_buf__)) {
case 0:
while (1) {
longjmp(ex_buf__, 2);
printf("Hello World\n");
break;
case 1:
printf("Something wrong\n");
break;
case 2:
printf("More thing wrong\n");
break;
case 3:
printf("Yet another thing wrong\n");
break;
}
default:{
printf("Clean resources\n");
break;
}
}
} while (0);
return 0;
}
可以發現其實整個程式是包在一個 switch
敘述中,藉由調整 ex_buf__
的值來控制程式行進的方向。
用巨集模擬語法其實算是 C 語言的一種反模式 (anti-pattern),要不要使用這樣的巨集就由讀者自行決定。