【在主畫面加入捷徑】
       
【選擇語系】
繁中 简中

[C 語言] 程式設計教學:如何撰寫函式 (Function)

【分享本文】
Facebook Twitter LinkedIn LINE Skype EverNote GMail Yahoo Email
【贊助商連結】

    在先前的文章中,絕大部分的程式的程式碼全都寫在主函式裡,在規模短小的程式這樣子做並沒有什麼不好,但隨著程式規模成長,這種模式就漸漸行不通了。函式 (function) 是程序抽象化 (procedure abstraction) 的實踐方式,使用函式有以下的好處:

    • 減少撰寫重覆的程式碼
    • 將程式碼以有意義的方式組織起來
    • 在相同的流程下,可藉由參數調整程式的行為
    • 搭配模組 (module),可組織和分享程式碼
    • 做為資料結構 (data structures) 和物件 (objects) 的基礎

    宣告函式

    C 沒有使用額外的保留字來宣告函式,而有一個固定的函式格式,其格式可參考以下虛擬碼:

    return_type function_name(parameter list)
    {
        // Implement the function here.
    }
    

    函式包含以下數個部分:

    • 函式的名稱 (identifier)
    • 函式的參數 (parameters),相當於輸入
    • 函式的回傳值 (return value),相當於輸出
    • 函式的本體 (body)

    電腦程式中的函式會改變程式的狀態,不像數學的函式那麼純粹 (pure)。

    只看虛擬碼會覺得有點抽象,我們看幾個簡例。以下是一個指數運算的函式:

    double power(double base, int expo)
    {
        assert(base);
    
        if (expo == 0)
            return 1;
        
        double result = 1.0;
    
        if (expo > 0) {
            for (int i = 0; i < expo; i++)
                result *= base;
        }
        else if (expo < 0) {
            for (int i = 0; i < -expo; i++)
                result /= base;
        }
        
        return result;
    }

    這個函式的

    • 名稱是 power
    • 參數有兩個,分別是 base (double 型別) 和 expo (int 型別)
    • 回傳值的型別是 double

    使用實例如下:

    assert(power(3, 2) == 9);
    assert(fabs(power(3, -2) - 1.0 / 9.0) < 0.000001);

    我們另外看一個打招呼程式:

    #include <stddef.h>
    #include <stdbool.h>
    #include <stdlib.h>
    #include <stdio.h>
    #include <string.h>
    
    char * hello(char name[])
    {
        char s[] = "Hello ";
    
        size_t sz_s = strlen(s);
        size_t sz_n = strlen(name);
    
        char *out = malloc((sz_s + sz_n + 1) * sizeof(char));
        if (!out)
            return out;
        
        for (size_t i = 0; i < sz_s; i++)
            out[i] = s[i];
        
        for (size_t i = 0; i < sz_n; i++)
            out[i+sz_s] = name[i];
    
        out[(sz_s+sz_n)] = '\0';
    
        return out;
    }
    
    int main(void)
    {
        char *s_a = hello("Michael");
        if (!s_a)
            goto ERROR;
    
        printf("%s\n", s_a);
    
        char *s_b = hello("Alice");
        if (!s_b)
            goto ERROR;
    
        printf("%s\n", s_b);
    
        char *s_c = hello("John");
        if (!s_c)
            goto ERROR;
    
        printf("%s\n", s_c);
    
        free(s_c);
        free(s_b);
        free(s_a);
    
        return 0;
    
    ERROR:
        if (s_c)
            free(s_c);
    
        if (s_b)
            free(s_b);
    
        if (s_a)
            free(s_a);
    
        return 1;
    }

    由於這個版本的 hello 回傳一個由堆積 (heap) 動態配置的字串,不能直接將其導向 printf,要用字元指標去接,之後透過該指標才能順利釋放記憶體。此外,本例有限度地使用 goto 來簡化記憶體釋放的流程。

    如果要更泛用,就是直接寫一個自製的 strcat 函式,傳入兩個字串為參數,將其相接成一個字串,讀者可自行嘗試。

    const 修飾字

    在函式參數中加入 const 修飾字,可以防止程式修改這個參數的值。由於函式會拷貝值,這個修飾詞用在函式參數時,對基本型別沒有意義,會用於指標型別。如下例:

    bool stack_is_empty(const stack_t *self)
    {
        assert(self);
    
        return !(self->top) ? true : false;
    }

    函式原型

    在先前的例子中,長長的函式後才接到主程式程式碼,其實不是很好閱讀,如果函式一多這情形會更嚴重。利用 C 語言的函式原型 (function prototype) 可以改善這個現象。將上述的 C 程式碼以函式原型改寫:

    #include <stddef.h>
    #include <stdbool.h>
    #include <stdlib.h>
    #include <stdio.h>
    #include <string.h>
    
    // Declare function prototype.
    char * hello(char []);
    
    int main(void)
    {
        // Implement main program here.
    }
    
    char * hello(char name[])
    {
        // Implement function `hello` here.
    }

    為了突顯函式原型的部分,我們將所有的實作部分拿掉。在這個 C 虛擬碼中,我們在程式碼上方加上函式原型的宣告,就不需要直接實作 hello 函式,可以先寫主函式,再程式碼下方補上 hello 的實作即可。

    在撰寫程式時,建議將主要的程式寫在最上方,而將實作內容寫在下方,這樣在閱讀程式碼時,就可以由上而下逐漸追到實作細節。剛好 C 語言的函式原型可以協助我們達到這個撰碼方式。這並不是筆者隨意提出的想法,讀者可在一些軟工的書籍看到類似的思維。

    在撰寫函式原型時,參數不需加名稱,只要寫入型別標註 (annotation) 即可;有些 C 程式碼會在函式原型加參數名稱,基本上僅是為了閱讀方便,函式原型的參數名稱不需和函式實作的參數名稱相同。

    不定參數函式

    有時候我們會看到這樣的函式:

    double max(double a, double b)
    {
        return a > b ? a : b;
    }

    這種函式,僅能固定接收兩個參數,通用性不是很好。我們希望可以做到這樣:

    // C pseudocode.
    
    // max with 3 items.
    m = max(3.0, 1.0, 2.0);
    
    // max with 5 items.
    n = max(4.0, 2.0, 1.0, 5.0, 3.0);

    目前的 C 語言無法做到上述虛擬碼的樣子,但也相差不遠,大概可以做到下面這個例子:

    // C pseudocode.
    
    // Function Def: max(size, n_1, n_2, ...)
    
    // max with 3 items.
    m = max(3, 3.0, 1.0, 2.0);
    
    // max with 5 items.
    n = max(5, 4.0, 2.0, 1.0, 5.0, 3.0);

    以下是實例:

    #include <assert.h>
    #include <stdarg.h>
    #include <stddef.h>
    
    double max(size_t sz, double value, ...);
    
    int main(void)
    {
        // max with 3 items.
        assert(max(3, 7.0, 4.0, 6.0) == 7.0);
        
        // max with 5 items.
        assert(max(5, 4.0, 2.0, 1.0, 5.0, 3.0) == 5.0);
        
        return 0;
    }
    
    double max(size_t sz, double value, ...)
    {
        // Declare args.
        va_list args;
        
        // Get the first item.
        va_start(args, value);
        
        double max = value;
        double temp;
        for (size_t i = 1; i < sz; i++) {
            // Get each subsequent item.
            temp = va_arg(args, double);
            
            max = max > temp ? max : temp;
        }
        
        // Clean args.
        va_end(args);
        
        return max;
    }

    不定參數函式需要 stdarg.h 函式庫的協助,過程如下:

    • va_list 宣告代表不定參數的變數
    • va_start 取得第 1 項參數
    • va_arg 取得之後的參數
    • va_end 清理該變數

    由於不定參數本身無法預先取得參數數量的資訊,要由外部傳入;另外,也要補足參數型別的資訊。

    遞迴函式

    遞迴 (recursion) 是指將某個問題分解成更小的子問題來解決該問題的方式。由於 C 語言有實作遞迴,有些問題就可以透過遞迴很優雅地解決掉。

    初心者一開始往往不知道遞迴函式如何寫,基本上有兩個要件:

    • 終止條件
    • 縮小問題的方式

    我們透過 Fibonacci 數來看遞迴函式怎麼寫,這是一個常見的例子:

    #include <assert.h>
    #include <stddef.h>
    
    typedef unsigned int uint;
    
    // Function prototype.
    uint fib(uint);
    
    int main(void)
    {
        uint arr[] = {0, 1, 1, 2, 3, 5, 8, 13, 21};
        
        for (size_t i = 0; i < 9; i++)
            assert(fib(i+1) == arr[i]);
        
        return 0;
    }
    
    // `n` starts from 1.
    uint fib(uint n)
    {
        assert(n > 0);
        
        if (n == 1)
            return 0;
        
        if (n == 2)
            return 1;
        
        return fib(n - 1) + fib(n - 2);
    }

    fib 的終止條件有兩個:

    • n == 1 時,回傳 0
    • n == 2 時,回傳 1

    除了這個條件外,碰到其他的 n 就用 fib(n - 1) + fib(n - 2) 逐漸將問題縮小到前述條件。

    我們人工追蹤一下這個函式,來拆解遞迴函式。當 n == 1 時,結果如下:

    fib(1) -> 0
    

    當 n == 2 時,結果如下:

    fib(2) -> 1
    

    當 n == 3 時,結果如下:

    fib(3) -> fib(2) + fib(1)
           -> 1 + 0
           -> 1
    

    當 n == 4 時,結果如下:

    fib(4) -> fib(3) + fib(2)
           -> (fib(2) + fib(1)) + 1
           -> (1 + 0) + 1
           -> 2
    

    當 n == 5 時,結果如下:

    fib(5) -> fib(4) + fib(3)
           -> (fib(3) + fib(2)) + (fib(2) + fib(1))
           -> ((fib(2) + fib(1)) + 1) + (1 + 0)
           -> ((1 + 0) + 1) + 1
           -> 3
    

    還可以再繼續追蹤下去,有興趣的讀者可以自行嘗試。

    由於遞迴在電腦科學中很重要,有空時最好自行練習一下,以下是一些常見的例子:

    • 階乘 (factorial)
    • Fibonacci 數
    • 最大公因數 (greatest common divisor)
    • 二元搜尋樹 (binary search tree)
    • 河內塔 (towers of hanoi)
    • 走訪特定資料夾內的所有資料夾和檔案

    傳值呼叫 vs. 傳址呼叫

    我們想寫一個將兩數互換的函式,但這樣寫行不通:

    #include <assert.h>
    
    // Useless swap.
    void swap(int, int);
    
    int main(void)
    {
        int a = 3;
        int b = 4;
        
        // No real effect.
        swap(a, b);
        
        // Error!
        assert(a == 4);
        assert(b == 3);
    
        return 0;
    }
    
    void swap(int a, int b)
    {
        int temp = a;
        a = b;
        b = temp;
    }

    這牽涉一些從語法上看不出來的函式的行為。我們這次加上一些額外的訊息:

    #include <assert.h>
    #include <stdio.h>
    
    // Useless swap.
    void swap(int, int);
    
    int main(void)
    {
        int a = 3;
        int b = 4;
    
        fprintf(stderr, "a in main, before swap: %d\n", a);
        fprintf(stderr, "b in main, before swap: %d\n", b);
        
        // No real effect.
        swap(a, b);
        
        fprintf(stderr, "a in main, after swap: %d\n", a);
        fprintf(stderr, "b in main, after swap: %d\n", b);
    
        return 0;
    }
    
    void swap(int a, int b)
    {
        fprintf(stderr, "a in swap, before swap: %d\n", a);
        fprintf(stderr, "b in swap, before swap: %d\n", b);
        
        int temp = a;
        a = b;
        b = temp;
        
        fprintf(stderr, "a in swap, after swap: %d\n", a);
        fprintf(stderr, "b in swap, after swap: %d\n", b);
    }

    印出訊息如下:

    a in main, before swap: 3
    b in main, before swap: 4
    a in swap, before swap: 3
    b in swap, before swap: 4
    a in swap, after swap: 4
    b in swap, after swap: 3
    a in main, after swap: 3
    b in main, after swap: 4
    

    我們發現 abswap 函式中的確有互換,但在函式結束後卻沒有實際的效果。這是因為函式在傳遞參數時,並不是直接將參數傳進去函式內部,而是將其複製一份後傳入。以本例來說,我們只是交換 ab 的複製品而已。

    將函式的參數修改成傳遞指標,如下:

    #include <assert.h>
    #include <stdio.h>
    
    // It really works.
    void swap(int *, int *);
    
    int main(void)
    {
        int a = 3;
        int b = 4;
    
        swap(&a, &b);
        
        assert(a == 4);
        assert(b == 3);
    
        return 0;
    }
    
    void swap(int *a, int *b)
    {
        int temp = *a;
        *a = *b;
        *b = temp;
    }

    這個程式如同我們預期的方式來運作。

    有些 C 語言教材會用傳值呼叫 (pass by value) 和傳址呼叫 (pass by reference) 來區分;但實際上 C 的函式呼叫皆為傳值呼叫,只是在傳遞指標時的「值」是記憶體位址,簡單地說,C 在傳指標時,將指標的位址複製一份後傳入函式內部。

    回傳指標

    我們想寫一個字串相接的函式,但以下程式不會正常運作:

    #include <assert.h>
    #include <stddef.h>
    #include <stdio.h>
    #include <stdlib.h>
    #include <string.h>
    
    // Useless string concat.
    char * my_strcat(char [], char []);
    
    int main()
    {
        char *s = my_strcat("Hello ", "World");
        
        printf("%s\n", s);
        
        return 0;
    }
    
    char * my_strcat(char a[], char b[])
    {
        // `s` is a local variable.
        char s[256];
    
        strcat(s, a);
        strcat(s, b);
        
        // `s` is a junk value.
        return s;
    }

    GCC 已經告訴我們原因:

    returnArr.c:34:5: warning: function returns address of local variable [-Wreturn-local-addr]
         return s;
    

    在本例中,my_strcat 的變數 s 從堆疊 (stack) 自動配置記憶體,在函式結束時,s 已經被清除,回傳的值是垃圾值 (junk value),沒有實質意義。

    修改成以下程式碼即可正常運作:

    #include <assert.h>
    #include <stddef.h>
    #include <stdio.h>
    #include <stdlib.h>
    #include <string.h>
    
    char * my_strcat(char [], char []);
    
    int main()
    {
        char *s = my_strcat("Hello ", "World");
        
        printf("%s\n", s);
        
        free(s);
        
        return 0;
    }
    
    char * my_strcat(char a[], char b[])
    {
        size_t sz_a = strlen(a);
        size_t sz_b = strlen(b);
        size_t sz = sz_a + sz_b + 1;
        
        char *s = calloc(sz, sizeof(char));
    
        strcat(s, a);
        strcat(s, b);
        
        return s;
    }

    在本例中,我們回傳字元指標。由於我們從 heap 配置記憶體,在 my_strcat 函式結束時,記憶體不會清除,故程式可正常運作。只是主函式結束時要記得手動釋放記憶體。

    回傳多個值

    C 語言的函式僅能回傳單一值,那麼,當我們需要回傳多個值時要如何處理呢?一個常見的方法就是在參數中使用指標,透過指派至該參數來回傳值。以下是一個確認陣列物件是否有特定值的函式:

    bool array_contains(const array_t *self, int value, size_t *index)
    {
        assert(self);
    
        for (size_t i = 0; i < array_size(self); i++) {
            if (array_at(self, i) == value) {
                *index = i;
                return true;
            }
        }
    
        *index = 0;
        return false;
    }

    在這個函式中,我們回傳 bool 值表示函式中的確含有這個值 value,但我們額外用一個指標 index 來接收 value 所在的索引值。這個函式的使用方式如下:

    size_t *index = (size_t *) malloc(sizeof(size_t));
    if (!index) {
        // Error handling.
    }
    
    if (array_contains(arr, 13, index)) {
        // `index` is valid here.
    }
    
    // free `index` later.

    另外一個方式是回傳一個含有兩個屬性的結構體:

    typedef struct {
        size_t index;
        bool has_value;
    } result_t;

    在這個情境下,每次要使用 index 前,都要檢查 has_value 為真。

    函式指標

    函式也可以做為型別,透過函式指標可以宣告函式型別。像以下宣告:

    typedef int (*compFn)(int, int);

    透過這個宣告,我們宣告了 compFn 型別,該型別是一個函式,接收兩個整數,回傳一個整數。我們以這個型別寫一個實際的例子:

    #include <assert.h>
    
    typedef int(*compFn)(int, int);
    
    int compute(compFn, int, int);
    int add(int a, int b);
    int sub(int a, int b);
    
    int main(void)
    {
        assert(compute(add, 3, 4) == 7);
        assert(compute(sub, 3, 4) == -1);
    
        return 0;
    }
    
    int compute(compFn fn, int a, int b)
    {
        return fn(a, b);
    }
    
    int add(int a, int b)
    {
        return a + b;
    }
    
    int sub(int a, int b)
    {
        return a - b;
    }

    在本例中,compute 實際的行為由 fn 決定,我們只要傳入合於 compFn 型別的函式即可改變 compute 的運作方式。雖然 C 語言不是函數式語言,可以透過這項特性做一些高階函式,我們於後續文章會說明。

    我們將上例修改如下:

    #include <assert.h>
    #include <stdbool.h>
    #include <string.h>
    
    typedef int(*compFn)(int, int);
    
    int compute(char [], int, int);
    
    int main()
    {
        assert(compute("+", 3, 4) == 7);
        assert(compute("-", 3, 4) == -1);
    
        return 0;
    }
    
    int add(int a, int b);
    int sub(int a, int b);
    
    int compute(char comp[], int a, int b)
    {
        compFn fn;
    
        if (strcmp(comp, "+") == 0
            || strcmp(comp, "add") == 0) {
            fn = add;        
        }
        else if (strcmp(comp, "-") == 0
            || strcmp(comp, "sub") == 0) {
            fn = sub;        
        }
        else
            assert("No valid comp" && false);
    
        return fn(a, b);
    }
    
    int add(int a, int b)
    {
        return a + b;
    }
    
    int sub(int a, int b)
    {
        return a - b;
    }

    在這個例子中,變數 fn 的值會隨著傳入的參數而改變,也就是說,函式就像值一樣可傳遞,函數式程式的前提就是建立在此項特性上。

    【分享本文】
    Facebook Twitter LinkedIn LINE Skype EverNote GMail Yahoo Email
    【贊助商連結】
    標籤: C 語言, FUNCTION
    【贊助商連結】
    【分類瀏覽】