前面三篇文章,我们的指针一直指向的是"数据"——变量、数组、结构体。但你是否想过:指针除了指向数据,能不能指向函数?

答案是可以的——而且这是 C 语言最有魅力的特性之一。函数编译后是一段机器指令,存放在内存的代码段中,它的入口地址就是函数的"门牌号"。指向这个门牌号的变量,叫函数指针。有了它,你可以把函数当作参数传递、可以动态决定调用哪个函数、可以构建出优雅的"回调"机制。C 标准库中大名鼎鼎的 qsort 函数,正是依赖函数指针实现的——它能排序任意类型的数据,靠的就是把"怎么比较"这件事交给回调函数。

这篇文章的信息密度很高。我们会先梳理指针家族中的几个"新面孔"(字符指针、数组指针、二维数组传参),然后集中火力攻克函数指针及其两大核心应用:回调函数和转移表。

字符指针变量

char* 最常见但不是最直观的用法,是指向字符串常量:

#include <stdio.h>
 
int main()
{
    const char *pstr = "hello bit.";
    printf("%s\n", pstr);
    return 0;
}

很多人的第一反应是:"hello bit." 被放到了 pstr 变量里面。这是完全错误的理解。真相是:"hello bit." 是一个字符串常量,存储在内存的只读数据区。const char *pstr 里存放的不是整个字符串,而只是字符串首字符 'h' 的地址。%s 会从这个地址开始一直打印到 \0。

这里有第一篇文章讲过的"地址 VS 内容"的完整应用:pstr 变量只占 8 字节(64 位指针大小),存的是一个地址;字符串本身的 11 个字符(含 \0)在只读数据区,通过 pstr 间接访问。

有一道经典的面试题能帮你彻底理解这个机制:

#include <stdio.h>
 
int main()
{
    // 两个独立的字符数组,各自在栈上有自己的空间
    char str1[] = "hello bit.";
    char str2[] = "hello bit.";
 
    // 两个指针,指向同一个字符串常量
    const char *str3 = "hello bit.";
    const char *str4 = "hello bit.";
 
    if (str1 == str2)
        printf("str1 and str2 are same\n");
    else
        printf("str1 and str2 are not same\n");
 
    if (str3 == str4)
        printf("str3 and str4 are same\n");
    else
        printf("str3 and str4 are not same\n");
 
    return 0;
}
// 输出:
// str1 and str2 are not same
// str3 and str4 are same

用内存图解释:

栈区(每个数组独立分配)          常量区(编译器优化,相同字符串合并)
┌──────────────┐              ┌──────────────────┐
│ str1[11]     │              │ "hello bit.\0"    │
│ h e l l o ...│              └──┬───────────────┘
├──────────────┤                 │
│ str2[11]     │    str3 ────────┤
│ h e l l o ...│    str4 ────────┘
└──────────────┘
 ↑              ↑
 str1的地址     str2的地址
 不同!         相同!
  • str1 和 str2 是两个独立的数组,虽然内容相同但地址不同——str1 == str2 比较的是两个数组的首地址,地址不同,输出 "not same";
  • str3 和 str4 都指向常量区中同一个字符串常量(大多数编译器会合并相同的字符串),所以地址相同——str3 == str4 比较的是两个指针的值,值相同,输出 "same";
  • 需要提醒的是:字符串常量合并不是 C 标准强制要求的,编写依赖"相同字符串常量地址相等"的代码是不可移植的。

另外注意,char *p = "hello"; 之后不能 p[0] = 'H';——字符串常量在只读区,修改它是未定义行为。正确的做法是用 const char *p 声明,或者把字符串放到字符数组中。

char str[] 和 char *p 是笔试最爱对比的一对,用表格钉死:

维度char str[] = "hello";char *p = "hello";
本质字符数组(栈上 6 字节:h e l l o \0)指针(8 字节地址,指向常量区字符串)
内存位置栈(内容可修改)指针在栈,字符串在只读区
str[i] = 'X'✅ 可以❌ 未定义行为(只读区)
sizeof(str)6(整个数组)8(指针大小)
strlen(str)55
str = p❌ 数组名不可赋值✅ 指针可赋值
p = str—(从 str 到 p 赋值是合法的)✅ 可以

记住一句话:char str[] 是"你自己的一份可修改的字符串",char *p 是"指向公共只读字符串的指针"。什么时候用哪个?要改字符串内容就用数组;只是读、或者要动态指向不同字符串,用指针。

思考题(字符指针)

  1. str1 == str2 比较的是什么?(两个数组的首元素地址,不是内容!比较内容要用 strcmp)
  2. 为什么 char *p = "hello"; p[0] = 'H'; 危险?(字符串常量在只读区,修改是未定义行为)
  3. char *p = "hello"; 的 sizeof(p) 是多少?(8,指针大小;这和第 5 篇的 sizeof/strlen 对比题密切相关)

参考答案与详解(字符指针)

  1. str1 == str2 比较的是什么:str1、str2 是两个独立的字符数组(各自在栈上分配 11 字节)。表达式中数组名 str1、str2 都退化为首元素地址,所以 str1 == str2 比较的是两个数组的首地址(而不是内容)。它们地址不同 → 结果为假(输出 not same)。比较字符串内容必须用 strcmp(str1, str2),用 == 比地址是新手最常犯的错(且在两数组内容相同时尤其迷惑人)。
  2. char *p = "hello"; p[0] = 'H'; 为什么危险:p 指向的内存是字符串常量,存在于只读数据区。通过 p[0] = 'H' 去写只读段是未定义行为——大多数系统上会触发段错误崩溃,有时"碰巧没崩"也只是因为写了不该写的内存。正确做法:要改字符串就用 char str[] = "hello";(栈上可写副本),或写成 const char *p = "hello"; 让编译器帮你拦住这种错误赋值。
  3. sizeof(p) 是多少:p 是一个指针变量(char*),sizeof(p) 计算的是指针的大小——64 位平台是 8 字节,32 位是 4 字节。它不是字符串的长度,也不等于 strlen(p)(那才是 5)。注意这里的"8"和数组 sizeof(str)=6"是两码事:数组算的是整个字符数组大小(含 \0),指针算的只是那 8 字节地址。这正是 part5 要重点对比的 sizeof/strlen 陷阱。

数组指针变量

前一篇我们讲了指针数组(int *p[10]),这里来看它的"孪生兄弟"——数组指针(int (*p)[10]):

int *p1[10];    // p1是数组,元素是int*      —— 指针数组
int (*p2)[10];  // p2是指针,指向int[10]    —— 数组指针

解析规则:[] 的优先级高于 *。所以 int *p1[10] 中 p1 先和 [10] 结合,p1 是数组;int (*p2)[10] 中括号改变了优先级,p2 先和 * 结合,p2 是指针。

数组指针的初始化和使用:

#include <stdio.h>
 
int main()
{
    int arr[10] = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10};
 
    // p是指针,指向一个包含10个int的数组
    int (*p)[10] = &arr;   // 注意:是 &arr,不是 arr
 
    // arr      → int*       (首元素地址)
    // &arr     → int(*)[10] (整个数组的地址)
    // 类型必须匹配!
 
    printf("arr     = %p\n", (void*)arr);
    printf("&arr    = %p\n", (void*)&arr);
    printf("p       = %p\n", (void*)p);
    // 三个值相同,但类型不同
 
    int i;
    for (i = 0; i < 10; i++)
    {
        // *p 得到整个数组 → 再[i]下标访问
        printf("%d ", (*p)[i]);
    }
    printf("\n");
 
    return 0;
}

类型解析:

int   (*p)   [10]   = &arr;
 │      │      │
 │      │      └── p指向的数组有10个元素
 │      └── p是数组指针变量名,*表示指针
 └── p指向的数组的元素类型是int

三个关键点:

  1. *p 解引用后得到整个数组。数组在表达式中退化为首元素地址,所以 (*p)[i] 等价于 arr[i]。
  2. p + 1 跳过 40 字节——p 指向的类型是 int[10],步长是整个数组。
  3. 括号不能丢:*p[i] 会解析成 *(p[i]),语义完全错误。

数组指针最重要的应用在下一节——二维数组传参,那里 int (*p)[N] 就是唯一的正确形参类型。

思考题(数组指针)

  1. int (*p)[5] = &arr; 中 arr 必须是多大?(恰好 5 个 int;类型不匹配会报警告)
  2. (*p)[i] 和 *p[i] 的区别?(前者正确:解引用数组再取第 i 个元素;后者错误:p[i] 是指针,再解引用)
  3. 数组指针和指针数组怎么一眼区分?(看括号:(*p)[N] 是指针,*p[N] 是数组)

参考答案与详解(数组指针)

  1. int (*p)[5] = &arr 里 arr 必须多大:必须恰好是"5 个 int"的数组。因为 p 声明为指向 int[5] 的数组指针,&arr 必须是 int (*)[5] 类型才能匹配。如果 arr 有 6 个 int,&arr 的类型就是 int (*)[6],赋给 int (*)[5] 会报类型不匹配(incompatible pointer type)。一句话:数组指针的元素个数要和被赋的数组完全一致。
  2. (*p)[i] vs *p[i]:
    • (*p)[i]:先解引用 p(得到它指向的整个数组,退化为首元素地址),再取下标 i 的元素——正确,等价于 arr[i]。
    • *p[i]:因为 [] 优先级高于 *,它等价于 *(p[i])——把 p 当成指针数组用(p[i] 是第 i 个指针),再解引用。但 p 其实是数组指针、p[i] 得到的根本不是"单个指针",语义完全错了,结果不可预测。
    • 两者只差括号,含义天差地别——括号是数组指针的生命线。
  3. 一眼区分:看变量名和 [] 谁先结合。
    • int (*p)[N]:名字后的 () 里的 * 先和 p 结合 → p 是指针,指向数组 → 数组指针。
    • int *p[N]:[] 优先级高,p 先和 [N] 结合 → p 是数组,元素是指针 → 指针数组。
    • 口诀:带括号 (*p) 是指针;不带括号 *p[N] 是数组。

二维数组传参的本质

一维数组传参时,数组名退化为首元素地址。那二维数组呢?二维数组的每个元素是一维数组。所以二维数组的数组名是第一行的地址——而第一行本身是一个一维数组,类型是数组指针。

int arr[3][5]:

    arr[0] → [1, 2, 3, 4, 5]   ← 第0行,类型 int[5]
    arr[1] → [2, 3, 4, 5, 6]   ← 第1行,类型 int[5]
    arr[2] → [3, 4, 5, 6, 7]   ← 第2行,类型 int[5]

    arr(数组名)→ 第一行arr[0]的地址,类型 int(*)[5]

这意味着二维数组传参时,形参可以写成两种等价形式:

#include <stdio.h>
 
// 写法1:写成二维数组形式(最直观)
void print_array1(int a[3][5], int r, int c)
{
    int i, j;
    for (i = 0; i < r; i++)
    {
        for (j = 0; j < c; j++)
        {
            printf("%d ", a[i][j]);
        }
        printf("\n");
    }
}
 
// 写法2:写成数组指针形式(揭示本质)
void print_array2(int (*p)[5], int r, int c)
{
    int i, j;
    for (i = 0; i < r; i++)
    {
        for (j = 0; j < c; j++)
        {
            // p+i 跳过一行,*(p+i) 得到该行(一维数组)
            // *(p+i)+j 是该行第j个元素的地址
            printf("%d ", *(*(p + i) + j));
        }
        printf("\n");
    }
}
 
int main()
{
    int arr[3][5] = {{1,2,3,4,5}, {2,3,4,5,6}, {3,4,5,6,7}};
 
    printf("写法1(数组形式):\n");
    print_array1(arr, 3, 5);
 
    printf("写法2(指针形式):\n");
    print_array2(arr, 3, 5);
 
    return 0;
}

*(*(p+i)+j) 的拆解过程:

p            → 指向第一行的数组指针
p + i        → 跳过i行,指向第i行
*(p + i)     → 解引用,得到第i行(类型int[5],退化为int*)
*(p + i) + j → 第i行第j个元素的地址
*(*(p + i) + j) → arr[i][j]的值

二维数组传参时只需要指定第二维的大小(编译器需要它来计算偏移),第一维可以省略。void func(int arr[][5], int rows); 和 void func(int (*arr)[5], int rows); 是全等的写法。为什么第二维必须写?因为编译器要计算 arr[i][j] 的地址,必须知道一行有多少字节——行内偏移 j×4 + 行偏移 i×20,少了"一行 20 字节"这个信息就没法算地址。

这里必须讲一个高频错误:int ** 不能接收二维数组。

void func(int **p);          // 错误预期:想用二级指针接收二维数组
 
int arr[3][5];
func(arr);                   // ❌ 编译警告/错误!

为什么不行?arr 的类型是 int (*)[5](指向第一行的数组指针),int ** 是"指向指针的指针"。两者结构完全不同:arr 指向的数据是一行 5 个 int(连续 20 字节),而 int ** 解引用得到的是一个 int *(8 字节指针)。把 int (*)[5] 强塞给 int **,解引用时会把"行的前 8 个字节"当成指针去解——完全是垃圾。记住:二维数组名只能匹配 int (*)[N] 形参,不能匹配 int **。 想用 int ** 处理二维数据,必须手动构造"指针数组"(每行一个 int*),这就是上一篇"指针数组模拟二维数组"的用途。

思考题(二维数组传参)

  1. void f(int arr[][5]) 里的第二维 5 能不能省略?(不能,编译器算地址需要每行大小)
  2. int ** 为什么不能接收 int arr[3][5]?(类型不匹配:前者是"指针的指针",后者是"指向数组的指针")
  3. *(*(p+i)+j) 和 p[i][j] 等价吗?(完全等价,都是下标运算展开)

参考答案与详解(二维数组传参)

  1. 第二维 5 不能省略:编译器要计算 arr[i][j] 的地址需要知道"一行有多少字节"。arr[i][j] 的地址 = 起始地址 + i×行字节 + j×元素字节,其中"行字节"必须知道第二维列数才能算出来(一行 5 个 int = 20 字节)。省略第二维编译器就无从知道每行多宽,无法计算偏移,所以报错。第一维(行数)对地址计算没有影响,可以省略。
  2. int ** 为什么不能接收 int arr[3][5]:arr 退化后是 int (*)[5](指向一行的数组指针),而 int ** 是"指向指针的指针"。两者布局完全不同:int (*)[5] 解引用得到一整行(5 个连续 int),int ** 解引用得到一个 int*。把 int (*)[5] 塞给 int **,会让 int ** 把"行的前 8 字节"误当成指针去继续解引用——数据全是垃圾。要接收二维数组名只能用 int (*)[N];想用 int ** 就必须先构造一个指针数组(每行一个 int*)。
  3. *(*(p+i)+j) 与 p[i][j] 等价:p[i][j] 展开是 *(*(p+i)+j)。拆解:p+i 指向第 i 行 → 解引用 *(p+i) 得到第 i 行(退化为 int*)→ +j 是该行第 j 个元素地址 → 再解引用 *(*(p+i)+j) 得 arr[i][j] 的值。等价的两个唯一区别只是写法,标准说 E1[E2] 就是 *((E1)+(E2)),完全等价。

函数指针变量

函数编译后存放在代码段(Text Segment)中,它的入口地址就是函数的地址。和数组名是首元素地址类似,函数名本身就是函数的地址:

#include <stdio.h>
 
void test()
{
    printf("hehe\n");
}
 
int main()
{
    printf("test:  %p\n", (void*)test);
    printf("&test: %p\n", (void*)&test);
    // 两个值完全一样!
    return 0;
}

函数名和 &函数名 打印出来完全一样——函数名的"地址常量"性质比数组名更纯粹(函数名在所有语境下都不退化,&func 和 func 永远等价)。

函数指针的声明语法和数组指针类似——加括号让 * 先和变量名结合:

#include <stdio.h>
 
int Add(int x, int y)
{
    return x + y;
}
 
int main()
{
    // pf是一个函数指针,指向"返回int、接受两个int参数"的函数
    int (*pf)(int, int) = Add;        // 函数名就是地址
    // int (*pf)(int x, int y) = &Add; // 等价写法,参数名可省略
 
    int result1 = (*pf)(2, 3);    // 显式解引用调用
    int result2 = pf(3, 5);       // 直接调用(C标准支持)
 
    printf("(*pf)(2,3) = %d\n", result1);   // 5
    printf("pf(3,5)    = %d\n", result2);   // 8
 
    return 0;
}

(*pf)(2, 3) 和 pf(3, 5) 两种调用方式都合法——C 标准允许函数指针直接当作函数名调用,解引用是"可选的装饰"。很多人以为必须写 (*pf)(...),其实 pf(...) 更简洁,两者语义完全相同。

类型解析:

int      (*pf)   (int x, int y)
 │         │       -------------
 │         │             │
 │         │             pf指向函数的参数类型和个数
 │         函数指针变量名
 pf指向函数的返回类型

一个常见的混淆:int *fp(int, int); 声明的是函数(返回 int*),int (*fp)(int, int); 才是函数指针。括号是关键。typedef 可以极大简化复杂函数指针类型:

typedef void (*callback_t)(int);     // callback_t是"接受int,返回void的函数指针"类型
callback_t cb = my_handler;          // 使用——清爽多了

用 typedef 重命名数组指针和函数指针时,有一个容易踩的坑:新类型名必须写在 * 的右边。比如把"指向 int[5] 的数组指针"类型命名为 parr_t、把"接受 int 返回 void 的函数指针"类型命名为 pfun_t:

typedef int (*parr_t)[5];      // 新类型名parr_t在*的右边
typedef void (*pfun_t)(int);   // 新类型名pfun_t在*的右边
 
int arr[5];
parr_t parr = &arr;            // 声明一个数组指针变量
pfun_t pfun = my_handler;      // 声明一个函数指针变量

如果写成 typedef int *parr_t[5];,因为 [] 的优先级高于 *,它重命名的其实是"存放 5 个 int* 的指针数组",语义完全不同。

《C陷阱与缺陷》收录过两段著名的代码,专门考验复杂声明的阅读能力:

(*(void (*)())0)();                          // 代码1
void (*signal(int, void(*)(int)))(int);      // 代码2

代码1 拆开看:(void (*)())0 把整数 0 强转成"返回 void、无参数的函数指针",* 解引用得到那个函数,最外层 () 调用它——合起来就是"调用地址 0 处的函数"。这种写法在单片机、操作系统的启动代码里真实存在(0 地址往往放着复位向量),普通应用代码几乎不会用到,但它把"函数指针类型转换 + 解引用 + 调用"三个知识点压进了一行。

代码2 的 signal 是一个函数名:它接收两个参数(一个 int 和一个函数指针 void(*)(int)),返回值是 void(*)(int)——也就是"返回函数指针的函数"。这种声明可读性太差,用刚才的 typedef 改写后一目了然:

typedef void (*pfun_t)(int);
pfun_t signal(int, pfun_t);   // signal: 接收int和函数指针,返回函数指针

顺便说一句,signal 其实是 C 标准库 <signal.h> 里的真实函数,用来注册信号处理程序,它的声明形式正如代码2所示——这说明读懂复杂声明在阅读库源码时很有用。

还有两个函数指针的常识:

  1. 函数指针的大小和普通指针一样(64 位平台 8 字节),因为函数指针存的只是入口地址。
  2. 函数指针不能与 void* 互相转换(标准 C)。C 标准只保证 void* 能转换回原类型指针,不保证能存函数指针;实际工程中(POSIX、Windows)常常这样用,但那是扩展行为。需要"通用函数指针"时用 void (*)()(无参数原型)做统一的类型。

思考题(函数指针)

  1. int (*fp)(int); 和 int *fp(int); 的区别?(前者是函数指针变量,后者是返回 int* 的函数声明)
  2. (*pf)(2,3) 和 pf(2,3) 等价吗?(等价,C 标准允许函数指针直接调用)
  3. 为什么 signal 的声明要读作"接收 int 和函数指针、返回函数指针的函数"?(从变量名往外拆:signal(...) 是函数,返回 void(*)(int))

参考答案与详解(函数指针)

  1. int (*fp)(int); vs int *fp(int);:区别在 []/() 优先级与括号。
    • int (*fp)(int);:* 在括号里先和 fp 结合 → fp 是函数指针变量,指向"接收 int、返回 int 的函数"。
    • int *fp(int);:没有括号,fp 先和 (int) 结合(函数调用语法)→ fp 是一个函数,返回类型是 int*。它是一个函数声明,不是指针变量。
    • 一个加括号、一个不加括号,就决定了它是"变量(能改地址)"还是"函数"。typedef 就是为了把这种绕口声明变简洁。
  2. (*pf)(2,3) 和 pf(2,3) 等价:C 标准允许把函数指针直接当成函数名来调用,pf(2,3) 本质是 (*pf)(2,3) 的简写(解引用是"可选的装饰")。两者输出的结果完全一样。很多人以为必须写 (*pf)(...),其实 pf(...) 更简洁,而且两种都合法。
  3. signal 声明怎么读:void (*signal(int, void(*)(int)))(int);
    • 抓住"从里往外读":signal(int, void(*)(int)) 说明 signal 是一个函数,它有两个参数:一个 int、一个 void(*)(int)(函数指针)。
    • signal(...) 整体再被 void (*...)(int) 包围,说明它的返回类型是 void(*)(int)(函数指针)。
    • 合起来:signal 是一个"接收 int 和 void(*)(int) 两个参数、返回 void(*)(int) 的函数"。这正是 C 库 <signal.h> 里注册信号处理函数的真实原型——用 typedef 改写 typedef void(*pfun_t)(int); pfun_t signal(int, pfun_t); 后一目了然。

函数指针数组与转移表

函数指针数组——顾名思义,就是把一组函数指针放进数组里。它的经典应用是转移表(Transfer Table),用来消除冗长的 switch-case。

先看传统写法(计算器):

#include <stdio.h>
 
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; }
int divide(int a, int b) { return a / b; }
 
int main()
{
    int x, y, input, ret;
 
    do
    {
        printf("*************************\n");
        printf("  1:add           2:sub  \n");
        printf("  3:mul           4:div  \n");
        printf("  0:exit                 \n");
        printf("*************************\n");
        printf("请选择:");
        scanf("%d", &input);
 
        switch (input)
        {
        case 1:
            printf("输入操作数:");
            scanf("%d %d", &x, &y);
            ret = add(x, y);
            printf("ret = %d\n", ret);
            break;
        case 2:
            printf("输入操作数:");
            scanf("%d %d", &x, &y);
            ret = sub(x, y);
            printf("ret = %d\n", ret);
            break;
        case 3:
            printf("输入操作数:");
            scanf("%d %d", &x, &y);
            ret = mul(x, y);
            printf("ret = %d\n", ret);
            break;
        case 4:
            printf("输入操作数:");
            scanf("%d %d", &x, &y);
            ret = divide(x, y);
            printf("ret = %d\n", ret);
            break;
        case 0:
            printf("退出程序\n");
            break;
        default:
            printf("选择错误\n");
            break;
        }
    } while (input);
 
    return 0;
}

每个 case 的逻辑几乎一模一样——只有调用的函数不同,造成了大量重复。用转移表重构:

#include <stdio.h>
 
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; }
int divide(int a, int b) { return a / b; }
 
int main()
{
    int x, y, input, ret;
 
    // 转移表:下标就是操作码,元素就是对应的函数指针
    // 下标0占位不用,方便 input 直接作为索引
    int (*operation[5])(int, int) = {NULL, add, sub, mul, divide};
 
    do
    {
        printf("*************************\n");
        printf("  1:add           2:sub  \n");
        printf("  3:mul           4:div  \n");
        printf("  0:exit                 \n");
        printf("*************************\n");
        printf("请选择:");
        scanf("%d", &input);
 
        if (input >= 1 && input <= 4)
        {
            printf("输入操作数:");
            scanf("%d %d", &x, &y);
            ret = operation[input](x, y);  // 通过数组索引直接调用函数!
            printf("ret = %d\n", ret);
        }
        else if (input == 0)
        {
            printf("退出程序\n");
        }
        else
        {
            printf("选择错误\n");
        }
    } while (input);
 
    return 0;
}

核心就是 operation[input](x, y);——通过数组下标取出对应的函数指针,直接调用。新增操作只需在数组中加一个元素,不需要改任何控制逻辑。这就是开闭原则(对扩展开放,对修改封闭)在 C 语言中的体现。

转移表为什么下标 0 要占位不用?因为菜单编号从 1 开始,如果表直接按下标 1~4 放函数,input 就能直接当索引用,省一次 input-1 运算——这是"用空间换简洁"的典型取舍。如果你想把 0 也利用上,把 NULL 换成别的处理函数即可(operation[0] 对应"退出",可以直接 return operation[0]())。

转移表的思想在真实工程里到处是:命令解析器(字符串命令 → 函数指针数组)、中断向量表(硬件中断号 → 处理函数)、状态机(状态号 → 处理函数)。本质都是"用一个编号查表得到函数,然后调用"。

思考题(函数指针数组)

  1. int (*operation[5])(int, int) 怎么读?(operation 是数组,5 个元素,每个元素是指向"返回 int、接受两个 int"函数的指针)
  2. 转移表相比 switch-case 的好处?(消除重复代码、新增操作不改逻辑、索引查表 O(1))
  3. 如果 input 非法(如 99),operation[99] 会怎样?(越界访问,未定义行为——所以必须有 input >= 1 && input <= 4 检查)

参考答案与详解(函数指针数组)

  1. int (*operation[5])(int, int) 怎么读:从内往外拆——operation 先和 [5] 结合([] 优先级高于 *),说明 operation 是一个数组,有 5 个元素;再看元素的类型 int (*)(int, int),即"指向接收两个 int、返回 int 的函数的指针"。合起来:operation 是"元素为 int(*)(int,int) 函数指针"的数组(函数指针数组)。转移表里下标 1~4 分别放 add/sub/mul/divide。
  2. 转移表比 switch-case 的好处:①消除重复代码——原来每个 case 都有几乎相同的"输入/输出操作",搬进 calc 或直接用 operation[input] 一行代替;②新增功能不改控制逻辑——加一个新操作只需要往数组里加一个函数指针元素,if/else 或 switch 的框架一点不用动(开闭原则);③索引查表 O(1)——直接按下标取出函数并调用,比一长串分支判断更高效、更好维护。
  3. operation[99] 越界:operation 只有 5 个元素(下标 0~4),operation[99] 会访问数组界限之外的未知内存,把那里的垃圾值当函数指针去调用——这是未定义行为,可能崩溃也可能跳进错误代码。这也是为什么代码里必须先做 if (input >= 1 && input <= 4) 的越界检查,把非法输入挡在查表之前。

回调函数与qsort

回调函数(Callback) 是指把一个函数 A 的指针作为参数传递给另一个函数 B,让 B 在特定时机调用 A。B 只负责"框架",A 负责"具体行为"——B 不需要知道 A 做什么,只需要知道 A 的签名。

qsort 是 C 标准库中最典型的回调函数案例:

#include <stdio.h>
#include <stdlib.h>  // qsort 在这里
 
// 回调函数:告诉qsort"怎么比较两个元素"
int cmp_int(const void *a, const void *b)
{
    int ia = *(const int *)a;
    int ib = *(const int *)b;
    return ia - ib;   // 升序:a-b;降序:b-a
}
 
int main()
{
    int arr[] = {3, 1, 7, 5, 8, 9, 0, 2, 4, 6};
    int sz = sizeof(arr) / sizeof(arr[0]);
    int i;
 
    printf("排序前: ");
    for (i = 0; i < sz; i++)
        printf("%d ", arr[i]);
    printf("\n");
 
    // qsort(数组首地址, 元素个数, 每个元素大小, 比较函数)
    qsort(arr, sz, sizeof(arr[0]), cmp_int);
 
    printf("排序后: ");
    for (i = 0; i < sz; i++)
        printf("%d ", arr[i]);
    printf("\n");
 
    return 0;
}

qsort 的原型是:

void qsort(
    void *base,              // 数组首地址(泛型指针,可排序任意类型)
    size_t num,              // 元素个数
    size_t size,             // 每个元素的大小(字节)
    int (*compar)(const void*, const void*)  // 比较函数的函数指针
);

这就是 void* 的真正用武之地——qsort 不知道你要排序什么类型的数据,但通过 void* 接收数组地址,通过函数指针接收比较逻辑,实现了算法与数据类型的分离。

关于比较函数,有几个必须掌握的细节:

  1. 返回值语义:compar(a, b) 返回负值表示 a 排在 b 前,0 表示相等,正值表示 a 排在 b 后。所以 return ia - ib 是升序;想降序就 return ib - ia。
  2. const void * 要先强转再解引用:*(const int *)a。直接 *a 是错的(void* 不能解引用)。
  3. int 比较的减法溢出问题:ia - ib 在 ia、ib 差距超过 INT_MAX 时会溢出(未定义行为)。面试中可以提一句:严谨写法是 return (ia > ib) - (ia < ib)(返回 -1/0/1)。实际排序场景很少遇到这么极端的值,但知道这个细节能加分。
  4. double 不能用减法:return (double)a - (double)b 会截断成 int,0.5 的差变成 0。double 的严谨比较:
    int cmp_double(const void *a, const void *b)
    {
        double da = *(const double *)a;
        double db = *(const double *)b;
        return (da > db) - (da < db);   // 返回 -1/0/1
    }
  5. 排序字符串数组(char * 数组)是经典易错点:数组元素是 char *,比较函数拿到的是 char **,必须先解引用成 char * 再 strcmp:
    int cmp_str(const void *a, const void *b)
    {
        // a 指向的元素是 char*,所以要 *(const char **)a
        const char *sa = *(const char **)a;
        const char *sb = *(const char **)b;
        return strcmp(sa, sb);
    }
     
    char *words[] = {"banana", "apple", "cherry"};
    qsort(words, 3, sizeof(char *), cmp_str);   // 结果:apple banana cherry
    *(const char **)a 是新手最容易写错的一行——想清楚"数组元素是 char*,比较函数参数是指向元素的指针,所以是指向 char* 的指针,即 char**"就不会错。
  6. qsort 不保证稳定:相等的元素排序后相对顺序可能改变。标准未承诺稳定性。

你甚至可以用 qsort 排序结构体:

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
 
typedef struct
{
    char name[20];
    int age;
    float score;
} Student;
 
int cmp_by_age(const void *a, const void *b)
{
    const Student *s1 = (const Student *)a;
    const Student *s2 = (const Student *)b;
    return s1->age - s2->age;
}
 
int cmp_by_name(const void *a, const void *b)
{
    const Student *s1 = (const Student *)a;
    const Student *s2 = (const Student *)b;
    return strcmp(s1->name, s2->name);
}
 
int main()
{
    Student stu[] = {
        {"Zhang", 20, 88.5f},
        {"Li",    19, 92.0f},
        {"Wang",  21, 85.0f},
        {"Zhao",  20, 95.5f}
    };
    int sz = sizeof(stu) / sizeof(stu[0]);
    int i;
 
    printf("按年龄排序:\n");
    qsort(stu, sz, sizeof(Student), cmp_by_age);
    for (i = 0; i < sz; i++)
        printf("  %s, %d岁, %.1f分\n", stu[i].name, stu[i].age, stu[i].score);
 
    printf("\n按姓名排序:\n");
    qsort(stu, sz, sizeof(Student), cmp_by_name);
    for (i = 0; i < sz; i++)
        printf("  %s, %d岁, %.1f分\n", stu[i].name, stu[i].age, stu[i].score);
 
    return 0;
}

有了 qsort,你可以对任何类型的数据排序——只需要写出该类型的比较规则。回调函数在实际工程中无处不在:图形界面的按钮点击事件、网络编程中的数据到达回调、操作系统中的信号处理器、数据库中的 SQL 执行回调……理解了 C 语言的函数指针回调机制,你就理解了所有语言中"事件驱动编程"的底层思想。

第五篇我们还会用冒泡排序自己实现一个"玩具版 qsort",把 void* 逐字节交换的机制彻底拆开——那是理解通用算法实现的关键一步。

思考题(qsort 与回调)

  1. 为什么比较函数参数是 const void * 而不是具体类型?(qsort 是通用算法,不知道元素类型,void* 是通用接口)
  2. cmp_int 想降序怎么写?(return *(int*)b - *(int*)a;)
  3. 排序 char *words[] 时,比较函数里为什么是 *(const char **)a?(数组元素是 char*,参数指向元素,即指向 char* 的指针)

参考答案与详解(qsort 与回调)

  1. 为什么参数是 const void *:qsort 是通用排序函数,它在设计时不知道也不关心你要排的是 int、double 还是结构体。void * 作为"泛型地址",可以接收任意类型的数组首地址;比较函数收到的是"两个指向元素的 void*",再由调用者把它强转回自己的元素类型再比较。这样"算法框架"(快排/元素互换)和"数据类型"(比较逻辑)实现了分离——这正是回调 + void* 的威力。
  2. 想降序怎么写:把 cmp_int 里 return ia - ib; 改成 return ib - ia;(即交换被减数位置),当 ib > ia 时返回正数,大数排在前面。等价写法 return *(const int *)b - *(const int *)a;。回到"返回值语义":返回负值表示第一个参数排前,正值表示第二个排前,所以调换顺序就是倒序。
  3. 为什么是 *(const char **)a:words 的每个元素是 char *(指向字符串的指针)。qsort 调用比较函数时,a 的大小是 "一个元素的大小",而 a 的类型是 const void *——它指向的是数组里的某一个元素,也就是"一个 char* "。所以 a 本质是"指向 char* 的指针",即 const char **。因此要先 (const char **)a 再解引用 *(...) 才拿到那个 char*(字符串首地址),才能交给 strcmp。这是最容易写错的一行:数组元素是 char*,比较参数就要解引用成 char** 再取。

这篇文章里,指针家族新添了三位成员:字符指针(指向字符串常量而非内容本身)、数组指针(指向整个数组而非首元素)、函数指针(指向代码段的入口地址)。函数指针是其中最强大的——它让函数变成了"可传递的数据",由此衍生出回调函数和转移表两种设计模式。

在进入下一篇之前,做两个小练习验收:

练习 1:实现一个"命令处理器":用函数指针数组把 "help"、"quit"、"version" 三个命令映射到三个函数,读入字符串命令并调用对应函数(不会的命令打印"unknown")。

练习 2:用 qsort 对 double score[] = {85.5, 92.0, 78.5, 99.0} 降序排序,注意比较函数不要用减法(用 (a>b)-(a<b) 模式)。

练习 1 参考答案

#include <stdio.h>
#include <string.h>
 
// 三个命令对应的处理函数(都返回 void、无参数,签名一致才能进同一个函数指针数组)
void CmdHelp(void)     { printf("可用命令: help, quit, version\n"); }
void CmdQuit(void)     { printf("再见!\n"); }
void CmdVersion(void)  { printf("version 1.0\n"); }
 
// 用结构体把"命令字符串"和"处理函数指针"绑在一起,便于线性查找
typedef struct
{
    const char *name;
    void (*handler)(void);
} Command;
 
int main()
{
    // 转移表(命令 → 函数指针)
    Command table[] = {
        {"help",    CmdHelp},
        {"quit",    CmdQuit},
        {"version", CmdVersion}
    };
    int n = sizeof(table) / sizeof(table[0]);
 
    char input[64];
    printf("请输入命令: ");
    scanf("%s", input);
 
    int i;
    int found = 0;
    for (i = 0; i < n; i++)
    {
        if (strcmp(table[i].name, input) == 0)  // 匹配命令名
        {
            table[i].handler();   // 找到对应函数并调用(回调)
            found = 1;
            break;
        }
    }
    if (!found)
        printf("unknown\n");
 
    return 0;
}
// 输入 help    → 输出"可用命令: ..."
// 输入 version → 输出"version 1.0"
// 输入 xyz     → 输出"unknown"

解析:这是"命令解析器 + 转移表"的最小版。把命令字符串和处理函数指针放进结构体数组,相当于一张"字符串 → 函数"的查找表;用 strcmp 匹配命令,命中后通过 table[i].handler() 调用对应函数。不难看出,把 table 换成哈希表/字典就是真实命令行工具(shell)的命令分发原理。核心还是那条:一个编号/名字 查表 → 得到函数指针 → 调用。

练习 2 参考答案

#include <stdio.h>
#include <stdlib.h>
 
// 降序比较函数:返回负值表示 a 应排在 b 后面(大数在前)
int cmp_double_desc(const void *a, const void *b)
{
    double da = *(const double *)a;
    double db = *(const double *)b;
    // 用 (da < db) - (da > db):da<db 返回 -1 → a 排后 → 降序
    // 只用 1/0/-1 三个值,避免了把 double 转成 int 时 0.5 之类小数被截断的坑
    return (da < db) - (da > db);
}
 
int main()
{
    double score[] = {85.5, 92.0, 78.5, 99.0};
    int sz = sizeof(score) / sizeof(score[0]);
    int i;
 
    qsort(score, sz, sizeof(double), cmp_double_desc);
 
    for (i = 0; i < sz; i++)
        printf("%.1f ", score[i]);
    printf("\n");
 
    return 0;
}
// 输出:99.0 92.0 85.5 78.5

解析:降序让 da < db 时返回负数(a 排到 b 后面),所以返回 (da<db) - (da>db)。为什么不能用 (double)a - (double)b? 因为 return 的返回类型是 int,减法结果会被截断成整数——78.5 - 78.0 = 0.5 会变成 0,qsort 会误认为两个元素相等。用 (da<db)-(da>db) 只产生 -1/0/1,精确反映有序关系,是 double 比较函数的标准写法。

下一篇我们将回到指针运算的细节——深入对比 sizeof 和 strlen、void* 逐字节操作的核心技巧、数组参数在函数中的退化规律。这些内容是笔试面试的高频考点,也是你指针拼图中最后一块关键积木。


本文基于C语言教学课件《第14讲:深入理解指针(4)》的内容框架撰写,所有代码示例均在GCC/MinGW环境下验证通过。