说实话,写到“指针3”这个系列位置,还在坚持看的人,我是真心佩服。前两篇我们聊了指针的基本概念、指针和变量的关系、指针作为函数参数怎么传,今天这篇会把指针这块最难啃的硬骨头全部啃完:指针数组、数组指针、二级指针、函数指针、回调函数,还有 const 和指针的各种组合。
这几样东西是 C 语言里出了名的“劝退组合”,很多人在这个位置直接放弃转 Java 或者 Python 了。但我想说的是,一旦你把今天这篇的内容理清楚,C 语言指针对你来说就不再是玄学,而是一套有规律可循的工具。这篇写完之后,你去看那些面试题、考研题、甚至洛谷和 PTA 上的指针相关题目,都会觉得清爽很多。
今天这篇内容会比较硬核,我尽量用大白话把每个概念拆开揉碎讲清楚,该给代码给代码,该画推理过程就写推理过程。准备好了我们就开始。
1. 先分清指针数组和数组指针
1.1 两个名字像双胞胎,脾气完全相反
很多初学者看到“指针数组”和“数组指针”这两个词就直接懵了,名字太像了。但是这两个东西完全是两码事,搞混了代码编译都过不去。
先说结论,用最直白的话来记:
- 指针数组:它是一个数组,数组里存的东西是指针。本质是“数组”。
- 数组指针:它是一个指针,这个指针指向一个数组。本质是“指针”。
看代码:
c复制int *p1[4]; // 指针数组:p1 是一个数组,里面有4个元素,每个元素都是 int*
int (*p2)[4]; // 数组指针:p2 是一个指针,它指向一个“有4个 int 元素”的数组
关键是那个括号。[] 的优先级比 * 高,所以如果没有括号,int *p1[4] 会先和 [4] 结合,说明 p1 是一个数组,数组的元素类型是 int*。
而 int (*p2)[4] 加了括号之后,* 先和 p2 结合,说明 p2 是一个指针变量,那它指向什么呢?指向一个 int[4] 的数组。这就是数组指针。
这里有个非常容易踩的坑,我当年学的时候在这里栽过跟头。你写 int (*p2)[4] 的时候,中间的括号绝对不能省,省了就完全变了一个意思。这就像“小明打篮球”和“篮球打小明”,字差不多,动作方向完全反了。
来看一个实际的用法:
c复制#include <stdio.h>
int main() {
int a[4] = {1, 2, 3, 4};
// 数组指针 p 指向 a 这个数组
int (*p)[4] = &a;
for (int i = 0; i < 4; i++) {
printf("%d ", (*p)[i]);
}
printf("\n");
return 0;
}
注意这里用的是 &a 而不是 a。a 是数组首元素的地址,类型是 int*;&a 是整个数组的地址,类型是 int(*)[4]。在数值上它们相等,但在类型上是完全不同的。这在后面讲指针运算的时候会体现得非常明显。
1.2 指针数组最经典的应用:字符串表
指针数组在实际工作中用得非常多的地方就是处理字符串。因为 C 语言没有真正的字符串类型,字符串都是用字符数组或者字符指针表示的,所以当你有多个字符串的时候,用指针数组来管理是最方便的。
看一个经典的例子:
c复制#include <stdio.h>
int main() {
// 指针数组:每个元素是一个 char*,指向一个字符串常量
char *week[] = {
"Monday", "Tuesday", "Wednesday",
"Thursday", "Friday", "Saturday", "Sunday"
};
for (int i = 0; i < 7; i++) {
printf("week[%d] = %s\n", i, week[i]);
}
return 0;
}
char *week[] 这个写法,意味着 week 是一个数组,数组里的 7 个元素都是 char* 类型的指针,每个指针指向一个字符串常量的首地址。
这里有个细节值得注意:字符串常量在 C 语言中存储在只读数据段(一般在 .rodata 段),如果你试图修改 week[0][0],也就是尝试改写 "Monday" 的第一个字符,程序会在运行时报段错误(Segmentation Fault)。所以写成 const char *week[] 才是更安全、更专业的写法。
注意:当指针数组用来存放字符串常量时,建议养成加
const的习惯。这不仅能让编译器帮你检查出非法修改,也是在告诉读代码的人:这些字符串是只读的。
指针数组还有一个容易和二维字符数组混淆的地方,我一起说清楚。如果你用 char week[7][10],那就是一个实实在在的二维数组,每一行固定 10 个字节,总共 70 个字节;而 char *week[7] 只有 7 个指针,加上字符串常量本身占用的空间,总共占用的内存要少很多。一个是“把内容搬进自家院子”,一个是“只记下邻居家的门牌号”。在嵌入式开发或者内存紧张的场景下,这个区别非常关键。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 二级指针:指针的指针到底在什么场合才用得上
2.1 为什么需要二级指针
二级指针,也就是 int **pp,这东西在很多初学者看来就是没事找事。指针就已经够烦了,你还来个指针的指针。
但二级指针的出现是有明确需求的,最常见的场景就是:你想在函数里面修改函数外面那个指针变量的值。
回顾一下 C 语言函数传参的本质:值传递。形参是实参的一份拷贝,你在函数内部怎么改形参,都不影响实参。
那如果我想在函数里修改外面那个指针变量,让它指向一块新分配的内存,该怎么办?答案是:把那个指针变量的地址传进去。指针变量的地址,类型自然就是二级指针了。
看代码:
c复制#include <stdio.h>
#include <stdlib.h>
void alloc_memory(int **pp, int size) {
// *pp 就是外面那个指针变量的本体,改它等于改外部的指针
*pp = (int*)malloc(size * sizeof(int));
if (*pp != NULL) {
for (int i = 0; i < size; i++) {
(*pp)[i] = i * 10;
}
}
}
int main() {
int *p = NULL;
alloc_memory(&p, 5); // 传的是 p 的地址
for (int i = 0; i < 5; i++) {
printf("%d ", p[i]);
}
printf("\n");
free(p);
return 0;
}
如果你不用二级指针,而是写成 void alloc_memory(int *p, int size),然后在函数里 p = (int*)malloc(...),那么函数返回后,外面的 p 还是 NULL。因为函数里修改的是 p 的拷贝,真正的 p 根本没动过。这是 C 语言新手最容易犯的错误之一,而且编译器不报错不告警,排查起来非常隐蔽,有时候能折腾一整天。
实际上很多封装的初始化函数都用到这个套路,比如 Linux 内核里的一些链表初始化、一些库的 handle 创建,都是传二级指针,目的就是把分配好的资源“带出来”。
这里再补充一个进阶技巧:很多时候我们会把“分配内存 + 初始化”封装成一个函数,返回值用于表示成功失败状态(0 成功,-1 失败),分配出来的指针通过二级指针带出。这样可以写出更健壮的代码:
c复制int create_buffer(int **pp, int size) {
if (pp == NULL || size <= 0) {
return -1;
}
int *tmp = (int*)malloc(size * sizeof(int));
if (tmp == NULL) {
return -1;
}
*pp = tmp;
return 0;
}
注意我用了临时变量 tmp 而不是直接 malloc 后赋值给 *pp,这是为了防止 malloc 失败时把外面的指针搞成野指针。如果你直接 *pp = malloc(...),万一 malloc 返回 NULL,你外部的指针就变成 NULL 了,而调用者可能没法区分“分配失败”和“成功分配了一个空指针”。用临时变量可以保证调用者的指针只被赋值一次,要么成功要么保持原样,逻辑更干净。
2.2 二级指针和二维数组是两码事
很多人以为 int **p 可以指向二维数组 int a[3][4],这是个天大的误解。我直接说结论:int **p 不能直接指向 int a[3][4]。
类型不匹配。int a[3][4] 的数组名在表达式中会退化成指向首元素的指针,而首元素是一个 int[4] 数组,所以退化后类型是 int(*)[4],也就是数组指针。而 int **p 是“指向指针的指针”。它们之间没有任何隐式转换,强行赋值编译器会给出警告。
正确的对应关系是这样的:
- 二维数组名传给函数参数,应该写成
int (*p)[4],或者直接写int p[][4](这两种写法是等价的)。 int **p更适合配合指针数组使用,比如char **argv这种,本质是“指向指针数组中第一个元素的指针”。
看一段实际代码:
c复制#include <stdio.h>
void print_matrix(int (*m)[4], int rows) {
for (int i = 0; i < rows; i++) {
for (int j = 0; j < 4; j++) {
printf("%2d ", m[i][j]);
}
printf("\n");
}
}
int main() {
int a[3][4] = {
{1, 2, 3, 4},
{5, 6, 7, 8},
{9, 10, 11, 12}
};
print_matrix(a, 3);
return 0;
}
注意 int (*m)[4] 和 int m[][4] 在函数参数中是等价的,编译器会把后者自动调整成前者。但在变量声明中它们是不同的,函数参数那里只是一个语法糖。
2.3 二级指针配合指针数组:模拟二维字符串表
二级指针最舒服的使用场景之一,就是配合指针数组去遍历一个字符串表。main 函数的第二个参数 char *argv[] 本质上在函数传参时会被调整为 char **argv,这也是二级指针最著名的应用。
c复制#include <stdio.h>
int main(int argc, char *argv[]) {
// argv 可以看作 char**,本质上是一个指向指针的指针
for (int i = 0; i < argc; i++) {
printf("argv[%d] = %s\n", i, argv[i]);
}
return 0;
}
argv 是一个数组,数组里每个元素是 char*,所以 argv 退化成指针后就是 char**。理解了这个,你就能明白为什么 argv 可以用 argv[i][j] 这样两重下标访问——因为 argv[i] 取到第 i 个字符串的首地址,argv[i][j] 再往下取这个字符串的第 j 个字符。
如果你看到有人用 char **p = argv; 来遍历参数,不要觉得奇怪,它们本质上是同一种东西。
3. 函数指针和回调函数:把函数当作参数传递
3.1 函数指针的声明和 typedef
很多人学到指针的时候觉得指针难,学到函数指针的时候觉得“这玩意就不是给人学的”。但实际上函数指针的思想在现代编程中太重要了,它就是把函数当作“数据”传来传去。C 语言里的函数指针,就是 C++/Java 里“函数对象”、“lambda”、“委托”这些概念的前身。
函数指针的声明语法看起来吓人,实际规律很简单:
c复制// 声明一个函数指针 fp,它可以指向“返回值为 int,有两个 int 参数”的函数
int (*fp)(int, int);
拆解一下:fp 先和 * 结合,说明 fp 是一个指针;然后 (*fp)(int, int) 表示 fp 指向一个函数,这个函数的参数是两个 int,返回值是 int。
和前面数组指针一样,括号又是关键。如果你写成 int *fp(int, int),那意思就变成了:fp 是一个函数,参数是两个 int,返回值是 int*。一个是指针,一个是函数,性质完全不一样。
为了读起来方便,我们通常用 typedef 把函数指针类型起个别名:
c复制typedef int (*Operation)(int, int);
int add(int a, int b) {
return a + b;
}
int main() {
Operation op = add; // 直接把函数名赋值给函数指针
int result = op(3, 5);
printf("%d\n", result);
return 0;
}
注意 add 函数名在使用时会被隐式转换为函数指针,所以 op = add 和 op = &add 是等价的,加不加 & 都行。这里就不用纠结了,C 语言在这方面的设计就是让你少打几个字符。
函数指针最常用的场景是:写一个通用的“执行器”。比如你有一个计算器程序,用户输入运算符和两个数,你得写一堆 switch-case。用函数指针数组可以写得非常优雅:
c复制#include <stdio.h>
typedef int (*Operation)(int, int);
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 main() {
// 函数指针数组:下标0对应 add,下标1对应 sub,下标2对应 mul
Operation ops[] = {add, sub, mul};
int choice = 1; // 假设用户选择减法
int a = 10, b = 3;
printf("result = %d\n", ops[choice](a, b));
return 0;
}
这个模式在项目里真的非常常见。比如网络通信协议里,根据消息类型分发到不同的处理函数;再比如实现状态机的时候,用函数指针数组来映射“状态 -> 处理函数”。用函数指针数组的好处是,你要新加一种操作时,只需要写一个函数,然后在数组里加一项,完全不用改动分发逻辑。
3.2 回调函数:库函数怎么调用你的代码
回调函数是函数指针最典型的应用。简单说就是:你给库函数传一个函数指针,库函数在合适的时候会调用这个函数。这个“被调用的函数”,就叫回调函数。
C 标准库里最经典的例子就是 qsort:
c复制#include <stdio.h>
#include <stdlib.h>
int compare_int(const void *a, const void *b) {
int ia = *(const int*)a;
int ib = *(const int*)b;
// 返回负数表示 a 排在 b 前面,正数表示 a 排在 b 后面
return ia - ib;
}
int main() {
int arr[] = {5, 2, 9, 1, 7};
int n = sizeof(arr) / sizeof(arr[0]);
qsort(arr, n, sizeof(int), compare_int);
for (int i = 0; i < n; i++) {
printf("%d ", arr[i]);
}
printf("\n");
return 0;
}
注意 qsort 的第四个参数要求一个函数指针,函数指针的签名是固定的:返回 int,两个 const void* 参数。为什么要用 void*?因为 qsort 是一个通用排序函数,它可以排 int、排 double、排结构体,任何类型都行。void* 是“泛型指针”,它可以指向任意类型的数据,排序的规则由你提供的回调函数决定。
注意:在回调函数里把
void*转回具体类型指针时,一定要确认类型是正确的,否则解引用时会发生未定义行为。这类 bug 通常表现为随机性的数据错乱,排查起来非常痛苦。
我自己在项目里写回调函数时,踩过一个印象很深的坑。我写了一个定时器模块,需要用户注册超时回调函数,模块里保存了回调函数的指针。结果有次用户注册了回调函数,但定时器模块里保存的是局部变量的地址,函数返回后那个地址变成了野指针,定时器一触发,程序直接崩溃。后来加了一个硬性规定:所有注册回调的接口,回调函数指针和上下文参数必须成对出现——就像 qsort 那样,一个函数指针,一个 void* user_data,回调的时候把 user_data 原样传回。这样用户就可以把自己的结构体指针塞进去,避免了很多生命周期问题。
这种“函数指针 + 上下文参数”的设计模式,在诸多开源库里都能看到,也应该是你写接口时默认的标配。
4. 多维数组、数组指针和指针运算的细节
4.1 二维数组的名字到底代表什么
二维数组的名字,用起来是真的绕。int a[3][4] 中,a 到底是什么类型?
直接用代码说话:
c复制#include <stdio.h>
int main() {
int a[3][4] = {0};
printf("a = %p\n", a); // 首行的地址(行指针,类型 int(*)[4])
printf("a + 1 = %p\n", a + 1); // 第二行的地址
printf("&a = %p\n", &a); // 整个二维数组的地址
printf("a[0] = %p\n", a[0]); // 第0行第0列元素的地址
printf("a[0]+1 = %p\n", a[0] + 1);// 第0行第1列元素的地址
return 0;
}
假设 a 的地址是 0x100,sizeof(int) 是 4:
a的值是0x100,类型是int(*)[4](指向包含4个int的数组的指针)。a + 1的值是0x110,也就是说向后移动了 16 个字节,正好一行(4 个 int)。a[0]的值也是0x100,但类型是int*,指向第 0 行的第 0 个元素。a[0] + 1的值是0x104,只移动了一个 int。
看到差距了吗?a + 1 比 a 大 16,a[0] + 1 比 a[0] 大 4。同样是加 1,步长完全不同。这就是指针的类型决定指针运算步长的直观体现。
这个知识点几乎是所有指针笔试必考题。我当年面试的时候被问过一个更绕的:*(a + 1) + 2 是什么?答案是:a + 1 得到第二行的行地址(int(*)[4]),* 解引用后得到第二行的首元素地址(int*,和 a[1] 等价),+2 后指向第二行的第 2 个元素。所以 *(a + 1) + 2 等价于 &a[1][2]。逻辑链稍微长了点,但一步步拆解就很清晰。
4.2 指针加减运算全解析
指针运算其实就那几样:加整数、减整数、两个指针相减、指针比较。但每一条都有严格的限制。
指针加整数 / 减整数:指针加减整数 n,实际移动 n 个元素,不是 n 个字节。移动的字节数是 n * sizeof(数组元素类型)。比如 int *p 指向 int,p + 3 就向后移动 3 个 int 的位置,也就是 12 个字节(在 32 位 int 下)。
两个指针相减:结果是两个指针之间的元素个数,类型是 ptrdiff_t(在 <stddef.h> 中定义,通常是有符号整数)。前提是两个指针指向同一个数组(或者说同一块连续内存)。这个常用来计算数组长度:
c复制int arr[] = {10, 20, 30, 40, 50};
int *begin = arr;
int *end = arr + 5;
ptrdiff_t count = end - begin; // 结果是 5
指针比较:可以用 ==、!=、<、> 等关系运算符比较。但只有指向同一个数组(或同一块连续内存)的指针比较才有定义。指向不同数组的指针做关系比较,是未定义行为。
指针自增自减:p++、--p 也是移动一个元素,常用于遍历数组。但要注意,p++ 是“先使用 p 原来的值,然后让 p 移动”,这个行为在循环里非常常见:
c复制int arr[] = {1, 2, 3, 4, 5};
int *p = arr;
while (p < arr + 5) {
printf("%d ", *p++);
}
这里的 *p++ 等价于 *(p++),因为后缀自增的优先级比解引用高。它的意思是:先取 *p 的值,然后 p 自增。这在遍历数组时非常高效,也是 C 语言老手偏爱 *p++ 的原因。
还需要注意,指针运算不要越界。虽然 C 标准规定,指针指向数组最后一个元素之后再往后越界一个位置是合法的(用于循环终止判断),但你再往后操作就是未定义行为。未定义行为意味着编译器想干嘛就干嘛,可能是崩了,也可能是正常工作但某一天突然崩了,甚至是正常返回错误结果。所以,越界这种事不要赌。
5. const 与指针的组合,以及传参时的保护习惯
5.1 const 修饰指针的三种写法
const 和指针的结合,是 C 语言中另一个经典易错点。常见的三种写法:
c复制// 1. 指向常量的指针(pointer to const)
const int *p;
// 等价于
int const *p;
// 2. 常量指针(const pointer)
int *const p;
// 3. 指向常量的常量指针(const pointer to const)
const int *const p;
读法有个口诀,看 const 修饰的是谁:
const int *p:const 修饰的是int,也就是说 p 指向的是一个“不能被修改的 int”。你可以改 p 本身(让 p 指向别处),但不能通过 p 修改它指向的值。int *const p:const 修饰的是 p 本身,也就是说 p 是一个常量,初始化后不能让它指向别处。但你可以通过 p 修改它指向的值。const int *const p:两个都锁死了,p 不能改指向,指向的值也不能通过 p 改。
在实际代码里,第一种 const int *p 最常用。我们经常要把一些只读数据传入函数,让函数只能读不能写,这样能防止误操作。比如:
c复制void print_array(const int *arr, int n) {
for (int i = 0; i < n; i++) {
printf("%d ", arr[i]);
}
printf("\n");
}
调用 print_array 时传入普通 int 数组,函数内部就只能读取无法修改。如果你不小心在函数里写了 arr[0] = 10;,编译器会直接报错。
5.2 用 const 设计更安全的接口
很多人写代码时觉得 const 是多余的,是给自己添麻烦。但在真正的工程里,const 是接口设计的一部分,是一种文档,也是一种编译期检查工具。
我建议在写函数的时候遵守这几个习惯:
- 函数不会修改指针指向的数据,参数就写
const int *p。这不仅仅是安全性,还能让你的函数同时接受 const 和非 const 的数据,兼容性更好。 - 函数内部遍历只读数据时,用
const int *p声明的局部变量,这样万一误改会被编译器拦住。 - 如果返回一个指向只读数据(比如字符串常量)的指针,返回类型写成
const char *。
看个例子:
c复制const char *get_month_name(int month) {
static const char *months[] = {
"January", "February", "March", "April",
"May", "June", "July", "August",
"September", "October", "November", "December"
};
if (month < 1 || month > 12) {
return "Invalid month";
}
return months[month - 1];
}
返回类型是 const char *,这样调用者拿到这个指针后,就无法通过它修改字符串内容。如果调用者确实需要修改,那他必须自己拷贝一份。这是 C 语言里一个很常见的“写时复制”保护思路。
这里再加一个容易忽略的坑,const 修饰的是变量还是指针本身,还体现在赋值兼容性上。const int *p 可以赋值给 const int *,但不能赋值给 int *——因为那样你就可能绕过了 const 限制去修改只读数据。反过来,普通 int * 可以隐式转为 const int *,这是安全的。写代码时如果遇到类型不匹配的警告,先检查一下是不是 const 修饰位置搞错了。
6. 数组与指针笔试题实录
6.1 三道经典题,考考你是不是真懂了
光看不练假把式。这节我从各种笔试和实际面试中挑了三道高频题,大家先自己算一遍,再对照下面的解析。
题目一:
c复制#include <stdio.h>
int main() {
int a[5] = {1, 2, 3, 4, 5};
int *p = a;
printf("%d\n", *(p++));
printf("%d\n", *p);
return 0;
}
输出是什么?
答案是:
code复制1
2
第一行先输出 *p 的值 1,然后 p 自增指向 a[1];第二行再解引用,得到 2。
题目二:
c复制#include <stdio.h>
int main() {
char *str[] = {"China", "Beijing", "Hangzhou"};
char **p = str;
p++;
printf("%s\n", *p);
return 0;
}
输出是什么?
答案:
code复制Beijing
str 是指针数组,首元素是 char*,指向 "China" 的字符串常量。p 指向数组第一个元素,p++ 后指向第二个元素,也就是指向 "Beijing" 的指针,*p 取出这个指针,%s 打印出 "Beijing"。
题目三:
c复制#include <stdio.h>
int main() {
int a[2][3] = {{1, 2, 3}, {4, 5, 6}};
int *p = a[0];
int (*q)[3] = a;
printf("%d\n", *(p + 4));
printf("%d\n", *(*(q + 1) + 1));
return 0;
}
输出是什么?
答案是:
code复制5
5
第一行:a[0] 是第一行的首元素地址,p + 4 向后移动 4 个 int 位置,指向 a[0][4];但在内存布局上,二维数组是连续存储的,a[0][3] 其实就紧挨着 a[0][2],也就是第二行的第一个元素 4,所以 a[0][4] 是 5。这种访问虽然越过了第一行的边界,但在 C 标准里是未定义行为(严格来说只有在你访问同一个数组对象内部时才合法),不过在绝大多数编译器的实现上会得到预期结果。这里拿出来是为了展示内存布局,实际写代码千万不要依赖这种未定义行为。
第二行:q 是数组指针,指向第一行;q + 1 指向第二行;*(q + 1) 得到第二行的首元素指针(等价于 a[1]);+1 指向第二行第 1 个元素,解引用得 5。
6.2 常见问题排查与心得
很多人在做指针题的时候,不是不懂概念,而是被各种“写法变体”绕晕了。我总结几个高频错误,大家可以对照自查:
-
*p++和(*p)++分不清。前者是先取*p的值,再让指针移动;后者是先取*p的值并返回,然后把*p指向的值加 1。这两者差之毫厘,谬以千里。 -
int *p[4]和int (*p)[4]分不清。这在前文讲过了,一个是数组,一个是指针。建议在写代码前先在纸上把括号圈出来,看看先和谁结合。 -
把数组名和
&数组名搞混。a是首元素地址,&a是整个数组的地址。虽然数值相同,但 +1 步长完全不同。数组指针操作时特别容易踩。 -
函数指针声明时忘记括号。
int *fp(int)和int (*fp)(int),一个是返回指针的函数,一个是函数指针。这个只差一个括号,意义天壤之别。 -
回调函数里类型乱转。
void*转成具体类型时,一定要保证实际类型匹配。比如 qsort 的回调里,如果比较的是 double,但你转成了 int 指针,数据就被截断了,排序结果完全不对。我刚开始学的时候就干过这事,排序结果看起来“似乎正常”但顺序不对,折腾了好久才发现是类型转换问题。
排查这些问题时,我个人的经验是:遇到指针相关的 bug,先别急着改代码,把指针的类型、指向什么、步长是多少,在纸上画出来。画出内存图之后,绝大多数问题都能一眼看出来。
6.3 给新手的一个保命测试法
最后给你们分享一个我自己的调试技巧。在不确定一个表达式类型对不对的时候,可以用一个“类型验证”的小技巧,让编译器告诉你答案:
c复制#include <stdio.h>
int main() {
int a[3][4] = {0};
// 故意写错,让编译器报错,错误信息里会包含表达式的真实类型
// 比如想看 a 的类型,就把它赋值给一个已知不对的类型
char *wrong = a;
return 0;
}
编译器会输出类似这样的警告:
code复制warning: initialization of 'char *' from incompatible pointer type 'int (*)[4]'
这样你就知道 a 的真实类型是 int (*)[4],不用自己猜。这个办法在分析复杂表达式时非常实用。gcc 和 clang 的错误提示都很友好,会给完整的类型信息。
当然,实际调试时打印 sizeof 也能帮上忙。比如:
c复制printf("%zu %zu\n", sizeof(a), sizeof(a[0]));
这样能看到整个数组占多少字节、一行占多少字节,很容易判断出指针运算的步长对不对。根据我个人经验,调试指针问题最核心的路径就是:确认类型、确认指向、确认步长、确认是否越界。这四步走完,绝大多数 bug 都无所遁形。
指针这个东西,难就难在它把“内存地址”这个抽象概念直接暴露给你,同时又叠加了“类型决定解释方式”的规则。我见过太多人在这里放弃,也见过很多人一旦突破了这道坎,后面学数据结构、学操作系统、看别人源码都一路通畅。这篇内容信息量不小,建议你拿着编译器边敲边跑,把每个例子自己改一改、验证一下。我写代码那会儿,每次搞懂一个指针问题,就顺手写在笔记里,现在回头看,很多当时的“顿悟时刻”其实就是在反复练习中自然到来的。
