1. 拿到“第一次作业”后,我做的第一件事不是写代码
大概每个编程新手都会经历这样一个时刻:老师把题目往屏幕上一贴,下面跟着一串示例输入输出,然后教室安静三秒,所有人脑子里同时冒出一句“这到底要我干嘛”。我当时的作业长这样:读取一个 CSV 文件,里面是一堆学生的姓名、学号和三次作业成绩,最后输出平均分从高到低排序的名单,并且要把不及格的标出来。
题目本身不难,但难的是“第一次”这三个字。第一次意味着你连开发环境都是刚装好的,第一次意味着你还分不清“报错看不懂”和“代码没写完”哪个更让人崩溃,第一次也意味着你根本不知道一份看起来能跑的作业,离“合格”到底有多远。
现在回过头看,那次作业我前后写了大概三四天,真正写代码的时间可能就半天,剩下的时间全花在拆需求、查资料、改 bug、重读题目的循环里。这篇博文就把这个过程完整复盘一遍,把我踩过的坑、绕过的弯、还有后来才想明白的设计思路都摊开讲。如果你也正卡在类似的第一次作业上,希望这份记录能让你少走几步弯路。
提示:这篇文章的作业实例以 Python 实现一个成绩统计命令行工具为主,但拆解需求、设计结构、调试排查的思路是通用的,换成 C 语言、Java 或者 JavaScript 都能套用。
2. 拆解题目,才是第一道真正的题
刚拿到作业的时候我差点直接打开文件就开始写。后来想了想,题目里其实是藏了要求的,只是不会有人主动给你划重点。
2.1 把一句话题目拆成五条可执行清单
我的作业原文大概是这样一段描述:“编写一个程序,读取 data.csv,每个学生有三项成绩(平时、期中、期末),计算总评(平时占百分之二十、期中占百分之三十、期末占百分之五十),按总评降序输出,并在总评低于 60 的姓名后面标注‘不合格’。最后把结果写到 result.txt。”
看起来就一句话,但我把它拆成了这些子任务:
- 读文件:CSV 是什么格式、用什么函数读、文件读不到怎么办
- 算成绩:加权平均怎么算、浮点数精度要不要处理
- 排序:按总评降序,那总评相同的怎么办
- 标记:低于 60 分加标注,是跟在名字后面还是单独一列
- 写文件:编码格式用 UTF-8 还是 GBK,写完之后要不要控制台也打印一份
别小看这个拆解的过程。它本质上是一次“需求评审”,只不过评审人是你自己。把模糊的题目变成可逐项打勾的步骤,后面写的时候脑子就不会乱。我那会儿甚至拿纸画了一个流程:读数据 → 清洗 → 计算 → 排序 → 标记 → 输出。画完之后才发现顺序很重要,比如排序必须在标记之前,不然标了“不合格”再排序会把这个字符串也带进去比较。
2.2 先搞清楚“合格”长什么样
作业题的末尾通常会写“提交要求”,这一步很多人直接忽略了。我那次作业的要求是:提交一个源代码文件和一个说明文档,文档里写清楚运行方式。但后来听老师说,全年级一百多份作业里,将近三分之一的人没写运行方式——你让人家怎么跑?拿着 Python 源码干瞪眼吗?
这就是第一次作业真正要教给你的东西:完整交付。功能是对的,只是及格线;能让别人一分钟之内跑起来,才是良好;如果代码注释清楚、结构清晰、连异常情况都考虑到了,那才是优秀。我当时也没意识到这一点,直到后来做课程设计被老师要求补 README,才慢慢养成“交付物要让人能直接用”的习惯。
所以拿到任何作业,先花十分钟确认三件事:交什么文件、用什么格式、按什么标准验收。哪怕题目里没有写,也要主动问老师或者看往年的提交模板。这十分钟省下的,可能是你之后补交作业的整整一晚。
3. 设计思路:先想清楚再动手,真不是浪费时间
很多人第一次写作业最没耐心的就是设计阶段,觉得“不就是几行代码吗”。我一开始也这么想,直到在排序上栽了个跟头才明白,先设计后编码,其实是在给自己省 debug 的时间。
3.1 用数据流图代替架构图
不用画那种很唬人的系统架构图,一个小作业而已。但你可以像我一样,在草稿纸上画一个数据流图,把输入、处理、输出三个环节画清楚。
我当时画的流程是这样的:
- 输入:data.csv 文件
- 处理1:逐行读取,跳过表头
- 处理2:拆分字段,做类型转换
- 处理3:十个学生的数据存成什么结构
- 处理4:按总评排序
- 处理5:生成带“不合格”标记的展示字符串
- 输出:result.txt 和控制台打印
画完之后我意识到,整个程序其实可以拆成五个小函数:read_data、calculate_score、sort_students、format_output、write_result。主程序只需要把这五个函数按顺序串起来,一共不超过二十行。这个认知对当时的我来说是很震撼的——原来代码不是一坨写完的,而是拼积木一样拼出来的。
3.2 选择数据结构:字典还是列表
存储学生的数据,我想了两种方案:
- 用列表套列表:[[name, id, score1, score2, score3], ...],好处是直观,坏处是代码里到处都是 students[i][2] 这种魔法索引,写多了自己都分不清哪个是哪个
- 用列表套字典:{"name": ..., "sid": ..., "scores": [..], "total": ...},好处是字段名一目了然,坏处是稍微啰嗦
我最后选了字典。理由是:排序的时候用 key=lambda s: s["total"],读起来极其清晰,后面格式化输出也方便。这种“多写几个键名,换来终身可读性”的买卖,怎么算都划算。
3.3 提前想好“边界情况”
设计阶段最容易漏掉的是边界情况。老师给的数据文件永远是规规矩矩的十个学生、三列成绩,但如果你只针对“完美情况”写代码,那你的程序就是个玻璃娃娃。我当时列了几个问题:
- 文件里空行怎么办
- 学号带前导零怎么处理
- 成绩缺失或者不是数字怎么办
- 两个人的总评完全一样,排序是否稳定
前三个问题我当时没全解决,但至少在设计里想到了,后来实现的时候一个个补上了。这个意识很重要,因为真实的程序跑在手里的数据上,永远不如你想的那么干净。
4. 核心实现与实操细节
这一段我会把那次作业的最终实现拆开来讲,包括每一步为什么这么写、参数为什么这么选、还有我踩过的具体坑。代码都是当时那个水平的,谈不上优雅,但足够诚实。
4.1 读取 CSV:不要硬编码路径
我一开始写的是:
with open("data.csv", "r", encoding="utf-8") as f: lines = f.readlines()这段代码在 PyCharm 里能跑通,但换一个目录运行就崩溃。后来我知道这是因为工作目录的问题。更稳的做法是显式指定路径,或者用相对于脚本文件的路径:
from pathlib import Path file_path = Path(__file__).parent / "data.csv"注意:Path(file).parent 取的是当前脚本所在目录,而不是“执行命令时所在的目录”。这个技巧在很多小工具类项目里都管用,尤其是你把脚本放到别的机器上跑的时候。
如果一定要用内置的 csv 模块,那就别手动 split(","),因为 CSV 里如果出现引号包裹的逗号,手动 split 直接炸掉。作业数据可能没这么复杂,但养成用 csv.reader 的习惯没有坏处。
import csv rows = [] with open(file_path, "r", encoding="utf-8") as f: reader = csv.reader(f) header = next(reader) # 跳过表头 for row in reader: if not row: # 跳过空行 continue rows.append(row)4.2 计算加权总评:浮点数是第一个坑
加权计算本身不难,难在浮点数精度。比如平时 88.5 分,期中考 71.3 分,期末考 90.2 分,总评算出来可能是 81.95000000000002。你的程序往 result.txt 里一写,老师打开一看后面一堆小数,印象分直接掉一半。
解决的办法是保留两位小数。但要注意:四舍五入的时机。我当时图省事,在算每一项成绩的时候就 round,结果导致最终总评有偏差。正确的做法是先算完整加权总和,再对最终结果做 round。
def calculate_total(scores, weights=(0.2, 0.3, 0.5)): total = sum(float(s) * w for s, w in zip(scores, weights)) return round(total, 2)这里还有个 Python 特有的坑:round(2.675, 2) 的结果是 2.67,不是 2.68。因为 2.675 在二进制浮点数里根本没有精确表示。如果老师特别抠这种细节,可以用 Decimal,但作业场景下 round 足够了。
4.3 排序:stable 排序 vs 单字段排序
排序我一开始用的是 sorted(students, key=lambda s: s["total"]),但这默认从小到大,所以要加 reverse=True。
sorted_students = sorted( students, key=lambda s: s["total"], reverse=True )然后我就想:总评相同的时候怎么排序?题目没说。但 Python 的 sorted 是稳定排序,也就是说如果总评相同,原来的顺序会被保留。那原来的顺序是哪来的?是我从 CSV 读进来的顺序。这个细节可能对老师来说无所谓,但我觉得一个成绩统计程序按学号排一下更合理。所以我改成了双关键字排序:
sorted_students = sorted( students, key=lambda s: (s["total"], s["sid"]), reverse=True )这段代码的缺点是,reverse=True 会把学号也变成降序。如果你想让总评降序、学号升序,得这样写:
sorted_students = sorted( students, key=lambda s: (-s["total"], s["sid"]) )一个小技巧:加负号代替 reverse=True,把排序方向控制到单字段级别。这个思路后来在很多场景都帮我避免了“反向全反”的尴尬。
4.4 输出与写文件:编码问题在 Windows 上特别痛
控制台打印,用中文一点问题没有。但往文件里写中文,Windows 上默认编码可能是 GBK,而 PyCharm 里默认又是 UTF-8,两边不对上,就会出现乱码或者 UnicodeEncodeError。
最稳妥的方案是写文件的时候显式指定编码:
with open("result.txt", "w", encoding="utf-8") as f: f.write("\n".join(lines))但注意,如果老师用的是 Windows 记事本打开你的 result.txt,UTF-8 文件没有 BOM 头的话,记事本老版本可能也会显示乱码。这种情况可以考虑 encoding="utf-8-sig",它会多写三个不可见字符。我自己后来在 Windows 上交付文本文件,都会默认加 utf-8-sig,市面上很多课程作业其实都不在意这个,但你做了,至少不会错。
4.5 给代码加注释,但别注解得像凑字数
我第一次提交作业的时候,注释写了满屏,基本每行都加一句“把这一行读取进来”。后来学了点工程经验才明白,注释要写的是“为什么”,不是“是什么”。比如:
# 成绩相同情况下按学号升序,保证输出顺序可复现 key=lambda s: (-s["total"], s["sid"])这种注释才有价值。至于“读取一个学生记录”这种话,看一眼代码就知道了,注释纯粹是噪音。第一次作业的注释是非常加分的项,因为它直接向老师传递一个信号:这个人知道自己在写什么。
5. 调试实录:那些让我半夜崩溃的 bug
作业写得快,不代表 bug 少。我那次遇到的几个问题,我挑典型的、每个新手大概率都会遇到的写出来。
5.1 第一个 bug:列表下标越界
报错信息大概长这样:IndexError: list index out of range。
出在哪?我在计算总评的时候访问了 scores[2],但有一行数据的第三列是空的。原因特别蠢:CSV 文件里有一行末尾逗号后面是空格,csv.reader 读完以后这一行的字段比正常行少了一个。
解决方案是加一行数据清洗,字段数不足的直接跳过或者补默认值:
if len(row) < 5: continue这个处理看着丑,但对应的是真实地从异常数据里保护程序。你也可以用 try-except 包住,但在作业场景里,一个 if 判断就够清楚了。
5.2 第二个 bug:sort 排序结果“看起来”不对劲
排名出来后,我发现有两个人的名次好像反了。查了半天,发现一个学生的期末成绩是 89.5,另一个是 89.49。我 sort 的时候用的是总评,两个总评在两位小数上完全相同,但实际原始分数不同。
这个 bug 让我意识到一个事情:如果 sort 的 key 只取总评,那丢失精度是必然的。后来我把 key 改成原始加权未取整的值,也就是不在 calculate_total 里 round,而是在最终展示时用 format 控制两位小数输出。这个修改一石二鸟:排序更精确,输出也干净。
def calculate_total(scores, weights=(0.2, 0.3, 0.5)): return sum(float(s) * w for s, w in zip(scores, weights)) # 展示时四舍五入 print(f"{student['total']:.2f}") # round 的逻辑交给 format5.3 第三个 bug:反复打开文件导致句柄堆积
我在调试的时候习惯每改一次都跑一遍程序,结果发现越跑越慢。这个其实不算程序的 bug,是我自己的问题:我在终端开了一段循环跑脚本,但每次打开 result.txt 都没关,句柄越来越多。Python 进程结束时会自动释放,但我是在交互式环境里反复执行代码段调试的,所以积累起来了。
用 with open 就是为了避免这种问题。当时 PyCharm 里报了个 ResourceWarning,我一查才知道是这个原因。从那之后写文件必用 with,板上钉钉。
5.4 调试技巧:print 大法强无敌
刚学编程的时候,正经 debugger 我根本不会用。我做的就是在每个关键节点打一行 print,把中间结果打出来看。比如:
print("read rows:", rows[:3]) print("calculated:", students[:3]) print("sorted:", sorted_students[:3])这一串 print 打出来之后,我就知道是数据读错了、计算错了还是排序错了,定位问题基本不超过三分钟。等代码能跑通了,再把这些临时 print 删掉或者改成日志,不会耽误多少事。
6. 常见问题速查与避坑清单
我整理了一份当时遇到的问题清单,也包含一些后来帮学弟学妹改作业时遇到的问题。这个表格可以直接当成自查表用。
| 现象 | 常见原因 | 解决思路 |
|---|---|---|
| 文件找不到 | 工作目录不对、路径写错 | 用 Path(file).parent 构造绝对路径 |
| 中文乱码 | 文件编码不一致 | 读写路径统一用 encoding="utf-8" 或 utf-8-sig |
| 数字格式不对 | 读进来是字符串没转 float | 显式类型转换,加 float() |
| 排序方向反了 | reverse=True 作用范围太大 | 用负号字段控制单字段方向 |
| 输出带有长尾小数 | 浮点数精度问题 | 展示时用 format 控制位数 |
| list index out of range | 数据行字段数不一致 | 读取后检查字段长度 |
| 程序跑完没反应 | 函数定义了没调用 | 检查主流程是否漏了调用步骤 |
| 控制台能跑,双击 .py 闪退 | 没有 input 或窗口直接关闭 | 末尾加 input() 暂停或打包时注意 |
| 代码缩进不一致报 IndentationError | 混用 Tab 和空格 | 统一用 4 空格缩进 |
6.1 自查清单:交作业前五分钟照着过一遍
我后来养成了一个习惯,所有作业提交前五分钟,必须按照这个清单过一遍:
- 重新读一遍题目,确认每一句要求都有对应的功能实现
- 用干净环境跑一次程序,确认不依赖我自己电脑的某个自定义环境
- 删除调试用的 print 语句
- 检查文件名、提交格式、命名规范是否与要求一致
- 代码文件加一个简单的文件头注释,说明学号和姓名
- 确认打开输出文件验证内容,而不是只看控制台结果
这个清单很简单,但每一条都是我用“扣分”换来的。尤其是第二点,很多人的代码在自己电脑跑得好好的,换个目录就崩,就是因为写了绝对路径或者依赖了某个自定义模块。
6.2 不要抄作业,但可以“拆作业”
说句实在话,第一次写作业看到同学已经弄完了,那种焦虑感谁经历过谁知道。但直接复制别人的代码,你省下的只是时间,损失的是这个完整的试错过程。我当时是找了一份学长写的作业,不是抄,而是“阅读源码”,边读边问他为什么这么写。等我自己动手写完,我发现他的结构还没我清晰呢。
看清代码的逻辑权,比拥有一份能跑通的代码重要得多。第一次作业最大的收获就是这个,它会让你从“看代码都晕”变成“能拆开一段代码讲清楚它是干嘛的”。
7. 一些我后来才想明白的经验
回头看第一次作业,它的价值不在于那个成绩统计程序本身,而在于它逼着我完成了几个认知升级:
第一是一段程序应该拆成多个函数,而不是从头写到底。一开始我整个程序就是一段流程式的代码,后来一个地方出错就得从头扫到尾。拆成函数之后,每个小方块可以单独测试,定位问题快了不止一倍。
第二是输入数据的质量会直接决定程序的复杂度。当时的作业数据几乎完美,但让我提前想了一遍“脏数据”的问题,这个习惯在后来处理真实项目时直接救了我的命。真实的数据永远是乱糟糟的,缺字段的、格式错的、交叉重复的,什么都有。在程序入口就把数据清洗做好,后面逻辑部分就能省太多心。
第三就是别怕改代码。我第一版写得很糟糕,函数命名全是 temp1、temp2 这种,后来重新理了一遍,虽然代码长了点,但每个函数都能说得出来它干嘛。重构不是老师要求的,但做完之后,我对这份代码的信心完全不一样了。
最后再分享一个小技巧:把所有作业放进一个以课程命名的文件夹里,每份作业建一个子目录,命名格式用“日期-课程-作业名”。我当时随便乱放,结果学期末整理的时候发现自己连“第一作业.py”和“作业1.final.py”这种文件名都有三个版本。从第一次作业开始建立清晰的目录习惯,后面整个学期都会感谢自己。