这一讲是《C语言对话》系列的第五篇,标题我琢磨了好一阵子,最后是从一句大家耳熟的西方谚语里找到的灵感:A rose by any other name would smell as sweet——玫瑰就算换个名字,开出来照样香。乍一听和C语言没什么关系,但它恰好点破了指针这个概念最核心的特征:同一块数据,完全可以通过不同的名字去访问。变量名是一种叫法,指针变量是另一种叫法,函数形参又是第三种叫法,它们背后指向的其实是同一片内存。本讲适合刚学完变量、运算符、分支循环和函数,正准备跨进指针门槛的读者。我会沿用对话的方式,把一个知识点掰开揉碎,附上可以直接运行的代码,顺便把初学者最容易掉的坑一个个指给你看。
1. 玫瑰不叫玫瑰:变量、地址和"另一个名字"是什么意思
正式开始之前,我先问A同学一个问题:你在纸上写了一个int rose = 10;,然后把它交给另一个人,对方想知道 rose 的数据到底存放在哪里,你能用什么方式告诉他?
最直接的办法是把 rose 的地址抄给他。在C语言里,这恰好就是&运算符的用途。&rose的意思是"取出变量 rose 所在内存位置的地址"。你可以把内存想象成一条街道,每个字节都有一个门牌号,编译器负责给变量安排房间,而我们通过&运算符,可以把房间号掏出来给人看。
1.1 先用一段最小代码看看"门牌号"长什么样
#include <stdio.h> int main(void) { int rose = 10; printf("rose 的值 = %d\n", rose); printf("rose 的地址 = %p\n", (void *)&rose); return 0; }这里有两个细节值得较真。第一,%p是专门打印地址的占位符,而&rose的类型是"指向 int 的指针",也就是int *。有些编译器在标记严格的参数下会提醒你转成void *再传,因为%p期待的是void *。虽然绝大多数平台上int *和void *的打印结果一样,但养成强转习惯,能让代码在更多编译环境下不报警告。
第二,地址其实是一串十六进制数。你运行两次程序,会发现两次数值很可能不一样,这是现代操作系统普遍的内存地址随机化机制在起作用。你完全不需要关心具体门牌号是多少,只需要建立两个认知:每个变量都占据一块内存,&能把这块内存的起点地址取出来。
1.2 指针的定义:专门用来存地址的变量
A同学听到这里插了一句:"既然&rose能取地址,那这个地址总得有个地方放吧?"
对,这就引出了指针变量。声明一个指针,只需要在变量名前加一个星号:
int *p;这条语句读作:p 是一个"指向 int 的指针变量",它可以保存一个 int 类型变量的地址。这里有一个很常见的误区:int *是一个整体类型,星号属于类型,不属于变量名。写int* p和int *p都合法,但如果你写int* p, q;,那么只有 p 是指针,q 只是普通 int。想让两个都是指针,必须写int *p, *q;。这个细节每年都要绊倒一批人。
拿到地址之后,给这块内存起"第二个名字"就顺理成章了。下面的例子把 rose 的地址装进 p,然后用*p去读写同一块内存:
int rose = 10; int *p = &rose; printf("%d\n", *p); // 读出 10 *p = 99; printf("%d\n", rose); // 现在变成 99*p的意思很直白:把 p 里存的地址"解开来",访问它指向的那个 int。你可以把 p 想象成一把钥匙,钥匙本身是一串地址编号,而*p就是打开门之后看到的东西。给*p赋值,等于隔着另一个名字去修改原来的变量——这正是标题里"通过任何其他名字"想要表达的动作。
2. 从 & 到 *:给一块内存建立"第二名字"的三段式操作
很多教材一上来就堆概念,把指针讲得像玄学。其实初始化一个指针并让它工作,只需要三步:声明、指向、解引用。每一步都有明确的语法规则,把这三步拼起来,就是完整的"第二个名字"构造流程。
2.1 三段式:声明只做一件事,赋值必须绑定,使用才能访问
先说声明。int *p;做的事情,是向编译器申请一个"用来存 int 地址的变量"。它像一个还没填门牌号的信封。此时 p 里面的值是随机的,我们管这种状态叫野指针,绝对不能立刻解引用。
第二步是绑定。p = &rose;就是把 rose 的门牌号装进信封。你也可以在声明的时候直接绑定:int *p = &rose;,这是最安全的写法,我强烈推荐新手这么做。声明与绑定一步到位,就不会出现"忘了赋值就去用"的惨剧。
第三步才是访问。*p出现的位置,几乎可以等价于 rose 本身:读它,得到 rose 的值;写它,修改 rose 的值。用一个完整的程序把三步串起来:
#include <stdio.h> int main(void) { int rose = 10; int *p = &rose; // 第一步 + 第二步:声明并绑定 printf("原始值 = %d\n", rose); printf("通过 *p 读取 = %d\n", *p); // 第三步:读 *p = 20; // 第三步:写,等价于 rose = 20 printf("修改 *p 后 = %d\n", rose); return 0; }运行结果:
原始值 = 10 通过 *p 读取 = 10 修改 *p 后 = 20A同学看到结果后很兴奋:"真的改了!好像我只是换了个名字去叫它,里面的内容一点没变。"
这正是指针最大的价值:无论你管这块内存叫 rose 还是叫*p,它从头到尾就是同一个 int。所谓"通过任何其他名字",描述的就是这种同一性。
2.2 scanf 里的 & 为什么不能省
讲到这里,顺便把一个新手必踩的坑解释掉:很多人在scanf的变量前忘记写&。比如scanf("%d", a);,编译器会给出格式不匹配的警告,但往往仍能编译通过,运行时会试图把输入写到"以 a 的数值作为地址"的位置——那个地址几乎肯定是非法的,轻则崩溃,重则悄悄改坏其他内存。正确写法是scanf("%d", &a);。
scanf之所以必须传地址,是因为它需要在函数内部修改 main 里定义的变量,而函数默认只能拿到值的副本。要"隔着另一个名字"改真正的内容,就得把地址传进去,让scanf顺着地址找过去写数据。这个"被调用者通过地址修改调用者变量"的机制,在下一节讲函数交换变量时,会看得更清楚。
3. 函数参数为什么要绕一个弯:交换两个变量的千古名题
学指针的时候,几乎每个教程都会用交换变量的例子,因为它把"值传递的局限性"和"指针参数的解决方案"演得明明白白。先来看大多数人第一次写的版本。
3.1 值传递:你递给函数的是复印件,不是原件
#include <stdio.h> void swap_value(int x, int y) { int temp = x; x = y; y = temp; } int main(void) { int a = 3, b = 7; swap_value(a, b); printf("a = %d, b = %d\n", a, b); return 0; }输出是a = 3, b = 7。因为C语言的函数参数一律是值传递。调用swap_value(a, b);时,实际发生的是把 a 和 b 的值各抄一份,放进形参 x 和 y,函数体内交换的是 x 和 y 这两张"复印件",main 里的 a 和 b 从头到尾没被动过。你可以这样理解:别人借走了你名字的复印件,复印件上再乱涂乱画,原件不会受影响。
3.2 指针版本:把地址传进去,改的才是原件
要让函数真正修改调用者的变量,就得把变量的地址传进去:
void swap_pointer(int *x, int *y) { int temp = *x; *x = *y; *y = temp; }调用方式也变了,原来是swap_value(a, b),现在得写swap_pointer(&a, &b)。在这里,x 和 y 里放的是 a 和 b 的地址,*x和*y直接操作的就是 main 里那块内存。打个比方:你把自家的门牌号告诉了别人,对方顺藤摸瓜进到屋里,帮你把家具调换了位置。所以执行完swap_pointer后,a 和 b 的值真的对调了。
A同学问:"为什么非要用指针?能不能把变量声明成全局变量,在函数里直接改全局?"
能,但这是很差的做法。全局变量会让函数之间的依赖变得隐晦,程序一复杂就难以维护。指针参数的价值,在于明确告诉读者:这个函数可能会修改传入的变量,调用时大家心里都有数。这也是C语言编程风格里非常看重的一点——能局部就局部,传参尽量显式。
3.3 哪些场景真的需要指针参数
根据我的使用经验,可以总结成四条,遇到下面这些情况,优先考虑指针参数。
第一个场景是修改调用侧的变量。除了交换,最常见的还有计数器、累加器这类场景,函数要把结果通过指针"带出去"。
第二个场景是函数需要返回多个结果。一个函数只能return一个值,想同时返回商和余数、平面坐标的 x 和 y,可以让函数返回其中一个,另一个通过指针参数输出。
第三个场景是传大型对象时避免拷贝。一个结构体可能几百个字节,每次传值都要整体复制一份,传指针只复制一个地址,成本低得多。后面学到结构体、链表的时候,你会发现几乎所有接口都是用指针传大型对象的。
第四个场景是数组参数。数组作为形参时,天然就是指针,这个下一节专门展开。
4. 数组名带着地址"隐身":数组和指针的相爱相杀
数组是C语言里最早让人对指针产生困惑的地方,因为数组名总是指针,却不完全是指针。搞懂它们的关系,指针才算真正入门。
4.1 arr 与 &arr[0]:写法不同,地址相同
先看一段代码:
#include <stdio.h> int main(void) { int arr[5] = {10, 20, 30, 40, 50}; printf("arr = %p\n", (void *)arr); printf("&arr[0] = %p\n", (void *)&arr[0]); return 0; }输出两行地址完全一样。原因很简单:数组是一段连续的内存,首元素的地址就是整块内存的起点。而数组名在绝大多数表达式中会"退化"成指向首元素的指针,于是int *p = arr;和int *p = &arr[0];完全等价。
这里有一个非常关键的区别:&arr和arr不是一回事。arr 的类型是int *,指向 int;而&arr的类型是int (*)[5],指向"包含5个int的整个数组"。虽然它们的数值相同,但类型不同,做指针运算时行为完全不同:p + 1会跳到下一个 int 元素,而int (*)[5]类型指针加 1 会直接跳过整个数组。新手看到 p 和 &p 混用就懵,根源就是类型没区分开。
4.2 指针运算:p + 1 不是简单地地址加一
数组是连续内存,我们就能靠"第几个元素"来导航。假设指针 p 指向 arr[0],那么 p + 1 指向 arr[1],p + 2 指向 arr[2]。这里最反直觉的一点是:p + 1 的地址值并不总是加 1。如果 arr 是 int 数组,int 占 4 字节,p + 1 实际让地址值加 4;如果 arr 是 double 数组,double 占 8 字节,p + 1 让地址值加 8。
C语言规定:指针加 n,等于跳过 n 个"它所指向类型"的长度。这是给你方便的设计,不是来坑你的。你用int *遍历 int 数组,加一个正好落到下一个元素起点,完全不用关心元素占多少字节。标准库里的遍历代码,全都依赖这个语义。
看一段指针遍历数组的对比:
int arr[5] = {10, 20, 30, 40, 50}; int *p = arr; // 下标写法 for (int i = 0; i < 5; i++) printf("%d ", arr[i]); // 指针写法 for (int *q = arr; q < arr + 5; q++) printf("%d ", *q);q < arr + 5这个比较也值得琢磨:两个同类型的指针可以比较大小,本质是比较它们指向的地址先后。q 从 arr 出发,每轮加 1,直到越过 arr[4] 的位置,遍历结束。很多老手偏爱指针写法,因为它让"我正在处理元素的存储位置"这个意图更明显。
4.3 数组参数退化:函数里再也算不出数组大小
这是每个学指针的人都必须接受的事实:数组作为参数时,传的不是整个数组,而是首元素地址。
#include <stdio.h> void print_size(int arr[]) { printf("函数内 sizeof(arr) = %zu\n", sizeof(arr)); } int main(void) { int arr[10] = {0}; printf("main 中 sizeof(arr) = %zu\n", sizeof(arr)); print_size(arr); return 0; }在 main 里,sizeof(arr)算出的是 10 个 int 的总字节数,比如 40;在函数里,int arr[]这种形式会被编译器自动解释成int *arr,所以sizeof(arr)算出的只是一个指针的大小,比如 8。两个值天差地别,这就是"数组参数退化"。
A同学看到这个结果特别惊讶:"那函数里怎么知道数组有多长?"
答案很直接:不知道,所以必须另外传一个长度参数。大量C语言库函数的接口都遵循这个约定,比如字符串处理函数的原型里,基本都带着长度上限参数。这也是C语言和其他带"数组自带长度"的语言最大的不同。将来你学链表时,会发现链表的长度还得自己用一个计数器维护,本质上都是同一个原因。
5. typedef 与 void*:另外两种"换名字"的玩法
指针已经让同一块内存有了第二个名字,而在C语言里,"换名字"这件事还有另外两种姿势:typedef给类型换名字,void *给地址换容器。用好了,代码的可读性和通用性能上一大截。
5.1 typedef:给类型起个小名,代码瞬间好读
typedef的作用是给已有类型起一个新名字,本质是编译期的别名替换。最经典的例子是处理无符号长整型:
typedef unsigned long long u64; u64 total = 0;写u64比每次打unsigned long long短得多,而且暗示了数据范围,一眼就知道这些变量是 64 位无符号整数。很多项目里你会看到u8、u16、s32之类的别名,都是同一个套路。
和结构体配合时效果更明显。如果不用typedef,每次定义结构体变量都要写struct关键字:
struct Book book;有了typedef就能直接写:
typedef struct { char title[100]; int pages; } Book; Book b;注意,typedef并不会创造一种"新"类型,它只是给现有类型换了个称呼。所以用 Book 定义出来的变量,和用原始结构体定义出来的变量,在类型层面完全一致——又回到标题那句话:不叫玫瑰,依然是玫瑰。
5.2 void*:装着任何地址的"万能容器"
void *是一个特殊的指针类型:它可以接收任意类型的地址。这也是一种"换名字"——不管原先是什么类型的指针,都能装进void *这个口袋里。
#include <stdio.h> int main(void) { int a = 42; double b = 3.14; void *p = &a; printf("%d\n", *(int *)p); // 取出时必须告诉编译器:这是个 int * p = &b; printf("%.2f\n", *(double *)p); // 现在又变成 double * return 0; }这里的关键是:void *能存任何地址,但编译器不知道它指向什么类型的对象,所以解引用前必须用(int *)或(double *)把它转回具体类型,否则没法判断该读几个字节、按什么格式解释。后面学动态内存分配时,malloc的返回类型就是void *,所以常见写法是int *p = (int *)malloc(...),本质就是在"叫回"它真正的类型名字。
void *给C语言带来泛化能力,同时把类型安全的责任交给了程序员。传送地址的一方和接收地址的一方必须约定好真实类型,一旦转错,运行时不会有任何提示,数据解释就会完全错乱。这正是"换名字"最需要谨慎的地方。
5.3 指针使用中最容易摔的四个坑
结合我在实际项目中看到的报错和线上问题,把最常见的四类坑整理成一张表。建议收藏,写代码之前先扫一眼:
| 问题 | 典型写法 | 后果 | 正确做法 |
|---|---|---|---|
| 野指针 | int *p;直接*p = 10; | 段错误,或悄悄改坏未知内存 | 声明后立即指向有效地址,或先赋 NULL |
| 空指针解引用 | p = NULL;然后*p = ... | 段错误 | 使用前先if (p != NULL)判断 |
| 类型不匹配 | double *p = &intVar; | 编译警告,运行时数据错乱 | 指针类型必须与指向对象一致 |
| 数组名与取地址混淆 | int *p = &arr; | 编译警告,p + 1 会跳过整个数组 | 写int *p = arr;或&arr[0] |
野指针和空指针是两大主力。野指针最危险的地方在于它指向哪里完全不确定,解引用可能不会立刻报错,却在未来的某一次运行中突然崩溃,排错成本极高。空指针至少是确定的,借用一个空地址几乎必然触发段错误,而段错误在调试器里排查起来反而清楚。我的习惯是:暂时没有合法目标的指针,一律先赋NULL,宁可让它"确定地崩",也不要让它"随机地坏"。
6. 动手串一串:把"第二名字"用到极致的练手程序
理论讲得再多,不如亲手写一个。这一节我们写一个完整的小程序,把地址、指针、数组参数和函数指针参数全部串起来。功能很简单:读一个整数数组,反转顺序,再打印出来。
6.1 完整代码
#include <stdio.h> void print_array(int arr[], int len) { for (int i = 0; i < len; i++) printf("%d ", arr[i]); printf("\n"); } void reverse_array(int arr[], int len) { int left = 0; // 左下标 int right = len - 1; // 右下标 while (left < right) { int temp = arr[left]; arr[left] = arr[right]; arr[right] = temp; left++; right--; } } int main(void) { int numbers[6] = {1, 2, 3, 4, 5, 6}; int *p = numbers; printf("原始数组: "); print_array(numbers, 6); reverse_array(numbers, 6); printf("反转数组: "); for (int i = 0; i < 6; i++) printf("%d ", *(p + i)); printf("\n"); return 0; }6.2 逐段拆解:每一行在做什么
main 里的int *p = numbers;让 p 成为 numbers 的"另一个名字"。随后打印数组用的是*(p + i)而不是numbers[i],目的就是让你习惯从地址的角度看数组:p + i 是第 i 个元素的地址,*(p + i)是它的值。两种写法完全等价,只是视角不同。
print_array的形参虽然写成int arr[],但正如前面所说,它实际是int *arr。函数里既可以写arr[i],也可以写*(arr + i)。这个函数自己不知道数组有多长,所以 len 必须由调用者传进来。
reverse_array是整个程序的精华:它接收数组地址后,通过arr[left]这样的形式修改了 main 里的 numbers。如果当初误以为参数传入的是"整个数组的副本",反转就不会生效——现在你应该能看出来,arr[]和int *arr在参数列表里是同一个东西,传的都是地址,改的自然是原件。
A同学盯着代码看了一会儿,说:"原来我一直以为arr[]是复制一份,今天才知道传的是地址。怪不得以前自己写的排序函数总不生效。"这句话本身就是这个知识点最好的注脚。无数初学者写了看似正确的排序、反转函数,主程序里数组纹丝不动,根因就在这里。
6.3 马上能试的三种小改版
为了加深记忆,你可以在当前程序上做三个小改动,每改一处就重新编译运行一次。
第一,把reverse_array的数组下标交换改成纯指针写法。用int *left = arr; int *right = arr + len - 1;,然后交换*left和*right,每次循环left++、right--。注意别把循环条件写成left <= right,两个指针相等后再交换一次,会把中间元素又换回去。
第二,把print_array的形参故意改成int *arr,你会发现函数体内完全不用动,再次印证了数组参数和指针参数在编译层面是同一件事。
第三,在reverse_array里加一句printf("%zu\n", sizeof(arr));,亲眼确认函数内的sizeof(arr)是 8 而不是 24,亲手摸到"数组参数退化"的边界。
这三步做完,书上的理论就变成手上的手感了。
6.4 说点教学心得:这一课最容易卡住的地方
这些年带过不少初学者,我观察到,指针这课真正让人卡住的地方,往往不是某一个语法,而是思维方式的转变。此前学变量,大家都习惯了"变量名等于数据"这个等式;到了指针,必须强迫自己接受"变量名只是一个找到数据的线索,数据本身住在内存里,地址才是定位它的根本"。一旦接受内存模型,指针其实没什么神秘。
还有一个非常实际的习惯建议:画内存图。学到指针、数组、结构体这些内容时,纸笔比屏幕高效得多。画一个小方框代表变量,旁边标注它的地址,再用箭头画出指针指向的对象,很多绕脑子的问题,看图十秒就想通了。
A同学临走前问了一句:"下一讲是不是要说到字符串了?到时候char *又该怎么理解?"
我笑着回答:"等跑熟今天这几段代码,字符串里的char *就只是'指向字符的指针',顺着今天的思路往下走就行。名字可以换,内存还是那块内存。"