函式指標是 C 語言最強大也最容易令初學者卻步的特性之一。它讓「行為」本身變成可以傳遞、儲存、替換的資料, 是實作回呼(callback)、命令分派(dispatch table)、外掛架構(plugin)與泛型演算法(如 qsort)的基礎。 本單元以 K&R §5.11 的通用排序程式為主軸,完整介紹函式指標的語法與實務應用。

學習目標

  • 理解「函式名稱在運算式中會退化為函式指標」的規則
  • 能正確宣告、初始化、呼叫函式指標
  • 能將函式指標作為參數傳遞,實作回呼(callback)機制
  • 熟悉標準函式庫 qsort 的介面,能撰寫符合規格的比較函式
  • 能使用函式指標陣列建立命令分派表(jump table / dispatch table)
  • 會用 typedef 簡化函式指標型別,提高可讀性

對照章節 / 參考來源

  • K&R《The C Programming Language, 2nd Edition》§5.11 Pointers to Functions(通用排序程式)
  • K&R §5.12 Complicated Declarations(複雜宣告,詳見下一單元)
  • C11 標準 §6.3.2.1(函式指定子的轉換)、§7.22.5.2(qsort)
  • man 3 qsort

觀念講解

1. 函式指標的宣告與基本語法

函式也佔據記憶體(程式碼段),因此可以有位址。指向「接收兩個 int、回傳 int 的函式」的指標宣告如下:

int (*fp)(int, int);        /* fp 是函式指標 */

注意括號不可省略:

int *fp(int, int);          /* 這是「回傳 int* 的函式」的宣告,不是指標! */
int (*fp)(int, int);        /* 這才是「指向函式的指標」 */

指派與呼叫:

int add(int a, int b) { return a + b; }

int (*fp)(int, int) = add;   /* 函式名稱自動轉成位址,&add 亦可 */
int r1 = fp(3, 4);           /* 直接呼叫(現代慣用寫法) */
int r2 = (*fp)(3, 4);        /* 先解參考再呼叫(K&R 傳統寫法),效果相同 */

add、&add、*add、**add 在運算式中通通等價——因為函式名稱退化為指標後, 對函式指標解參考又得到函式指定子,再退化回指標,如此循環。這是 C 的特殊規則。

2. 函式指標作為參數:回呼(callback)

把函式指標傳入另一個函式,讓「呼叫者決定部分行為」,這種模式稱為回呼:

/* 對陣列每個元素套用 f,將行為參數化 */
void map_int(int *a, int n, int (*f)(int))
{
    for (int i = 0; i < n; i++)
        a[i] = f(a[i]);
}

呼叫端可以傳入 square、negate、double_it 等不同函式,map_int 本身完全不需修改。 這就是「策略模式」在 C 語言的原生實現,也是事件驅動程式(GUI、網路伺服器)的核心機制。

3. qsort 與比較函式(K&R §5.11 的現代版)

K&R §5.11 自己寫了一個通用 qsort;現代 C 直接用 <stdlib.h> 提供的版本:

void qsort(void *base, size_t nmemb, size_t size,
           int (*compar)(const void *, const void *));

比較函式收到的是「指向元素的 const void*」,必須自己轉型回真正的型別:

int cmp_int(const void *a, const void *b)
{
    int x = *(const int *)a, y = *(const int *)b;
    return (x > y) - (x < y);   /* 避免 x - y 溢位的慣用寫法 */
}

注意:排序字串陣列(char *arr[])時,比較函式收到的是 char **, 需要寫成 *(char *const *)a,這是最常見的 qsort 誤用點。

4. 函式指標陣列:命令分派表

把多個「同型別」的函式指標放進陣列,用索引或查表選擇要執行的函式, 可以取代冗長的 switch,且新增功能只需在表裡加一列:

typedef int (*op_fn)(int, int);

struct command {
    const char *name;
    op_fn       fn;
};

struct command table[] = {
    { "add", op_add },
    { "sub", op_sub },
    { "mul", op_mul },
};

實務應用:直譯器的位元組碼分派、shell 內建命令、選單系統、狀態機的狀態轉移表。

5. typedef 簡化函式指標型別

沒有 typedef 時,「回傳函式指標的函式」宣告非常難讀:

int (*get_op(char c))(int, int);          /* 天書 */

用 typedef 之後:

typedef int (*op_fn)(int, int);
op_fn get_op(char c);                     /* 一目了然 */

建議:專案中只要函式指標出現超過一次,就應該 typedef。命名慣例常用 _fn、_cb、_handler 結尾。

6. 函式指標與資料指標的差異

  • 標準不保證函式指標可以與 void * 互轉(POSIX 的 dlsym 是例外,實務上多數平台可行)。
  • 函式指標之間可以互相轉型,但用「不相容的型別」呼叫是未定義行為(UB)。
  • 比較函式指標是否相等(==、!=)是合法的;比大小則不合法。

範例程式

檔案 內容 編譯 / 執行
01_basics.c 宣告、初始化、兩種呼叫語法、等價性示範、typedef cc -std=c11 -Wall -Wextra -o out 01_basics.c && ./out
02_callbacks.c map/filter 回呼、qsort 排序整數/字串/結構、多鍵排序 cc -std=c11 -Wall -Wextra -o out 02_callbacks.c && ./out
03_dispatch_table.c 函式指標陣列、命令分派表實作簡易計算指令直譯器 cc -std=c11 -Wall -Wextra -o out 03_dispatch_table.c && ./out

所有程式都內建示範資料,直接執行即可看到輸出,不需要任何互動。

常見錯誤與陷阱

  1. 括號放錯位置:int *f(int) 是函式宣告,int (*f)(int) 才是指標。編譯器對前者不會報錯, 直到你想指派位址給它才發現不對勁。
  2. qsort 比較函式轉型錯誤:排序 char *arr[] 時把參數當 char * 用(實際是 char **), 結果是比較指標值的位元組而非字串內容,排序結果看似隨機。
  3. 比較函式用 x - y:兩個很大的 int 相減會 signed overflow(UB)。 正確寫法是 (x > y) - (x < y)。
  4. 呼叫尚未初始化的函式指標:野指標呼叫直接跳到垃圾位址,通常是立即 crash(bus error / segfault), 宣告時務必初始化為具名函式或 NULL 並在呼叫前檢查。
  5. 簽名不相容仍強制轉型呼叫:例如把 int f(void) 轉成 int (*)(int) 再呼叫是 UB, 在某些 ABI 上恰好可動,換平台就爆炸。

練習題

  1. 【易】寫一個 int apply(int x, int (*f)(int)),並用它套用 abs、平方、加一等三種函式。
  2. 【易】用 qsort 將 double 陣列由大到小排序(注意浮點數比較不能用相減)。
  3. 【中】寫 void for_each(void *base, size_t n, size_t size, void (*visit)(void *)), 模仿 qsort 的泛型介面,對任意型別陣列的每個元素呼叫 visit。
  4. 【中】為結構 struct student { char name[16]; int score; } 實作兩個比較函式: 先依分數遞減、同分依姓名字典序遞增,用單一 qsort 完成多鍵排序。
  5. 【難】實作一個迷你「狀態機」:狀態以 enum 表示,轉移表為 state_fn table[N_STATES],每個狀態函式回傳下一個狀態,主迴圈只有三行。
  6. 【難】仿 K&R §5.11 自己實作泛型 my_qsort(用插入排序即可), 介面與標準 qsort 完全相同,需以 char * 位移與 memcpy 做元素交換。

補充:效能與設計備忘

  • 函式指標呼叫是「間接呼叫」,CPU 無法在編譯期確定目標, 分支預測器要多做工;熱路徑上的間接呼叫可能比直接呼叫慢, 但差距通常遠小於它換來的架構彈性——先量測再最佳化。
  • 現代編譯器在看得到目標的情況下能把函式指標「去虛擬化」 (devirtualization)成直接呼叫甚至內聯。
  • 回呼介面通常會多帶一個 void *user_data 參數(context pointer), 讓呼叫端夾帶自己的狀態——標準的 qsort_r(BSD/GNU 擴充)就是為此而生。
  • 命名慣例小結:型別以 _fn/_cb 結尾、表格用 const 靜態陣列、 查無指令回傳 NULL 並由呼叫端統一處理。

延伸閱讀

  • K&R §5.11–5.12:通用排序與複雜宣告
  • 《Expert C Programming》(Peter van der Linden) 第 3 章:解讀 C 宣告
  • cppreference:Pointer declaration — Pointers to functions
  • man 3 qsort、man 3 bsearch(同樣使用比較函式的二分搜尋)
  • glibc 原始碼中 qsort 的實作(實際上是 merge sort / introsort 混合)