先说一下背景。这份“python第四次作业”是我这段时间带的一个入门班里的真实练习,题目是“学生成绩分析系统”。前面的三次作业分别是变量与类型、条件与循环、列表与字符串处理,到第四次就该把函数、字典、文件读写和基础统计串起来用了。说白了,这是从“能写代码”到“能写小工具”的一个转折点,很多同学也是从这一次作业开始真正体会到Python的实用性。这篇就完整记录一下这次作业的题目拆解、代码实现、调试过程和一些容易被忽略的坑,给同样卡在作业上的朋友一个参考。
1. 作业需求拆解与整体框架设计
1.1 作业题目的核心逻辑
这次的作业题目要求实现一个“学生成绩分析系统”,功能比较典型,基本覆盖了Python入门的核心知识点。原题大致要求如下:
- 支持录入学生信息,包括姓名、学号和课程成绩。
- 支持删除学生记录。
- 支持修改某个学生的成绩。
- 能计算班级的平均分、最高分、最低分和及格率。
- 将数据保存到文件,程序重启后能重新加载。
- 可选加分项:用matplotlib绘制成绩分布直方图。
我拿到题目后首先做的事不是写代码,而是把需求列成一张表,明确每个功能点背后对应的是什么知识点:
| 功能 | 涉及知识点 |
|---|---|
| 录入学生 | 字典、列表、输入输出 |
| 删除学生 | 列表遍历、字典删除 |
| 修改成绩 | 字典更新、条件判断 |
| 统计平均分/最高分/最低分 | 循环、列表操作、max/min函数 |
| 及格率计算 | 条件统计、百分比计算 |
| 文件保存与加载 | CSV文件读写、异常处理 |
| 可视化直方图 | matplotlib基础、数据处理 |
列出这张表之后,整个作业的脉络就清晰了。这不仅能帮自己理清思路,在写代码的时候也更容易判断哪些地方应该用什么方式实现。
1.2 编程环境的准备与工具选择
作业要求里没有限制编辑器,但我建议直接装一个纯正的Python环境,然后用VS Code写代码。装的时候注意勾选“Add Python to PATH”,这个如果不勾,后面在命令行里运行python命令会提示找不到,很多新手在这儿卡了半天。
版本选择上,装Python 3.10或3.11稳定版就行,不需要追最新。为什么?因为很多第三方库在最新版本上可能还没来得及适配,反而老版本更稳。我这边的经验是直接用Anaconda也行,自带了很多常用的库,省得后面可视化的时候还要单独装matplotlib。不过如果不想装这么大个全家桶,直接用官方Python解释器加pip安装也是可以的,命令就是:
bash复制pip install matplotlib
安装的时候如果遇到网络慢的情况,可以换成国内镜像源:
bash复制pip install matplotlib -i https://pypi.tuna.tsinghua.edu.cn/simple
这个镜像源是清华的,速度快很多,我平常都习惯用这个。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 核心知识点拆解与方案选型
2.1 数据结构:字典与列表如何取舍
整个程序里最核心的数据结构就是怎么存学生信息。我先说说我当时在群里看到的几种写法。
第一种写法是用并列关系的外部结构,把学生信息放在一个列表里,每个学生是一个字典。这是最常见也最推荐的方式:
python复制students = [
{"name": "张三", "sno": "2024001", "score": 88},
{"name": "李四", "sno": "2024002", "score": 74}
]
这种结构的优点是清晰直观,遍历的时候能直接拿到每个学生的完整信息。缺点是如果学生数量大,遍历匹配会比较慢,不过对作业级别来说完全够用。
第二种写法是用学号做键的字典:
python复制students = {
"2024001": {"name": "张三", "score": 88},
"2024002": {"name": "李四", "score": 74}
}
这种结构的好处是查找某个学生时直接按学号取值就行,时间复杂度是O(1),不用遍历整个列表。坏处是如果同一个班级有多个学生想按姓名查找,就必须自己遍历value。
两种写法都能完成作业要求,我的建议是选择第一种。原因很简单:这个作业的核心操作是“遍历统计”,比如算平均分、最高分、最低分,这些操作需要遍历所有学生的成绩。用列表套字典的写法,计算时只需要一次for循环,逻辑最直接,也好调试。而且作业里需要展示的通常是一整张表,列表结构在打印输出的时候顺序是稳定的,体验更好。
2.2 函数设计:怎么拆才合理
第四次作业重点考察的就是函数的封装。很多同学会把所有逻辑全写在一个大while循环里,也能跑通,但代码会非常长,而且出了问题很难定位。我建议按照功能职责来拆函数,每个函数只干一件完整的事。
比如我最终拆出来的函数有这些:
load_data():从CSV文件加载学生数据。save_data():把学生数据保存到CSV文件。add_student():录入一个新学生。delete_student():按学号删除学生。modify_score():按学号修改成绩。show_all():展示所有学生信息。cal_statistics():计算并显示统计结果。draw_histogram():绘制成绩分布直方图。main():主菜单循环。
这里有一个新手常犯的问题,就是喜欢把所有输入输出都放主函数里,子函数只是被调用的“工具人”。这样做的结果是主函数非常臃肿,而子函数做不了独立的决策。更合理的做法是让每个子函数负责自己的输入和输出,主函数只做菜单跳转和流程控制。比如add_student(),它就是负责提示用户输入姓名、学号、成绩,然后添加到列表中,它内部可以把数据校验也做了。这样主函数看起来干净,每个功能模块也可以单独测试。
2.3 文件读写:存什么格式、怎么处理
文件保存这一块,我选了CSV格式,不用自造的.txt格式。原因是CSV可以直接用Excel打开,调试的时候看着方便,而且Python标准库的csv模块处理这个格式非常顺。如果你用自定义的分隔符(比如逗号或竖线)自己拼字符串去存,最后会遇到转义和读取时不匹配的问题,白白增加工作量。
CSV的读写其实很简单:
python复制import csv
def save_data():
with open("students.csv", "w", newline="", encoding="utf-8-sig") as f:
writer = csv.writer(f)
writer.writerow(["name", "sno", "score"])
for stu in students:
writer.writerow([stu["name"], stu["sno"], stu["score"]])
注意几个细节。newline=""这个参数一定要加上,否则在Windows下写出来的CSV每行之间会多一个空行。编码用utf-8-sig而不是utf-8,因为utf-8-sig会写入BOM头,Excel打开时才不会出现中文乱码。如果直接用utf-8,Windows下的Excel默认按ANSI解析,中文会变成乱码。我当初第一次存文件就踩了这个坑,被乱码问题搞了半天,换了个编码就解决了。
读取数据时类似:
python复制def load_data():
students.clear()
if not os.path.exists("students.csv"):
return
with open("students.csv", "r", encoding="utf-8-sig") as f:
reader = csv.DictReader(f)
for row in reader:
students.append({
"name": row["name"],
"sno": row["sno"],
"score": int(row["score"])
})
之所以用DictReader,是因为它会把第一行当表头,自动把每一行数据转成字典,按列名取值,非常直观。这里也要注意成绩取出来后要转成int,不然后面计算平均分会变成字符串拼接。
3. 完整代码实现与逐段解析
3.1 主程序骨架与菜单循环
先看全局结构和主函数。这个程序的主体是一个循环,每次打印菜单、接收用户输入、执行对应功能,直到用户选择退出。代码如下:
python复制import os
import csv
import matplotlib.pyplot as plt
students = []
def main():
load_data()
while True:
print("\n===== 学生成绩分析系统 =====")
print("1. 添加学生")
print("2. 删除学生")
print("3. 修改成绩")
print("4. 显示所有学生")
print("5. 统计成绩")
print("6. 绘制成绩分布图")
print("0. 退出并保存")
choice = input("请选择操作:")
if choice == "1":
add_student()
elif choice == "2":
delete_student()
elif choice == "3":
modify_score()
elif choice == "4":
show_all()
elif choice == "5":
cal_statistics()
elif choice == "6":
draw_histogram()
elif choice == "0":
save_data()
print("已保存,再见!")
break
else:
print("输入无效,请重新选择")
这段代码里最值得注意的地方是函数的命名,直接用了动词开头add_、delete_、modify_等,这样从名字就能看出函数是干什么的。另一个细节是load_data()在程序一开始就调用,这样上次保存的数据能自动加载回来,实现“断点续传”的效果。
有些同学会纠结要不要在每次操作后都调用save_data(),我的建议是不要,只在退出时保存一次就好。因为如果每次都保存,频繁的磁盘写入没这个必要,而且万一程序跑到一半出错了,最后退出时没保存,那之前的修改就全丢了。更稳妥的方案是每次操作后提示“修改尚未保存,退出前请执行0”,但作业规模不需要这么复杂,退出时统一保存就够了。
3.2 增删改查函数实现
这四个函数是作业的基础功能,也是操作最频繁的部分。先说添加学生:
python复制def add_student():
name = input("请输入姓名:")
while True:
sno = input("请输入学号:")
if find_by_sno(sno):
print("该学号已存在,请重新输入")
else:
break
while True:
try:
score = int(input("请输入成绩(0-100):"))
if 0 <= score <= 100:
break
else:
print("成绩必须在0-100之间")
except ValueError:
print("请输入整数成绩")
students.append({"name": name, "sno": sno, "score": score})
print(f"学生 {name} 添加成功")
这里有两个值得学习的点。第一个是利用find_by_sno()函数做学号唯一性校验,防止同一个学号被录两遍。这个find_by_sno是额外抽出来的一个辅助函数,作用是按键查找学生:
python复制def find_by_sno(sno):
for stu in students:
if stu["sno"] == sno:
return stu
return None
之所以单独抽这个函数,是因为删除和修改功能里也要按学号查找学生,不抽出来就要写三遍相同的for循环,代码会显得很啰嗦。这就是函数封装的典型好处。
第二个是成绩输入校验。我做了一个try...except结构,如果用户输入的不是整数,int()会抛ValueError异常,程序不会直接崩溃,而是提示重新输入。如果输入的数字不在0-100范围内,也会提示并再次循环。这个输入校验看着繁琐,但实际运行时体验完全不一样,没有校验的程序,用户手滑输了个abc,程序直接崩溃退出,很打击使用信心。
删除和修改的逻辑类似,都依赖find_by_sno,代码如下:
python复制def delete_student():
sno = input("请输入要删除学生的学号:")
stu = find_by_sno(sno)
if stu is None:
print("未找到该学号")
return
students.remove(stu)
print(f"已删除学生 {stu['name']}")
def modify_score():
sno = input("请输入要修改学生的学号:")
stu = find_by_sno(sno)
if stu is None:
print("未找到该学号")
return
while True:
try:
new_score = int(input(f"请输入 {stu['name']} 的新成绩:"))
if 0 <= new_score <= 100:
stu["score"] = new_score
print("修改成功")
break
else:
print("成绩必须在0-100之间")
except ValueError:
print("请输入整数成绩")
删除的时候有个细节,list.remove()是依据值来删除的,所以传入的是整个学生字典对象。它内部会遍历列表,找到第一个值相等的元素并移除。因为我们的字典在列表里是唯一的,不会出现误删的问题。
3.3 统计分析与可视化
统计功能是这次作业的核心加分项,也是最体现逻辑能力的部分。
python复制def cal_statistics():
if not students:
print("暂无学生数据")
return
scores = [stu["score"] for stu in students]
avg = sum(scores) / len(scores)
highest = max(scores)
lowest = min(scores)
passed = [s for s in scores if s >= 60]
pass_rate = len(passed) / len(scores) * 100
print(f"学生人数:{len(students)}")
print(f"平均分:{avg:.1f}")
print(f"最高分:{highest}")
print(f"最低分:{lowest}")
print(f"及格率:{pass_rate:.1f}%")
这里用到了列表推导式scores = [stu["score"] for stu in students],一次循环就把所有成绩提取出来。这一行代码相比传统的for循环写法更简洁,是Python的典型风格,也是这次作业想考察的重点之一。sum()、max()、min()这三个内置函数可以直接对列表操作,不需要自己写累加循环。
及格率的计算有个隐藏的坑:如果直接用len(passed) / len(scores),在Python 3里这个结果是浮点数,没问题。但在Python 2里会变成整数除法,结果永远是0。虽然现在Python 2已经很少有人用了,但还是提一下,以防万一。另外如果班级人数为0,这里会直接报除零错误,所以要先判断一下students是否为空。
可视化部分的代码如下:
python复制def draw_histogram():
if not students:
print("暂无学生数据")
return
scores = [stu["score"] for stu in students]
plt.hist(scores, bins=10, range=(0, 100), edgecolor='black')
plt.xlabel("分数段")
plt.ylabel("人数")
plt.title("学生成绩分布直方图")
plt.show()
bins=10表示分成10个分数段,range=(0, 100)确保横轴范围是0到100,不会因为最高分不到100而自动扩出多余的空间。edgecolor='black'给每个柱子加上黑色边框,让图看起来更清晰。
这里有个小问题:如果你的系统是Linux服务器,没有图形界面,plt.show()可能不会弹窗。这也是为什么作业里标注了“加分项”——它需要本机测试,或者在你的电脑上装了图形环境。如果在Linux远程环境跑,可以把plt.show()改成plt.savefig("histogram.png")保存成图片,这样也能看到结果。
3.4 完整代码汇总
把上面的函数拼起来就是完整程序,为了减少重复我直接把所有代码贴在一起:
python复制import os
import csv
import matplotlib.pyplot as plt
students = []
def find_by_sno(sno):
for stu in students:
if stu["sno"] == sno:
return stu
return None
def load_data():
students.clear()
if not os.path.exists("students.csv"):
return
with open("students.csv", "r", encoding="utf-8-sig") as f:
reader = csv.DictReader(f)
for row in reader:
students.append({
"name": row["name"],
"sno": row["sno"],
"score": int(row["score"])
})
def save_data():
with open("students.csv", "w", newline="", encoding="utf-8-sig") as f:
writer = csv.writer(f)
writer.writerow(["name", "sno", "score"])
for stu in students:
writer.writerow([stu["name"], stu["sno"], stu["score"]])
def add_student():
name = input("请输入姓名:")
while True:
sno = input("请输入学号:")
if find_by_sno(sno):
print("该学号已存在,请重新输入")
else:
break
while True:
try:
score = int(input("请输入成绩(0-100):"))
if 0 <= score <= 100:
break
else:
print("成绩必须在0-100之间")
except ValueError:
print("请输入整数成绩")
students.append({"name": name, "sno": sno, "score": score})
print(f"学生 {name} 添加成功")
def delete_student():
sno = input("请输入要删除学生的学号:")
stu = find_by_sno(sno)
if stu is None:
print("未找到该学号")
return
students.remove(stu)
print(f"已删除学生 {stu['name']}")
def modify_score():
sno = input("请输入要修改学生的学号:")
stu = find_by_sno(sno)
if stu is None:
print("未找到该学号")
return
while True:
try:
new_score = int(input(f"请输入 {stu['name']} 的新成绩:"))
if 0 <= new_score <= 100:
stu["score"] = new_score
print("修改成功")
break
else:
print("成绩必须在0-100之间")
except ValueError:
print("请输入整数成绩")
def show_all():
if not students:
print("暂无学生数据")
return
print(f"{'姓名':<8}{'学号':<12}{'成绩':<6}")
print("-" * 30)
for stu in students:
print(f"{stu['name']:<8}{stu['sno']:<12}{stu['score']:<6}")
def cal_statistics():
if not students:
print("暂无学生数据")
return
scores = [stu["score"] for stu in students]
avg = sum(scores) / len(scores)
highest = max(scores)
lowest = min(scores)
passed = [s for s in scores if s >= 60]
pass_rate = len(passed) / len(scores) * 100
print(f"学生人数:{len(students)}")
print(f"平均分:{avg:.1f}")
print(f"最高分:{highest}")
print(f"最低分:{lowest}")
print(f"及格率:{pass_rate:.1f}%")
def draw_histogram():
if not students:
print("暂无学生数据")
return
scores = [stu["score"] for stu in students]
plt.hist(scores, bins=10, range=(0, 100), edgecolor='black')
plt.xlabel("分数段")
plt.ylabel("人数")
plt.title("学生成绩分布直方图")
plt.show()
def main():
load_data()
while True:
print("\n===== 学生成绩分析系统 =====")
print("1. 添加学生")
print("2. 删除学生")
print("3. 修改成绩")
print("4. 显示所有学生")
print("5. 统计成绩")
print("6. 绘制成绩分布图")
print("0. 退出并保存")
choice = input("请选择操作:")
if choice == "1":
add_student()
elif choice == "2":
delete_student()
elif choice == "3":
modify_score()
elif choice == "4":
show_all()
elif choice == "5":
cal_statistics()
elif choice == "6":
draw_histogram()
elif choice == "0":
save_data()
print("已保存,再见!")
break
else:
print("输入无效,请重新选择")
if __name__ == "__main__":
main()
最后这个if __name__ == "__main__":看起来有点多余,但对python项目来说是一个好习惯。它的作用是:只有当这个文件被直接运行时才执行main()。如果这个文件被其他文件当作模块导入,就不会自动运行,方便以后复用函数。
4. 运行结果与调试记录
4.1 实际运行效果
我在本地跑了一遍完整流程,加了3个学生,数据情况是这样的:
text复制===== 学生成绩分析系统 =====
1. 添加学生
2. 删除学生
3. 修改成绩
4. 显示所有学生
5. 统计成绩
6. 绘制成绩分布图
0. 退出并保存
请选择操作:1
请输入姓名:王小明
请输入学号:2024001
请输入成绩(0-100):86
学生 王小明 添加成功
然后继续添加了李华(74分)和张伟(59分)。接着执行统计功能,输出:
text复制学生人数:3
平均分:73.0
最高分:86
最低分:59
及格率:66.7%
这个结果和手算一致,说明统计逻辑没有问题。执行绘制直方图时,matplotlib弹出了一个窗口,横轴是分数段,纵轴是人数,可以直观看到成绩的分布情况。保存退出后,students.csv文件里的内容是这样的:
csv复制name,sno,score
王小明,2024001,86
李华,2024002,74
张伟,2024003,59
再次启动程序,load_data()自动把这三个学生加载回来了。
4.2 常见问题与解决方案
我在跑这个作业的时候,连同学生在群里反馈的问题,整理了最常见的几个坑:
| 问题 | 原因 | 解决方案 |
|---|---|---|
| CSV文件中文乱码 | 存储编码用了utf-8,Excel按ANSI解析 |
改用encoding="utf-8-sig" |
| 写CSV每行之间有空行 | 没有指定newline="" |
打开文件时加newline=""参数 |
| 成绩可以输入负数 | 没有做范围校验 | 增加0 <= score <= 100判断 |
| 输入字母导致程序崩溃 | 没有处理ValueError异常 |
用try...except包裹转换 |
| 删除学生后列表错乱 | 直接在遍历过程中修改列表 | 先找到目标,再remove |
| matplotlib无法显示中文 | 缺少中文字体配置 | 使用plt.rcParams['font.sans-serif'] = ['SimHei'] |
最后一条关于中文字体的配置,刚好我在画图时也会遇到。如果你画出来的直方图横轴标题“分数段”显示为方块,那是因为matplotlib的默认字体不支持中文。在代码开头加上这两行就好了:
python复制plt.rcParams['font.sans-serif'] = ['SimHei']
plt.rcParams['axes.unicode_minus'] = False
第二行是为了让负号能正常显示。这个在Windows上一般能生效,Linux上如果没装中文字体,可以安装fonts-wqy-zenhei之类的中文字体包。
还有一种情况是版本兼容问题。matplotlib在3.5以上版本有一些API调整,但基础的plt.hist、plt.xlabel这些用法是稳定的,不会出现兼容性问题。如果你是用Anaconda装的,matplotlib已经预装了,不需要额外操作。
5. 扩展思路与我的个人体会
5.1 作业还能怎么改
这次作业虽然完成了,但我自己跑通之后觉得还有不少可以优化的地方。比如现在的删除和修改都是按学号操作,实际使用中用户可能只知道姓名,不知道学号。可以考虑增加一个按姓名模糊查找的功能,用if key in stu["name"]做部分匹配,这也是字符串操作在真实场景中的应用。
另外,现在的成绩存储为整数,但真实成绩可能有小数。把int改成float就可以支持小数,但输入校验里也要改成float()转换。如果后续课程学数据库,这个程序还可以直接对接SQLite,把CSV的存取换成数据库操作,功能可以继续扩展。
还有一个挺有意思的方向是“人狗大作战”(一个课外的博弈小游戏)那种思路,把数据结构换成更复杂的对象,用类来管理学生和班级。这样代码更加面向对象,也更容易扩展班级、课程、教师等维度。不过那是后话了,作业阶段用函数式写法就可以很好地理解基本逻辑。
5.2 这次作业踩过的坑
说实话,我写这个程序时踩过最狠的坑是文件编码问题。第一次写完直接用utf-8存,在命令行里跑完全正常,但用Excel打开CSV,中文全成了乱码。后来查到要改成utf-8-sig,问题直接就解决了。这个细节标准教程里很少会提到,但实际开发中非常常见,我现在不管写什么文件,只要涉及中文都用utf-8-sig,就不会有这个问题。
第二个坑是打印输出对齐的问题。用print直接打印列表数据,姓名长短不一时对齐很难看。后来我用格式化字符串f"{stu['name']:<8}",<8表示左对齐并占8个字符宽度,这样输出就很整齐。这个小技巧在处理表格输出时非常实用,强烈建议记一下。
第三个坑相对隐蔽:list.remove()在删除字典时,如果字典内容完全相同,会删除第一个匹配项。如果列表里有两个学生信息完全一样(虽然学号应该唯一),会删掉第一个而不是你指定的那个。所以在删除场景下,先按学号定位,再删除,是最安全的做法。
最后想说的是,这次作业看起来只是一个小系统,但它把编程里最核心的几个概念都串起来了:数据结构怎么组织、函数怎么拆分、数据怎么持久化、用户输入怎么校验。这些能力在以后做任何项目都会用得上。我自己当年学编程时,也是从类似的“学生管理系统”开始,一点点建立代码思维的。如果卡在哪个环节,别急着往下写,先回头看看数据是怎么流动的,逻辑通了,代码自然就顺了。
