C 語言程式設計教學:函式 (Function)

PUBLISHED ON AUG 28, 2018 — PROGRAMMING

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

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

    何謂函式

    函式包含以下數個部分:

    • 函式名稱,為黑盒子的名稱
    • 參數 (parameters),相當於輸入
    • 回傳值 (return value),相當於輸出
    • 函式本體,相當於黑盒子本身

    當然,程式中的函式會改變程式的狀態,不像抽象的函式那麼純粹 (pure)。本文以 C 的角度來看函式。

    宣告函式

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

    return_type fn_name(type param_a, type parma_b, ...)
    {
        // Write something here.
    }
    

    只看虛擬碼會覺得有點抽象,我們看幾個簡例。以下是 Hello World 的函式版:

    #include <stdio.h>
    
    // Declare function `hello`.
    void hello()
    {
        printf("Hello World!\n");
    }
    
    int main()
    {
        // Call function `hello`.
        hello();
        
        return 0;
    }

    在這個 hello 函式中,沒有回傳值,故填上 void,也沒有參數,就不寫參數。

    上述的函式的行為是固定的,完全沒有參數的程式相對實用性較少,我們可以在函式中加入參數:

    #include <stdio.h>
    
    void hello(char name[])
    {
        printf("Hello %s!\n", name);
    }
    
    int main(void)
    {
        hello("Michael");
        hello("Alice");
        hello("John");
        
        return 0;
    }

    雖然這個函式仍然很單調,至少可以根據參數改變印出的字串。

    上述的函式的輸出是寫死的,固定輸出到終端機上,這樣的函式泛用性相對不佳,我們將其改為回傳字串:

    #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)
    {
        bool failed = false;
    
        char *s_a = hello("Michael");
        if (!s_a) {
            return EXIT_FAILURE;
        }
    
        printf("%s\n", s_a);
    
        char *s_b = hello("Alice");
        if (!s_b) {
            failed = true;
            goto STR_A_FREE;
        }
        
        printf("%s\n", s_b);
        
        char *s_c = hello("John");
        if (!s_c) {
            failed = true;
            goto STR_B_FREE;
        }
        
        printf("%s\n", s_c);
    
        free(s_c);
    STR_B_FREE:
        free(s_b);
    STR_A_FREE:    
        free(s_a);
        
        if (failed) {
            return EXIT_FAILURE;
        }
    
        return 0;
    }

    由於這個版本的 hello 回傳一個由 heap 動態配置的字串,不能直接將其導向 printf,要用字元指標去接,之後透過該指標才能順利釋放記憶體。這個範例比先前長一些,除了實作的部分變多外,要處理記憶體也造成一些影響;本例有限度地使用 goto 來簡化記憶體釋放的流程。

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

    const 修飾字

    在函式參數中加入 const 修飾字,代表我們不會修改這個參數的值,多用於指標型別,如下例:

    bool stack_is_empty(const Stack *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>
    
    // Function prototype.
    char * hello(char []);
    
    int main(void)
    {
        // Implement main program here.
    }
    
    char * hello(char name[])
    {
        // Implement function `hello` here.
    }

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

    在撰寫程式時,建議將主要的程式寫在最上方,而將實作內容寫在下方,這樣在閱讀程式碼時,就可以由上而下逐漸追到實作細節。剛好 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;
    
    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)
    • 走訪特定資料夾內的所有資料夾和檔案
    comments powered by Disqus