C语言函数指针怎么用:结合示例理解声明与调用

C语言函数指针怎么用:结合示例理解声明与调用

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 程序中可靠而实用的接口工具。

© 版权声明

相关文章