我带的几个新人里,十个有八个在指针这一关卡住过。有人写了个链表,跑起来直接段错误;有人把数组名当成普通指针传进函数,sizeof 算出来的长度永远是 8;还有人free完之后又去读那块内存,程序在小数据集上稳如老狗,数据量一大就开始随机崩溃。这些现场我见得太多,所以想把“深入理解指针”这件事从头捋一遍——不是复述教材,而是按一个真正天天和内存打交道的人的顺序来讲:先看清内存长什么样,再搞懂指针变量本身只是个存地址的普通变量,然后是指针类型、指针运算、数组退化、野指针、二级指针和 const 这几块硬骨头,最后用一段能跑起来的代码把理解验证一遍。
这篇是“深入理解指针”系列的第一部分,只讲基础和最容易翻车的那几条路。适合两类人:一类是刚学完 C 语言语法、能写for循环但一看到*和&就发怵的同学;另一类是有几年经验、但每次出内存问题都靠打印大法硬猜的开发者。读完你至少能做到三件事:看到一行指针声明就知道它占多少字节、加一会走到哪;能自己判断一个指针此刻是不是野的;能在函数传参时想清楚到底该传一级还是二级指针。
1. 先把“内存”这件事讲透:指针的地基
指针这东西之所以让人晕,九成原因不在指针本身,而在于脑子里没有一张内存的图。语法只是表象,真正的底层模型是“一大片连续编号的字节”。这张图建不起来,后面学什么都是背结论。
1.1 内存不是玄学,就是一排带门牌号的储物柜
我习惯这样跟人打比方:把内存想象成一栋楼里的一排储物柜,每个柜子只有一个字节那么大,从 0 开始往上编号,这个编号就是地址。你往柜子里放东西,放的是 0 到 255 之间的一个数。所谓“一个 int 变量”,实际上是连续占了 4 个柜子(常见平台上),这 4 个柜子合起来表示一个整数,而这 4 个柜子里编号最小的那个,就是这个变量的地址。
这个细节很多人没注意:一个 int 变量占 4 个字节,但它只有一个地址,就是首字节的地址。你&a拿到的是首地址,编译器知道“这是个 int,往后读 4 个字节”,所以它自己能算对范围。这也解释了一个常见困惑——为什么&a和&a + 1差了 4 而不是 1,因为加一的单位是“一个 int”,不是“一个字节”。
再往下说一层,栈上的局部变量、堆上malloc出来的内存、全局区的变量、只读的字符串常量,虽然都在同一片地址空间里,但位置和生命周期完全不同。局部变量出了作用域,那块柜子就被回收了,但柜子里残留的数值往往还在——这就是后面“悬垂指针”读出来的脏数据看着还挺正常的原因。
1.2 变量名、地址、值三者的关系
初学者最容易混的是这三者。变量名是给人看的,编译器在编译期就把它翻译成地址了,运行期根本没有“名字”这个东西;地址是给机器看的编号;值才是你真正关心的内容。写int a = 42;,可以理解成“在某个编号开始的 4 个柜子里,按小端方式存进 42,并且以后用a这个名字指代那个编号”。
而指针变量,是“值恰好是一个地址”的变量。它自己也占柜子,也有自己的地址。所以下面这段代码里p有两层含义,必须分清:
int a = 42; int *p = &a; printf("%p\n", (void*)&p); /* p 自己的地址 */ printf("%p\n", (void*)p); /* p 里存的值,也就是 a 的地址 */ printf("%d\n", *p); /* 顺着 p 找到 a,取出 42 */&p和p打印出来的值通常差得很远,一个在栈上靠近你的局部变量区,一个可能也在栈上但位置不同。能把这三个打印结果对应到储物柜图上,指针就算入门一半了。我建议每个人都亲手跑一遍这段代码,把三个地址抄在纸上画一画,比看十页书管用。
1.3 为什么 C 语言非要暴露地址这个东西
有人会问:Java、Python 都不让碰地址,也挺好,C 为什么非要这么“危险”?因为 C 的定位就是“可移植的汇编”,它要在不牺牲性能的前提下给你贴近硬件的能力。传一个大结构体进函数,如果按值拷贝,几百字节复制一遍;传指针只复制 8 个字节。要在函数里修改调用方的变量,不传地址根本做不到。要动态分配不定长的内存、要写数据结构、要直接操作硬件寄存器映射的内存,都绕不开地址。
所以指针不是 C 的“高级特性”,而是它的基本盘。躲是躲不掉的,唯一的出路是把模型建对。我个人经验是:凡是觉得指针难的人,问题几乎都能追溯到“没把地址和值分开”这一条上。
2. 指针变量的本质:它只是一个存地址的普通变量
很多人把指针神秘化,觉得int *p里的星号是什么特殊符号。真不是。星号在声明里只是类型的一部分,读作“指向 int 的指针”;在表达式里才是解引用运算符。这两个场景长得一样,含义完全不同,这是第一道坎。
2.1 声明、取地址、解引用三件套
三件套必须形成肌肉记忆:
int *p;声明一个指针变量,此时它里面是垃圾值,绝不能解引用。p = &a;把 a 的地址存进 p,这叫取地址。*p = 100;顺着 p 找到那块内存并写入,这叫解引用,等价于a = 100。
这里有个书写习惯我强烈推荐:int *p和int* p在编译器眼里完全一样,但后者容易让人误以为int*是一个整体类型,于是写出int* p, q;这种代码——结果 p 是指针,q 是普通 int。我个人的写法是星号紧贴变量名int *p, *q;,虽然丑一点,但不会骗人。团队里如果统一风格,争议就少。
还有一个动作要养成:声明指针时立刻初始化。int *p = NULL;或者int *p = &a;,别留一个“待会儿再赋”的空指针。C 标准里未初始化局部变量的值是不确定的,你解引用它就是未定义行为,可能崩、可能不崩但读到脏数据,后者更可怕。
2.2 指针类型到底决定了什么
这是本节的核心,也是最容易被跳过的一环。int *和char *在 64 位平台上都是 8 个字节,那类型区别在哪?区别不在“存多大的地址”,而在解引用时读几个字节、加一时走多远、按什么规则解释这串二进制。
| 指针类型 | 解引用读取字节数 | p + 1实际前进 | 解释方式 |
|---|---|---|---|
char * | 1 | 1 字节 | 按字符/字节 |
short * | 2 | 2 字节 | 有符号短整型 |
int * | 4 | 4 字节 | 有符号整型 |
double * | 8 | 8 字节 | 双精度浮点 |
void * | 不允许直接解引用 | 标准不允许算术 | 无类型,需转换 |
这张表要能背下来,因为它直接解释了无数 bug。比如你把一个int数组的首地址强转成char *再逐个加一,你拿到的是每个字节的内容,而不是每个元素;在判断字节序、序列化数据、处理二进制协议时这是常用手法,但在普通业务逻辑里这么写,八成是搞错了类型。
参数计算这里补一句:p + 1的地址增量 =sizeof(*p)个字节。这个公式记住,任何指针算术都能自己推。double *p加一前进 8,因为sizeof(double)是 8;struct Foo *p加一前进的是这个结构体的大小,可能有内存对齐填充,所以别手算,直接用sizeof或者offsetof。
2.3 指针的大小与平台差异
sizeof(int *)在 32 位平台是 4,64 位平台是 8,这是很多人踩过的坑:在本机测出来是 8,移植到 32 位嵌入式板子上就变成 4,如果代码里把指针塞进int或者把指针长度写死成 8,直接翻车。
正确的做法是:需要存指针的地方用void *或具体指针类型;需要存“地址的整数形式”用uintptr_t(在stdint.h里);需要打印地址一律用%p并把参数转成(void *),别用%d硬打,那在 64 位上是错的,输出会被截断。我见过有人用%x打地址,在 32 位机器上看着挺正常,换 64 位就少了一半,然后对着日志排查了整整一下午。
还有一点,sizeof的结果类型是size_t,无符号。写for (int i = 0; i < sizeof(arr); i++)在开启警告时会提示有符号无符号比较,虽然这里不出事,但养成用size_t的习惯能省掉很多隐式转换的麻烦。
3. 指针运算与数组:最容易翻车的一段路
数组和指针在 C 里关系暧昧:它们不是一回事,但在大多数表达式里数组名会“退化”成指向首元素的指针。这一章就是把这层暧昧关系说清楚,因为面试和实际代码里的坑,八成集中在这一块。
3.1 指针加减法走的是“元素”不是“字节”
先记住一句话:指针的加减法单位是元素,不是字节。int *p; p + 1前进 4 个字节,但语义上是“指向下一个 int”。这个设计让遍历数组变得极其自然:
int arr[5] = {10, 20, 30, 40, 50}; int *p = arr; for (int i = 0; i < 5; i++) { printf("%d\n", *(p + i)); /* 等价于 arr[i] */ }*(p + i)和arr[i]在编译器生成层面几乎一样,因为arr[i]本来就被定义为*(arr + i)。所以i[arr]这种写法居然是合法的(等价于arr[i]),不过这纯属炫技,别在产品代码里写,会挨打。
减法同理,可以用来倒序遍历。但要记住:指针只能在同一块数组(或末尾后一位)范围内运算。越界哪怕只是“算出来”而不解引用,在标准里也是未定义行为。实践中大多数平台不会立刻崩,但开了优化的编译器可能做出你完全想不到的事,比如把整个循环直接优化掉。
3.2 数组名退化的真相:三个场景要分清
数组名在多数表达式里退化为指向首元素的指针,但有三处例外,必须记牢:
| 场景 | 数组名的行为 | 示例 |
|---|---|---|
作为sizeof的操作数 | 不退化,得到整个数组字节数 | sizeof(arr)是 20(5 个 int) |
作为&的操作数 | 不退化,得到“数组指针” | &arr类型是int (*)[5] |
| 用字符串字面量初始化字符数组 | 不退化,是拷贝 | char s[] = "abc"; |
其他情况下——传参、赋值、参与算术——都会退化。这就解释了那个经典 bug:函数里sizeof(arr)永远是指针大小,而不是数组长度。因为传参时数组名已经退化成指针了,函数根本不知道原数组有多长。
void bad(int arr[]) { /* 写法上像数组,实际就是 int *arr */ size_t n = sizeof(arr) / sizeof(arr[0]); /* 永远等于 2 或 1,错 */ } void good(int *arr, size_t n) { /* 长度显式传,永远可靠 */ for (size_t i = 0; i < n; i++) { /* ... */ } }我个人的规矩是:函数参数里一律用int *arr, size_t n的形式,不写int arr[],因为它会给人“这里面还能 sizeof”的错觉。C99 之后还有一种int arr[static n]的写法,能表达“至少 n 个元素”的约定,但用的人不多,团队不统一的话反而增加阅读成本。
3.3 指针相减的语义与越界边界
两个指针相减,前提是它们指向同一块数组。结果类型是ptrdiff_t,值是相隔的元素个数,不是字节差。这个特性在写“当前指针距离开头多远”时很好用,比如在解析缓冲区的状态机里记录偏移量。
int arr[5]; int *start = &arr[0]; int *cur = &arr[3]; printf("%td\n", cur - start); /* 输出 3 */注意这里用%td而不是%d,因为ptrdiff_t不是 int。这个细节在 64 位平台上尤其重要,%d可能截断。
关于&arr和arr的区别,我建议专门记一笔:arr退化后类型是int *,&arr的类型是int (*)[5](指向“5 个 int 的数组”的指针)。两者地址值相同,但加一的行为完全不同:arr + 1前进 4 字节,&arr + 1前进 20 字节。写二维数组传参、写动态分配的多维数组时,这个类型差别会直接决定代码能不能编译过。我见过有人为了让&arr能传进函数,硬加强转到int *,虽然地址一样能跑,但这是在拿未定义行为赌运气,换成更严格的编译选项就未必还是这个结果。
4. 空指针、野指针、悬垂指针:三类事故现场复盘
线上一旦出现“随机崩溃”“数据偶尔错乱”,十次有六次逃不出这三类指针问题。它们的区别经常被混为一谈,其实成因和防法完全不同,必须分开处理。
4.1 野指针:从声明到赋值之间的那段真空
野指针指的是指向不确定地址的指针,最典型的就是声明了没初始化:
int *p; /* 里面是什么,标准没规定 */ *p = 10; /* 未定义行为,写到一个随机地址 */很多人在本机跑这段不崩,因为那块栈内存恰好可写,于是误以为“这样写也行”。这是最危险的心态。换编译选项、换运行环境、在别的函数调用之后,同一行代码就可能变成段错误。我踩过的坑是:一个函数里的数组溢出,恰好覆盖了另一个函数的栈帧,把里面的指针改成了乱值,然后再解引用直接飞掉,排查时完全看不出和溢出点有联系。
防野指针的规矩就三条:声明即初始化(int *p = NULL;)、使用前判空、释放后立刻置空。这三条没有例外,也没有“这次应该没事”。编译器警告-Wuninitialized一定要开,它能提前抓到一大半。
4.2 空指针:判空是习惯,不是可选项
空指针(NULL)是“明确不指向任何东西”的指针,解引用它一定会崩,这是好事——至少崩得干脆。真正麻烦的是把NULL当成一个正常值往下传,然后在某个深层调用里才炸,栈回溯很长,日志里还看不清是谁传进来的。
我的做法是把判空尽量往前压:
- 函数入口对每个指针参数判空,早退出。
- 对外接口一律返回明确的成功/失败码,而不是返回可能为空的指针让调用来猜。
- 结构体里的指针成员,创建时全部置
NULL,析构函数统一判空释放。
这里的顺序也有讲究:先判断再解引用,不能反过来。if (*p != 0 && p != NULL)这种写法是无效的,因为*p先执行了,p为空时已经崩了。正确写法是if (p != NULL && *p != 0),利用短路求值的顺序。这类笔误我见过不止一次,评审时特别容易漏。
4.3 悬垂指针:free 之后那块内存还“看着正常”
悬垂指针指的是指向的内存已经被释放或已经失效,但指针本身没清。经典现场:
int *p = malloc(sizeof(int)); *p = 42; free(p); printf("%d\n", *p); /* 未定义行为 */ p = NULL; /* 这一行如果忘了,就是悬垂指针 */它之所以比野指针更阴险,是因为free之后那块堆内存通常还没被覆写,你立刻读出来还是 42,于是产生“没问题啊”的错觉。等到后面别的地方malloc到同一块内存并写入新数据,你读到的就是别人的数据,症状是“数据莫名其妙变了”。这种 bug 在压力测试下才暴露,线下复现极难。
还有一类更隐蔽:函数返回局部数组的地址。
char *get_name(void) { char buf[64]; /* ... */ return buf; /* buf 是栈内存,函数返回就失效 */ }编译器一般会警告return-local-addr,但如果你把buf换成结构体里的成员再返回,警告就未必有了。判据很简单:谁分配、谁释放,返回出去的内存必须是从堆上来的,或者指向调用方提供的内存。栈上临时缓冲只能当场用完,不能带出门。
5. 指针与函数:值传递的真相与二级指针
“C 语言里所有的参数传递都是值传递”——这句话背过很多次,但真正在写swap和链表插入时理解它的人不多。理解了它,一级指针和二级指针的使用场景就自然清楚了,不需要死记。
5.1 为什么 swap 传指针才能改
先看经典问题:
void swap_wrong(int a, int b) { int t = a; a = b; b = t; /* 改的是副本 */ } void swap_ok(int *a, int *b) { int t = *a; *a = *b; *b = t; /* 顺着地址改原件 */ }swap_wrong里改的是形参副本,函数返回后一切照旧。swap_ok传进去的是地址,函数里通过地址找到调用方的变量本身,所以能改到。这里的关键认识是:你传的永远是一个值,只不过当这个值恰好是地址时,你才有机会“远程修改”。
顺带一个细节:swap_ok内部要判空吗?看你接口约定。如果是内部静态函数、调用点已知非空,加判空是噪音;如果是对外接口,判空是必须的。这个判断本身也是经验的一部分——不是所有地方都无脑判空,那会让代码充满防御性垃圾,关键看信任边界在哪里。
5.2 二级指针到底什么时候才需要
这是最常被问的问题。答案是:当你要在函数内部修改“指针本身”的时候,需要传它的地址,也就是二级指针。
场景一:函数内分配内存,通过参数带出去。
int alloc_buf(char **out, size_t n) { char *p = malloc(n); if (!p) return -1; *out = p; /* 修改的是调用方那个指针变量 */ return 0; } /* 调用 */ char *buf = NULL; if (alloc_buf(&buf, 128) == 0) { /* 用 buf */ free(buf); buf = NULL; }如果这里写char *out,函数里改的只是副本,调用方的buf依然是NULL,然后一用就崩。这不是语法问题,是“值传递”的直接推论。
场景二:单链表的头插、按值删除节点。头插会改变头指针,要么返回新的头,要么传Node **head。我个人的偏好是返回新头指针,因为它的表达式更清晰、不容易漏解引用;但删除操作如果没有返回值,就只能用二级指针。团队里两种都要能读懂,别硬争哪种更好——看现有代码风格走。
场景三:字符串数组的遍历,char **argv这种。理解成“指向 char * 的指针”,外面那层是用来遍历的,里面那层是指向字符的,拆开看就不晕了。
关于三级及以上,基础阶段不用碰,真正在工程里也很少出现,见到了先想清楚是不是设计有问题。
5.3 const 与指针的四种组合
const放在*左边还是右边,含义完全不同。这条必须记清,不能靠猜。
| 写法 | 含义 | 能否改指向的内容 | 能否改指向本身 |
|---|---|---|---|
const int *p | 指向常量的指针 | 不能 | 能 |
int const *p | 同上,写法等价 | 不能 | 能 |
int * const p | 常量指针,指针本身不可改 | 能 | 不能 |
const int * const p | 两者都不可改 | 不能 | 不能 |
记忆窍门:看const在*的哪一边。左边修饰的是指针指向的内容,右边修饰的是指针本身。或者从右往左读:int * const p读作“p 是一个 const 指针,指向 int”。
实际用法上,函数参数尽量加const:void print_str(const char *s),意思是“我保证不改你的字符串”。这是接口契约,既能让编译器帮你检查误写,也让调用方放心。我自己踩的坑是:一个函数本该只读,忘了加const,后来有人在里面顺手改了一下,导致一个被多处共享的常量缓冲区被污染,症状极其诡异。加上const之后,这类问题编译期就暴露了。
6. 动手验证:用一段小代码把理解落地
光看不练记不住。这一节给一段能直接编译运行的代码,把前面的地址、类型、运算、退化几个点全串一遍。建议你自己敲一遍,不要复制,敲的过程中感受一下每个&和*。
6.1 亲手打印地址、类型与步长
#include <stdio.h> #include <stddef.h> struct Demo { char c; int i; double d; }; int main(void) { int a = 0x11223344; int *pi = &a; char *pc = (char *)&a; printf("&a = %p\n", (void *)&a); printf("pi = %p\n", (void *)pi); printf("pi + 1 = %p\n", (void *)(pi + 1)); printf("pc + 1 = %p\n", (void *)(pc + 1)); printf("sizeof(int*) = %zu\n", sizeof(int *)); printf("sizeof(char*) = %zu\n", sizeof(char *)); int arr[5] = {1, 2, 3, 4, 5}; printf("sizeof(arr) = %zu\n", sizeof(arr)); printf("arr + 1 = %p\n", (void *)(arr + 1)); printf("&arr + 1 = %p\n", (void *)(&arr + 1)); printf("element count = %td\n", (ptrdiff_t)(&arr[4] - &arr[0])); struct Demo s; printf("sizeof(struct Demo) = %zu\n", sizeof(s)); return 0; }跑出来你会看到几个值得盯住的地方。pi + 1和pi差 4,pc + 1和pc差 1,同一块内存、同样的地址值,加一走出去的距离完全不同,这就是“指针类型决定步长”的最直观证据。sizeof(arr)是 20,sizeof(int*)是 8,同一个arr在sizeof面前是数组、在arr + 1面前是退化后的指针,两个身份共存。&arr + 1比arr + 1大出一整个数组的长度,这是“数组指针”加一的含义。
再盯一下sizeof(struct Demo)。如果按1 + 4 + 8 = 13算,实际输出通常是 16,因为编译器会给int后面塞 3 个字节的对齐填充,让double落在 8 字节边界上。这直接关系到struct Demo *p; p + 1走多远——走的是 16 不是 13。所以永远用sizeof算,不要手算,尤其不要假设“结构体成员大小相加就是结构体大小”。
6.2 用调试器看真实地址,比打印更直观
打印有个局限:你只能看到数字,看不到结构。我强烈建议至少用一次调试器来建立直觉。以 GDB 为例,编译时gcc -g -O0 test.c -o test,进 GDB 之后:
break main run next print &a print pi print *pi x/4xb &ax/4xb &a是按十六进制逐字节看 4 个字节,你会看到a的值 0x11223344 在内存里是44 33 22 11的顺序(小端),这就是为什么用char *去读一个 int 能读到每个字节,也是字节序判断的标准做法。这个操作比读十页《深入理解计算机系统》记得牢。
顺手可以试试print arr和print &arr,GDB 会给出不同的类型提示,一个是int *,一个是int (*)[5],和前面讲的完全对应。再print sizeof(arr)和print sizeof(pi),20 和 8,一目了然。
调试器里还有一个动作值得养成:崩溃之后第一件事不是加打印,而是看栈回溯bt,定位到出事的行,然后print那一行涉及的所有指针。野指针和悬垂指针在这一步常常直接现形——地址值看着就不像正常分配区,或者已经和别的变量地址重叠了。
7. 常见问题速查与避坑清单
前面讲的都是“本该如此”,这一节全是“实际会怎样”。我把这些年被问得最多、自己也踩过的坑整理成表格和清单,建议收藏,出问题时对着查。
7.1 高频问题速查表
| 现象 | 最可能原因 | 排查动作 | 修法 |
|---|---|---|---|
段错误,崩溃行涉及*p | p 未初始化或已释放 | 打印p的值,判断是否在合理地址范围 | 初始化 + 释放后置空 |
函数里sizeof(arr)恒为 8 | 数组传参退化为指针 | 看函数签名是不是int arr[] | 显式传长度参数 |
| 数据偶尔错乱、重启就好 | 越界写覆盖了相邻变量 | 用 ASan 编译跑一遍 | 修边界,数组遍历用< n |
free时崩溃 | 重复释放或释放了非堆地址 | 检查是否有两处 free 同一指针 | 释放后置空,明确所有权 |
| 打印地址只有 8 位十六进制 | 用了%x | 看格式串 | 改%p并转(void *) |
| 结构体指针加一位置不对 | 忘了对齐填充 | sizeof实际值和手算对比 | 用sizeof算步长 |
| 函数分配的内存外面拿不到 | 传了一级指针 | 看是否要修改指针本身 | 改char **或返回值 |
ASan 这个工具我想单独强调一下。编译加-fsanitize=address -g,跑一遍测试,堆越界、释放后使用、重复释放它基本都能当场抓住并打印出分配和释放的调用栈。我第一次用它的时候,十分钟内抓出了三个靠打印大法查了两天的 bug。这个投入产出比太高了,没有理由不用。
7.2 实操心得:几条用血换来的规矩
第一条,指针声明必须初始化,没有例外。我现在的习惯是int *p = NULL;写在同一行,哪怕下一行立刻赋值。中间那段真空期就是野指针的温床,多写四个字符能省掉半天调试。
第二条,所有malloc都必须配一个明确的释放点。最好的结构是“谁分配谁释放”,让所有权沿着调用链单向流动。如果所有权要转移,就在函数注释里写清楚“调用方负责释放”,别指望别人读懂你的脑回路。
第三条,对外接口不要返回裸指针表示失败。返回NULL表示失败时,调用方必须判空,而人总有忘的时候。用返回码加输出参数的形式,编译器至少能逼着调用方处理返回值(配合warn_unused_result)。
第四条,不要用int存指针,不要用%d打地址。64 位平台上这一条能省掉一整类诡异 bug。需要整数形式就用uintptr_t,需要打印就用%p。
第五条,指针运算只在自己的数组范围内做。包括“算出来不解引用”也不行。标准里这是未定义行为,开了优化之后编译器有充分理由做出任何事。宁可用下标,下标有边界问题至少还看得见。
第六条,二维数组传参时先想清楚类型。int arr[3][4]传给函数,参数要写int (*p)[4],不是int **。后者是“指向指针的指针”,和二维数组在内存布局上完全不同,硬转能编译过但运行时一定出事。这一条初学者几乎必踩,我当年也踩过。
第七条,看到void *就要想到“类型信息丢了”。malloc返回void *,赋给具体指针类型时 C 里可以隐式转换,C++ 里必须显式。跨语言混编的项目里这条会造成莫名其妙的编译错误,提前统一风格能省事。
如果这几条你现在只能记住一条,那就记“声明即初始化、释放即置空”。这一条能挡掉我在实际项目里见过的最多一类崩溃,而且执行成本几乎为零。系列后面的部分我会接着聊函数指针、多级指针在数据结构里的实际用法,以及指针和数组在性能上的真实差异——那块内容比基础部分更有意思,但也更依赖今天这些地基,地基不牢,后面全是背结论。