【C 语言】深入理解指针(3):字符指针、数组指针、二维数组传参、函数指针与转移表

🔥 星光编译者 · 个人主页

📚 学习专栏: 《C/C++ 成长笔记》 · 《Linux 实践手册》 · 《数据结构与算法》

🌄 向云端飞扬,编译属于自己的代码星河。


☕ 写在开篇

  你好,这里是 星光编译者。

  这里记录我在 C/C++、Linux、数据结构与算法 学习中遇到的真实问题、亲手验证过的代码,以及那些容易被忽略的实现细节。

  比起简单罗列结论,我更愿意从问题出发,把一个知识点的来由讲清楚,把"为什么会这样"和"应该怎样解决"说明白,让每一次踩坑都沉淀成可以复用的经验。

  如果这篇记录能帮你少绕一点路,或让某个模糊的地方忽然变得清晰,那么这次分享便有了意义。愿我们在一次次阅读、编译与调试中稳步向前,慢慢搭起属于自己的技术世界。


🔥 本文定位:从"指针保存地址"继续向前,系统梳理字符串字面量、数组指针、二维数组传参、函数指针、函数指针数组和转移表之间的联系。

💡 学习目标 :能从变量名出发读懂复杂声明;区分指针数组与数组指针;解释二维数组为什么必须保留列数;能用 typedef 简化函数指针,并用转移表替代重复的分支派发。

📌 阅读说明:本文根据课程 PDF《第13讲:深入理解指针(3)》整理,并补充 ISO C 语义下的边界说明。示例按 C11 编写;涉及输入、除零和整数溢出的地方均给出必要保护。


文章目录


一、这一次,指针从"数据地址"走向"类型系统"

前面学习指针时,我们常把它概括成一句话:指针变量保存地址。这句话没有错,却还不完整。真正决定一段指针表达式怎样解释的,是地址背后的类型。

c 复制代码
int *pi;             /* 指向 int */
char *pc;            /* 指向 char */
int (*pa)[5];        /* 指向"含 5 个 int 的数组" */
int (*pf)(int, int); /* 指向"接收两个 int、返回 int 的函数" */

它们在某个平台上可能占用相同字节数,但编译器看到的含义完全不同:

  • pi + 1 跨过一个 int;
  • pc + 1 跨过一个 char;
  • pa + 1 跨过一个完整的 int[5];
  • pf 不是拿来做数据指针算术的,而是用来调用符合签名的函数。

因此,本讲真正的主线不是"又多记几种指针",而是建立两个能力:

  1. 看声明就能还原类型结构;
  2. 知道这个类型允许怎样访问、移动和调用。

后面的字符指针、数组指针与函数指针看似分散,其实都是同一条规则:先确定"指向什么",再判断能做什么。


二、字符指针:字符串字面量里究竟存了什么

2.1 指向单个字符

字符指针最直接的用法,是保存一个字符对象的地址:

c 复制代码
#include <stdio.h>

int main(void)
{
    char ch = 'w';
    char *pc = &ch;

    *pc = 'W';
    printf("%c\n", ch);
    return 0;
}

这里 pc 指向可修改的对象 ch,所以通过 *pc 写入是合法的。pc 保存的是地址,*pc 才是该地址处的字符对象。

2.2 指向字符串字面量

再看另一种写法:

c 复制代码
const char *message = "hello bit.";
printf("%s\n", message);

右侧不是把整串字符"塞进"指针变量。字符串字面量会形成一个字符数组,表达式在通常场景中转换为指向其首元素的指针,message 保存的是第一个字符 'h' 的地址。

字符串末尾还隐含一个空字符 \0:

text 复制代码
'h' 'e' 'l' 'l' 'o' ' ' 'b' 'i' 't' '.' '\0'
 ^
 message

应把指向字符串字面量的指针写成 const char *。C 语言为了兼容历史代码,字符串字面量的类型不是 const 数组,但试图修改字符串字面量的行为未定义:

c 复制代码
const char *message = "hello";
/* message[0] = 'H'; */ /* 错误思路:不要修改字符串字面量 */

如果确实需要修改内容,就用字符数组创建自己的可写副本:

c 复制代码
char message[] = "hello";
message[0] = 'H';

2.3 数组与指针不是同一个东西

下面两行看起来都能配合 %s 输出,但对象模型不同:

c 复制代码
char text[] = "hello";
const char *pointer = "hello";
比较项 char text[] = "hello" const char *pointer = "hello"
创建的对象 含 6 个字符的数组 一个指针变量,指向字符串字面量
字符内容能否修改 能,在数组范围内修改 不能修改所指字面量
sizeof 结果 数组总字节数,这里为 6 指针本身的大小
能否让名字改指向别处 数组名不能被赋值 指针变量可以重新赋值

数组表达式经常会转换成首元素地址,但这种转换不能把数组和指针变成同一种对象。sizeof、一元 & 和字符串初始化等场景会让差别显现出来。

2.4 比较字符串要比较内容,而不是猜地址

课程中有一个经典问题:

c 复制代码
char str1[] = "hello bit.";
char str2[] = "hello bit.";
const char *str3 = "hello bit.";
const char *str4 = "hello bit.";

str1 与 str2 是两个独立数组,转换成首元素指针后通常不同。对于 str3 与 str4,编译器可以 合并相同字符串字面量,也可以不合并;ISO C 不保证两者地址必定相同。因此,无论数组还是指针,只要业务问题是"文本内容是否相同",都应使用 strcmp:

c 复制代码
#include <stdio.h>
#include <string.h>

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

    printf("str1 与 str2 内容:%s\n",
           strcmp(str1, str2) == 0 ? "相同" : "不同");
    printf("str3 与 str4 内容:%s\n",
           strcmp(str3, str4) == 0 ? "相同" : "不同");
    return 0;
}

== 比较的是两个指针值是否表示同一地址;strcmp 比较的是以 \0 结束的两个字符串内容。二者回答的是不同问题。


三、先读懂声明:从变量名向外看

很多人第一次看到 int (*p)[10] 或 int (*pf)(int, int) 会本能地从左向右念,结果越念越乱。更稳定的方法是:

先找到变量名,再按括号和操作符优先级向外扩展。

3.1 [] 与 * 谁先结合

c 复制代码
int *p1[10];
int (*p2)[10];

读取 p1:

  1. 先找到名字 p1;
  2. [] 比 * 结合得更紧,p1[10] 表明它是数组;
  3. 数组的每个元素是 int *。

所以 p1 是指针数组 :数组里放 10 个 int *。

读取 p2:

  1. 括号让 p2 先与 * 结合,因此 p2 是指针;
  2. 指针右侧是 [10],说明它指向含 10 个元素的数组;
  3. 数组元素类型是 int。

所以 p2 是数组指针 :它指向一个 int[10]。

3.2 一条实用口诀

可以把常见声明按这个顺序读:

text 复制代码
名字 → 括号内最近的符号 → 下标/函数参数 → 星号 → 最左侧基础类型

但口诀只是辅助。真正可靠的是识别声明符结构:

  • name[...]:name 是数组;
  • name(...):name 是函数;
  • *name:name 是指针;
  • 括号改变默认结合方式。

3.3 不要把"指针数组"和"数组指针"只当名词题

二者不仅考试名称不同,访问方式与步长也不同:

c 复制代码
int a = 1, b = 2, c = 3;
int *pointer_array[3] = {&a, &b, &c};

int matrix[2][3] = {{1, 2, 3}, {4, 5, 6}};
int (*array_pointer)[3] = matrix;

pointer_array + 1 跨过一个指针元素;array_pointer + 1 跨过一整行 int[3]。类型直接决定了指针算术的单位。


四、数组指针:一步跨过一整行

4.1 创建与初始化

一维数组本身也有地址:

c 复制代码
int arr[10] = {0};
int (*p)[10] = &arr;

&arr 的类型是"指向 int[10] 的指针",即 int (*)[10],恰好与 p 匹配。

这里最容易混淆的是 arr 和 &arr:

  • 在大多数表达式里,arr 转换为指向首元素的 int *;
  • &arr 是整个数组的地址,类型为 int (*)[10];
  • 两者数值表示常常看起来相同,但类型与 +1 的步长不同。
c 复制代码
printf("%zu\n", sizeof *arr);   /* 一个 int 的大小 */
printf("%zu\n", sizeof *p);     /* 整个 int[10] 的大小 */

不要用 %p 直接打印任意指针而忽略格式要求。打印对象指针时应转换为 void *:

c 复制代码
printf("arr  = %p\n", (void *)arr);
printf("&arr = %p\n", (void *)&arr);

4.2 数组指针的步长

假设 p 指向 int[10],那么 p + 1 指向"下一个 int[10]"。这正是数组指针处理二维数组时最有价值的能力:它天然以行为单位移动。

c 复制代码
int matrix[3][5] = {
    {1, 2, 3, 4, 5},
    {6, 7, 8, 9, 10},
    {11, 12, 13, 14, 15}
};

int (*row)[5] = matrix;

printf("%d\n", row[1][3]);       /* 9 */
printf("%d\n", *(*(row + 1) + 3)); /* 同样是 9 */

因为:

text 复制代码
row + 1         → 指向第 2 行
*(row + 1)      → 第 2 行这个数组;表达式中再转换为首元素地址
*(row + 1) + 3  → 指向第 2 行第 4 个元素
*(*(row + 1)+3) → 取出该元素

实际代码中优先写 row[i][j],它更直接;展开形式适合用来理解下标运算的本质。

4.3 不要越过所属数组对象

指针算术只在同一个数组对象及其"尾后位置"范围内有定义。可以形成尾后指针,但不能解引用:

c 复制代码
int (*end)[5] = matrix + 3; /* 合法:尾后位置 */
/* printf("%d", (*end)[0]); */ /* 未定义行为:不能访问尾后位置 */

地址在机器上看似连续,不代表任意算术都被 C 语言语义允许。


五、二维数组传参的本质

5.1 二维数组是"元素为一维数组的数组"

c 复制代码
int matrix[3][5];

可以把它理解为:matrix 有 3 个元素,每个元素的类型都是 int[5]。因此:

  • matrix[0] 是第 1 行,类型为 int[5];
  • matrix[1] 是第 2 行,类型为 int[5];
  • matrix 在通常表达式中转换为指向首行的指针,类型为 int (*)[5]。

5.2 两种形参写法实际对应同一种类型

c 复制代码
void print_matrix(int a[][5], size_t rows);
void print_matrix(int (*a)[5], size_t rows);

在函数形参列表中,数组形式会调整为对应的指针形式,因此这两种声明描述的是同一种参数类型。完整实现可以这样写:

c 复制代码
#include <stdio.h>

#define COLS 5

void print_matrix(const int a[][COLS], size_t rows)
{
    for (size_t i = 0; i < rows; ++i) {
        for (size_t j = 0; j < COLS; ++j) {
            printf("%d%c", a[i][j], j + 1 == COLS ? '\n' : ' ');
        }
    }
}

int main(void)
{
    const int matrix[][COLS] = {
        {1, 2, 3, 4, 5},
        {2, 3, 4, 5, 6},
        {3, 4, 5, 6, 7}
    };

    print_matrix(matrix, sizeof matrix / sizeof matrix[0]);
    return 0;
}

5.3 为什么第一维可以省略,第二维不能随便省略

函数接收到的是首行地址,本身不知道总共有多少行,所以行数通常额外传入。编译器计算 a[i][j] 时,却必须知道每一行多宽,才能从 a + i 找到第 i 行:

text 复制代码
第 i 行地址 = 首行地址 + i × sizeof(int[COLS])

如果固定列数丢失,编译器便不知道"跨一行"要跨多少字节。因此传统写法中,最左边的行数可以省略,后续维度必须完整。

5.4 列数运行时才知道怎么办

C99 引入了可变长度数组(VLA)形参写法,支持把列数作为参数:

c 复制代码
void print_vla(size_t rows, size_t cols,
               const int a[rows][cols])
{
    for (size_t i = 0; i < rows; ++i) {
        for (size_t j = 0; j < cols; ++j) {
            printf("%d%c", a[i][j], j + 1 == cols ? '\n' : ' ');
        }
    }
}

参数顺序很重要:rows 与 cols 必须先出现,后面的数组声明才能使用它们。还要注意,VLA 支持在 C11 起成为可选特性,并非所有编译器都同样支持;跨平台项目可考虑传入一维连续缓冲区,并手动使用 i * cols + j 计算下标。

5.5 int ** 不能替代真正的二维数组

c 复制代码
void wrong(int **p); /* 不能用来接收 int matrix[3][5] */

int ** 指向的是 int *;真正的二维数组转换后是 int (*)[5],它指向的是 int[5]。两者的目标类型、内存解释和步长都不同。

只有当数据结构本身是"指针数组",例如每一行分别动态分配,再把各行地址存在 int *rows[] 中时,才可能得到与 int ** 兼容的接口。连续二维数组与行指针表是两种布局,不要只因为都能写两个下标就混为一谈。


六、函数指针:把"行为"也交给地址

6.1 函数也可以被指向

定义一个普通函数:

c 复制代码
int add(int left, int right)
{
    return left + right;
}

与数组名类似,函数名在大多数表达式中会转换为指向该函数的指针。可以这样声明并初始化:

c 复制代码
int (*operation)(int, int) = add;

从 operation 向外读:

  1. (*operation):operation 是指针;
  2. (int, int):它指向的函数接收两个 int;
  3. 最左侧 int:函数返回 int。

括号不可省略。若写成下面这样:

c 复制代码
int *operation(int, int);

operation 会先与 () 结合,含义变成"一个接收两个 int、返回 int * 的函数",不再是函数指针变量。

6.2 通过函数指针调用

两种常见调用形式都可以:

c 复制代码
int result1 = operation(2, 3);
int result2 = (*operation)(2, 3);

第一种更简洁,第二种更直观地强调了"先解引用函数指针再调用"。在 C 语言语义中,两者效果相同。

一个完整例子:

c 复制代码
#include <stdio.h>

int add(int left, int right)
{
    return left + right;
}

int calculate(int left, int right,
              int (*operation)(int, int))
{
    return operation(left, right);
}

int main(void)
{
    printf("%d\n", calculate(20, 22, add));
    return 0;
}

calculate 不关心具体执行加法、减法还是乘法,只要求传入的函数符合约定签名。这就是回调思想的雏形:调用者提供行为,被调用者在合适时机执行它。

6.3 函数指针类型必须兼容

不要把参数或返回类型不同的函数强行转换后调用:

c 复制代码
double average(double, double);
int (*operation)(int, int);

/* operation = (int (*)(int, int))average; */ /* 危险且错误的设计 */

即使转换能让编译器少报一条诊断,通过不兼容类型调用函数仍会产生未定义行为。参数传递方式、寄存器使用和返回值约定都可能不同。正确做法是统一接口,或写一个签名匹配的适配函数。

6.4 函数地址的打印并不完全可移植

printf("%p", (void *)add) 在许多常见平台上能工作,但 ISO C 只明确保证对象指针与 void * 之间的转换;函数指针未必能无损转换成 void *。学习函数指针时,无须依赖"把地址打印出来"来证明函数存在地址,直接进行类型正确的赋值与调用更可靠。


七、用 typedef 驯服复杂函数指针

7.1 普通类型重命名

typedef 为已有类型创建别名,不会创建一种语义全新的类型:

c 复制代码
typedef unsigned int uint;
typedef int *int_ptr;

但指针别名有一个著名陷阱:

c 复制代码
typedef int *int_ptr;
const int_ptr p = NULL;

这里 const 修饰的是别名代表的整个指针类型,因此 p 是"常量指针",不是"指向常量整数的指针"。若希望所指对象只读,应明确写 const int *p,避免别名隐藏星号后造成误判。

7.2 数组指针别名

c 复制代码
typedef int (*row_ptr)[5];

int matrix[3][5] = {0};
row_ptr row = matrix;

新类型名 row_ptr 放在原来变量名所在的位置。它代表 int (*)[5],即指向一行 5 个 int 的指针。

7.3 函数指针别名

c 复制代码
typedef int (*binary_operation)(int, int);

binary_operation operation = add;

复杂声明一下变得像普通变量声明。将它作为参数也更清楚:

c 复制代码
int calculate(int left, int right, binary_operation operation)
{
    return operation(left, right);
}

7.4 解读 signal 风格的复杂声明

课程中给出过这样一条声明:

c 复制代码
void (*signal(int, void (*)(int)))(int);

从 signal 开始读:

  1. signal(...):signal 是函数;
  2. 它接收一个 int 和一个"接收 int、返回 void 的函数指针";
  3. 它返回一个"接收 int、返回 void 的函数指针"。

用别名可明显降低认知负担:

c 复制代码
typedef void (*signal_handler)(int);
signal_handler signal(int number, signal_handler handler);

工程代码的目标不是向读者展示能写多复杂的声明,而是让接口的角色一眼可见。别名应表达概念,如 signal_handler、compare_fn、event_callback,而不是只写含义模糊的 pf_t。


八、函数指针数组:把多个行为排成表

如果一个函数指针保存一种行为,那么函数指针数组就能保存一组签名一致的行为:

c 复制代码
int (*operations[4])(int, int);

从 operations 开始读:

  1. operations[4]:它先是一个含 4 个元素的数组;
  2. *operations[4]:每个元素都是指针;
  3. (int, int):每个指针指向接收两个 int 的函数;
  4. 最左侧 int:这些函数返回 int。

借助别名更自然:

c 复制代码
typedef int (*binary_operation)(int, int);
binary_operation operations[4];

8.1 初始化与调用

c 复制代码
int add(int a, int b)      { return a + b; }
int subtract(int a, int b) { return a - b; }
int multiply(int a, int b) { return a * b; }

binary_operation operations[] = {
    add,
    subtract,
    multiply
};

int result = operations[1](9, 4); /* subtract(9, 4),结果为 5 */

8.2 函数指针数组与"返回函数指针的函数"

这两种声明也容易混淆:

c 复制代码
int (*table[4])(int, int);   /* table 是数组 */
int (*select_op(int))(int, int); /* select_op 是函数 */

前者的名字先与 [] 结合,是函数指针数组;后者的名字先与 () 结合,是一个返回函数指针的函数。仍然从名字向外读,就不会只靠星号数量猜答案。

8.3 派发表适合什么场景

函数指针数组经常用于:

  • 菜单命令与处理函数映射;
  • 状态机中的状态处理器;
  • 协议类型与解析函数映射;
  • 驱动接口或平台适配层;
  • 解析器中的操作码派发;
  • 排序、遍历等算法接收回调。

它的价值不只是"少写几个 case",而是把编号到行为的映射变成数据结构,便于统一校验、扩展和测试。


九、转移表实战:重构一个安全的计算器

课程使用计算器演示转移表。先看传统思路:读取菜单编号,再通过 switch 调用不同函数。当分支逐渐增多时,每个 case 都会重复读取参数、调用函数和输出结果。

转移表把操作编号映射到函数指针:

text 复制代码
编号 1 → add
编号 2 → subtract
编号 3 → multiply
编号 4 → divide

选择通过校验后,统一执行 table[choice],公共流程只保留一份。

9.1 先统一函数接口

为了让所有函数都能放进同一个数组,它们必须具有兼容签名。除法还需要报告除零和 INT_MIN / -1 的溢出风险,因此与其让四个操作都简单返回 int,不如统一成"成功或失败 + 输出参数"的接口:

c 复制代码
typedef int (*operation_fn)(int left, int right, int *result);

返回 1 表示成功,返回 0 表示失败,真正的计算结果通过 result 输出。这样所有操作都能一致地表达错误。

9.2 完整实现

c 复制代码
#include <limits.h>
#include <stdio.h>

typedef int (*operation_fn)(int left, int right, int *result);

static int add_op(int left, int right, int *result)
{
    if ((right > 0 && left > INT_MAX - right) ||
        (right < 0 && left < INT_MIN - right)) {
        return 0;
    }
    *result = left + right;
    return 1;
}

static int sub_op(int left, int right, int *result)
{
    if ((right < 0 && left > INT_MAX + right) ||
        (right > 0 && left < INT_MIN + right)) {
        return 0;
    }
    *result = left - right;
    return 1;
}

static int mul_op(int left, int right, int *result)
{
    if (left > 0) {
        if ((right > 0 && left > INT_MAX / right) ||
            (right < 0 && right < INT_MIN / left)) {
            return 0;
        }
    } else if (left < 0) {
        if ((right > 0 && left < INT_MIN / right) ||
            (right < 0 && left < INT_MAX / right)) {
            return 0;
        }
    }
    *result = left * right;
    return 1;
}

static int div_op(int left, int right, int *result)
{
    if (right == 0 || (left == INT_MIN && right == -1)) {
        return 0;
    }
    *result = left / right;
    return 1;
}

int main(void)
{
    static const operation_fn table[] = {
        NULL, add_op, sub_op, mul_op, div_op
    };
    static const char *const names[] = {
        "exit", "add", "subtract", "multiply", "divide"
    };

    int choice = 0;

    for (;;) {
        int left = 0;
        int right = 0;
        int result = 0;

        printf("1:add 2:sub 3:mul 4:div 0:exit\n");
        printf("请选择:");

        if (scanf("%d", &choice) != 1) {
            puts("输入不是有效整数,程序结束。");
            return 1;
        }

        if (choice == 0) {
            puts("退出计算器。");
            break;
        }

        if (choice < 1 ||
            (size_t)choice >= sizeof table / sizeof table[0]) {
            puts("菜单编号无效。");
            continue;
        }

        printf("请输入两个整数:");
        if (scanf("%d%d", &left, &right) != 2) {
            puts("操作数输入无效,程序结束。");
            return 1;
        }

        if (table[choice](left, right, &result)) {
            printf("%s 结果:%d\n", names[choice], result);
        } else {
            puts("运算失败:请检查除零或 int 范围溢出。");
        }
    }

    return 0;
}

9.3 为什么要先校验下标

table[choice] 本质上是数组下标访问。若 choice 越界,程序会读取数组之外的内存;若得到的位模式再被当成函数指针调用,后果不可预测。因此顺序必须是:

  1. 读取选择;
  2. 处理退出值;
  3. 验证下标范围;
  4. 确认表项不是空指针;
  5. 最后才调用。

若编号稀疏、来源不可信或需要权限控制,还可以用结构体把编号、名称和函数放在一起,再通过查表找到匹配项,而不是直接把外部整数当数组下标。

9.4 转移表并不总比 switch 好

当各分支的参数、返回类型和前置条件差异很大时,强行统一函数签名可能产生大量包装函数。分支很少且逻辑简单时,switch 更直观。选择标准应是:映射关系是否稳定、处理器签名是否一致、扩展与测试是否因此更清晰,而不是单纯追求"少写代码"。


十、常见错误、安全边界与调试方法

10.1 忘记 const,误以为字符串字面量可写

c 复制代码
char *text = "hello"; /* C 中可能通过编译,但容易诱发错误写入 */

优先写:

c 复制代码
const char *text = "hello";

编译器会在尝试通过 text 修改内容时及时诊断。

10.2 用 == 比较字符串内容

c 复制代码
if (left == right) { /* 比的是地址 */ }

内容比较应使用 strcmp(left, right) == 0,并确保两个参数都指向有效、以 \0 结束的字符串。

10.3 把 int ** 当作二维数组指针

int ** 与 int (*)[COLS] 的内存模型不同。打开编译器警告后,这类不兼容参数通常会被指出。不要用强制转换压掉诊断;修正函数形参类型。

10.4 数组指针列数写错

c 复制代码
int matrix[3][5];
/* int (*p)[4] = matrix; */ /* 类型不兼容 */

列数属于目标类型的一部分。写错以后不仅是"每行少看一个元素",而是整个步长都错了。

10.5 函数指针签名不兼容

回调的参数、返回类型必须与约定匹配。启用严格警告:

text 复制代码
GCC / Clang: -std=c11 -Wall -Wextra -Wpedantic -Wconversion
MSVC: /W4

不要把警告当成"编译器太严格"。指针章节里,许多警告正是在阻止未定义行为。

10.6 调用空函数指针

c 复制代码
operation_fn operation = NULL;
/* operation(1, 2, &result); */ /* 错误 */

对可选回调或稀疏表项,调用前检查:

c 复制代码
if (operation != NULL) {
    operation(1, 2, &result);
}

10.7 表下标来自外部输入却没有校验

菜单编号、网络操作码和文件中的类型码都不可信。验证范围后才能索引;对负数要特别小心,因为转成 size_t 后可能变成很大的无符号值。

10.8 调试时先看类型,再看地址

遇到指针问题,可以按这份清单逐项检查:

  1. 变量的完整声明是什么?
  2. 它指向单个元素、整行数组,还是函数?
  3. +1 的单位是什么?
  4. 解引用后得到的类型是什么?
  5. 对象的生命周期是否仍然有效?
  6. 是否保留了 const 限定?
  7. 数组下标和函数指针是否经过校验?
  8. 编译器是否已经给出不兼容类型警告?

地址值相同并不能证明类型正确。类型、边界与生命周期才是解释指针行为的依据。


十一、高频问答与动手练习

高频问答

Q1:char *p = "abc" 为什么在 C 中常能编译?

C 为兼容历史代码,没有把字符串字面量的数组类型直接写成 const,但修改它仍是未定义行为。现代代码应主动使用 const char *p。

Q2:arr 与 &arr 打印出来可能一样,为什么类型还要区分?

它们可能表示同一起始位置,但 arr 通常转换为首元素指针,&arr 指向整个数组。arr + 1 跨一个元素,&arr + 1 跨整个数组。地址数值只是信息的一部分。

Q3:二维数组传参为什么不用传第一维?

形参接收首行指针,函数不会从指针类型中得知总行数,所以行数通常单独传入;列数决定一行的类型和步长,传统数组指针写法必须保留。

Q4:函数名和 &函数名 有什么区别?

在用于初始化或调用的常见场景中,两者都可得到指向该函数的指针,类型可兼容。写 operation = add; 通常更简洁。

Q5:pf(args) 为什么不写 (*pf)(args) 也能调用?

C 的函数调用运算符允许函数指针作为被调用表达式;两种写法语义相同。团队保持一种清晰风格即可。

Q6:函数指针数组是不是"数组指针"?

不是。int (*table[4])(int, int) 的 table 先与 [] 结合,所以它是数组,数组元素才是函数指针。数组指针的变量名会先与 * 结合,例如 int (*p)[4]。

Q7:用 typedef 会不会改变底层类型?

不会。它创建别名,让声明更可读;类型兼容性和底层调用规则没有因此改变。

动手练习

  1. 分别解释 int *a[5]、int (*b)[5] 和 int (*c[5])(int)。
  2. 写一个函数,接收 int matrix[][4] 与行数,返回所有元素之和。
  3. 定义 compare_fn,表示"接收两个 const void *、返回 int 的函数指针"。
  4. 为转移表增加取模操作,并处理除数为 0。
  5. 说明为什么 const char *x = "same"; const char *y = "same"; 中 x == y 的结果不能用来判断文本内容。
  6. 判断 int **p 能否接收 int a[2][3],并说明目标类型差异。

参考答案要点:

  1. a 是 5 个 int * 组成的数组;b 是指向 int[5] 的指针;c 是 5 个函数指针组成的数组,每个函数接收 int 并返回 int。
  2. 形参可写 const int matrix[][4] 或 const int (*matrix)[4],双层循环累加,并根据业务范围选择足够宽的结果类型。
  3. typedef int (*compare_fn)(const void *, const void *);。
  4. 新增 mod_op,先判断右操作数是否为 0,再将函数加入表,并同步扩展名称与范围校验。
  5. 实现可以合并相同字面量,也可以分别保存;内容比较用 strcmp。
  6. 不能。前者指向 int *,后者转换后是指向 int[3] 的 int (*)[3]。

总结

这一讲把指针从"保存某个数据的地址"推进到了更完整的类型系统:字符指针让我们区分字符串字面量与字符数组;数组指针让指针算术以整行为单位;二维数组传参揭示了列数其实属于元素类型;函数指针让程序可以保存、传递和选择行为;函数指针数组与转移表则把"编号到行为"的关系组织成可扩展的数据结构。

面对复杂声明时,不要数星号,也不要只盯着地址。先从变量名向外读出完整类型,再问三个问题:它指向什么、一步跨多远、解引用或调用后得到什么。当这套分析顺序形成习惯,指针就不再是一团符号,而会变成可以逐层验证的规则。

相关推荐
垆边人似月.2 小时前
华为机考题:最小代价爬楼梯
c语言·c++
可涵不会debug2 小时前
C++ std::bind 实战教程:线程池、回调场景全覆盖
linux·开发语言·c++
不正经的码狗2 小时前
Java 开发环境搭建与 Eclipse 使用速通教程
java·开发语言·eclipse
用户8181870627462 小时前
第37章 Java应用在K8s里的经典坑:容器内存/CPU limit与JVM参数不匹配导致的OOMKilled
java·后端
马剑威(威哥爱编程)2 小时前
【AI全栈后端12-03】Spring Boot 用多模型路由把智能客服成本降下来
java·人工智能·spring boot
阿钱真强道2 小时前
24 嵌入式操作系统 | 项目启动与环境准备(PyQt5 / Flask 两个“最小可跑“)
开发语言·qt·flask·架构设计·协议契约
霸道流氓气质3 小时前
AI模型幻觉检测与抑制完全指南:从规则引擎到RAG对比的Java生产级实战
java·开发语言·人工智能
前端 贾公子3 小时前
LangChain核心组件 == 提示词(Prompts)
开发语言·c#
niucloud-admin3 小时前
JAVA V6 多商户商城 开发文档——手机端前端
java·开发语言·前端