C语言指针入门到理解:一篇文章系统梳理指针核心知识(3)

前两篇文字我们已经把指针的基础和数组相关内容系统梳理过了,比如:

  • 指针的本质
  • 指针和数组的关系
  • 一维数组、二维数组的传参
  • 二级指针
  • 指针数组

这一篇继续往下走,进入指针相关的更进一步的内容:

  • 字符指针变量到底存的是什么?
  • 数组指针和指针数组怎么区分?
  • 二维数组传参为什么可以写成指针形式?
  • 函数指针到底是什么?
  • 函数指针数组有什么实际用途?
  • 什么是转移表?

这部分是 C 语言指针体系里看上去比较困难,但实际上只要抓住“类型决定意义”这条主线,其实并不难。


一、字符指针变量:它存的不是字符串本身

我们先看一个最常见的字符指针写法:

char ch = 'w';
char *pc = &ch;
*pc = 'w';

这个很好理解:
pc 是一个字符指针,里面存的是字符变量 ch 的地址。

但很多同学真正困惑的是下面这种写法:

const char* pstr = "hello world.";
printf("%s\n", pstr);

很多人第一眼会误以为:

是把字符串 "hello world." 整体放进了指针变量 pstr

其实不是。

这句代码的本质是:

把字符串常量 "hello world."首字符地址,存放到字符指针 pstr 中。

也就是说,pstr 里存放的是首字符 'h' 的地址,而不是整个字符串对象本身。

这里为什么要写 const char*

因为字符串字面量通常存放在常量区,不应该通过指针去修改它,所以更合理的写法是:

const char* pstr = "hello bit.";

如果这里无法理解的话请联想一些常量的赋值是违规的操作,例如:

3 = 5 //这种操作

二、一个经典面试题:为什么有的字符串地址相同,有的不同

看下面这段代码:

#include <stdio.h>

int main()
{
    char str1[] = "hello world.";
    char str2[] = "hello world.";
    const char *str3 = "hello world.";
    const char *str4 = "hello world.";

    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

为什么会这样?

1. str1str2 为什么不同

因为:

char str1[] = "hello world.";
char str2[] = "hello world.";

这是用同样的内容初始化了两个不同的数组
数组初始化时,会各自开辟独立空间,所以 str1str2 不是同一块内存。

2. str3str4 为什么相同

因为:

const char *str3 = "hello world.";
const char *str4 = "hello world.";

这里不是创建数组,而是让两个指针都去指向同一个字符串常量
编译器通常会把相同的字符串常量放到同一块常量区内存中,所以 str3str4 很可能相等。

ps:这也同样可以进一步佐证前文为何要加const,因为这是一个常量,所以我们不希望它被修改。

总结

  • 数组名比较的是各自数组首元素地址
  • 字符指针比较的是它们指向的常量字符串地址

所以这个例子非常适合理解:

“字符数组”和“字符指针”虽然都能处理字符串,但底层模型并不一样。


三、数组指针变量:它是指针,不是数组

这一块是最容易和“指针数组”混掉的地方。

先看两个定义:

int *p1[10];
int (*p2)[10];

很多人会懵:到底哪个是数组指针?

答案是:

int (*p2)[10];

才是数组指针变量


四、什么是数组指针

数组指针,本质上是:

一个指针变量,这个指针指向的是数组。

例如:

int (*p)[10];

怎么理解这句?

先看优先级

因为 [] 的优先级高于 *,所以必须加括号:

(*p)

这表示先说明 p 是一个指针。

然后再看:

(*p)[10]

表示 p 指向一个有 10 个元素的数组。

如果数组元素类型是 int,那最终它的含义就是:

p 是一个指针,指向一个 int[10] 类型的数组。


五、数组指针怎么初始化

既然数组指针是“指向数组的指针”,那它存的就应该是数组的地址

而数组的地址怎么取?

用:

&数组名

例如:

int arr[10] = {0};
int (*p)[10] = &arr;

这里:

  • &arr 是整个数组的地址
  • p 是数组指针变量
  • p&arr 类型一致

数组指针拆解理解

int (*p)[10] = &arr;

可以拆成三层:

  • p 是变量名
  • *p 说明 p 是指针
  • (*p)[10] 说明它指向一个有 10 个 int 元素的数组

所以记忆方法很简单:

数组指针是指针,指针数组是数组。


六、二维数组传参的本质:传的是第一行的地址

先看一个常见写法:

#include <stdio.h>

void test(int a[3][5], int r, int c)
{
    int i = 0;
    int j = 0;
    for(i = 0; i < r; i++)
    {
        for(j = 0; j < c; j++)
        {
            printf("%d ", a[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}
    };
    test(arr, 3, 5);
    return 0;
}

这段代码大家通常都见过,但很多人并没有真正理解:

二维数组传参,传过去的到底是什么?


七、二维数组本质上是什么

二维数组可以理解为:

每个元素都是一维数组的数组

比如:

int arr[3][5];

它可以理解成:

  • 整个数组有 3 行
  • 每一行是一个 int[5] 的一维数组

也就是说,二维数组的首元素不是一个 int,而是第一行这个一维数组

所以根据“数组名表示首元素地址”的规则:

arr

表示的不是单个 int 的地址,而是第一行的地址

第一行的类型是:

int [5]

那么第一行地址的类型就是:

int (*)[5]

这正好就是数组指针类型


八、所以二维数组形参也可以写成数组指针

于是二维数组传参,完全可以写成下面这样:

#include <stdio.h>

void test(int (*p)[5], int r, int c)
{
    int i = 0;
    int j = 0;
    for(i = 0; i < r; i++)
    {
        for(j = 0; j < c; 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}
    };
    test(arr, 3, 5);
    return 0;
}

这里:

  • p + i 表示走到第 i
  • *(p + i) 表示第 i 行这个一维数组
  • *(p + i) + j 表示第 i 行第 j 个元素地址
  • *(*(p + i) + j) 才是真正的元素值

ps:这里如果忘记了*操作符的作用请回顾我上篇文章

所以结论是:

二维数组传参时,形参既可以写成:

int a[3][5]

也可以写成:

int (*p)[5]

但无论哪种写法,本质上都是在接收第一行的地址


九、函数指针变量:它存放的是函数地址

前面学了很多种指针:

  • 整型指针:存整型变量地址
  • 字符指针:存字符地址
  • 数组指针:存数组地址

那自然就会想到:

能不能有一种指针,专门用来存函数地址?

答案当然是可以,这就是函数指针变量


十、函数真的有地址吗?

先看例子:

#include <stdio.h>

void test()
{
    printf("hehe\n");
}

int main()
{
    printf("test: %p\n", test);
    printf("&test: %p\n", &test);
    return 0;
}

你会发现:

  • test
  • &test

都能打印出函数地址,而且结果通常一样。

这说明:

函数名本身就表示函数地址,也可以用 &函数名 来取地址。


十一、函数指针怎么定义

例如有这样一个函数:

int Add(int x, int y)
{
    return x + y;
}

那对应的函数指针可以写成:

int (*pf)(int, int) = Add;

也可以写成:

int (*pf)(int, int) = &Add;

这两种写法都可以。

如何理解这个定义

int (*pf)(int, int)

拆开看:

  • pf 是变量名
  • *pf 说明它是一个指针
  • (int, int) 说明它指向的函数参数是两个 int
  • 最前面的 int 说明该函数返回值类型是 int

所以这句的完整含义是:

pf 是一个函数指针,指向的函数形参是 (int, int),返回值类型是 int


十二、函数指针怎么调用函数

有了函数指针之后,可以通过它调用对应函数:

#include <stdio.h>

int Add(int x, int y)
{
    return x + y;
}

int main()
{
    int (*pf)(int, int) = Add;

    printf("%d\n", (*pf)(2, 3));
    printf("%d\n", pf(3, 5));

    return 0;
}

输出:

5
8

这里两种调用方式都对:

(*pf)(2, 3);
pf(3, 5);

因为对于函数指针来说,前面的* 在写的时候可以省略,所以平时更常写的是第二种,简洁一些。


十三、复杂函数指针看不懂怎么办?用 typedef

函数指针类型一复杂,代码可读性会迅速下降。

比如说:

void (*signal(int, void(*)(int)))(int);

这种写法一眼看过去确实头大。

这时候最好的解决方案就是:

用 typedef 给复杂类型起别名

例如:

typedef void(*pfun_t)(int);
typedef long long LL;

这表示把:

void(*)(int)

重命名为:

pfun_t

那上面那句复杂定义就能简化为:

pfun_t signal(int, pfun_t);

可读性一下就上来了。

除了函数指针,数组指针也可以这样简化

例如:

typedef int(*parr_t)[5];

以后看到 parr_t,你就知道它是“指向 int[5] 的数组指针类型”。


十四、函数指针数组:数组里存的是函数地址

前面学过:

int *arr[10];

这是指针数组,表示数组中每个元素都是 int*

那如果数组中每个元素都是“函数指针”,就得到了:

函数指针数组

例如:

int (*parr1[3])();

这个定义中:

  • parr1 先和 [] 结合,说明它是数组
  • 数组元素类型是 int (*)(),也就是函数指针

所以 parr1 是一个函数指针数组


十五、函数指针数组最典型的用途:转移表

函数指针数组最经典的应用场景,就是转移表

比如我们要实现一个简单计算器:

  • 1:加法
  • 2:减法
  • 3:乘法
  • 4:除法

传统写法通常会用 switch-case

switch (input)
{
case 1:
    ret = add(x, y);
    break;
case 2:
    ret = sub(x, y);
    break;
case 3:
    ret = mul(x, y);
    break;
case 4:
    ret = div(x, y);
    break;
}

这种写法当然能用,但如果功能越来越多,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 div(int a, int b)
{
    return a / b;
}

int main()
{
    int x, y;
    int input = 1;
    int ret = 0;
    int (*p[5])(int, int) = {0, add, sub, mul, div}; // 转移表

    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 = (*p[input])(x, y);
            printf("ret = %d\n", ret);
        }
        else if(input == 0)
        {
            printf("退出计算器\n");
        }
        else
        {
            printf("输入有误\n");
        }

    } while(input);

    return 0;
}

这里最关键的一句

int (*p[5])(int, int) = {0, add, sub, mul, div};

这就是一个函数指针数组,也就是转移表。

它的含义是:

  • p[1] 指向 add
  • p[2] 指向 sub
  • p[3] 指向 mul
  • p[4] 指向 div

之后只需要根据用户输入,直接调用:

(*p[input])(x, y);

这样就把“菜单选择 -> 函数跳转”的流程做成了表驱动结构。

这就是所谓的:

转移表

它的优势在于:

  • 结构更清晰
  • 扩展更方便
  • 代码更容易维护

十七、这一篇的几个易错点:

最后把本篇最容易出错的地方集中总结一下。

1. 字符指针不等于字符数组

const char *p = "hello";

这里 p 里存的是首字符地址,不是把整个字符串“装进了指针”。

2. 相同内容的字符数组不一定地址相同

char str1[] = "abc";
char str2[] = "abc";

这是两个不同数组,地址不同。

3. 相同内容的字符串常量指针可能地址相同

const char *p1 = "abc";
const char *p2 = "abc";

它们可能指向同一个常量区字符串。

4. 数组指针是指针,不是数组

int (*p)[10];

p 是指针,指向一个 int[10] 数组。

5. 指针数组是数组,不是指针

int *arr[10];

arr 是数组,元素类型是 int*

6. 二维数组传参本质上传的是第一行地址

所以形参可以写成:

int a[][5]

也可以写成:

int (*p)[5]

7. 函数指针的关键是先看 pf 和谁结合

int (*pf)(int, int);

先看 (*pf),说明 pf 是指针;再看后面的参数列表和前面的返回值类型。

8. 函数指针数组的本质还是数组

只是数组里的元素,不再是普通数据,而是“函数地址”。


十八、总结

这一篇我们的主要结论如下

  1. 字符指针存的是字符地址,字符串字面量本质上传递的是首字符地址。
  2. 数组指针是“指向数组的指针”,指针数组是“存放指针的数组”。
  3. 二维数组传参本质上传的是第一行的地址,所以形参可以写成数组指针。
  4. 函数名就是函数地址,函数指针变量就是用来存函数地址的。
  5. 函数指针数组可以构造转移表,让代码从分支驱动变成表驱动,从而更好的维护自己的项目。

实际上在学习指针这一块的时候,有一个很大的诀窍,那就是:

先认清变量是谁,再看它指向什么。

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐