前言
在先前的文章中,絕大部分的程式的程式碼全都寫在主函式裡,在規模短小的程式這樣子做並沒有什麼不好,但隨著程式規模成長,這種模式就漸漸行不通了。這時候,我們會利用函式將程式碼分離開來。
使用函式的益處
函式 (function) 是程序抽象化 (procedure abstraction) 的實踐方式,使用函式有以下的好處:
- 減少撰寫重覆的程式碼
- 將程式碼以有意義的方式組織起來
- 在相同的流程下,可藉由參數調整程式的行為
- 藉由函式庫可組織和分享程式碼
- 做為資料結構 (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 <stdbool.h> /* 1 */
#include <stdlib.h> /* 2 */
#include <stdio.h> /* 3 */
#include <string.h> /* 4 */
char * hello(char name[]) /* 5 */
{ /* 6 */
char s[] = "Hello "; /* 7 */
size_t sz_s = strlen(s); /* 8 */
size_t sz_n = strlen(name); /* 9 */
char *out = malloc((sz_s + sz_n + 1) * sizeof(char)); /* 10 */
if (!out) /* 11 */
return out; /* 12 */
for (size_t i = 0; i < sz_s; i++) /* 13 */
out[i] = s[i]; /* 14 */
for (size_t i = 0; i < sz_n; i++) /* 15 */
out[i+sz_s] = name[i]; /* 16 */
out[(sz_s+sz_n)] = '\0'; /* 17 */
return out; /* 18 */
} /* 19 */
int main(void) /* 20 */
{ /* 21 */
char *s_a = hello("Michelle"); /* 22 */
if (!s_a) /* 23 */
goto ERROR; /* 24 */
printf("%s\n", s_a); /* 25 */
char *s_b = hello("Alice"); /* 26 */
if (!s_b) /* 27 */
goto ERROR; /* 28 */
printf("%s\n", s_b); /* 29 */
char *s_c = hello("John"); /* 30 */
if (!s_c) /* 31 */
goto ERROR; /* 32 */
printf("%s\n", s_c); /* 33 */
free(s_c); /* 34 */
free(s_b); /* 35 */
free(s_a); /* 36 */
return 0; /* 37 */
ERROR: /* 38 */
if (s_c) /* 39 */
free(s_c); /* 40 */
if (s_b) /* 41 */
free(s_b); /* 42 */
if (s_a) /* 43 */
free(s_a); /* 44 */
return 1; /* 45 */
} /* 46 */
在這個例子中,第 5 行至第 19 行的部分是函式 hello()
。這個函式類似於一個自製的字串相接函式,只是第一段文字皆為 "Hello "
。
第 20 行至第 46 行的部分為主函式。在主函式中,我們分別在第 22 行、第 26 行、第 30 行呼叫 hello()
函式,每次皆傳入相異的參數。
由於這個版本的 hello()
回傳一個由堆積 (heap) 動態配置的字串,不能直接將其導向 printf()
,要用字元指標去接,之後透過該指標才能順利釋放記憶體。我們在第 34 行至第 36 行間逐一釋放掉配置的記憶體。
此外,本例使用 goto
來簡化錯誤處理的流程。
如果要更泛用,就是直接寫一個自製的 strcat()
函式,傳入兩個字串為參數,將其相接成一個字串,讀者可自行嘗試。
使用 const
修飾字防止不當修改
在函式參數中加入 const
修飾字,可以防止程式修改這個參數的值。由於函式會拷貝值,這個修飾詞用在函式參數時,對基本型別沒有意義,會用於指標型別。如下例:
bool stack_is_empty(const stack_t *self)
{
assert(self);
return !(self->top) ? true : false;
}
函式原型
在先前的例子中,長長的函式後才接到主程式程式碼,其實不是很好閱讀,如果函式一多這情形會更嚴重。利用 C 語言的函式原型 (function prototype) 可以改善這個現象。將上述的 C 程式碼以函式原型改寫:
#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, ...) */
/* Call max() with 3 items. */
m = max(3, 3.0, 1.0, 2.0);
/* Call max() with 5 items. */
n = max(5, 4.0, 2.0, 1.0, 5.0, 3.0);
以下是實例:
#include <assert.h> /* 1 */
#include <stdarg.h> /* 2 */
#include <stddef.h> /* 3 */
double max(size_t sz, double value, ...); /* 4 */
int main(void) /* 5 */
{ /* 6 */
/* Call max() with 3 items. */ /* 7 */
assert(max(3, 7.0, 4.0, 6.0) == 7.0); /* 8 */
/* Call max() with 5 items. */ /* 9 */
assert(max(5, 4.0, 2.0, 1.0, 5.0, 3.0) == 5.0); /* 10 */
return 0; /* 11 */
} /* 12 */
double max(size_t sz, double value, ...) /* 13 */
{ /* 14 */
/* Declare args. */ /* 15 */
va_list args; /* 16 */
/* Get the first item. */ /* 17 */
va_start(args, value); /* 18 */
double max = value; /* 19 */
double temp; /* 20 */
for (size_t i = 1; i < sz; i++) { /* 21 */
/* Get each subsequent item. */ /* 22 */
temp = va_arg(args, double); /* 23 */
max = max > temp ? max : temp; /* 24 */
} /* 25 */
/* Clean args. */ /* 26 */
va_end(args); /* 27 */
return max; /* 28 */
} /* 29 */
第 4 行為函式原型,其目的是要將函式本體下移,就可以把主要的程式寫在上方。
第 5 行至第 12 行為主函式。值得注意的部分在兩次呼叫 max()
函式時參數的數量是相異的。
第 13 行至第 29 行的部分為 max()
函式的實作。這個函式的關鍵在於使用不定參數的特性來接收不等數量的參數。
不定參數函式需要 stdarg.h 函式庫的協助,過程如下:
- 以
va_list
宣告代表不定參數的變數,位於第 16 行 - 以
va_start
取得第 1 項參數,位於第 18 行 - 以
va_arg
取得之後的參數,位於第 19 至第 25 行 - 以
va_end
清理該變數,位於第 27 行
由於不定參數本身無法預先取得參數數量的資訊,要由外部傳入;另外,也要補足參數型別的資訊,像是我們在第 23 行的指令。
遞迴函式
遞迴 (recursion) 是指將某個問題分解成更小的子問題來解決該問題的方式。由於 C 語言有實作遞迴,有些問題就可以透過遞迴很優雅地解決掉。
初心者一開始往往不知道遞迴函式如何寫,基本上有兩個要件:
- 終止條件
- 縮小問題的方式
我們透過 Fibonacci 數來看遞迴函式怎麼寫,這是一個常見的例子:
#include <assert.h> /* 1 */
#include <stddef.h> /* 2 */
typedef unsigned int uint; /* 3 */
/* Function prototype. */ /* 4 */
uint fib(uint); /* 5 */
int main(void) /* 6 */
{ /* 7 */
uint arr[] = {0, 1, 1, 2, 3, 5, 8, 13, 21}; /* 8 */
for (size_t i = 0; i < 9; i++) /* 9 */
assert(fib(i+1) == arr[i]); /* 10 */
return 0; /* 11 */
} /* 12 */
uint fib(uint n) /* 13 */
{ /* 14 */
/* `n` starts from 1. */ /* 15 */
assert(n > 0); /* 16 */
if (n == 1) /* 17 */
return 0; /* 18 */
if (n == 2) /* 19 */
return 1; /* 20 */
return fib(n - 1) + fib(n - 2); /* 21 */
} /* 22 */
第 6 行至第 12 行的部分為主函式。在這裡我們檢查前 9 個 Fibonacci 數,確認 fib()
函式的值是正確的。
第 13 行至第 22 行的部分是 fib()
函式的實作。這個函式的重點在於使用遞迴讓函式更加簡潔。
fib()
的終止條件有兩個:
- n 等於 1 時,回傳 0,位於第 17 行至第 18 行
- n 等於 2 時,回傳 1,位於第 19 行至第 20 行
除了這個條件外,碰到其他的 n 就用遞迴呼叫逐漸將問題縮小到前述條件,位於第 21 行。
我們人工追蹤一下這個函式,來拆解遞迴函式。當 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
我們發現 a
和 b
在 swap
函式中的確有互換,但在函式結束後卻沒有實際的效果。這是因為函式在傳遞參數時,並不是直接將參數傳進去函式內部,而是將其複製一份後傳入。以本例來說,我們只是交換 a
和 b
的複製品而已。
將函式的參數修改成傳遞指標,如下:
#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> /* 1 */
#include <stddef.h> /* 2 */
#include <stdio.h> /* 3 */
#include <stdlib.h> /* 4 */
#include <string.h> /* 5 */
/* Useless string concat. */ /* 6 */
char * my_strcat(char [], char []); /* 7 */
int main() /* 8 */
{ /* 9 */
char *s = my_strcat("Hello ", "World"); /* 10 */
printf("%s\n", s); /* 11 */
return 0; /* 12 */
} /* 13 */
char * my_strcat(char a[], char b[]) /* 14 */
{ /* 15 */
/* `s` is a local variable. */ /* 16 */
char s[256]; /* 17 */
strcat(s, a); /* 18 */
strcat(s, b); /* 19 */
/* `s` is a junk value. */ /* 20 */
return s; /* 21 */
} /* 22 */
我們在第 17 行配置一塊型態為字串 (字元陣列) 的自動記憶體 s
,並試圖在第 21 行回傳 s
。
但 my_strcat()
函式是無效的,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> /* 1 */
#include <stddef.h> /* 2 */
#include <stdio.h> /* 3 */
#include <stdlib.h> /* 4 */
#include <string.h> /* 5 */
char * my_strcat(char [], char []); /* 6 */
int main() /* 7 */
{ /* 8 */
char *s = my_strcat("Hello ", "World"); /* 9 */
printf("%s\n", s); /* 10 */
free(s); /* 11 */
return 0; /* 12 */
} /* 13 */
char * my_strcat(char a[], char b[]) /* 14 */
{ /* 15 */
size_t sz_a = strlen(a); /* 16 */
size_t sz_b = strlen(b); /* 17 */
size_t sz = sz_a + sz_b + 1; /* 18 */
char *s = calloc(sz, sizeof(char)); /* 19 */
strcat(s, a); /* 20 */
strcat(s, b); /* 21 */
return s; /* 22 */
} /* 23 */
我們在第 19 行時手動配置字串 (字元陣列) 型態的記憶體 s
,並在第 22 行回傳。
在本例中,我們回傳字元指標。由於我們從堆積 (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 invalid here. */
/* Handle the error. */
}
/* free `index` later. */
另外一個方式是回傳一個含有兩個屬性的結構體:
typedef struct result_t result_t;
struct result_t {
size_t index;
bool has_value;
};
在這個情境下,每次要使用 index
前,都要檢查 has_value
為真。
函式指標 (Function Pointer)
函式也可以做為型別,透過函式指標可以宣告函式型別。像以下宣告:
typedef int (*comp_fn)(int, int);
透過這個宣告,我們宣告了 comp_fn
型別,該型別是一個函式,接收兩個整數,回傳一個整數。我們以這個型別寫一個實際的例子:
#include <assert.h> /* 1 */
typedef int(*comp_fn)(int, int); /* 2 */
int compute(comp_fn, int, int); /* 3 */
int add(int a, int b); /* 4 */
int sub(int a, int b); /* 5 */
int main(void) /* 6 */
{ /* 7 */
assert(compute(add, 3, 4) == 7); /* 8 */
assert(compute(sub, 3, 4) == -1); /* 9 */
return 0; /* 10 */
} /* 11 */
int compute(comp_fn fn, int a, int b) /* 12 */
{ /* 13 */
return fn(a, b); /* 14 */
} /* 15 */
int add(int a, int b) /* 16 */
{ /* 17 */
return a + b; /* 18 */
} /* 19 */
int sub(int a, int b) /* 20 */
{ /* 21 */
return a - b; /* 22 */
} /* 23 */
在第 2 行中,我們宣告函式指標型態的別名 comp_fn
。接著,在第 3 行中,把 comp_fn
當成第一個參數。
如果我們不用別名的話,第 3 行需改寫如下:
int compute(int (*)(int, int), int, int);
雖然效果相同,但在軟工的觀點上,這樣寫比較不好。因為 int (*)(int, int)
對程式設計者來說是沒有意義的符號。
在本例中,函式 compute()
實際的行為由參數 fn
決定,我們只要傳入合於 comp_fn
型別的函式即可改變函式 compute()
的運作方式。雖然 C 語言不是函數式語言,可以透過這項特性做一些高階函式,我們於後續文章會說明。
我們將上例修改如下:
#include <assert.h> /* 1 */
#include <stdbool.h> /* 2 */
#include <string.h> /* 3 */
typedef int(*comp_fn)(int, int); /* 4 */
int compute(char [], int, int); /* 5 */
int main() /* 6 */
{ /* 7 */
assert(compute("+", 3, 4) == 7); /* 8 */
assert(compute("-", 3, 4) == -1); /* 9 */
return 0; /* 10 */
} /* 11 */
int add(int a, int b); /* 12 */
int sub(int a, int b); /* 13 */
int compute(char comp[], int a, int b) /* 14 */
{ /* 15 */
comp_fn fn; /* 16 */
if (0 == strcmp(comp, "+") /* 17 */
|| 0 == strcmp(comp, "add")) { /* 18 */
fn = add; /* 19 */
} /* 20 */
else if (0 == strcmp(comp, "-") /* 21 */
|| 0 == strcmp(comp, "sub")) { /* 22 */
fn = sub; /* 23 */
} /* 24 */
else /* 25 */
assert("No valid comp" && false); /* 26 */
return fn(a, b); /* 27 */
} /* 28 */
int add(int a, int b) /* 29 */
{ /* 30 */
return a + b; /* 31 */
} /* 32 */
int sub(int a, int b) /* 33 */
{ /* 34 */
return a - b; /* 35 */
} /* 36 */
在這個例子中,compute()
傳入的值為字串,該函式藉由傳入的參數動態修改變數 fn
的值,這段動作位於第 17 行至第 26 行。
從這個例子可以看到,函式就像值一樣可傳遞,函數式程式的前提就是建立在此項特性上。