最近翻旧项目时看到一个很典型的C++练手实验,题目叫“学生成绩统计”,要求从键盘输入一个班学生某门课的成绩,最多不超过30人,输入负值时结束,然后统计不及格人数并打印不及格学生名单。这题看起来简单到不像话,但实际带过的学生里,能一次性把边界情况、输入结束条件和数组安全性全部写对的人,真不多。这篇就把这个题彻底拆开聊一聊,从思路上讲清楚为什么要这样写,再给出一版可以直接复用的代码,最后把常见的踩坑点整理出来。适合C++刚学完数组和循环的初学者,也适合期末复习想查漏补缺的同学。
1. 考题拆解:一句话需求背后的边界思维
1.1 三个功能点,真正的考点不在功能上
这个题目表面上只有三个要求:读入成绩、判断负值结束、统计不及格名单。但如果你只按这个字面意思去写,大概率会写出一个能运行、但一测试就出问题的程序。因为这道题真正想考察的是三件事:
- 静态数组的上限处理。班级最多30人,意味着数组必须提前开好空间,但如果实际输入的人数不到30,你靠什么来判断“读入结束”?题目说负值结束,那负值就是一个哨兵值,它本身不是成绩数据,不能存进数组。
- 循环控制的写法。输入次数不确定,应该用while而不是for,而且要处理好“先读入再判断”还是“先判断再读入”的逻辑顺序。
- 信息输出的时机。不及格名单的打印,是边输入边打印,还是全部输入完成后再统一统计,这关系到程序的扩展性。
我批改作业时看过很多种写法,最典型的问题就是固定循环30次,让老师必须凑满30个成绩才能结束,或者每输入一个分数就立刻判断是否及格,虽然也能得到结果,但和题目要求的“输入结束后再统计”语义不一致。这道题真正想要的,是先完整收集数据,再基于整份数据进行统计,这是后续所有排序、平均分、分段统计等功能的公共基础。
1.2 名单到底怎么存:两种思路的取舍
题目要求打印不及格学生名单,这就涉及一个很实际的问题:不及格名单需要存储吗?很多人的第一反应是为不及格的同学单独开一个数组。但仔细想想,你已经有全班成绩数组了,姓名呢?这道题并没输入姓名,那名单只能按“第几个输入的同学”来标识,或者只打印成绩本身。这种情况下,完全不必单独开数组,只需要在全部输入完成后,遍历一遍成绩数组,把低于60分的成绩和序号打印出来即可。
如果你非要单独开一个failed数组存不及格的下标,代码也能跑,但属于多余的内存开销。当然,如果未来题目改成“打印不及格学生的姓名”,那就要引入结构体或者并行数组,存储方案就完全不同了。这也是为什么我建议初学者先在纸上画一遍数据流:输入阶段写到数组的哪个位置,统计阶段从数组的哪些位置读取,输出阶段需要哪些信息。把这一步想清楚,代码就是水到渠成的事。
2. 从第一版代码到一版能“交作业”的代码
2.1 第一版:先按直觉写,把流程跑通
很多同学拿到题的第一反应是:
#include <iostream> using namespace std; int main() { double score[30]; int n = 0; double x; cin >> x; while (x >= 0) { score[n] = x; n++; cin >> x; } int failCount = 0; for (int i = 0; i < n; i++) { if (score[i] < 60) { cout << "第" << i + 1 << "个学生不及格,成绩: " << score[i] << endl; failCount++; } } cout << "不及格人数: " << failCount << endl; return 0; }这段代码逻辑上没错,能跑,但有几个隐藏问题得分不高。第一,数组写死了30,如果哪天真有31个人,程序就崩了;第二,如果输入的人数少于30,循环结束依赖x >= 0的判断,但如果你输入的是字母或者EOF,程序行为就不可控;第三,超过30人后继续输入,score[n]会越界,这是最危险的隐患;第四,输出时逐个cout,如果需要在名单前后加格式,代码会越改越乱。
不过这段代码最大的价值在于,它把“输入哨兵控制循环”和“遍历数组查成绩”这两个核心步骤完整地表现出来了。这个骨架是对的,后续所有优化都在这个基础上做。
2.2 第二版:工程化写法,从命名开始
把第一版稍微收拾一下,就能得到一版更符合实际开发习惯的代码。改动点主要集中在这几个方面:
- 用const常量代替魔法数字。30、60、-1这些数字直接出现在代码里,看的人不知道什么意思,用常量命名后一目了然。
- 输入和统计分开。读入阶段只负责填数组,统计阶段只负责算结果,函数职责单一,后续维护成本低。
- 边界显式判断。while循环的条件里不仅判断成绩是否为负,还要判断数组是否已满,这样即使输入超过30个成绩,程序也不会越界。
我给出一个实际可跑的版本:
#include <iostream> using namespace std; const int MAX_STUDENTS = 30; const double PASS_LINE = 60.0; const double SENTINEL = -1.0; int main() { double scores[MAX_STUDENTS]; int count = 0; double input; cout << "请输入成绩(输入负数结束,最多" << MAX_STUDENTS << "人):" << endl; while (cin >> input) { if (input < 0) { break; } if (count >= MAX_STUDENTS) { cout << "已达到最大人数,后续输入被忽略。" << endl; break; } scores[count] = input; count++; } if (count == 0) { cout << "没有输入任何有效成绩。" << endl; return 0; } int failCount = 0; cout << "不及格名单:" << endl; for (int i = 0; i < count; i++) { if (scores[i] < PASS_LINE) { cout << "第" << i + 1 << "个学生,成绩: " << scores[i] << endl; failCount++; } } if (failCount == 0) { cout << "无" << endl; } cout << "不及格人数: " << failCount << endl; return 0; }这段代码里我特意处理了几个细节。cin >> input放在while条件里,能避免第一版代码重复写两次输入语句的问题,也天然对非数字输入不那么容易死循环。input < 0作为退出条件,放在break里比放在while条件里更灵活,因为你可以在break前加提示信息,或者做额外的收尾工作。数组满的判断放在写入之前,确保永远不会越界。最后单独处理count == 0的情况,避免后面循环不执行时输出误导性的统计结果。
关于及格线,有个细节值得说明。题目没明确说多少分算及格,但常规默认是60分,所以我把PASS_LINE定义成常量。你可能会想,直接用if (scores[i] < 60)不就行了吗?从功能上确实没区别,但使用常量的意义在于:如果老师哪天把及格线改成70分,你只需要改一行,而不是满文件搜索“60”这个数字。这种习惯在写任何超过50行的程序时都会受益。
3. 常见错误与排查心得:这题最容易翻车的地方
3.1 输入结束条件与空输入问题
很多同学会把退出条件写成while (x >= 0),然后每次循环末尾再读一次x。这种方式本身没问题,但搞不清楚停止条件到底是“读到负数”还是“最后一次输入非法”。我见过最离谱的写法是先读一个值,然后循环里用if判断再读下一个值,结果需要输入两次负数才能结束。
排查这类问题,核心方法是打印调试。在循环开头加一行cout << "当前输入: " << input,运行一遍就能看到循环体执行的次数和每次读入的值,立刻就能明白逻辑错在哪里。另外要注意,如果第一次输入就是负数,程序应该直接结束并且不统计任何成绩;如果某次输入既不是数字也不是负数,cin会进入失败状态,这时候while (cin >> input)可以直接阻挡后续所有操作,程序不会像scanf那样留下脏数据。
3.2 数组越界与最大人数限制
最多不超过30人,这句话很多人当耳边风。实际上,只要你在循环里不加count < MAX_STUDENTS这个条件,输入第31个成绩时scores[30]就越界了。C++不会像Java那样抛异常,它会安静地覆盖相邻内存,运行结果可能看起来正常,也可能在main函数结束时莫名其妙崩溃,这种bug最难查。
解决思路有两个方向:一是每次写入前检查count是否等于上限,等于就停止接收输入;二是使用动态数组vector,但题目说“最多30人”实际上是想练习静态数组,所以我不建议在这个实验里用vector。真要用vector,这道题的难度性质就变了,老师可能会怀疑你刻意规避考点。
3.3 全及格与全不及格的边界表现
我的测试习惯里有一组雷打不动的边界用例:全部满分、全部不及格、只有一个学生、没有输入直接负数结束。曾经有个学生写的程序,在全班都及格的情况下输出了“不及格人数: 0”但名单列表那里全部是乱码,原因是他先输出表头,然后循环里什么都不输出,最后表头下面直接接统计行,看起来非常奇怪。
正确的做法是:输出名单表头之前先做一个判断,如果failCount为0,直接输出“无”,再输出统计行。或者先统计完failCount,再决定要不要打印名单。我的习惯是先统计,再根据统计结果打印。这样表头不会多余出现,逻辑也更顺畅。
3.4 调试日志与测试用例设计
这道题有了完整代码后,还应该自己写几个测试用例跑一遍。我通常按这五组来测:
| 测试场景 | 输入数据 | 期望输出 |
|---|---|---|
| 正常混合 | 85 72 59 63 0 45 -1 | 2人不及格 |
| 全部及格 | 90 80 70 -1 | 0人不及格,名单输出“无” |
| 全部不及格 | 10 30 50 -1 | 3人不及格 |
| 空输入直接结束 | -1 | 提示无有效成绩 |
| 超过30人 | 连续输入31个成绩 | 第31个被忽略,给出提示 |
为什么强调测试用例?因为很多初学者写完代码感觉良好,一交上去就被测试数据打回。代码能不能过,靠的不是“我觉得没问题”,而是有没有覆盖各种输入情况。尤其是空输入和满员这两种边界,十次有八次会出问题。
4. 功能升级:从一个名单到成绩统计工具的扩展
4.1 函数化拆分:让结构更清晰
统计不及格名单只是这个题目的最低要求。如果你想在实验报告里拿高分,或者以后复用这套逻辑,我建议把它改造成函数式结构。可以通过函数把代码拆成更清晰的三段:
int readScores(double scores[], int maxSize); void printFailList(const double scores[], int count); int countFail(const double scores[], int count);这样主程序就变成:
int main() { double scores[MAX_STUDENTS]; int count = readScores(scores, MAX_STUDENTS); int failCount = countFail(scores, count); printFailList(scores, count); cout << "不及格人数: " << failCount << endl; return 0; }函数化有什么好处?最大的好处是单独测试。我想验证printFailList在名单为空时是否输出“无”,就不需要重新输入一堆数据,直接构造一个空数组调用即可。另外,这个readScores函数可以复用到后续的平均分统计、最高分统计等题目里,不会每次都得重写一遍输入循环。
4.2 统计扩展:平均分、最高分、分数段分布
既然已经有一份完整的成绩数组,顺手扩展其他统计项几乎不费吹灰之力。比如计算平均分只需要在遍历时累加总分再除以count;求最高分只需要定义一个max变量,遍历时不断更新;统计90分以上的人数,也只需在循环里多几个判断分支。
这些扩展的价值在于,同一个实验项目可以做成一整套成绩管理系统,你的实验报告可以写“基于同一份成绩数据,实现了多项统计功能”,这个立意比单一的不及格名单高很多。
4.3 防御式编程:除了负数还有哪些非法输入
题目只规定负值是结束标志,但实际使用程序时,用户可能输入1000分,也可能输入“abc”,还可能什么都不输入直接按Ctrl+Z。如果只考虑负值,程序对异常输入的容忍度很低。升级版本里,我会加入分数有效性判断,比如0到100分范围内才算合法成绩,超过范围的提示“成绩需在0到100之间”,然后跳过本次输入继续读取。
这里有一个度的问题:实验题的目的是练习数组和循环,加过多校验会让代码显得臃肿,反而掩盖核心逻辑。我的建议是,在实验报告里说明“基础功能已完成,扩展功能包括分数范围校验”,把代码注释写清楚,让老师看到你主动思考了问题,但不要在v1版本里强行引入一堆防御逻辑。
5. 实测记录:三组最容易出问题的输入样本
为了写这篇分享,我特意重新跑了三组平时学生问得最多的输入样本,并记录了实际输出情况。
第一组是输入中途出现非数字字符,比如输入“85 72 abc 59 -1”。使用while (cin >> input)的版本在遇到abc时会直接退出循环,count只记了2个成绩,后面59根本不会被读到,程序“安静地”结束了。很多初学者发现输入错一个字符整个统计就少了数据,感到莫名其妙,其实这就是 cin 的失败状态导致的。解决办法是读入字符串再转换,或者提示用户重新输入,但这已经超出本题范围,知道现象和原因就行。
第二组是超过30人的情况,连续输入30个成绩之后继续输入第31个,此时带数组满检查的版本会打印提示并退出循环,不带检查的版本会越界写入。表面上看两个程序可能都能跑完,实际用调试工具看内存,scores数组后面已经被写入了垃圾数据,只是没触发崩溃而已。这种越界bug最可怕的地方在于“看起来正常”,一旦触发就很难复现,建议所有涉及静态数组的代码都加上越界保护。
第三组是输入0分的边界情况,0分算不及格吗?按照常规标准,小于60分都算不及格,0分当然算。但有的同学会把条件写成<= 60,结果恰好60分被误判为不及格,测试的时候没发现,输出一校核才露馅。60分到底是及格还是不及格,是这条题目的一个隐形考点,很多教科书默认60分及格,但不代表题目明确说了,最稳妥的做法是定义及格线为60分,小于60为不及格,把边界写在注释里,让别人看代码时不需要猜。
6. 一点操作心得
回到这道题本身,它最好的练习方式不是“照着答案敲一遍”,而是先写一个能跑通全部功能的版本,再照着上面的优化点逐步改。我见过不少同学喜欢直接看完整代码,然后边看边抄,抄完觉得会了,合上资料让自己重写时又卡在while循环的结束条件上。原因很简单,抄代码只看到了结构,没看到结构产生的逻辑过程。
我的建议是拿张纸,先把“输入数据存数组”、“负值终止循环”、“遍历数组找不及格”这三件事分别写在三个盒子里,盒子之间画上箭头,然后再对着箭头写代码。写不出来或者写错了,回头对比标准答案的循环条件和数组下标,基本上一次就能找到自己的思维盲区。这个过程比抄十遍代码都有用,往后的考试题哪怕难度再高,只要分析框架搭好了,写起来就不会跑偏。