1. 第五次作业是个分水岭:考的从"记没记住"变成"会不会用"
1.1 前四次和第五次的本质区别
python第五次作业这个标题,乍看就是平平无奇的课程进度记录。但我这几年帮人改作业、带新人做练习,对这个节点感触特别深:它基本就是"照着写代码"和"想着写代码"之间的分界线。
前四次作业考的大多是语法记忆。列表怎么切片、for循环怎么写、函数def完怎么调用、字符串有哪些方法,这些本质上都是"你记住了没有"。哪怕完全不懂原理,只要把课件里的例子改吧改吧,也能交一份像模像样的上去。批改的时候我也能感觉到,大部分人这时候还处于"能跑就行、报错就懵"的状态。
到了第五次,题目通常就变了一副面孔,不再单独考某个语法点,而是要求你把好几样东西串起来做一个小程序。最常见的就是字典、列表、函数、文件读写混合使用,比如学生成绩统计、通讯录管理、文本词频统计。这一步最要命的地方在于:没有任何人告诉你这题该用哪个数据结构,你得自己判断、自己设计、自己把整个流程搭起来。
我把前四次和第五次常见的差异整理成了下面这张表,各位可以对号入座:
| 对比维度 | 前四次作业 | 第五次作业 |
|---|---|---|
| 考察重点 | 单个知识点是否掌握 | 多个知识点能否组合 |
| 题目提示 | 通常会指明用什么 | 基本不提示,全靠自己选 |
| 代码规模 | 几十行以内 | 往往上百行甚至更多 |
| 失败后果 | 改一处就能过 | 一处出错,全线崩盘 |
| 核心能力 | 记忆和理解 | 拆解和设计 |
很多人第一次挂科就挂在这次,不是因为他笨,而是他的学习方式还停留在"记住语法"阶段,突然被要求"设计系统",自然手足无措。这篇文章我就拿一道非常典型的学生成绩统计题做例子,把从数据分析到代码实现的完整思路走一遍,顺便把批改作业时反复遇到的那些坑也一起说了。
1.2 这次作业的常见配置:字典 + 函数 + 文件读写
我经手过的第五次作业,不管题目怎么换花样,内核基本就是三样东西的组合。
第一是字典。因为你需要存储有结构的数据,比如每个学生的姓名、学号、各科成绩。列表当然也能存,但如果要做"按学号查找某个人"这种操作,字典比列表高效得多,代码写起来也更直观。第二是函数。代码量一旦过百行,还全糊在main里面,调试的时候你自己都会疯掉。老师要求拆函数,不是为了让你好看,而是为了让你能活到第六次作业。第三是文件读写。作业题目通常会要求"把结果保存到文件",或者"从文件里读入数据再处理",这一步能拦住一大半人——不是不会open和write,而是编码、格式、异常这些细节根本没接触过。
这三样东西单拎出来都不难,难的是同时在同一个程序里用对。下面我就拿一个具体题目,完整走一遍。
2. 拿一道典型题目拆给你看:学生成绩统计系统
2.1 动手写代码前,先把需求翻译成数据结构
先看一道很典型的题目,我改作业时遇到它的频率高得惊人:
编写一个学生成绩统计程序。支持录入若干学生的学号、姓名以及语文、数学、英语三科成绩;计算每个学生的总分和平均分;统计每门课的班级平均分、最高分、最低分;最后按总分从高到低排序,并把完整结果保存到文件中,程序启动时如果文件存在,还需要能够读出来展示。
拿到这种题,新手最容易犯的错就是撸起袖子直接写。写两行发现不知道数据往哪放,然后开始胡搞,最后交上来一份自己都跑不通的代码。
我的建议永远是:动手前先用三五分钟把题目拆成几件独立的事。这道题拆开其实只有五件事:
- 录入:接收用户输入,存成结构化数据
- 计算:每个学生的总分、平均分,每门课的班级统计
- 排序:按总分降序排列
- 保存:把结果写入文件
- 加载:从文件读出并展示
每一件事对应到Python里,就是一个函数外加一个数据结构。你把题目拆到这里,脑子里就有一张清晰的施工图了,接下来只是逐个填空。
2.2 字典怎么设键:学号作键和列表存字典的取舍
数据存储是整个程序的地基,这一步选错了,后面处处别扭。常见方案有两个,我分别说下优缺点。
方案A:学号作为外层字典的键,值是一个嵌套字典:
students = { "2024001": { "name": "A同学", "scores": {"语文": 85, "数学": 92, "英语": 78} }, "2024002": { "name": "B同学", "scores": {"语文": 88, "数学": 79, "英语": 90} }, }方案B:用一个列表,列表里每个元素是一个字典:
students = [ { "sid": "2024001", "name": "A同学", "scores": {"语文": 85, "数学": 92, "英语": 78} }, { "sid": "2024002", "name": "B同学", "scores": {"语文": 88, "数学": 79, "英语": 90} }, ]方案A的好处是"按学号查人"极其自然,students["2024001"]一下就出来了,而且天然去重——同一个学号的键只会保留一个。缺点是字典本身没有顺序概念,排序时必须先转成列表再做。方案B的好处是列表天生有序,按总分排完序直接就是最终展示结果,遍历也顺手;缺点是按学号查找要写循环,还得自己处理重复学号。
这道题我实际推荐方案A。原因很简单:录入时"学号重复"是一个非常现实的错误场景,用学号当键,字典自动帮你挡住了这个错误。至于排序问题,sorted(students.items(), key=...)一行就能解决,完全不必为了排序牺牲去重能力。初学者容易犯的毛病是把"展示顺序"和"存储结构"混为一谈,总觉得列表存进去什么顺序就什么顺序,其实展示和存储本来就可以分开处理。
2.3 输入模块的写法:循环、去重、类型校验
确定好数据结构,第一个要写的函数就是录入。录入模块常见的要求是:可以连续录入多个学生,直到用户输入空行结束;学号不能重复;成绩必须是数字,不能是"abc"这种东西。
我给你看一个我比较满意的写法:
def input_students(): students = {} while True: sid = input("请输入学号(直接回车结束):").strip() if not sid: break if sid in students: print("学号已存在,请重新输入") continue name = input("请输入姓名:").strip() scores = {} for course in ("语文", "数学", "英语"): raw = input(f"请输入{name}的{course}成绩:").strip() try: scores[course] = int(raw) except ValueError: print("成绩必须是数字,请重新输入该同学的完整信息") break else: students[sid] = {"name": name, "scores": scores} return students这里有几个细节值得说。.strip()是用来去掉用户不小心打出来的空格,"2024001 "和"2024001"如果不去空格,就是两个不同的字符串,查重就会失效。if sid in students这一句利用字典的键判重,比自己在列表里循环找要简洁得多。还有那个try...except ValueError,这是防止用户输入"八十五"或者"8o"这种非法内容直接把程序干崩。
for...else这个结构很多初学者没见过,它在循环正常走完(没有被break打断)时执行else里的内容,刚好用来表示"三科成绩全部录入成功"。
3. 函数封装:为什么老师非要你把代码拆开
3.1 一大坨代码的问题,等你回头调试就明白了
我改作业时见过不少"千行英雄帖":整个程序从头到尾一个函数,全靠空行和注释分区。学生自己还觉得很自豪,觉得一个文件搞定多酷。我真不建议这样干,不是洁癖,是这套写法在调试时是真的痛苦。
想象一下,你程序跑到第80行报了个错,前面79行都在做录入、计算、排序混在一起的过程。你得一行一行读,一边读一边在脑子里记账:"这个变量现在是啥?""这个列表被改过几次?"人的短期记忆容量很有限,干这种事实在太吃力了。而如果你把功能拆成输入、计算、排序、保存几个独立的函数,程序崩了之后,你只需要想一件事:这个报错发生在哪一步?如果发生在保存那一步,前面几百行都不用看了,直接去查文件函数就行。
另外,独立函数还有一个隐藏福利:你可以单独测试它。比如写完了course_stats,立刻用一个写死的字典调用一下,看看结果对不对,不需要每次都得走完整个录入流程。这种"小步快跑"的验证方式,是新手进阶非常重要的习惯。
3.2 按职责拆函数:输入、计算、排序、存取
拿成绩统计这道题来说,我建议至少拆成下面这几个函数:
def input_students(): # 返回 students 字典 ... def student_total(student): # 计算单个学生的总分 return sum(student["scores"].values()) def course_stats(students, course): # 统计某门课的班级平均分、最高分、最低分 scores = [s["scores"][course] for s in students.values()] return { "平均分": sum(scores) / len(scores), "最高分": max(scores), "最低分": min(scores), } def ranked_list(students): # 返回按总分降序排列的列表 return sorted( students.items(), key=lambda item: sum(item[1]["scores"].values()), reverse=True ) def save_to_file(students, filename="result.txt"): ... def load_from_file(filename): ... def main(): ...为什么要单独写一个student_total,而不是在排序和保存的时候各写一遍sum(...)?因为重复代码是bug的温床。你需要改逻辑的时候,比如突然要求平均分的计算方式变成去掉一个最高分再去掉一个最低分,如果逻辑只写在一个地方,改一处就行;写了两三处,你很有可能漏掉其中一处,然后程序的表现就会变得非常诡异。
ranked_list里的sorted我用了students.items(),这样每个元素是(学号, 学生信息)的元组,lambda 表达式里item[1]取到学生信息,再sum(...)算总分。reverse=True就是降序。这里有个小坑:如果忘了reverse=True,默认升序,全班第一名会排到最后一名去。
3.3 可变默认参数:一个隐蔽又经典的坑
函数封装阶段,还有一个坑几乎每届学生都会踩,就是可变默认参数。
看这段错误写法:
def add_student(name, scores=[]): scores.append(score) # 假设这里要把成绩加进列表 return scores乍一看没什么问题,scores默认给一个空列表,调用时不传就用新的空列表。但实际跑起来你会发现,第一次调用返回[85],第二次调用返回[85, 92],第三次[85, 92, 78]——所有的调用共享了同一个列表!
原因是Python函数的默认参数在定义时只计算一次,之后每次都复用同一个对象。scores=[]这个空列表,在整个程序生命周期里始终是那一个列表,并不是每次调用都新建。这在函数式编程里叫"可变默认参数陷阱"。
正确写法是:
def add_student(name, scores=None): if scores is None: scores = [] scores.append(score) return scores用None当占位符,每次函数被调用时,如果没传参数,scores就是None,然后现场新建一个列表。这个方法你记不住原理也没关系,记住"可变对象(列表、字典、集合)不要做默认参数"这条铁律就能避开绝大多数事故。
4. 文件读写与异常处理:让程序真正"活"到下次运行
4.1 选什么格式保存:txt、CSV 还是 JSON
成绩统计这道题要求把结果保存到文件,那文件用什么格式就值得权衡一下。这听起来像小题大做,但格式选错了,加载读回的时候能折磨死你。
最朴素的做法是直接写txt,每一行用逗号或者空格分隔字段。它的优点是简单直观,记事本打开就能看;缺点是没有结构,自己解析时容易出错,比如某个字段里意外含了逗号。稍微好一点的做法是CSV,本质上是带格式约定的txt,Python标准库里有csv模块,读写都方便,Excel也能直接打开。如果再讲究一点,可以用JSON,结构清晰,json.dump和json.load一行搞定,是三种里最省心的。
考虑到这是课程作业,题目如果没有明确要求,我用CSV格式举例,因为它正好能展示"逐行解析"这个能力点,而且直观。下面是一个保存的示例:
def save_to_file(students, filename="result.txt"): with open(filename, "w", encoding="utf-8") as f: f.write("姓名,学号,语文,数学,英语,总分,平均分\n") for sid, info in students.items(): scores = info["scores"] total = sum(scores.values()) avg = total / len(scores) f.write( f"{info['name']},{sid},{scores['语文']},{scores['数学']}," f"{scores['英语']},{total},{avg:.1f}\n" )注意我用了with open(...) as f,这个写法能保证文件用完后自动关闭,程序中途崩了也不会留下没关闭的文件句柄。如果你用老式写法f = open(...)然后忘了f.close(),程序运行次数多了,Windows下会出现"文件被占用"这种莫名其妙的报错。
4.2 编码问题:UTF-8 和 GBK 的恩恩怨怨
文件读写里拦路最多的问题就是编码。很多同学的代码在课堂上跑得好好的,回家一跑,中文字符全变成了乱码,或者直接报错:
UnicodeDecodeError: 'gbk' codec can't decode byte 0x...原因很简单:Windows上Python默认的文本编码是GBK,而很多代码和文件用的是UTF-8。写入的时候用了UTF-8,读取的时候却按GBK解码,自然就炸了。
解决办法就是,读写文件时永远显式指定编码,别偷懒依赖系统默认值:
with open("result.txt", "w", encoding="utf-8") as f: ... with open("result.txt", "r", encoding="utf-8") as f: ...写入和读取都用同一套编码,就不会出问题。你要是把文件发给别人、或者放到云笔记里,UTF-8是最通用的选择,跨平台基本不会出乱子。这里也顺带提一句:如果你在Windows自带的记事本里打开UTF-8文件没乱码,但程序读出来是乱码,先检查是不是代码里少了encoding="utf-8"这个参数。
4.3 文件不存在、内容损坏:try/except 兜底
最后一个常见需求是"程序启动时如果文件存在,就读取并展示"。注意题目的措辞——"如果文件存在"。这意味着文件可能压根不存在,尤其是第一次运行的时候。如果你直接open("result.txt", "r"),文件不存在时程序直接崩给你看。
所以读取逻辑一定要做存在性判断和异常兜底:
def load_from_file(filename="result.txt"): students = {} try: with open(filename, "r", encoding="utf-8") as f: next(f) # 跳过表头 for line in f: parts = line.strip().split(",") if len(parts) < 4: continue # 行格式不对,跳过 sid = parts[1] name = parts[0] scores = { "语文": int(parts[2]), "数学": int(parts[3]), "英语": int(parts[4]), } students[sid] = {"name": name, "scores": scores} except FileNotFoundError: print("没有找到存档文件,直接进入录入模式") return studentstry...except FileNotFoundError是专门针对"文件不存在"这个预期情况的处理。它不是用来掩盖错误的,而是因为"文件不存在"在业务上完全合理——第一次运行当然没有历史文件,程序应该温柔地提示而不是粗暴地崩溃。
next(f)用来跳过表头那一行,这个技巧很多初学者没见过。文件对象本质上是一个迭代器,next(f)相当于手动取第一行然后扔掉,后面的循环自然就从第二行开始了。还有一个防御性判断if len(parts) < 4: continue,防止文件被手改坏了,读到残缺行导致程序崩掉。
5. 改了这么多份作业,这几个错误出现得最频繁
5.1 边遍历边删字典:RuntimeError 的现场
先说每年必现的一个报错。很多学生想把不及格的学生从字典里删掉,于是一口气写了这段代码:
for sid in students: if student_total(students[sid]) < 180: del students[sid] # RuntimeError: dictionary changed size during iteration然后当场收获一个RuntimeError: dictionary changed size during iteration。报错信息其实说得很明白:你在遍历字典的时候把字典大小改了。Python的字典迭代器会在内部记录当前的键数量,你一边遍历一边删,它会认为自己看到的数据结构被外部篡改了,立刻罢工。
正确做法是先把要删的学号收集到一个列表里,遍历结束后再统一删除:
drop_ids = [ sid for sid, info in students.items() if student_total(info) < 180 ] for sid in drop_ids: del students[sid]这个错误本身不难修,但我每次都拿出来讲,是因为它背后有一个更重要的思维方式:不要在执行"遍历+修改"的一瞬间同时动手。把"筛选"和"修改"拆成两个阶段,代码会清晰很多,也更不容易出逻辑错误。这个原则不止字典适用,列表里删元素、文件里删行,都是同一个思路。
5.2 排序搞错key:按姓名排还是按总分排
排序是这道题的核心需求,出错率也极高。第一个常见错误是忘了reverse=True,把降序写成了升序。第二个是key写错了对象。比如有学生这么写:
ranked = sorted(students.items(), key=lambda item: item[0])item[0]是学号,于是他按学号排了序,和总分没半毛钱关系。这种错法特别隐蔽,因为程序不报错,运行结果也有模有样地排好了序,只有仔细看才发现顺序根本不对。
还有一类错误是把key写成lambda s: s["total"],但你的学生信息里压根没有total这个字段,total是你现算出来的。这时候要么先在每个学生的字典里添加total字段,要么在lambda里现算。我推荐后者,不污染原始数据。另外,如果两个人的总分相同,你希望他们再按学号排,可以写:
ranked = sorted( students.items(), key=lambda item: (sum(item[1]["scores"].values()), -int(item[1]["sid"])), reverse=True )给 sorted 传一个元组作key,它会先比第一个元素,再比第二个元素。这里把学号取负,配合reverse=True,就实现了"总分相同则学号小者在前"的次要排序规则。这种写法在排序题里非常实用,建议记一下。
5.3 字符串和数字混着比:input() 挖的坑
第三个高频错误纯属阴沟里翻船。input()返回的永远是字符串,这个知识点课件里都会提,但一到实际代码里就忘。比较成绩的时候,有人直接拿原始输入来比:
score = input("请输入成绩:") if score >= "60": print("及格") else: print("不及格")这样写语法没错,"60" >= "60"也确实返回True。但问题出在字符串比较是按字典序一个字符一个字符比的:字符串"9"会大于"80",因为'9'的编码比'8'大,哪怕9在数值上远小于80。一旦出现两位数和一位数混杂的情况,判断就完全失灵了。
解决方法就是在录入阶段就完成类型转换:
try: score = int(input("请输入成绩:")) except ValueError: print("成绩必须是整数")我在这里想强调的其实不是转换本身,而是数据类型意识。写代码的时候要时刻清楚"这个变量现在是字符串还是数字",尤其从input、从文件里读出来的数据,默认都是字符串,需要参与计算的时候就当场转换,不要拖到后面,拖久了你自己都忘了它是什么类型。这也是为什么我平时调试时喜欢在可疑位置打一行print(type(var)),确认类型比猜靠谱得多。
5.4 输出格式地狱:教师机上的严格比对
第四个问题不太起眼,但杀伤力巨大:输出格式。很多作业是老师用自动比对脚本检查的,逐字符对比程序的输出和标准答案。你内容算得全对,但多了一个空格、少了一个冒号、小数点位数不对,结果就是0分。
比如平均分保留一位小数,应该用f"{avg:.1f}"而不是str(avg)。str(88.0)和f"{88.0:.1f}"都输出"88.0",看似一样,但str(83.66666666666667)输出一长串小数,而f"{83.66666666666667:.1f}"输出"83.7",直接不一样了。
我建议写完程序后,刻意做一次"格式自检":把输出结果复制到记事本里,和题目示例的输出逐字对比一遍。同时注意Windows下换行符的问题,如果你用print()输出的内容被重定向到文件,再读取比对时,CRLF和LF的差异可能会导致每一行都被判成不同。这个属于进阶问题,遇到再说,但"格式要和示例一模一样"这个意识一定要有。
5.5 排错链路:从看懂报错到定位问题
最后说一个通用方法论:程序报错时该怎么排错。很多新手看到红色报错就慌,然后开始在代码里瞎猜着改,运气好改对了,运气不好越改越乱。
我的排错步骤永远是固定的:
- 看报错信息的最后一行,那是错误类型和具体信息,比如
KeyError: 'total'告诉你字典里没有total这个键。先搞清楚"发生了什么",再想"为什么发生"。 - 看Traceback里的文件行号,从下往上读,最后一个代码框通常就是出错的那一行。先看那一行,大概率能直接发现问题。
- 如果看不出问题,就在出错位置前面加
print(),把涉及的变量一个一个打出来。打印内容要带上变量名和当前值,比如print("debug sid =", sid, "type =", type(sid))。这一步能立刻告诉你数据类型对不对、值是不是预期的。 - 修完之后,把临时print删掉,重新跑一遍,确认没有引入新问题。
这个流程听着简单,但能坚持这么做的人不多。大多数人倾向于"盯着代码看",而不是"让代码告诉你它看到了什么"。Python的报错信息已经写得很友善了,学会利用Traceback的信息量,排错时间能缩短一半以上。
6. 交完作业之后,我建议你顺手养成的几个习惯
6.1 命名和注释:写给一周后的自己
作业交上去,分数下来,这件事就算翻篇了?我觉得不是。第五次作业最大的价值,是你终于写出了第一个超过一百行的程序,这时候开始养成几个习惯,后面的路会顺很多。
第一个习惯是命名。a、b、c、list1、dict2这种名字,写的时候很爽,过三天你再回来看,完全不知道它是什么。我见过最夸张的一份作业,变量名从a排到z,排完了开始用aa、ab。算法再简单,光读懂这些变量名就要花十分钟。我建议变量名至少能说明含义,students、scores、course_names这种就行,不要求多高级,有意义就比没有强。
第二个习惯是注释写"为什么"而不是"是什么"。scores[sid] = ... # 把成绩存进字典这种注释就是废话,代码本身已经说明它在存成绩了。真正有价值的注释是那种解释原因的,比如:
if sid in students: continue # 学号主键去重,防止同一个人被录入两次这种注释能帮助未来的你理解当初的设计意图。
6.2 边界条件测试:空数据、重复学号、超范围分数
第二个习惯是测边界情况。很多同学的测试方法是"跑一遍,有结果,就交"。但程序真正的bug往往藏在边界里:
- 如果录入列表是空的,程序会不会崩?
- 学号重复了,程序有没有友好提示?
- 成绩输入了负数、输入了300分,程序有没有拦截?
- 文件里某一行被人为改坏了,读取时会不会崩?
- 如果只有一个学生,班级平均分、最高分最低分的逻辑还成立吗?
这些情况你自己不测,等老师用测试用例打你的代码,那就晚了。我每次写完程序,都会专门拿"空数据"和"极端数据"各跑一遍。空数据能暴露大部分"没有考虑到数据不存在"的逻辑缺陷,极端数据能暴露类型和范围校验的缺失。这个习惯花的时间不多,但能救你很多次。
6.3 多版本留档:一次改废了还能回退
第三个习惯是关于文件管理的。做作业时,千万别在同一个文件上反复大刀阔斧地改。今天加个功能,明天改个算法,改到一半发现思路错了,想回到昨天那个能跑的版本,结果发现昨天的版本被覆盖了,只能坐在电脑前重新敲一遍,那个滋味太难受了。
我现在写任何有一定规模的小程序都会做版本留档,最简单的做法就是按时间或者功能点存副本:
score_stat_v1.py基础录入和计算score_stat_v2.py加上排序score_stat_v3.py加上文件读写
每个版本之间的改动一目了然,出问题还能随时回退。如果你已经开始用版本控制工具,那更好,把这些练习项目都放进去,commit信息写成"完成录入模块""修复排序bug",回头翻起来就是一份自己的成长记录。
这次作业的代码量放在整个编程学习曲线里根本不值一提,但它标记着你第一次独立完成"需求→设计→编码→测试→交付"的完整闭环。把这个闭环走顺了,后面的每一份作业、每一个项目,都是在这条路上越走越远而已。