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