1. 为什么是练习72:指针应用的承上启下
刚开始学C语言的时候,很多人对指针都是半懂不懂的状态。我也一样,数组和结构体分开用都还行,一旦把它们串起来操作数据,脑子里就像打了结。菜鸟教程C经典100例做到第72题时,正好卡在这个关键节点上——题目开始要求你用指针配合结构体、数组去解决实际问题,而不是单纯为了用指针而用指针。
说实话,我当年第一次做到这道题时,被题目绕得有点晕。因为第72题不像前面那些题目直接告诉你“写一个函数交换两个数”或者“打印九九乘法表”,它更像一个综合应用:给你一组数据,让你用结构体存起来,再用指针去遍历、查找、修改。这类题目放在前面几十道里会显得太难,放在后面一百多道里又显得太简单,第72题的位置刚刚好——你已经见过了指针的基本用法,也写过结构体定义,现在该把这两样东西结合起来干点正经事了。
这个“承上启下”的位置,恰恰是整个C语言学习曲线里最容易放弃的地方。很多初学者做到这儿,一看题目涉及指针加结构体就头大,直接跳过去刷后面的题目。结果等到学链表、学文件操作的时候,发现根本接不上,因为链表的核心就是“结构体指针互相连接”,文件操作的数据缓冲也离不开指针。所以第72题虽然只是简单几十行的练习,但它把后面所有进阶知识的基石给夯实了。
我自己带过几个初学者,发现一个很普遍的现象:凡是踏踏实实把第72题这类综合练习做明白的人,后面学链表和动态内存分配时上手速度明显快很多。凡是跳题的人,往往会回来补课。这道题的价值不在题本身,而在于它逼着你去思考“指针到底存的什么地址、结构体怎么通过指针去访问成员”这两个最基本的问题。
2. 题目拆解:从题干到数据流动
2.1 经典示例的原题描述
菜鸟教程C经典100例的第72题,不同版本给出的题目文字略有差异,但核心内容基本一致:定义若干学生的信息(通常包含姓名、学号、成绩),存到结构体数组中,再用指针访问这些结构体,完成输出或查找功能。
这类题目在最初的教程版本里,代码往往很短,十几二十行就完了。下面这个是我根据常见版本整理的思路示例,为了看清楚数据是怎么流动的,我加了些注释:
#include <stdio.h> #include <string.h> struct Student { char name[20]; int score; }; void printStudent(struct Student *p, int n) { int i; for (i = 0; i < n; i++) { printf("%s %d\n", (p + i)->name, (p + i)->score); } } int main() { struct Student stu[3]; struct Student *p = stu; int i; for (i = 0; i < 3; i++) { printf("请输入第%d个学生的姓名和成绩:", i + 1); scanf("%s %d", (p + i)->name, &(p + i)->score); } printf("学生信息如下:\n"); printStudent(p, 3); return 0; }2.2 这段代码背后真正的考点
这段代码看似简单,如果你只是把它抄一遍运行通过就觉得自己会了,那就亏了。这道题至少考了你四个干活时必用的知识点:
第一,结构体数组的连续内存布局。你定义stu[3]时,三个结构体在内存里是紧挨着排的,p指向数组首地址后,p + 1会跳到下一个结构体的开头,而不是跳到某个成员中间。这个“跳多少”由sizeof(struct Student)决定,编译器已经帮你算好了,但你要理解p + i到底做了什么。
第二,->和.的区别。p->name等价于(*p).name,这个东西我当时老是搞混,后来我是这么记的:p是地址,地址不能直接点成员,得先解引用拿到结构体本身,再用点号;而->就是这两个操作的合成简写。写代码时,看到指针变量访问成员就用->,看到普通结构体变量访问成员就用.。
第三,数组名和指针的关系。struct Student *p = stu,这里stu作为数组名会退化成指向首元素的指针,这行赋值完全合法。但要注意:stu本身不是指针变量,你不能写stu++,而p可以随意移动。这道题里我们让p从头走到尾,正好体验一下“指针遍历数组”的写法。
第四,scanf对字符串的处理。scanf("%s", (p + i)->name)没写&,因为name本身是个字符数组名,已经代表地址了;而(p + i)->score是整数变量,必须写&。这个区别初学者特别容易漏,漏了轻则警告,重则运行直接闪退。
我建议你把上面代码里的printStudent函数改成用下标访问试一下,也就是stu[i].name的方式,跟指针方式对比着看。两段代码干的事完全一样,但写法思路不同。理解这两者的等价关系,是你后面读懂别人代码的基础——很多老代码爱用指针写法,阅读时早晚要遇到。
3. 把示例题升级为可用的信息管理小工具
只看原题那几行代码,说实话挺枯燥的。我当时练完一遍后觉得没意思,就试着把它扩展成一个“学生信息录入与查询”的小工具,既练了文件操作,又练了函数封装。这个扩展后的版本,更适合拿去当课程设计或者日常练习的参考,核心代码不长,但结构比原题清晰不少。
3.1 扩展思路:从三个学生到动态数量
原题写死循环三次,实际使用中当然不会这样。我们可以通过一个变量控制学生数量,然后用最笨的办法(数组)先把数据存下来。这样做的好处是不涉及malloc和free,初学阶段不至于被动态内存分散注意力。
#include <stdio.h> #include <string.h> #include <stdlib.h> #define MAX_STUDENTS 100 #define NAME_LEN 20 struct Student { char name[NAME_LEN]; int id; float score; }; void inputStudents(struct Student *p, int n) { int i; for (i = 0; i < n; i++) { printf("第%d个学生 -> 姓名 学号 成绩:", i + 1); scanf("%s %d %f", (p + i)->name, &(p + i)->id, &(p + i)->score); } } void printStudents(struct Student *p, int n) { int i; printf("\n%-10s %-10s %-10s\n", "姓名", "学号", "成绩"); for (i = 0; i < n; i++) { printf("%-10s %-10d %-10.2f\n", (p + i)->name, (p + i)->id, (p + i)->score); } } void searchByScore(struct Student *p, int n, float threshold) { int i, found = 0; printf("\n成绩大于%.2f的学生:\n", threshold); for (i = 0; i < n; i++) { if ((p + i)->score > threshold) { printf("%s 学号%d 成绩%.2f\n", (p + i)->name, (p + i)->id, (p + i)->score); found = 1; } } if (!found) { printf("没有找到符合条件的学生。\n"); } } int main() { struct Student students[MAX_STUDENTS]; struct Student *p = students; int total; float threshold; printf("请输入学生数量(不超过%d):", MAX_STUDENTS); scanf("%d", &total); if (total <= 0 || total > MAX_STUDENTS) { printf("输入不合法。\n"); return 1; } inputStudents(p, total); printStudents(p, total); printf("\n请输入成绩阈值:"); scanf("%f", &threshold); searchByScore(p, total, threshold); return 0; }3.2 为什么这个版本更适合学习时练习
这个版本虽然代码长了点,但每个函数干的事非常清楚,正好对应“输入、输出、查询”三个流程。
先说输入函数inputStudents。我在里面用(p + i)->name这种写法,而不是p[i].name,就是为了刻意强化指针访问的熟练度。你去读很多开源代码会发现,老手写结构体数组遍历时两种混着用,但读代码时两种都得认识,所以练习阶段两边都碰一碰没坏处。
再说printStudents里的%-10s、%-10d格式控制。-号代表左对齐,10代表至少要占10个字符宽度。不加这个,输出会很挤,加上之后表格效果立刻出来了。写这类小工具时,格式控制用得好,输出结果的专业程度直接上一个台阶。
最后是searchByScore。这里用到了“遍历加条件判断”的模式,其实就是后面学链表时最常见的操作套路。我现在看这函数感觉很简单,但第一次写时还犯过比较float相等的错——score > threshold没问题,如果改成score == threshold就可能有精度问题。初学者一定要养成习惯:浮点数不要直接比较相等,除非你完全清楚自己在做什么。
4. 文件保存:让数据真正落地
控制台程序有个尴尬之处:数据输入完,程序一关什么都没了。练习阶段没问题,但如果你想把这个学生信息管理小工具真正用起来,就得把数据写到文件里。这一步也是从“练题”到“做项目”的关键跨越。
4.1 写入文件的基本操作
先看一个最朴素的写法:把学生数据输出到文本文件。
void saveToFile(struct Student *p, int n, const char *filename) { FILE *fp = fopen(filename, "w"); int i; if (fp == NULL) { printf("无法打开文件 %s 进行写入。\n", filename); return; } for (i = 0; i < n; i++) { fprintf(fp, "%s %d %.2f\n", (p + i)->name, (p + i)->id, (p + i)->score); } fclose(fp); printf("数据已保存到 %s。\n", filename); }这里有三个细节新手很容易踩坑:
第一个,fopen返回后必须检查是否为NULL。我早期写文件操作时经常偷懒不检查,万一路径不存在或者没权限,程序就会在后续的fprintf时崩溃。加一行判断成本极低,收益却很直接。
第二个,fclose一定要写。可能有人觉得程序结束系统会自动回收资源,确实会,但如果你循环里反复打开文件却忘了关闭,文件描述符迟早耗尽。养成良好的成对习惯:“打开必有关闭”。
第三个,模式串"w"是覆盖写。第二次运行程序保存数据时,原来的文件内容会被清空重写。如果想追加,要用"a"模式。对于保存当前输入数据这个需求,"w"是对的,但你心里得清楚两模式的差别。
4.2 从文件读回数据:反向操作同样重要
只存不读,等于数据写进黑洞。配套的读取函数也顺手写了:
int loadFromFile(struct Student *p, int maxCount, const char *filename) { FILE *fp = fopen(filename, "r"); int count = 0; if (fp == NULL) { printf("文件 %s 不存在或无法打开。\n", filename); return 0; } while (count < maxCount && fscanf(fp, "%s %d %f", (p + count)->name, &(p + count)->id, &(p + count)->score) == 3) { count++; } fclose(fp); printf("从 %s 读入 %d 条记录。\n", filename, count); return count; }写读取函数时,我用fscanf的返回值判断是否成功读到了三个字段。fscanf返回成功匹配并赋值的参数个数,在这里就是3。文件读到末尾时,它会返回EOF,循环自然结束。这种写法比while(!feof(fp))可靠得多,feof那个套路容易多读一次或者死循环,别用。
另外一个细节是maxCount参数。读取时必须避免把数组写爆,因为p指向的缓冲区大小是有限的。我在前面定过MAX_STUDENTS,传入这个值再加一个count < maxCount条件,双保险。
到这一步,这个练习已经从一个纯粹的语法练习题,变成了一个包含录入、保存、读取、查询的完整小项目。虽然每一环单独拆开都不难,但把它们串起来,你其实已经碰了一遍C语言日常开发的核心循环:定义结构体、用指针操作数据、通过文件做持久化。
5. 常见报错和排查思路
练习72这波操作里,有几个报错或者“运行结果不对”的情况,我当年都遇到过,现在把它们列出来,你对照着看能省不少时间。
5.1 运行闪退:scanf里的&符号问题
症状是程序一运行,输入到一半就直接崩了,或者提示什么内存访问违例。
排查顺序:
- 看
scanf格式串,数一下要接收几个值。 - 检查每个普通变量前是否写了
&。 - 检查字符数组名前是否多余写了
&。
常见错误就是scanf("%s %d", &(p + i)->name, &(p + i)->score)。name已经是地址,&取出来的“数组的地址”类型和%s不匹配,行为未定义。换成(p + i)->name就行。
5.2 输出乱码或空白:遍历范围不对
症状是打印出来一堆空白行或者乱码。
排查顺序:
- 确认传入
printStudents的n是否正确。比如实际录入只有2个学生,你传了个3进去。 - 确认指针
p确实指向结构体数组的首地址。有时候你写p = students之前,p被别的地方改动了。 - 检查循环边界,
i < n写成了i <= n会导致越界访问。
这类问题通常不是语法错,是逻辑错,需要你顺着数据流走一遍:数据从哪来的、存在哪、访问到哪。
5.3 文件写入成功但内容为空:fopen路径问题
症状是程序显示保存成功,打开文件却是空的。
排查顺序:
- 用绝对路径或相对路径,确认文件到底写到哪了。
- 看当前工作目录和程序所在目录是否一致。在IDE里运行时,工作目录可能不是你想象的那个目录。
- 检查是否有权限创建文件,比如你试图往C盘根目录写,可能被系统拦了。
这个坑很隐蔽,因为代码全对,问题出在环境。我自己的习惯是:如果写文件,文件名直接用"students.txt"这种相对路径,然后在代码目录下找文件。最稳妥的方法是用程序打印一下当前路径,或者统一用绝对路径。
5.4 编译器警告:指针类型不匹配
症状是编译时出现warning,说什么从struct Student (*)[100]转成struct Student *。
这个基本是因为你写了struct Student *p = &students;而应该写struct Student *p = students;。students是数组名,本身代表首元素地址;&students虽然数值相同,但类型是“指向整个数组的指针”,两者不兼容。初学时复制粘贴容易搞混,记住:指针变量指向的是元素,不是数组整体。
6. 把这道题的价值榨干:三个进阶改造方向
原题和上面的扩展版本你都跑通之后,这道题就算真正练完了吗?我觉得还差点意思。下面这三个改造方向,任意挑一个做一遍,你的收获会远超刷十道简单题。
6.1 改造成链表的“预演版”
把固定数组替换成动态分配的数组,即malloc一块连续内存存放结构体:
struct Student *p = (struct Student *)malloc(n * sizeof(struct Student));使用方式跟数组几乎一样,但如果配合realloc动态扩容,就能体会到“手动管理内存”的感觉。这比直接上链表温和,又能为链表的学习做缓冲。用完后记得free(p),否则内存泄漏会被Valgrind之类的工具查出来。
6.2 加上排序功能
比如按成绩从高到低排。排序时用冒泡或选择排序都行,关键是交换两个结构体时注意别写错:
void sortByScoreDesc(struct Student *p, int n) { int i, j; struct Student temp; for (i = 0; i < n - 1; i++) { for (j = 0; j < n - 1 - i; j++) { if ((p + j)->score < (p + j + 1)->score) { temp = *(p + j); *(p + j) = *(p + j + 1); *(p + j + 1) = temp; } } } }注意这里temp = *(p + j)是结构体的整体赋值,C语言允许结构体直接赋值。如果换成用指针成员一个个拷贝,也不是不行,但没必要。排序跑通之后,你会对“内存里数据搬来搬去”有更具象的感知。
6.3 改成让别人能用的命令行工具
在main函数里解析argc和argv,让程序支持输入参数,比如:
program.exe -load students.txt -print这样程序就从“交互式输入”升级成了“批处理工具”。这一改动看着不大,但它逼着你把输入输出彻底分离,为以后写真正的工程项目打基础。命令行参数解析本身不复杂,argv[1]、argv[2]之类逐个比较字符串即可。遇到整型参数就用atoi或strtol转一下,同时做好异常值检查。
我实际练完这三个方向后,最大的感觉就是:第72题像下螺母的扳手,光拿在手里没用,得拧在螺栓上才知道它好不好使。做扩展改造的过程,就是拧螺栓的过程。
7. 我的实际体会:练习72的正确打开方式
最后说说我个人对这道题的整体判断和练习建议。
如果你现在刚做到第72题,我建议你不要急着往下刷。先把原始代码手敲一遍,别复制粘贴,然后关掉参考代码,自己在IDE里从头写出来,再到纸上画出p、p+1、p+2分别指向哪里、每个箭头对应哪个结构体。画不出来就说明还没真懂,回去再读一遍定义。
接着把数组访问改成指针访问、把指针访问改成数组访问,来回改三遍。这个过程看着花时间,实际上是在锻炼你的“语法翻译”能力,后面学链表时,你会在head->next这类代码里不断用到这种能力。
然后做我上面给的扩展版本,加上查询和文件保存。这一步最大的价值是让你发现:原来C语言写个小工具也没那么难,数据流和操作步骤理清楚,代码自然就写出来了。
最后如果有余力,挑一个进阶改造方向做掉。无论是动态内存、排序还是命令行参数,做完之后你对这道题的理解会远超那些只把原题跑通的人。
我见过不少人学到这儿就放弃了,理由是“指针太难”。但指针难不是因为知识点多,而是因为初学阶段缺少足够多的实际应用场景。第72题正好是一个低门槛的应用场景,它不涉及复杂的算法,数据结构也就一个结构体数组,把这块吃透了,后面再遇到“节点”“链表”“树”这些概念时,你就会发现,它们本质上也是在操作指针,只是结构体里多存了一个指向自己的指针而已。
所以别急着把这题翻过去,花一晚上把它变着花样玩一遍,这笔时间花得绝对值。等你学到链表时再回头看这道题,会有种“轻舟已过万重山”的感觉。