代码写了不少,但每次作业都像在“翻译”需求,而不是在设计程序?第三次作业往往是Python课程的分水岭——前面的作业都是单文件、几十行、跑通就算完,从这次开始,老师开始要求“结构”“封装”“复用”,甚至暗示你要用点“类”的东西。我接到第三次作业时,题目是“学生成绩管理系统”,需求就三行:录入、查询、统计。但交完作业被批注了一堆问题,从那次之后我才算真正开始理解什么叫“写程序”。这篇就把我踩过的坑、重新设计的思路、以及最终稳定版本的结构完整拆开讲,给正在做类似作业的同学做个参考。
1. 拿到作业后的第一件事:别急着写代码,先拆需求
我说句实在话,大部分人拿到这种“学生成绩管理系统”题目,第一反应就是打开编辑器,敲input(),然后开始写循环。写了两百行之后发现需求里面“删除”“排序”“按课程统计”全部挤在一个while True里面,函数一个没有,代码一团乱,查个bug能查半小时。
第三次作业的核心目的几乎都是同一个:逼你用函数和类来组织代码。不是为了实现功能,功能用if else也能堆出来。作业真正想考察的是你有没有开始建立“程序结构”的意识。
我当时的做法是先不碰电脑,拿纸把需求画成几个大块:
- 数据层:学生的信息字段有哪些?成绩是单科还是多科?要不要平时分、期末分?
- 操作层:录入、删除、修改、查询、排序、统计,这些操作各自的输入和输出是什么?
- 展示层:是控制台打印就行,还是需要简单的菜单循环?
这么一拆,思路就清楚了。数据层是核心,操作层只是对数据做加工,展示层则是跟用户打交道的外壳。很多同学把这三层全揉在main函数里,就是后面痛苦的根源。
另外一个很实际的问题:需求真的是“这样”吗?题目写的“学生成绩管理系统”,字段是“姓名+学号+成绩”,还是一个班级多门课?是每次运行重新录入,还是需要保存到文件下次接着用?这些老师在题目里不一定明说,你需要主动做假设并写清楚。我的习惯是直接在代码开头开一段注释:
""" 学生成绩管理系统 需求假设: 1. 学生信息:学号、姓名、语文、数学、英语三科成绩 2. 支持操作:添加、删除、修改、按学号查询、按总分排序、统计平均分 3. 数据持久化:JSON文件保存,程序启动时自动加载 """把“假设”写出来是一种非常好的习惯。答辩的时候老师问“你为什么这样设计”,你直接指注释;就算老师本意不是这样,他也会觉得你考虑得很周全。
2. 代码结构设计:从函数堆砌到类的封装
第一次我自己做的时候,还停留在“定义一堆函数,全局变量传来传去”的阶段。比如定义了一个students = []作为全局列表,然后写add_student()、delete_student()、search_student(),每个函数都去全局变量里操作。能跑,而且改起来很灵活,但这种设计有一个天生的问题:代码一多,你根本不知道哪个函数在什么时候改了什么数据。
后来我用了类来重构。我身边很多同学一听到“类”就头大,但实际上它就是把“数据”和“操作数据的函数”打包而已。你不用类也能把作业做完,但如果后续要加功能、要复用、要查问题,类结构会舒服得多。
我把整个系统拆成两个类:
Student类,负责一个学生的数据模型:
class Student: def __init__(self, student_id, name, scores): self.student_id = student_id # 学号 self.name = name # 姓名 self.scores = scores # 字典,例如 {"语文": 88, "数学": 95, "英语": 72} def total(self): return sum(self.scores.values()) def average(self): return self.total() / len(self.scores) if self.scores else 0这个类很简单,但有两个好处:第一,所有对学生数据的基本操作都放在一个地方,比如算总分、算平均分;第二,后续如果某个学生要加“排名”“绩点”字段,直接在类里加方法就行,不影响其它代码。
GradeManager类,负责整个系统的数据管理:
class GradeManager: def __init__(self, data_file="students.json"): self.data_file = data_file self.students = [] # 存放 Student 对象列表 self.load() def add(self, student): self.students.append(student) def remove(self, student_id): for s in self.students: if s.student_id == student_id: self.students.remove(s) return True return False def find(self, student_id): for s in self.students: if s.student_id == student_id: return s return None def load(self): # 从文件读取数据 pass def save(self): # 将数据写入文件 pass我当时重构完,最大的感受是:main函数里几乎不用关心数据怎么存、怎么查,只需要调用方法就行。
有人说“老师没要求用类,我自己搞个类不是多此一举吗?”我反而觉得,作业的意义不是满足最低要求,而是借机会练习。哪怕老师不查类,你用了类之后,再回头看看那些函数满天飞的代码,会立刻感受到差距。这也是第三次作业存在的意义——让你体验从“把代码写出来”到“把代码组织好”的转变。
3. 踩过的最深的坑:列表别名、浅拷贝与数据污染
每次写这种管理系统,最终必然遇到一个问题:你往列表里添加了一个学生,再添加第二个,结果发现第一个学生的数据跟着变了。我调试了很久才意识到,问题根本不在“添加”操作,而在对象的引用共用。
看下面这段代码:
scores_input = {"语文": 0, "数学": 0, "英语": 0} for i in range(2): scores_input["语文"] = int(input(f"请输入第{i+1}个学生的语文成绩:")) scores_input["数学"] = int(input(f"请输入第{i+1}个学生的数学成绩:")) scores_input["英语"] = int(input(f"请输入第{i+1}个学生的英语成绩:")) manager.add(Student("2024001" + str(i), f"学生{i}", scores_input))运行之后你会发现,两个学生的成绩完全一样,永远是最后一次输入的结果。原因就是你把同一个scores_input字典塞给了两个不同的Student对象,它们内部存的是同一个字典的引用,改一处全变。
这就是经典的浅拷贝陷阱。我当时上课的时候听到“可变对象”“引用”这些词完全没感觉,遇到这个bug后就彻底懂了。
解决办法很简单,给Student构造时复制一份数据:
class Student: def __init__(self, student_id, name, scores): self.student_id = student_id self.name = name self.scores = dict(scores) # 强制做一次浅拷贝,切断引用或者更保险一点,在录入时就新建字典:
scores = { "语文": int(input("语文成绩:")), "数学": int(input("数学成绩:")), "英语": int(input("英语成绩:")), } manager.add(Student(student_id, name, scores))除了字典,Python里常见的可变对象还有列表、集合、类的实例。只要你在一个循环里复用了同一个可变对象,就一定要想到“这个对象会不会被多个地方同时引用”这个问题。
我再补充一个相关的坑:列表的remove方法。如果你想在遍历列表的时候删除元素,千万不要顺手在for循环里删,会出现“跳过一个”的诡异情况。比如:
for s in self.students: if s.total() < 180: self.students.remove(s)这样做会漏删,因为列表在遍历过程中长度变了,索引指针却不等人。我当时处理“批量删除不及格学生”时就遇到过:明明有三个不及格,最后只删了两个,另一个怎么都找不到。
正确的做法是先收集要删除的下标,或者直接构建新列表:
self.students = [s for s in self.students if s.total() >= 180]这种“遍历时不能改结构”的坑,在第三次作业里出现频率非常高。如果你排查了很久找不出原因,多半就是这个问题。
4. 数据持久化的选择:为什么用JSON而不是手写TXT解析
题目往往不会强制要求保存数据,但我建议主动把“持久化”做进去。理由很简单:如果每次运行都要重新录入一遍20个学生的数据,测试时你一定会崩溃。而且,只有做了持久化,你才算真正接触到了“程序退出后数据怎么办”这个问题。
常见的三种存储方案各有利弊:
| 方案 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| TXT文本(自定义格式) | 简单、直观,老师好检查 | 解析容易出错,字段一多就乱 | 极简作业 |
| JSON文件 | 结构清晰、中文友好、Python原生支持 | 需要保证数据可序列化 | 最推荐 |
| CSV文件 | 可用Excel打开、格式通用 | 有同学会乱加逗号导致解析错行 | 数据导入导出 |
我选择JSON,原因非常实在:Python的json模块把“字典列表”直接变成文件,读的时候再变回原来的结构,不用自己写复杂的解析逻辑。代码量少到令人愉悦:
import json class GradeManager: def save(self): data = [] for s in self.students: data.append({ "student_id": s.student_id, "name": s.name, "scores": s.scores }) with open(self.data_file, "w", encoding="utf-8") as f: json.dump(data, f, ensure_ascii=False, indent=2) def load(self): try: with open(self.data_file, "r", encoding="utf-8") as f: data = json.load(f) self.students = [Student(item["student_id"], item["name"], item["scores"]) for item in data] except FileNotFoundError: self.students = []这一版本里有三个细节,全是经验:
第一,encoding="utf-8"必须写,不然Windows下读中文姓名可能直接乱码。这几乎是所有基础作业里都会遇到的问题,一上来就写好,能省很多事。
第二,ensure_ascii=False必须写,否则json.dump会把中文变成\u开头的转义序列。虽然功能上没错,但打开文件看到一堆\u5f20\u4e09的时候,你根本不知道自己存的数据是什么。
第三,load()里的try...except是针对“第一次运行还没有数据文件”的情况。如果你不处理异常,第一次启动程序就会直接崩掉。这个处理方式我给很多同学讲过,他们听完就一个反应:原来异常处理不是等到出错了才写,而是因为你知道这里一定会出错。
我自己实际使用中发现,json.load还有一个隐藏的风险点:如果数据文件被用户手动改坏了,程序会直接抛JSONDecodeError。作业场景下一般不用管,但如果你想让程序更强壮,可以在except里多接一个异常类型:
except (FileNotFoundError, json.JSONDecodeError): self.students = []我因为写作业时加了这一句,后来做课程项目的时候直接被队友夸过“稳”。但其实真的只是顺手。
5. 调试技巧与测试用例设计:让作业一次通过
很多同学的调试方式就是“哪里不对劲就打印哪里”,打印一多就忘了哪个是哪个。我推荐一个非常朴素的调试流程:先想清楚输入样例和预期输出,再拿一组数据去跑,跑不过就逐步缩小范围。
我设计了三组测试数据:
- 正常数据:语文 88,数学 95,英语 72,验证基本的添加、查询、统计。
- 边界数据:成绩输入0分、100分,或者学号重复,验证程序不会崩溃。
- 异常输入:输入的分数不是数字,比如“abc”,验证程序是否能给出提示而不是直接抛出异常。
“成绩能不能大于100”这种边界检查,很多同学会忽略。老师测试的时候非常喜欢输入101或者-1,如果你的程序直接存进去了,答辩时就会很难看。加一层判断很简单:
def get_score(subject_name): while True: raw = input(f"请输入{subject_name}成绩:") try: score = float(raw) if 0 <= score <= 100: return score else: print("成绩必须在0到100之间,请重新输入。") except ValueError: print("输入无效,请输入数字。")这个函数的逻辑不复杂,但体现了“防御性编程”的思路:不要信任用户的输入,要替用户兜底。很多作业评分就看这种细节,代码看起来好像多了几十行,但老师一看就知道你的水平。
另外,我强烈建议作业做完之后,做一次“从零开始运行测试”。怎么测呢?把你已经生成的数据文件先删掉,重新运行程序,完整走一遍“添加几个学生→查询一个不存在的学号→排序→删除→修改→退出→重新打开→确认数据还在”。整个过程如果一遍顺畅,那你的程序就相当稳了。
我做这个测试时发现了一个奇怪的问题:排序功能明明写了,排序结果却没按总分降序排列。后来查了半天,发现我犯了一个非常基础的错误——把sorted的返回值给丢了。
# 错误写法 students.sort() # Student对象没有实现 __lt__ 比较方法,直接崩 # 正确写法 self.students.sort(key=lambda s: s.total(), reverse=True)这里牵出一个知识点:Python的sort()是就地排序,sorted()返回新列表。而lambda表达式作为key,可以指定排序的依据,不用改Student类的比较逻辑。这个点我学到的时候觉得很简单,但实际用起来经常搞混。尤其是如果你想让Student对象直接支持<比较,需要自己实现__lt__方法,否则会遇到TypeError。对于作业来说,用key参数就够了,不用过度设计。
6. 菜单循环的交互设计:告别“程序跑完就消失”
如果只用input()一行一行的录数据,程序交互性会很差。我建议做一个简单的菜单:
def main(): manager = GradeManager() menu = """ ===== 学生成绩管理系统 ===== 1. 添加学生 2. 删除学生 3. 修改成绩 4. 按学号查询 5. 按总分排序 6. 显示全部学生 7. 统计各科平均分 0. 退出 ========================== """ while True: print(menu) choice = input("请选择操作:") if choice == "1": # 添加学生逻辑 pass elif choice == "2": pass # ... 省略其他分支 elif choice == "0": manager.save() print("已保存数据,再见!") break else: print("无效选项,请重新输入。")注意上面的逻辑里没有continue,其实每个分支处理完会自动回到while循环开头,重新打印菜单。退出时才save()一次,这样可以避免每次操作都写磁盘,效率更高。唯一的风险是如果程序在save()之前被强制关闭,数据会丢。作业场景一般无所谓,但如果你想让程序更稳,可以在每次“添加/删除/修改”之后都立刻保存。
菜单交互最大的好处是方便演示。老师运行你的程序时,看到一个结构清晰的菜单,第一印象就会很好。这就像写作文,字迹工整本身就是加分项。
我在设计菜单时还处理了一个常见bug:用户输入的不是数字,而是一个字符串。如果你直接choice = int(input(...)),输入“abc”就会崩溃。我选择让choice保持字符串,然后逐个跟"1"、"2"比较,这样无论用户输入什么都直接进入else分支,不用单独做类型转换。
7. 答辩和复盘:作业做完之后更要做的两件事
作业不是提交就结束了。如果你真的想从第三次作业里学到东西,做完之后花半小时做一次复盘,价值比写代码本身还要大。
第一件事,把代码里所有“写死”的数字全部换成常量。比如成绩科目数,你写len(scores)没问题,但如果你在某处写死了3,后面加一科就要改三个地方:
SUBJECTS = ["语文", "数学", "英语"]然后遍历SUBJECTS,任何地方都用这个常量,后面扩展就很方便。我当时只改了这一个地方,老师就夸我“有工程意识”。
第二件事,想象一下“如果需求增加一个功能,我要改哪些代码?”比如老师说:“再加一个‘按性别筛选’。”如果你的代码里所有逻辑都集中在main函数里,那这个需求会非常痛苦。但如果你用了GradeManager类,你只需要增加一个filter_by_gender方法,然后在菜单里加一个选项。这就是封装的直接价值。
很多同学问我:“老师会不会觉得我用类写得太复杂?”我的看法是:老师最怕的不是你写得复杂,而是你写得乱。类让你的代码变得更清晰、更好调,哪怕是老师来检查代码,他也更容易看懂。你担心复杂,其实是因为还没有真正理解类的用法;一旦你理解“类就是数据和方法的容器”,你就会发现它比散装函数简单得多。
再分享一个答辩技巧:介绍程序的时候,不要从第一行代码开始讲,而是先讲“我的程序分成了几个模块”“每个模块负责什么”“数据是怎么流入流出的”。老师一听就明白你是真的有思路的人,而不是只会抄代码的。
8. 最后的补充:给正在卡关的同学的几句实在话
如果你现在正卡在第三次作业上,感觉无从下手,我建议先别管别人的代码,也别急着查“完整源码”。你只需要做一件事:把题目里的功能点列出来,一个一个单独实现,最后再拼到一起。不要总想着“一次写出完美版本”,编程是一个迭代的过程,第一次跑起来就是巨大的进步。我第一版的时候连函数都没拆,但至少先跑通了核心流程,后面的重构才有基础。
如果你用的是类结构,却还没完全搞懂“对象”这个概念,不用焦虑。你会在这个作业里大量接触“创建对象、调用方法、对象属性”这些操作,做着做着就会自然理解了。我在做这个作业之前对self也是一知半解,但当我反复写self.students、self.save()之后,脑子里慢慢就形成了一个具象的画面:每一个对象就像一张表格,self就在表格上画了一个圈“这部分是我的数据”。这种理解是听课听不来的,只能靠亲手写代码获得。
最后,代码写完一定要格式化,缩进统一,变量名不要用a1、b2这种。好的代码本身就带着一种让人愿意读下去的节奏。把缩进调好、把注释写得像在跟人说话,你回头再看自己的代码,也会更愿意去改它。
我到现在还会偶尔翻出这个成绩管理系统的旧代码,每次看都能发现新的问题,也能想起当初埋头调试到深夜的经历。那次的收获不止是交了一个作业,而是我第一次感受到“把代码设计好”和“把代码写出来”完全是两回事。希望你做完这次作业之后,也会有同样的感受。