指针(三):指针与函数、函数指针作为参数、回调函数、动态内存分配

好,咱们继续往下走。前两章我们把指针的基本功打扎实了,这一章要聊的,是真正让 C 语言变得灵活、高效、甚至有点「高级」的东西。说白了,就是指针怎么跟函数配合,怎么在运行时动态地要内存、还内存。

我个人觉得,这部分内容是区分「会写 C」和「写好 C」的一道分水岭。你想想看,一个程序如果连内存大小都不能在运行时决定,那得多死板?

1. 指针作为函数参数

先从一个最基础的问题说起:为什么有时候函数参数要用指针?

C 语言里,函数参数默认是「值传递」。什么意思?就是你把一个变量扔进函数,函数拿到的是它的副本。你在函数里改来改去,外面的原变量纹丝不动。

核心概念:如果想在函数内部修改外部变量的值,就必须传递变量的地址(指针)。

举个例子,写一个交换两个整数的函数:

#include <stdio.h>

// 错误示范:值传递,改不了外面
void swap_wrong(int a, int b) {
    int temp = a;
    a = b;
    b = temp;
}

// 正确示范:传指针,改地址里的内容
void swap_right(int *a, int *b) {
    int temp = *a;
    *a = *b;
    *b = temp;
}

int main() {
    int x = 10, y = 20;
    swap_wrong(x, y);
    printf("swap_wrong 之后: x=%d, y=%d\n", x, y); // 还是 10, 20

    swap_right(&x, &y);
    printf("swap_right 之后: x=%d, y=%d\n", x, y); // 变成 20, 10
    return 0;
}

我在项目中遇到过好几次新手犯这个错。嗯,其实我自己刚学的时候也栽过跟头。记住一句话:想改谁,就传谁的地址。

2. 函数指针

函数也有地址?没错。函数编译之后,代码会放在内存的代码段里,函数名就是这段代码的入口地址。我们可以用一个指针变量来存这个地址,然后通过这个指针去调用函数。

声明一个函数指针,语法有点绕:

// 声明一个函数指针,指向「返回类型为 int,参数为两个 int」的函数
int (*func_ptr)(int, int);

注意括号不能省。如果写成 int *func_ptr(int, int),那就变成了「一个返回 int* 的函数声明」,完全两码事。

看个完整例子:

#include <stdio.h>

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

int main() {
    int (*op)(int, int);  // 声明函数指针

    op = add;             // 指向 add 函数
    printf("add: %d\n", op(10, 5));

    op = sub;             // 指向 sub 函数
    printf("sub: %d\n", op(10, 5));

    return 0;
}

小技巧:可以用 typedef 给函数指针起个别名,代码会清爽很多。

typedef int (*Operation)(int, int);
Operation op = add;

3. 函数指针作为参数 & 回调函数

函数指针最常见的用法,就是作为参数传给另一个函数。这个被传进去的函数,就叫「回调函数」。

为什么要这么干?说白了,就是把「做什么」和「怎么做」分开。你写一个通用的排序函数,它负责「怎么排」(比如冒泡排序的逻辑),但具体「怎么比大小」由调用者决定。这样代码就活了。

来看一个例子:

#include <stdio.h>

// 比较函数:升序
int ascending(int a, int b) {
    return a > b;
}

// 比较函数:降序
int descending(int a, int b) {
    return a < b;
}

// 通用冒泡排序,比较逻辑由回调函数决定
void bubble_sort(int arr[], int n, int (*compare)(int, int)) {
    for (int i = 0; i < n - 1; i++) {
        for (int j = 0; j < n - i - 1; j++) {
            if (compare(arr[j], arr[j + 1])) {
                int temp = arr[j];
                arr[j] = arr[j + 1];
                arr[j + 1] = temp;
            }
        }
    }
}

void print_array(int arr[], int n) {
    for (int i = 0; i < n; i++) printf("%d ", arr[i]);
    printf("\n");
}

int main() {
    int arr1[] = {3, 1, 4, 1, 5, 9};
    int arr2[] = {3, 1, 4, 1, 5, 9};
    int n = 6;

    bubble_sort(arr1, n, ascending);
    print_array(arr1, n);  // 升序

    bubble_sort(arr2, n, descending);
    print_array(arr2, n);  // 降序

    return 0;
}

你看,排序的逻辑没变,变的只是那个比较函数。这就是回调的威力。我在写嵌入式驱动的时候,经常用回调来处理中断——中断来了,调用你注册的那个函数,至于函数里干什么,我不关心。

注意:回调函数的类型必须严格匹配。参数个数、类型、返回值,差一点都不行。编译器会报错,但有时候报的错很隐晦,我曾经在这上面浪费过一下午。

4. 动态内存分配

好,接下来是重头戏。前面我们用的数组、变量,都是在编译时就已经确定好大小的。但很多时候,程序跑起来才知道要多大空间。比如你要读一个文件,文件多大?不知道。这时候就需要动态内存分配。

C 语言提供了四个函数:malloccallocreallocfree。它们都声明在 <stdlib.h> 里。

4.1 malloc

malloc 分配一块连续的内存,返回指向这块内存的指针。它不会初始化内存,里面的数据是随机的。

int *p = (int *)malloc(10 * sizeof(int));
if (p == NULL) {
    // 分配失败,处理错误
    printf("内存分配失败!\n");
    return -1;
}
// 使用 p[0] 到 p[9]

铁律:每次 malloc 之后,必须检查返回值是否为 NULL。内存不是无限的,分配失败是真实存在的。

4.2 calloc

callocmalloc 很像,但有两点不同:

  • 它接受两个参数:元素个数和每个元素的大小
  • 它会自动把分配的内存初始化为 0
int *p = (int *)calloc(10, sizeof(int));
// 等价于 malloc + memset(p, 0, 10 * sizeof(int))

我个人习惯:如果分配后马上要全部赋值,用 malloc;如果希望初始是干净的 0,用 calloc。省得自己再写一遍清零。

4.3 realloc

这个函数很实用。你之前 malloc 了 10 个 int,现在发现不够,想扩展到 20 个。用 realloc

int *p = (int *)malloc(10 * sizeof(int));
// ... 使用中 ...

int *new_p = (int *)realloc(p, 20 * sizeof(int));
if (new_p == NULL) {
    // realloc 失败,原来的 p 仍然有效
    printf("扩展内存失败!\n");
    free(p);
    return -1;
}
p = new_p;  // 更新指针

注意:realloc 可能会移动内存块。如果原位置后面空间不够,它会找一块新的更大的空间,把旧数据复制过去,然后释放旧空间。所以一定要用返回值更新指针。

我曾经犯过的错:直接写 p = realloc(p, new_size); 如果 realloc 失败返回 NULL,原来的 p 就丢了,造成内存泄漏。正确做法是先用临时变量接返回值,检查后再赋值。

4.4 free

有借有还,再借不难。动态分配的内存,用完之后必须释放,否则就会造成内存泄漏。

free(p);
p = NULL;  // 好习惯:释放后把指针置空

释放后把指针置 NULL,可以防止「野指针」问题。你想想看,free 之后那块内存可能已经被别人用了,但你的指针还指着那里,万一不小心又去读写,后果不堪设想。

5. 知识体系总览

下面这张图,把本章的核心逻辑串起来了:

指针与函数 · 动态内存 指针作为函数参数 函数指针 动态内存分配 值传递 vs 地址传递 修改外部变量 声明与调用 作为参数 → 回调函数 malloc / calloc / realloc free 释放 · 防内存泄漏 灵活 · 高效 · 可控

6. 综合示例:动态数组 + 回调排序

最后,我们把今天学的知识串起来,写一个完整的例子:动态分配一个数组,用回调函数决定排序方式。

#include <stdio.h>
#include <stdlib.h>

int ascending(int a, int b) { return a > b; }
int descending(int a, int b) { return a < b; }

void sort(int *arr, int n, int (*cmp)(int, int)) {
    for (int i = 0; i < n - 1; i++) {
        for (int j = 0; j < n - i - 1; j++) {
            if (cmp(arr[j], arr[j + 1])) {
                int t = arr[j];
                arr[j] = arr[j + 1];
                arr[j + 1] = t;
            }
        }
    }
}

int main() {
    int n;
    printf("请输入数组大小: ");
    scanf("%d", &n);

    int *arr = (int *)malloc(n * sizeof(int));
    if (arr == NULL) {
        printf("内存分配失败!\n");
        return 1;
    }

    printf("请输入 %d 个整数: ", n);
    for (int i = 0; i < n; i++) {
        scanf("%d", &arr[i]);
    }

    sort(arr, n, ascending);
    printf("升序结果: ");
    for (int i = 0; i < n; i++) printf("%d ", arr[i]);
    printf("\n");

    sort(arr, n, descending);
    printf("降序结果: ");
    for (int i = 0; i < n; i++) printf("%d ", arr[i]);
    printf("\n");

    free(arr);
    arr = NULL;
    return 0;
}

这个例子麻雀虽小,五脏俱全。你看到了动态分配、指针传参、回调函数、释放内存。嗯,把这些吃透了,C 语言的指针这块,你就基本过关了。

我的建议:自己动手把这段代码敲一遍,然后试着改一改——比如改成对字符串排序,或者加一个「按绝对值排序」的回调。动手是最好的学习方式。


公众号:蓝海资料掘金营,微信deep3321