位元詩人 [C 語言] 程式設計教學:錯誤處理 (Error Handling)

C 語言錯誤處理
Facebook Twitter LinkedIn LINE Skype EverNote GMail Yahoo Email

前言

即使程式本身沒有錯誤,不代表程式運行時不會發生錯誤;程式要面對許多外部錯誤,像是權限不足、檔案內容錯誤、網路無法連線等。一開始學程式設計時,我們會先忽略錯誤處理的部分,這是為了簡化範例程式碼,讓程式碼更易於學習。但我們在撰寫程式時,不能一廂情願地認為錯誤不會發生;應該要考慮可能的錯誤,撰寫相對應的程式碼。

我們在本文中介紹 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 巨集會直接中止程式,其用途是在開發時間幫程式設計者防呆。對於要實際上線的程式來說,還是得在程式中檢查外部資料是否正確。

setjmplongjmp

其實,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),要不要使用這樣的巨集就由讀者自行決定。

關於作者

身為資訊領域碩士,位元詩人 (ByteBard) 認為開發應用程式的目的是為社會帶來價值。如果在這個過程中該軟體能成為永續經營的項目,那就是開發者和使用者雙贏的局面。

位元詩人喜歡用開源技術來解決各式各樣的問題,但必要時對專有技術也不排斥。閒暇之餘,位元詩人將所學寫成文章,放在這個網站上和大家分享。