第 11 章 · 函数指针

代码也住在内存里(第 2 章的 Text 段),所以代码也有地址;存函数地址的指针,就是函数指针。它是 C 语言实现「把行为当数据传」的唯一手段:qsort 的比较器、信号处理函数、跳转表、乃至下一章结构体的「面向对象雏形」,全是它。本章把它从「声明最吓人的语法」变成「你手里最顺的工程工具」。原文小节编号错成 10.x,一并修正。

11.1 声明:一对括号定生死

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

int (*f)(int, int) = add;   // f:指向「(int,int) 返回 int 的函数」的指针
int r = f(3, 4);            // 通过指针调用,7

读法沿第 5 章的老规矩(从内向外):括号里 (*f) 先声明 f 是指针,外面 (int, int) 是参数、int 是返回值。括号省不得:int *f(int, int)(没有括号包 f)意思是「f 是一个函数,返回 int*」,两者差一个语义层。函数名自带退化(像数组名,第 4 章):f = add 和 f = &add 等价,f(3,4) 和 (*f)(3,4) 也等价,写简洁版就好。

裸声明太丑,typedef 一步到位:

typedef int (*BinOp)(int, int);   // BinOp 现在是个类型名
BinOp f = add;                    // 从此告别天书

11.2 回调:qsort 范式

函数指针最伟大的应用是回调:通用算法把「怎么比较」「怎么处理」留给调用者注入。标准库样板 qsort:

int cmp_int(const void *a, const void *b) {   // 统一签名,void* 进
    int x = *(const int *)a, y = *(const int *)b;
    return (x > y) - (x < y);      // 负/零/正;别写 x-y,int 最小值溢出是真实事故
}

int arr[] = {3, 1, 4, 1, 5};
qsort(arr, 5, sizeof(int), cmp_int);

注意这套设计的分工:void* 解决「不知道元素类型」(第 1 章的通用插座),比较函数指针解决「不知道大小关系」,qsort 自己只管算法骨架。三块拼起来,C 语言版的泛型就位。这个范式在信号处理(signal(SIGINT, handler))、线程(pthread_create 的入口函数)、事件循环里反复出现:框架定骨架,你注入血肉。

11.3 跳转表:if-else 的终结者

把函数指针放进数组,用下标派发:

typedef int (*Op)(int, int);
int add(int a, int b) { return a + b; }
int sub(int a, int b) { return a - b; }
int mul(int a, int b) { return a * b; }

Op ops[] = { add, sub, mul };
int r = ops[1](10, 3);             // sub(10,3) = 7

对比一串 if (op=="+") ... else if (op=="-") ...,跳转表三个优点:O(1) 派发(下标直达)、新增操作只加一行表项(开闭原则的 C 版)、语义集中(看表就知道系统有什么行为)。典型用途:菜单命令分发、协议解析的包处理表、状态机的转移表(状态 × 事件 的二维函数指针数组)。工程细节:表项可以加名字注释或用 designated initializer ops = { ['+'] = add, ['-'] = sub },可读性再上一档。

11.4 返回函数指针:typedef 救命

函数可以返回函数指针,裸声明是天书,typedef 之后稀松平常:

BinOp select(char op) {
    switch (op) {
        case '+': return add;
        case '-': return sub;
    }
    return NULL;                    // 别忘了失败路径
}
int r = select('*') == NULL ? 0 : 0;  // 先判空再调用!

工程提醒:返回的函数指针和所有指针一样有 NULL 状态,调用前判空;跳转表里也是同理,「表里混进一个 NULL」的崩溃会发生在最深的调用栈里,初始化表时宁可先全填一个默认函数。

11.5 机器视角与成本

函数指针在机器层就是「间接调用」:call [rax](地址在寄存器里),而直接调用是 call 0x401234(地址写死在指令里)。间接调用的代价:分支预测器(计组课第 6 章)对固定目标的预测容易,对变化的间接目标难,虚函数、回调密集的热循环会慢一点;编译器也因此难以内联。这是 C++ 虚函数、Java 接口调用都有的「动态派发税」。所以性能敏感的内核路径里,常见手法是「热点判断 + 内联直调,冷路径才走函数指针」,理解了机制,这类优化一看就懂。

常见错误

  • 忘了括号。int *f(int,int) 是函数不是指针;判别法:声明里 * 是否被括号和标识符绑在一起。
  • 签名不匹配也硬调。参数个数或类型对不上,编译器警告(甚至不警告),运行时栈被错位读取,症状千奇百怪;比较函数统一 const void * 签名是唯一正解。
  • 比较函数返回 x - y。减法在 INT_MIN/INT_MAX 处溢出,排序结果错乱;用三目或 (x>y)-(x<y)。

练习

  1. 实现:写 void qsort_by(void *base, size_t n, size_t size, int (*cmp)(const void*, const void*)) 的插入排序版,分别用 int、double、字符串三种比较器排序,验证泛型性。
  2. 动手:实现一个简易计算器:select(char op) 返回函数指针,main 里读「3 + 4」式输入并求值;把四则运算表改成 designated initializer 写法。
  3. 手算:写出「指向『参数为 (char*, int)、返回 char* 的函数』的指针」的声明,再给它起个 typedef 名。
  4. 思考:第 5 章的堆排用了「比较」吗?如果让你把第 8 章的插入排序改成「可插拔比较器」版本,签名怎么定?比较函数每元素调用一次,估算它给 O(n²) 算法引入的额外调用开销,想想为什么标准库宁愿让 qsort 慢一点也要回调。