
C语言函数指针怎么用:结合示例理解声明与调用
函数指针是指向函数的指针。它让程序能够把“要执行哪段代码”作为数据传递或保存下来,常用于回调、排序比较器、操作分发和接口设计。初看声明时,括号和星号容易让人困惑;真正使用时,关键是让函数指针的类型与目标函数的类型匹配。
本文从声明和调用讲起,通过可以单独编译运行的示例说明函数指针的常见用法,并讨论空指针、参数类型、强制转换和安全边界。示例使用标准 C 写法,可用支持 C11 的编译器编译,例如:
cc -std=c11 -Wall -Wextra -pedantic main.c -o main
一、先理解普通函数和函数指针
先看一个普通函数:
int add(int a, int b)
{
return a + b;
}
这个函数的类型可以描述为“接收两个 int 参数并返回 int”。指向这类函数的指针,声明如下:
int (*operation)(int, int);
读声明时,可以先看变量名 operation:它被括号包住并带有 *,说明它是指针;指针指向的对象是一个函数,该函数接收两个 int,返回一个 int。
括号不能省略。下面这个声明的含义不同:
int *operation(int, int);
它声明的是一个函数 operation,接收两个 int,返回 int *,并不是函数指针。理解这一区别,是写对函数指针声明的第一步。
二、赋值和调用
函数名在多数表达式中会转换为指向该函数的指针,因此可以把函数名赋给类型匹配的函数指针:
#include <stdio.h>
int add(int a, int b)
{
return a + b;
}
int main(void)
{
int (*operation)(int, int) = add;
printf("%d\n", operation(3, 4));
printf("%d\n", (*operation)(3, 4));
return 0;
}
两种调用形式都合法:operation(3, 4) 和 (*operation)(3, 4)。实际代码通常采用前者,较简洁。初始化时也可以写成 &add;在这里,add 和 &add 都能表示指向该函数的指针。
这段程序输出:
7
7
请注意,赋值和调用都必须符合函数类型。以下类型并不匹配:
int add_int(int a, int b)
{
return a + b;
}
double multiply(double a, double b)
{
return a * b;
}
/* 不能把 multiply 当作 int (*)(int, int) 来调用。 */
返回类型、参数数量、参数类型以及函数原型都需要按 C 的类型兼容规则匹配。不要通过强制转换把类型不兼容的函数指针“改成”目标类型,再用新类型调用。这样的转换不会改变被调用函数本身的参数和返回约定;通过不兼容类型调用函数,行为未定义。
三、用 typedef 简化声明
函数指针类型稍复杂时,可以用 typedef 起一个易读的名字:
#include <stdio.h>
typedef int (*BinaryOperation)(int, int);
int add(int a, int b)
{
return a + b;
}
int subtract(int a, int b)
{
return a - b;
}
int main(void)
{
BinaryOperation operation = add;
printf("%d\n", operation(8, 3));
operation = subtract;
printf("%d\n", operation(8, 3));
return 0;
}
BinaryOperation 是“指向接收两个 int、返回 int 的函数的指针类型”。它不是函数本身,也不是函数声明。对比下面的写法:
typedef int BinaryFunction(int, int);
BinaryFunction *operation;
这里 BinaryFunction 是函数类型的别名,而 BinaryFunction * 才是函数指针。日常代码里,直接定义函数指针别名通常更直观。
四、场景一:把操作作为参数传入
如果一段通用逻辑需要根据调用者的选择执行不同操作,可以将函数指针作为参数传入。这也是回调的一种基本形式。
#include <stdio.h>
typedef int (*BinaryOperation)(int, int);
int add(int a, int b)
{
return a + b;
}
int subtract(int a, int b)
{
return a - b;
}
int calculate(int a, int b, BinaryOperation operation)
{
if (operation == NULL) {
return 0;
}
return operation(a, b);
}
int main(void)
{
printf("%d\n", calculate(9, 4, add));
printf("%d\n", calculate(9, 4, subtract));
return 0;
}
这段示例使用了 NULL,实际编译时应包含定义它的标准头文件。下面是完整、可直接编译的版本:
#include <stdio.h>
#include <stddef.h>
typedef int (*BinaryOperation)(int, int);
int add(int a, int b)
{
return a + b;
}
int subtract(int a, int b)
{
return a - b;
}
int calculate(int a, int b, BinaryOperation operation)
{
if (operation == NULL) {
return 0;
}
return operation(a, b);
}
int main(void)
{
printf("%d\n", calculate(9, 4, add));
printf("%d\n", calculate(9, 4, subtract));
return 0;
}
输出为:
13
5
这里的 calculate 并不需要知道具体采用加法还是减法,只需按照既定签名调用传入的函数。这样做适合将算法框架和具体策略分开,例如遍历过程由库提供、遇到每个元素时的处理方式由调用者提供。
示例中遇到空指针就返回 0,只是展示一种处理方式。若 0 也是合法结果,调用者就无法区分“正常算出 0”和“没有提供操作”。实际接口可以改为返回状态码,并通过输出参数返回计算结果,或明确规定函数指针不能为空。重要的是把契约写清楚,而不是忽略空值。
五、场景二:给 qsort 提供比较函数
标准库函数 qsort 接收比较函数。比较函数以两个元素的地址为参数,返回负数、零或正数,分别表示第一个元素小于、等于或大于第二个元素。下面用它排序整数数组:
#include <stdio.h>
#include <stdlib.h>
static int compare_ints(const void *left, const void *right)
{
int a = *(const int *)left;
int b = *(const int *)right;
if (a < b) {
return -1;
}
if (a > b) {
return 1;
}
return 0;
}
int main(void)
{
int values[] = { 7, -2, 4, 4, 0 };
size_t count = sizeof values / sizeof values[0];
qsort(values, count, sizeof values[0], compare_ints);
for (size_t i = 0; i < count; ++i) {
printf("%d%s", values[i], i + 1 == count ? "\n" : " ");
}
return 0;
}
输出为:
-2 0 4 4 7
qsort 的比较函数类型大致是“接收两个 const void *,返回 int”。比较函数将参数转换为指向元素类型的指针,再读取元素。转换结果必须符合实际传入数组的元素类型;若把实际元素当成另一种无关类型读取,就会违反类型规则,可能导致未定义行为。
不要用 return a - b; 作为通用整数比较方式。若差值超出 int 的可表示范围,减法会发生有符号整数溢出,产生未定义行为。像示例那样通过大小关系返回负数、零或正数,不依赖具体差值,因而更稳妥。
比较器还应提供一致的排序关系:同一对值的比较结果应稳定,且比较结果应满足排序所需的一致性。若比较规则前后矛盾,排序结果就无法保证。qsort 不承诺稳定排序,因此比较结果为零的相等元素,排序后不一定保持原有顺序。
六、场景三:函数表和操作分发
当程序需要根据编号选择操作时,可以用函数指针数组保存可选函数,避免不断增加 switch 分支。以下例子用枚举值索引函数表:
#include <stdio.h>
#include <stddef.h>
typedef int (*BinaryOperation)(int, int);
enum Operation {
OP_ADD = 0,
OP_SUBTRACT,
OP_MULTIPLY,
OP_COUNT
};
static int add(int a, int b)
{
return a + b;
}
static int subtract(int a, int b)
{
return a - b;
}
static int multiply(int a, int b)
{
return a * b;
}
int main(void)
{
BinaryOperation operations[OP_COUNT] = {
add,
subtract,
multiply
};
int choice = OP_MULTIPLY;
int a = 6;
int b = 7;
if (choice < 0 || choice >= OP_COUNT) {
fprintf(stderr, "操作编号无效\n");
return 1;
}
if (operations[choice] == NULL) {
fprintf(stderr, "该操作未实现\n");
return 1;
}
printf("%d\n", operations[choice](a, b));
return 0;
}
这里先检查编号范围,再访问数组。对于来自文件、网络或用户输入的编号,范围检查尤其重要:数组下标越界会造成未定义行为,可能导致程序崩溃或错误执行。枚举类型本身不会在运行时自动阻止无效数值,因此仍需检查输入。
函数表还可以有未实现的项目,用空指针表示,并在调用前检查。但如果表由程序代码完全固定,且每一项都保证已初始化,可以按接口约定省略逐项判空;如果表可能动态填充、由外部配置决定或会被修改,就应明确校验初始化状态。
七、函数指针作为结构体成员
函数指针也常出现在结构体中,用来描述一组操作。例如,一个接口对象可以保存若干操作函数,调用方通过结构体访问它们:
#include <stdio.h>
typedef struct {
int (*open)(void);
void (*close)(void);
} DeviceOps;
static int device_open(void)
{
puts("device opened");
return 0;
}
static void device_close(void)
{
puts("device closed");
}
int main(void)
{
DeviceOps ops = {
device_open,
device_close
};
if (ops.open() != 0) {
return 1;
}
ops.close();
return 0;
}
这种结构能把“可执行操作”与调用它们的代码分开,适合驱动接口、模块边界和可替换实现。不过它不会自动提供面向对象语言中的访问控制或继承;函数指针成员只是普通结构体成员,接口约束仍需要通过类型、文档和实现共同维护。
八、函数指针的参数限制与类型匹配
1. 参数和返回类型必须兼容
函数指针的类型包含返回类型和参数列表。比如:
int (*transform)(const char *, size_t);
它指向的函数需要接收 const char * 和 size_t,并返回 int。不要只看参数个数就认为类型匹配。int、long、size_t 等类型即使在某台机器上宽度相同,也不因此变成相同类型。
2. 原型中的 void 和未指定参数不是一回事
在 C 中,int f(void) 明确表示函数不接收参数。旧式的 int f() 则不提供参数类型原型信息,含义并不等同于“参数列表为空”。新代码应使用带完整参数列表的函数原型,尤其不要用无原型声明来掩盖函数指针签名不匹配的问题。
3. 可变参数函数不能随意套用普通函数指针
像 printf 这样的可变参数函数有特定的函数类型。带省略号的函数指针声明与普通定参数量的函数指针不同,必须让声明与目标函数的类型兼容,并按照可变参数规则传递参数。可变参数无法由编译器完整检查,错误的类型或数量可能导致未定义行为。不要把普通函数指针强转成可变参数函数指针,或反过来使用。
4. 函数形参中的函数类型会调整为指针
如果在形参位置写函数类型,C 会将其调整为函数指针形参。例如下面两种声明等价:
void run(int callback(int));
void run(int (*callback)(int));
第二种更直接地展示了传入的是函数指针,阅读接口时通常更容易理解。与此不同,函数不能像普通对象那样按值复制或作为结构体成员直接保存;通常保存的是指向函数的指针。
5. 函数指针不是可移植的通用对象指针
函数指针和对象指针是不同类别的指针。C 标准不保证函数指针能与 void * 任意相互转换,也不保证对象指针和函数指针大小相同。某些平台或系统接口会提供特定转换约定,但那属于具体平台的规则,不应当作可移植 C 的通用做法。
同样,函数指针不能进行指针算术。它不是一个普通数据数组中的元素地址,不能通过给它加一来“跳到下一个函数”。需要管理一组函数时,应建立明确的函数指针数组或结构体成员。
九、空函数指针和生命周期
函数指针可以是空指针。赋值为 NULL 或进行空值初始化后,都不能直接调用:
typedef int (*Callback)(int);
Callback callback = NULL;
/* 不能在未确认非空时执行 callback(10)。 */
通过空函数指针调用函数是未定义行为。接口应明确规定函数指针是否必需;若可选,调用前要检查是否为空。如果必须提供,最好在接收参数时验证,并在发现无效值时报告错误,而不是等到调用时崩溃。
函数指针也不能让目标函数凭空延长生命周期。普通 C 程序中,函数定义在程序运行期间存在,但若函数来自动态加载的共享库,先卸载库、再调用保存在变量里的函数地址,就会出错。动态加载 API 的具体约定取决于平台;使用者必须确保代码模块仍然有效,并遵守对应系统的转换和卸载规则。
十、常见错误与排查方法
- 漏写括号:
int *f(int)是返回指针的函数,不是函数指针变量。检查(*名称)是否被括号包住。 - 参数类型不匹配:参数数量、限定符、返回类型或可变参数形式不符,都可能导致编译诊断或未定义行为。让声明和函数原型一致,不要靠强制转换压掉问题。
- 未初始化就调用:未初始化的函数指针包含不确定值,不应被读取或调用。初始化为有效函数,或显式设为
NULL并检查。 - 把函数地址当作数据地址:不要假设函数指针可以安全存入
void *,也不要对其做整数或字节级运算。 - 比较器用减法返回:整数相减可能溢出。用显式大小比较返回负数、零或正数。
- 函数表下标未检查:先验证输入范围,再索引数组;外部输入不能被当作可信的枚举值。
- 混淆指针和指向的函数:
callback是函数指针变量,callback(参数)才是调用。赋值时放入函数名,而不是函数的返回值。
编译器通常能发现不少函数指针不匹配问题。建议启用警告,例如 -Wall -Wextra -Wpedantic,并把警告视为需要排查的信号。警告选项不能证明程序没有未定义行为,但能帮助及早发现签名错误、空值处理遗漏和不恰当转换。
十一、函数指针与安全
函数指针是控制程序执行路径的机制,本身既不是安全漏洞,也不是安全防护。它可以帮助把可调用操作限定为程序内明确定义的函数,例如用固定函数表选择受支持的命令;但这类设计只有在选择范围、输入校验和调用权限都得到正确管理时,才有实际意义。
一个常见做法是避免根据外部输入拼接函数名、查找任意符号并直接调用,而是把可用操作放在固定表中,再将外部编号映射到已登记的项目。即便如此,也必须验证编号边界、检查项目是否初始化,并验证函数收到的参数。白名单函数表可以限制可选操作集合,但不能代替身份验证、授权、输入校验或错误处理。
如果函数指针值来自未可信的内存、越界写入、错误的反序列化数据或可被攻击者控制的输入,调用它可能造成任意代码执行或程序崩溃。不要把裸函数地址当作可跨进程、跨机器保存的稳定标识,也不要从文件或网络直接读取地址并调用。函数地址受进程布局、平台 ABI、构建方式和模块装载状态影响,不能作为通用接口数据。
部分编译器和平台提供控制流完整性(CFI)等加固机制,用于限制间接调用的目标类型或来源;具体能力、覆盖范围和配置方式因工具链及平台而异。它们是纵深防御的一部分,不能取代正确的类型声明和内存安全实践。标准 C 语言本身并不保证运行时检查每一次函数指针调用是否合法。
十二、返回函数指针的声明
有时函数的返回值本身也是函数指针。此类声明层级较多,建议先定义类型别名,再写接口:
typedef int (*Operation)(int, int);
Operation choose_operation(int mode);
choose_operation 返回一个 Operation,也就是函数指针。比起直接写出包含多层括号的复杂声明,使用别名更容易阅读、维护和检查类型。无论参数是函数指针还是返回值是函数指针,调用时都应确保该指针非空且指向的函数类型与调用方式兼容。
总结
使用函数指针时,可以按三个问题检查代码:它指向什么签名的函数?赋给它的目标函数是否类型兼容?调用前是否能保证指针有效且目标仍可用?
在此基础上,函数指针可以用于回调、比较器、操作表和模块接口。需要特别记住:函数指针不能任意与对象指针互换,不能进行指针算术;不兼容的强制转换不能修复错误签名;空指针和越界索引都必须在调用前处理。把类型写清楚、边界检查做好,函数指针就能成为 C 程序中可靠而实用的接口工具。









