秋招倒计时越来越近,我这个菜鸟还在跟C语言死磕。前四天我自认为已经把变量、运算符、流程控制、数组这些基础知识啃下来了,甚至能用循环打印各种星星图案,心里还有点小得意。结果到了第五天,指针直接把我打回原形——看着满屏的
*和&,我一度怀疑自己是不是不适合学编程。这篇文章就把我第五天的真实学习记录和踩坑经历完整写出来,希望能帮到和我一样在自学C语言、准备秋招的朋友,让大家少走点弯路。
1. 指针到底是什么鬼:我被教科书绕晕之后的自创理解
第四天晚上我翻来覆去睡不着,满脑子都是“指针就是变量的地址”、“指针变量用来存放地址”这类句子。我知道定义,就是不明白为什么要这么设计。教科书上画了一个方块代表内存,旁边一个箭头指向另一个方块,看着挺简单,可我一旦自己写代码就不知道什么时候该用&,什么时候该用*,更别提什么“指向指针的指针”了。
1.1 用快递单号理解指针:地址不是值本身
后来我换了个思路。内存就像一栋宿舍楼,每个房间有编号,比如0x001、0x002。变量就是一个房间,房间里住着数据。而指针是什么?指针就是一张快递单号,上面写的不是房间里的东西,而是房间的编号。你想拿到房间里那个数据,就得拿着单号去对应的房间取,这个“取”的动作就是解引用。
举个例子:
int a = 5; // 房间a里住了个数字5 int *p; // 我准备了一张单号,它能记房间编号 p = &a; // &a就是房间a的门牌号,我把它记在单号上 printf("%d\n", *p); // *p就是拿着单号去房间a取出来的数字,也就是5这看起来很简单,但菜鸟最容易晕的地方在于:p是单号本身,*p是单号对应房间里的数据,&a是把房间a的门牌号抄下来。这三者在脑子里必须区分清楚,否则后面全是糊的。
1.2 类型到底管什么用:为什么指针要有类型
我一开始还死背“指针变量要跟所指向的变量类型一致”。后来写代码才明白,类型不是摆设,它决定了两件事:第一,解引用时取多少字节;第二,指针加1时跳多少步。
int a = 10; char c = 'A'; int *pa = &a; char *pc = &c; printf("%p\n", pa); // 假设打印出0x100 printf("%p\n", pa + 1); // 会打印0x104,因为int占4字节 printf("%p\n", pc + 1); // 会打印0x101,因为char占1字节我当时用VS Code的调试器把这三个地址打出来,突然就明白了:所谓指针加1,并不是地址数字加1,而是跳到“当前类型的下一个元素”。这跟数组的下标访问直接挂钩。如果指针没有类型,编译器根本不知道要跳多远,所以int*、char*完全是不同的指针类型。
小提示:用
%p打印指针地址时,地址前面会带0x,这是十六进制表示法,不用慌。调试器里看到0x7ffe5d...这种超大地址,是栈地址,不是数字大小,只是分配位置靠后。
2. 指针与数组:这俩是不是故意来折磨我的
数组和指针在C语言里就像一对双胞胎,长得不一样,脾气却一模一样。第五天下午我花了大把时间梳理这层关系,越理越觉得妙,但也踩了不少坑。
2.1 数组名到底是什么:一个每天折腾人的问题
教科书上说“数组名是首元素的地址”,我就记下来了。结果编译器又反复教育我:sizeof(arr)不是指针大小,而是整个数组的大小。这让我当场裂开。
int arr[4] = {1, 2, 3, 4}; printf("%zu\n", sizeof(arr)); // 16,整个数组的字节数 printf("%zu\n", sizeof(arr + 0)); // 8,指针的大小(64位机器上)为什么arr作为sizeof的操作数时是数组,放在其他表达式里就“退化”成指针?这是C语言设计的一个历史性妥协。反正我现在的记忆方式是:除了作为sizeof的操作数和取地址&arr之外,数组名在任何地方都会当作指向首元素的指针来用。
最经典的坑是在函数传参时。数组传进函数,形参那行无论写int a[]还是int *a,本质上一模一样,都是指针。所以在函数里写sizeof(a)永远得到8而不是数组大小。
2.2 a和&a到底差在哪:一道秋招常考题的钥匙
很多公司笔试喜欢考这道题:
int a[5] = {0}; printf("%p\n", a); printf("%p\n", &a); printf("%p\n", a + 1); printf("%p\n", &a + 1);打印出来的数值,a和&a打印的地址是一样的,但a+1和&a+1就不一样了。a+1直接跳到第二个元素,而&a+1跳到整个数组的下一个位置,相当于跳过了全部5个元素。
我后来用代码实测确认:如果a的地址是0x100,那么a+1是0x104(假设int是4字节),而&a+1是0x114。因为&a的类型是int (*)[5],指针步长是5个int,也就是20字节。理解了“指针的类型决定步长”,上面那个坑就再也不会踩了。
2.3 指针遍历二维数组:从下标到指针的思维转换
二维数组int b[3][4]是很多菜鸟的噩梦。我原来只会用b[i][j],第五天强迫自己只用指针形式写。
int b[3][4] = {{1,2,3,4},{5,6,7,8},{9,10,11,12}}; int *p = &b[0][0]; // 等价于 int *p = (int *)b; for (int i = 0; i < 3; i++) { for (int j = 0; j < 4; j++) { printf("%2d ", *(p + i * 4 + j)); } printf("\n"); }这里p + i * 4 + j是利用二维数组在内存中是连续存放的规律,手动把它当成一维数组处理。二维数组名b的类型是int (*)[4],它指向的是“一行”,而不是单个元素。所以b+1直接跳到第二行。这个区别我花了很久才完全接受:b看起来是地址,但解引用*b之后还是地址,因为她指向一行,这一行本身又由4个int组成。
3. 指针做函数参数:交换两个数,我debug到凌晨
第五天晚上我遇到了第一个真正的逻辑Bug——写一个交换两个整数的函数。前四天已经学过函数了,所以我信心满满地写了:
void swap(int x, int y) { int temp = x; x = y; y = temp; } int main() { int a = 3, b = 5; swap(a, b); printf("a=%d, b=%d\n", a, b); return 0; }结果打印出来还是a=3, b=5。那一刻我整个人都是懵的,明明函数里交换了呀,为什么外面没变?
3.1 传值 vs 传址:C语言函数参数的底层真相
原因其实很简单:C语言默认都是传值调用。调用swap(a, b)时,是把a和b的值复制了一份,分别给了形参x和y。我的函数确实把x和y两个“副本房间”里的数字交换了,但原来a、b房间根本没受任何影响,函数结束副本房间就销毁了。
要让函数能修改实参,必须拿到实参的门牌号。也就是传指针:
void swap(int *x, int *y) { int temp = *x; *x = *y; *y = temp; } int main() { int a = 3, b = 5; swap(&a, &b); printf("a=%d, b=%d\n", a, b); // 这次输出 a=5, b=3 return 0; }当我第一次看到输出真的变了,我才彻底明白课本上“需要修改实参就传地址”这句话的分量。&a传过去的是a的门牌号,函数里*x就是通过门牌号打开房间a,把房间里的数字直接换掉。这是指针最核心的价值之一——通过地址间接修改调用者的数据。
3.2 我凌晨才发现的低级错误
上面写对了之后,我又尝试写一个“用函数给指针变量赋值”的代码,想模仿swap封装一个返回指针的函数,结果犯了一个特别蠢的错:
void getMemory(char *p, int num) { p = (char *)malloc(num); // 我只改了形参p,外面的p没变 } int main() { char *str = NULL; getMemory(str, 100); // str仍然是NULL strcpy(str, "hello"); // 程序崩溃 return 0; }这个错误本质和前面的swap一模一样:p是形参,函数里让p指向新分配的内存,只是改变了副本,外面的str还是NULL。正确写法需要二级指针char **p,函数里*p = (char *)malloc(num);才能修改外面的str。所以我又花了二十分钟搞懂“二级指针”存在的意义。
注意:面试时如果你能把“为什么二级指针能修改外部指针”用传值调用的原理讲清楚,面试官会认为你是真的理解指针,而不是背下来答案。
3.3 函数返回指针的陷阱:永远不要返回局部变量的地址
这一天我还顺手踩了一个经典大坑。我写了这样一个函数:
int *getValue() { int value = 42; return &value; }然后在主函数里调用后打印,发现有时候是42,有时候是乱码。我盯着代码看了半天才想起来:局部变量value在函数返回后,它的内存就失效了,那块栈内存会被其他东西重新使用。虽然编译器可能会给你一个warning,但菜鸟如果不注意,经常会在面试手写代码时犯这种错。
正确的做法要么是返回static变量(不推荐频繁使用),要么用malloc申请堆内存并由调用者释放,要么直接返回传入的指针。在秋招手写代码时,最好避免这种设计。
4. 字符串与指针:char *s和char s[]到底能不能改
到了第五天后半段,我开始看字符串和指针的关系。本来以为字符串不就是char数组嘛,结果又撞墙了。
4.1 字面量存在哪:为什么我改字符串会崩溃
我写了一段代码,想改一个字符串的内容:
char *str = "hello"; str[0] = 'H'; printf("%s\n", str);运行直接Segmentation fault。我一开始以为是数组索引写错了,折腾了半天。后来查资料才知道:"hello"这种字符串字面量存放在只读区(通常是代码段/只读数据段),你用char *str指向它,只能读,不能改。而用char str[] = "hello";初始化时,编译器会生成一个字符数组,把字符串复制进去,这个数组存放在栈上,是可写的。
char str1[] = "hello"; str1[0] = 'H'; // 合法 char *str2 = "hello"; str2[0] = 'H'; // 非法,运行时崩溃现在我的记忆口诀是:方括号是给自己修了个院子,以前可以随便改;星号是找了根皮筋指向别人的院子,只能看不能动。
4.2 用指针遍历字符串的典型写法
秋招笔试很喜欢让你用指针实现strlen这类基础函数。我第五天就专门练了这个:
int my_strlen(const char *s) { int len = 0; while (*s != '\0') { len++; s++; } return len; }注意这里形参写的是const char *s,表示这个函数承诺不会通过s修改字符串内容。菜鸟阶段先不用把const想得太复杂,它一是让调用者放心,二是防止自己手滑写出*s='a'这种崩溃代码。
我还写了删除字符串中空格的小程序,结果越界了。原因是我想用 `while (s[i] != '#include <stdio.h> #include <string.h>
void delete_space(char *s, char *result) { int j = 0; for (int i = 0; s[i] != '\0'; i++) { if (s[i] != ' ') { result[j++] = s[i]; } } result[j] = '\0'; } ')判断到某个位置,但没给result` 数组留足够的空间。写出了 bug 后,我才开始学着用调试器观察内存里数组越界时的现象。这里也提醒自己:字符串操作第一个要考虑的就是空间够不够,第二个才是逻辑对不对。
5. 秋招考题实战:这几道指针题,我现在能闭眼写了
第五天晚上,我把白天学的东西综合起来,找了四道秋招常见的指针题闭卷手写。一是为了检验自己是不是真的会了,二是为面试手撕代码做准备。写完对照标准答案,发现还是有不少细节要修正。
5.1 手写一:用指针统计字符串长度
这个题目看起来很基础,但要在白纸上不写循环变量、只用指针操作,对新手是个考验。我最终的答案:
int my_strlen(const char *str) { const char *p = str; while (*p) { p++; } return p - str; }这里返回p - str是关键。两个同类型的指针相减,结果是它们之间相隔的元素个数。因为p最后指向了字符串结尾的'\0',而str指向首字符,它们的差就是字符的个数。如果写成return p - str + 1就错了,因为那个位置是结束符,不算长度。
5.2 手写二:指针交换两个整数并打印过程
这道题我不但写了函数,还故意在主函数里把&a、&b和函数内x、y的地址全部打印出来,验证传址调用的本质。完整代码如下:
void swap(int *x, int *y) { int temp = *x; *x = *y; *y = temp; }如果面试官要求用“交换int*指针本身”来考察二级指针,我会写出:
void swap_int_ptr(int **ppx, int **ppy) { int *temp = *ppx; *ppx = *ppy; *ppy = temp; }这俩放一起对比,才能理解“一层指针修改值,两层指针修改指针”。
5.3 手写三:数组指针和指针数组的选择题
到了这里,我已经能一眼看出下面这行声明是什么了:
int *p[3]; // 指针数组:p是数组,里面3个元素都是int* int (*p)[3]; // 数组指针:p是指针,指向一个含3个int的数组记忆方法很简单:[的优先级高于*,如果没有括号,p先跟[3]结合,所以是数组;有了括号,p先跟*结合,所以是指针。这种题在笔试里几乎必出,以前我会蒙,现在靠着运算符优先级直接推出答案。
5.4 手写四:用函数指针做计算器
函数指针是我第五天额外加练的内容,原因是很多公司的笔试题会以函数指针作为延伸考点。我写了一个简单的加减乘除函数,还是那种最原始的写法:
#include <stdio.h> int add(int a, int b) { return a + b; } int sub(int a, int b) { return a - b; } typedef int (*CalcFunc)(int, int); int calc(int x, int y, CalcFunc f) { return f(x, y); } int main() { printf("%d\n", calc(10, 5, add)); printf("%d\n", calc(10, 5, sub)); return 0; }刚开始我不明白typedef int (*CalcFunc)(int, int);这一行是什么意思,后来才知道它是给“函数指针类型”起了一个别名,后面用起来方便得多。函数指针的价值在于可以把“操作”作为参数传递,实现类似策略模式的效果。对菜鸟来说,先要知道它长什么样、怎么调用,就算合格了。
6. Day5总结:菜鸟学指针,最重要的三个认知
从凌晨的崩溃到写完这些题,我第五天最大的收获不是背了多少语法,而是建立起了三个稳定的认知。如果让我跟明天早上的自己交代,我会说这三句话。
6.1 指针是地址,类型决定步长
任何指针,存进去的本质上都是一个地址。看到int *p,不要背“p是int型指针”,而是读“p是一个指针,指向int类型”。指针的加减运算,第一步先看类型:int *加1,跳4字节;char *加1,跳1字节;void *不能直接加,因为它没有类型步长。我在纸上画过一张表,列出了int*、char*、int**、int(*)[3]各自的步长,画完就通了一半。
6.2 数组名不等于数组本身
数组名在绝大多数场景下会退化为“指向首元素的指针”,但它还保留数组类型的一面,尤其在sizeof和&运算符下。遇到数组相关的指针题,先问自己一句:这里用的是数组名本身,还是退化后的指针?想清楚就不会被a和&a那类题坑到。
6.3 调试器是最好的老师
我第五天效率最高的一段时间,是自己打开VS Code的调试模式,一句一句单步执行,盯着变量窗口里p、*p、&a的变化。看着p从0x0变成某个地址,*p的值同步变化,那种直观带来的理解是看十遍书都换不来的。菜鸟遇到指针就卡住,多半是因为一直在脑子里凭空想象内存布局,而不是亲眼观察它。
最后再分享一个小技巧:我准备了几个固定大小的方格纸,每次遇到指针难题,就画出内存格子,把变量的地址、值、指针的指向全部标出来。这个方法虽然笨,但对菜鸟来说真的比任何高端工具都有效。第五天就这么过来了,感觉离秋招又近了一点点。