一、这个程序要做什么:一个真实的小需求
前面十几篇把工具一件件攒齐了:变量、判断、循环、列表、字典、函数、文件、异常、标准库。这一篇不教新语法,只干一件事——把这些零件拼成一个真能用的小程序。
需求来自老师的一句话:
scores.csv,里面是班里几个人的语文、数学、英语成绩(姓名,语文,数学,英语)。帮我算算每人总分和平均分,排个名次,统计一下各分数段有多少人,打印一张整齐的报表,再存成文件发给我。」
这段话里没有一个新词,但每一句都要落成代码。先把它拆成一张清单,"出题人"才看得见工作量:
| 需求 | 靠哪一篇 | 用到的工具 |
|---|---|---|
读出 scores.csv 里的成绩 | 第 26 篇《文件处理》 第 28 篇《常用标准库》 | open、csv.reader |
| 算出每人的总分、平均分 | 第 22 篇《函数》 第 9 篇《内置函数》 | def、sum、len、round |
| 按总分排出名次 | 第 16 篇《列表》 第 9 篇《内置函数》 | sorted、key、enumerate |
| 统计各分数段有多少人 | 第 20 篇《字典》 | 字典当计数器、.get |
| 打印一张对得齐的报表 | 第 10 篇《字符串》 | f-string、按显示宽度补齐 |
把结果存成 csv / json | 第 26 篇 第 28 篇 | csv.writer、json.dump |
| 文件不存在也别直接崩 | 第 25 篇《异常处理》 | try / except |
先说结果,后面再一步步拼出来。这段就是程序最后打印的东西(真实运行结果):
名次 姓名 总分 平均分 等级
1 小满 288 96.0 优秀
2 阿禾 267 89.0 良好
3 小鹿 259 86.3 良好
4 乐乐 226 75.3 中等
5 果果 200 66.7 及格
6 小北 174 58.0 不及格
全班 6 人 · 平均总分 235.7 · 平均分 78.6
优秀 1 人 · 良好 2 人 · 中等 1 人 · 及格 1 人 · 不及格 1 人
最高分 96.0(小满)· 最低分 58.0(小北)
课程思政|诚信与职业操守 · 答应的事,一条也不能漏
老师只说了一句话,里面却藏着读文件、算总分、排名次、分档统计、打报表、存文件六件事。动手敲代码之前,第一步是把对方要的东西一条条列清楚,再逐条落实。别人把需求交给你,就是把自己的事托付给你,少做一条、糊弄一条,早晚会在交付那一刻露馅。先把话听懂,后面才好动手。
二、先想清楚:一个人怎么装、一群人怎么装
动手写代码之前,先回答一个问题:数据长什么样?想不清这个,后面每一行都会别扭。
看一行成绩:小鹿,88,92,79。它有四个字段,字段有名字——这正是字典的样子(第 20 篇):
# 一个学生 = 一个字典
xiaolu = {'姓名': '小鹿', '语文': 88, '数学': 92, '英语': 79}
print(xiaolu)
print(xiaolu['数学']) # 按名字取,比 stu[2] 好读得多
输出:
{'姓名': '小鹿', '语文': 88, '数学': 92, '英语': 79}
92
一个班就是很多个这样的字典,用列表装起来(第 16 篇):
# 一个班 = 字典的列表
students = [
{'姓名': '小鹿', '语文': 88, '数学': 92, '英语': 79},
{'姓名': '乐乐', '语文': 76, '数学': 72, '英语': 78},
]
for stu in students:
print(stu['姓名'], '的数学是', stu['数学'])
输出:
小鹿 的数学是 92
乐乐 的数学是 72
再想一层:三个科目的名字会在代码里反复出现(算总分要用、算平均分要用、统计要用)。把它们收进一个变量,改科目时只改一处:
SCORE_KEYS = ['语文', '数学', '英语']
stu = {'姓名': '小鹿', '语文': 88, '数学': 92, '英语': 79}
print(sum(stu[key] for key in SCORE_KEYS))
输出:
259
stu['语文'] 这种叫得出名字的写法,而不是 row[1] row[2] 猜位置。真实项目里改数据结构,远比改几十行代码便宜。三、准备一份成绩单:scores.csv
程序要读的文件长这样(csv 就是「用逗号分隔的文本表」):
rows = [['姓名', '语文', '数学', '英语'],
['小鹿', 88, 92, 79], ['乐乐', 76, 72, 78],
['阿禾', 95, 88, 84], ['果果', 60, 72, 68],
['小满', 100, 96, 92], ['小北', 55, 61, 58]]
import csv
with open('scores.csv', 'w', newline='', encoding='utf-8') as f:
csv.writer(f).writerows(rows)
print('---- scores.csv ----')
print(open('scores.csv', encoding='utf-8').read())
输出:
---- scores.csv ----
姓名,语文,数学,英语
小鹿,88,92,79
乐乐,76,72,78
阿禾,95,88,84
果果,60,72,68
小满,100,96,92
小北,55,61,58
两个细节值得记住:
| 写法 | 为什么 |
|---|---|
newline='' | 不写它,在 Windows 上用 Excel 打开会每行多一个空行(后面「常见坑」有演示) |
encoding='utf-8' | 不写它,中文在别人的电脑上可能是乱码。读写两头都要写,且要一致 |
值得说明的是:真实场景里这个文件是别人交给你的,不是你写的。这里自己造一份,是为了让整个程序从零就能跑起来。
四、第 1 步:把成绩读进来
csv.reader 读出来的是「一行一个列表」。注意——所有字段都是字符串,这大概是读表格时最容易翻车的地方:
import csv
open('scores.csv', 'w', encoding='utf-8').write(
'姓名,语文,数学,英语\n小鹿,88,92,79\n乐乐,76,72,78\n')
with open('scores.csv', encoding='utf-8') as f:
rows = list(csv.reader(f))
print(rows)
print('第二行的数学是多少?', rows[2][2], type(rows[2][2]))
输出:
[['姓名', '语文', '数学', '英语'], ['小鹿', '88', '92', '79'], ['乐乐', '76', '72', '78']]
第二行的数学是多少? 72 <class 'str'>
92 读出来是字符串 '92'。字符串能比大小吗?能,但比的是字典序——'9' > '100' 是 True。所以第一步必须把它们转成整数:
# 承上:rows 已经读进来了(见上一段)
import csv
open('scores.csv', 'w', encoding='utf-8').write(
'姓名,语文,数学,英语\n小鹿,88,92,79\n乐乐,76,72,78\n')
with open('scores.csv', encoding='utf-8') as f:
rows = list(csv.reader(f))
SCORE_KEYS = ['语文', '数学', '英语']
students = []
for r in rows[1:]: # rows[0] 是表头,跳过
stu = {'姓名': r[0]}
for key, value in zip(SCORE_KEYS, r[1:]):
stu[key] = int(value) # ← 关键的一步
students.append(stu)
print(students[0])
print('现在可以相加了:', students[0]['语文'] + students[1]['语文'])
输出:
{'姓名': '小鹿', '语文': 88, '数学': 92, '英语': 79}
现在可以相加了: 164
stu[key] = value 就完事。漏掉 int(),后面 sum() 会报 TypeError,或者更隐蔽:字符串相加变成 '8892' 这种拼接,程序照样跑完,只是结果全错。「结果错了但程序不报错」是文件读取最危险的一类 bug。课程思政|工匠精神与严谨求实 · 不报错,未必算得对
csv 读出来的分数其实是字符串,忘了 int(),分数相加就会把数字当成文字拼起来——'88' + '92' 得到 '8892',程序照跑不误,数字却全错了。这类不报错的错误比崩溃更危险,因为它会让人误以为一切正常。永远拿实际结果说话,用一个小到能心算的样例验证一遍,是数据分析最该守住的底线。
五、第 2 步:算总分与平均分(写成函数)
总分 = 三科相加。但不要到处写 stu['语文'] + stu['数学'] + stu['英语']——那是「以后改科目要改很多处」的写法。包成函数(第 22 篇):
# 准备数据
import csv
open('scores.csv', 'w', encoding='utf-8').write(
'姓名,语文,数学,英语\n小鹿,88,92,79\n乐乐,76,72,78\n阿禾,95,88,84\n')
with open('scores.csv', encoding='utf-8') as f:
rows = list(csv.reader(f))
SCORE_KEYS = ['语文', '数学', '英语']
students = [{'姓名': r[0], '语文': int(r[1]), '数学': int(r[2]), '英语': int(r[3])}
for r in rows[1:]]
def total(stu):
"""三科总分。"""
return sum(stu[key] for key in SCORE_KEYS)
def average(stu):
"""三科平均分,保留一位小数。"""
return round(total(stu) / len(SCORE_KEYS), 1)
for stu in students:
print(stu['姓名'], total(stu), average(stu))
输出:
小鹿 259 86.3
乐乐 226 75.3
阿禾 267 89.0
两处小讲究:
| 写法 | 作用 |
|---|---|
sum(stu[key] for key in SCORE_KEYS) | 循环里只写一遍 stu[key];以后加一门「物理」,只改 SCORE_KEYS 就行 |
round(..., 1) | 平均分 86.33333… 没法看,保留一位小数。round 是第 9 篇学过的内置函数 |
average(stu) 里再调 total | 函数调函数很正常;不要为了让「看起来快」而复制一遍求和代码 |
total 和变量名不冲突,但为了读起来不别扭,通常把「算」的那部分叫 total(),把「存」的那部分叫 total_score。名字起对了,代码解释自己。六、第 3 步:按总分排名次
排名不需要自己写排序算法(第 16 篇讲过 sorted),把「按什么排」用 key 告诉它就行:
# 准备数据
import csv
open('scores.csv', 'w', encoding='utf-8').write(
'姓名,语文,数学,英语\n小鹿,88,92,79\n乐乐,76,72,78\n阿禾,95,88,84\n'
'果果,60,72,68\n小满,100,96,92\n小北,55,61,58\n')
with open('scores.csv', encoding='utf-8') as f:
rows = list(csv.reader(f))
SCORE_KEYS = ['语文', '数学', '英语']
students = [{'姓名': r[0], '语文': int(r[1]), '数学': int(r[2]), '英语': int(r[3])}
for r in rows[1:]]
def total(stu):
return sum(stu[key] for key in SCORE_KEYS)
ranked = sorted(students, key=total, reverse=True)
for i, stu in enumerate(ranked, start=1):
print(i, stu['姓名'], total(stu))
输出:
1 小满 288
2 阿禾 267
3 小鹿 259
4 乐乐 226
5 果果 200
6 小北 174
三个易错点,一眼扫过去:
| 写法 | 对 / 错 | 原因 |
|---|---|---|
key=total | ✅ 对 | 传的应该是函数本身;sorted 会拿每个元素去调它 |
key=total() | ❌ 错 | 加了括号就是立刻调用,此时还没传参数,直接 TypeError |
enumerate(ranked, start=1) | ✅ 对 | start=1 让名次从 1 开始,而不是默认的 0 |
sorted(students, key=total, reverse=True) 用的是总分排序,但列表里装的仍然是学生字典——排的是顺序,不是把学生变成了分数。这点想通,后面「既要名次又要姓名」就不别扭了。七、第 4 步:统计各分数段有多少人
先把「分数 → 等级」这条规则单独写成函数,规则变了只改这一处:
LEVELS = [('优秀', 90), ('良好', 80), ('中等', 70), ('及格', 60), ('不及格', 0)]
def level_of(avg):
for name, low in LEVELS:
if avg >= low:
return name
return '不及格'
for s in [96.0, 89.0, 86.3, 75.3, 66.7, 58.0]:
print(s, level_of(s))
输出:
96.0 优秀
89.0 良好
86.3 良好
75.3 中等
66.7 及格
58.0 不及格
把 LEVELS 从高到低排,第一个满足 avg >= low 的就是答案——这叫「查表」,比写一堆 if/elif 短得多,也不容易漏档。
接下来数人数。第 20 篇说过字典能当「计数器」,核心就一句 counts[x] = counts.get(x, 0) + 1:
LEVELS = [('优秀', 90), ('良好', 80), ('中等', 70), ('及格', 60), ('不及格', 0)]
def level_of(avg):
for name, low in LEVELS:
if avg >= low:
return name
return '不及格'
avgs = [96.0, 89.0, 86.3, 75.3, 66.7, 58.0]
counter = {}
for avg in avgs:
grade = level_of(avg)
counter[grade] = counter.get(grade, 0) + 1
for name, _ in LEVELS: # 按固定顺序打印,哪怕某档是 0 人
print(name, counter.get(name, 0), '人')
输出:
优秀 1 人
良好 2 人
中等 1 人
及格 1 人
不及格 1 人
| 写法 | 为什么这么写 |
|---|---|
counter.get(grade, 0) | 第一次见到「优秀」时字典里还没有这个键,get 给个默认 0,不然直接 counter[grade] += 1 会 KeyError |
for name, _ in LEVELS 而不是 for name in counter | 字典只存「出现过的档」,直接遍历会漏掉 0 人的档,报表就不完整了 |
_ 当下划线变量名 | 表示「这个位置有值,但我不用它」——一种通用约定,别人一看就懂 |
collections.Counter。Counter(等级列表) 一步到位。本页用手写循环,是因为它把 get 的用法讲透了;真实项目里你自己选顺手的那个。八、第 5 步:打印一张对得齐的报表
直接 print(名字, 总分) 出来的东西,列是歪的。原因是中文只算一个字符,却占两个格子的宽度:
rows = [('小满', 288), ('小鹿', 259), ('小北', 174)]
for name, score in rows:
print(f'{name:<6}{score:>5}')
输出:
小满 288
小鹿 259
小北 174
看上去 小鹿 那一行比 小满 多挤了一点。因为 len('小满') == 2,两个字符宽 4 格——脚本按「字符数」补空格,实际宽度就对不上。
解决办法也简单:自己写一个按显示宽度补齐的小函数(中文算 2 格):
def pad(text, width, align='<'):
"""按『显示宽度』补齐:中文占两格,半角占一格。"""
shown = sum(2 if ord(ch) > 127 else 1 for ch in text)
gap = max(0, width - shown)
if align == '<':
return text + ' ' * gap
return ' ' * gap + text
COLS = [('名次', 4, '>'), ('姓名', 6, '<'), ('总分', 6, '>'),
('平均分', 7, '>'), ('等级', 6, '<')]
def row_line(values):
return ' '.join(pad(str(v), w, a) for v, (_, w, a) in zip(values, COLS))
print(row_line([name for name, _, _ in COLS]))
for i, (name, score) in enumerate([('小满', 288), ('小鹿', 259), ('小北', 174)], start=1):
print(row_line([i, name, score, '%.1f' % (score / 3), '优秀']))
输出:
名次 姓名 总分 平均分 等级
1 小满 288 96.0 优秀
2 小鹿 259 86.3 优秀
3 小北 174 58.0 优秀
下面这段是这三个函数里最值得抄走的一行:
for text in ['小满', 'abcd', '小笼包']:
shown = sum(2 if ord(ch) > 127 else 1 for ch in text)
print(text, '的显示宽度是', shown)
输出:
小满 的显示宽度是 4
abcd 的显示宽度是 4
小笼包 的显示宽度是 6
ord(ch) > 127 的意思是「这个字符不是 ASCII」——中文、全角标点都算两格;sum() 把每个字符的宽度加起来,就是这一串真正占几列。abcd 那种纯半角的,宽度正好等于 len()。
csv 让对方用表格软件排版——下一节就做这件事。AI 助手小贴士|对齐打印是这类程序的门面,f 字符串的对齐写法值得专问。
打开任意 AI 对话工具(网页版或 App 都可以),试试这样问:f 字符串里 {name:<6} 和 {avg:>8.1f} 是什么意思?请解释左对齐、右对齐和保留一位小数的写法,给一张对齐整齐的成绩表示例
九、第 6 步:把结果存成 csv 与 json
打印在屏幕上,关掉窗口就没了。存成文件才叫「交差」。两种格式各有各的用处:
| 格式 | 存法 | 适合给谁看 |
|---|---|---|
csv | csv.writer(f).writerows(列表) | 给 Excel / WPS,能直接双击打开 |
json | json.dump(数据, f, ensure_ascii=False, indent=2) | 给程序读,也能给人看(indent 让它带缩进) |
import csv
import json
rows = [
{'名次': 1, '姓名': '小满', '总分': 288, '平均分': 96.0, '等级': '优秀'},
{'名次': 2, '姓名': '阿禾', '总分': 267, '平均分': 89.0, '等级': '良好'},
]
with open('report.csv', 'w', newline='', encoding='utf-8') as f:
writer = csv.writer(f)
writer.writerow(['名次', '姓名', '总分', '平均分', '等级'])
for r in rows:
writer.writerow([r['名次'], r['姓名'], r['总分'], r['平均分'], r['等级']])
with open('report.json', 'w', encoding='utf-8') as f:
json.dump(rows, f, ensure_ascii=False, indent=2)
print('---- report.csv ----')
print(open('report.csv', encoding='utf-8').read().rstrip())
print('---- report.json ----')
print(open('report.json', encoding='utf-8').read().rstrip())
输出:
---- report.csv ----
名次,姓名,总分,平均分,等级
1,小满,288,96.0,优秀
2,阿禾,267,89.0,良好
---- report.json ----
[
{
"名次": 1,
"姓名": "小满",
"总分": 288,
"平均分": 96.0,
"等级": "优秀"
},
{
"名次": 2,
"姓名": "阿禾",
"总分": 267,
"平均分": 89.0,
"等级": "良好"
}
]
ensure_ascii=False 千万别漏。默认值 True 会把中文写成 \u5c0f\u6ee1 —— 文件本身没错,程序读回来也正确,但人打开一看全是乱码样的字符,等于白存。indent=2 让它带缩进,人能直接看懂结构;给程序读时省不省缩进无所谓,你随意。课程思政|数据安全与法治素养 · 一份成绩表,就是一份责任
把报表存成 csv 和 json,程序才算真正交差。可这份文件里装着全班同学的成绩,一旦发出去、传开,就再也收不回来。自己练手可以用假数据,真做业务就得先问清这份文件该给谁看、能存多久。给文件起规范的名字、放进指定的目录、不随手外传,这些习惯,就是你将来面对真实信息系统的底线。
十、把六步收成一个完整程序
现在把上面六步装进一个文件。关键是函数各管一件事,最后用一个 main() 串起来:
| 函数 | 只干一件事 |
|---|---|
load_students(path) | 把 csv 读成学生字典的列表 |
total(stu) / average(stu) | 算一个人的总分 / 平均分 |
level_of(avg) | 把平均分翻译成等级 |
pad() / row_line() | 把一行数据摆成正的 |
build_report(students) | 排序 + 算出每个人的结果,返回报表和结果列表 |
save_csv() / save_json() | 各存一种格式 |
main() | 按顺序把它们叫一遍 |
文件末尾加两行——这是第 27 篇《模块与包》里讲过的入口约定:
if __name__ == '__main__':
main()
它保证:直接运行这个文件时程序会跑;但如果别人 import 你的文件,main() 不会自作主张地执行。少了这两行,别人一 import 你的模块,你的程序就在他那儿闷头打印一通报表。
AI 助手小贴士|把脚本收成完整程序最容易在缩进和作用域上翻车。
打开任意 AI 对话工具(网页版或 App 都可以),试试这样问:把六步脚本合并成一个带 main() 的完整程序时,常见的报错有哪些?出 2 道「这段为什么报 NameError」的排错题让我练
十一、加一层保险:文件不在、格式不对怎么办
写到这里程序「能跑」了,但只在你自己的电脑上、文件正好在、格式正好对的时候能跑。给别人用之前,先想清楚三种最可能出问题的情况:
| 情况 | 不处理会怎样 | 捕获哪个异常 |
|---|---|---|
| 文件不存在或路径写错 | 程序带着一屏 Traceback 直接退出 | FileNotFoundError |
文件里有空行 / 非数字,int() 转不动 | 同样整段崩掉 | ValueError |
| 文件里缺一列(比如只有两科) | zip 少配一个键,后面代码悄悄算错 | 主动检查列数 |
先看「不处理」是什么样子:
import csv
with open('没有这个文件.csv', encoding='utf-8') as f:
rows = list(csv.reader(f))
运行后会报错(逐字照抄解释器):
FileNotFoundError: [Errno 2] No such file or directory: '没有这个文件.csv'
再加一层 try / except(第 25 篇),让它变成一句人话:
import csv
def load_students(path):
try:
with open(path, encoding='utf-8') as f:
rows = list(csv.reader(f))
except FileNotFoundError:
print(f'找不到文件:{path}')
print('请把成绩单放在程序同一个文件夹里,再运行一次。')
return []
return rows
print('读到', len(load_students('没有这个文件.csv')), '行')
输出:
找不到文件:没有这个文件.csv
请把成绩单放在程序同一个文件夹里,再运行一次。
读到 0 行
再补上「某一格不是数字」的情况。注意 int() 失败时要知道是哪一行出的问题,不然文件一大根本找不到:
import csv
open('scores.csv', 'w', encoding='utf-8').write(
'姓名,语文,数学,英语\n小鹿,88,92,79\n乐乐,90,,78\n')
SCORE_KEYS = ['语文', '数学', '英语']
with open('scores.csv', encoding='utf-8') as f:
rows = list(csv.reader(f))
students = []
for i, r in enumerate(rows[1:], start=2): # 第 2 行开始是数据
try:
stu = {'姓名': r[0]}
for key, value in zip(SCORE_KEYS, r[1:]):
stu[key] = int(value)
except ValueError:
print(f'第 {i} 行「{r[0]}」的成绩有问题:{r[1:]},已跳过')
continue
students.append(stu)
print('成功读到', len(students), '人:', [s['姓名'] for s in students])
输出:
第 3 行「乐乐」的成绩有问题:['90', '', '78'],已跳过
成功读到 1 人: ['小鹿']
continue 跳过坏行,好行照常处理完后继续跑,还顺手把行号报出来——比直接崩掉友好得多。课程思政|工匠精神与严谨求实 · 别人电脑上,也要跑得动
前面写的程序,只有在文件正好在、格式正好对时才跑得动;换到别人的电脑上,一个 FileNotFoundError 就甩出一屏看不懂的英文。加一层 try / except,把报错翻译成一句人话,是在替使用者着想。真正的交付,是你在意对方打开它时会不会一头雾水——这份体贴,比多写二十行代码更值钱。
十二、完整代码:一次贴全
下面是最终版程序,一个文件 main.py,约 110 行。直接复制就能跑(前提是同目录有 scores.csv):
# 为了让下面这段能单独运行,开头先把 scores.csv 造出来;你自己的项目里不需要这 3 行
open('scores.csv', 'w', encoding='utf-8').write(
'姓名,语文,数学,英语\n'
'小鹿,88,92,79\n乐乐,76,72,78\n阿禾,95,88,84\n'
'果果,60,72,68\n小满,100,96,92\n小北,55,61,58\n')
import csv
import json
SCORE_KEYS = ['语文', '数学', '英语']
LEVELS = [('优秀', 90), ('良好', 80), ('中等', 70), ('及格', 60), ('不及格', 0)]
def load_students(path):
"""读 csv,返回「学生字典」的列表。"""
with open(path, encoding='utf-8') as f:
rows = list(csv.reader(f))
students = []
for r in rows[1:]: # 跳过表头
stu = {'姓名': r[0]}
for key, value in zip(SCORE_KEYS, r[1:]):
stu[key] = int(value) # 读出来是字符串,必须转成整数
students.append(stu)
return students
def total(stu):
"""三科总分。"""
return sum(stu[key] for key in SCORE_KEYS)
def average(stu):
"""三科平均分,保留一位小数。"""
return round(total(stu) / len(SCORE_KEYS), 1)
def level_of(avg):
"""按平均分给一个等级。"""
for name, low in LEVELS:
if avg >= low:
return name
return '不及格'
def pad(text, width, align='<'):
"""按『显示宽度』补齐:中文占两格,半角占一格。"""
shown = sum(2 if ord(ch) > 127 else 1 for ch in text)
gap = max(0, width - shown)
if align == '<':
return text + ' ' * gap
return ' ' * gap + text
COLS = [('名次', 4, '>'), ('姓名', 6, '<'), ('总分', 6, '>'),
('平均分', 7, '>'), ('等级', 6, '<')]
def row_line(values):
return ' '.join(pad(str(v), w, a) for v, (_, w, a) in zip(values, COLS))
def build_report(students):
"""返回 (报表文本行, 每人的结果字典)。"""
ranked = sorted(students, key=total, reverse=True)
lines = [row_line([name for name, _, _ in COLS])]
rows = []
for i, stu in enumerate(ranked, start=1):
avg = average(stu)
grade = level_of(avg)
rows.append({'名次': i, '姓名': stu['姓名'], '总分': total(stu),
'平均分': avg, '等级': grade})
lines.append(row_line([i, stu['姓名'], total(stu),
'%.1f' % avg, grade]))
return lines, rows
def save_csv(rows, path):
with open(path, 'w', newline='', encoding='utf-8') as f:
writer = csv.writer(f)
writer.writerow(['名次', '姓名', '总分', '平均分', '等级'])
for r in rows:
writer.writerow([r['名次'], r['姓名'], r['总分'], r['平均分'], r['等级']])
def save_json(rows, path):
with open(path, 'w', encoding='utf-8') as f:
json.dump(rows, f, ensure_ascii=False, indent=2)
def main():
students = load_students('scores.csv')
lines, rows = build_report(students)
for line in lines:
print(line)
counter = {}
for r in rows:
counter[r['等级']] = counter.get(r['等级'], 0) + 1
scores = [stu[key] for stu in students for key in SCORE_KEYS]
print()
print('全班 %d 人 · 平均总分 %.1f · 平均分 %.1f'
% (len(rows), sum(r['总分'] for r in rows) / len(rows),
sum(scores) / len(scores)))
print(' · '.join('%s %d 人' % (name, counter.get(name, 0))
for name, _ in LEVELS))
top, low = rows[0], rows[-1]
print('最高分 %.1f(%s)· 最低分 %.1f(%s)'
% (top['平均分'], top['姓名'], low['平均分'], low['姓名']))
save_csv(rows, 'report.csv')
save_json(rows, 'report.json')
if __name__ == '__main__':
main()
运行结果(真实输出):
名次 姓名 总分 平均分 等级
1 小满 288 96.0 优秀
2 阿禾 267 89.0 良好
3 小鹿 259 86.3 良好
4 乐乐 226 75.3 中等
5 果果 200 66.7 及格
6 小北 174 58.0 不及格
全班 6 人 · 平均总分 235.7 · 平均分 78.6
优秀 1 人 · 良好 2 人 · 中等 1 人 · 及格 1 人 · 不及格 1 人
最高分 96.0(小满)· 最低分 58.0(小北)
同一份数据,另外两处细节可以看一眼——report.csv 长这样:
---- report.csv ----
名次,姓名,总分,平均分,等级
1,小满,288,96.0,优秀
2,阿禾,267,89.0,良好
report.json 长这样:
---- report.json ----
[
{
"名次": 1,
"姓名": "小满",
"总分": 288,
"平均分": 96.0,
"等级": "优秀"
},
{
"名次": 2,
"姓名": "阿禾",
"总分": 267,
"平均分": 89.0,
"等级": "良好"
}
]
main.py 不需要它们,直接从 import csv 开始就行。十三、进阶:用类重写数据层(选学)
到此为止,程序里的「一个学生」都是用字典装的:{'姓名': '小鹿', '语文': 88, ...}。字典够用,但有个隐患——字段名是字符串,手一抖写成 row['数學'],写入的时候一声不吭,要等到取值运算那一刻才炸。学完《30-面向对象编程》之后,可以做得更好:把「一个学生」升级成一个 Student 对象,让数据和对数据的操作住进同一个类。
class Student:
def __init__(self, name, chinese, math, english):
self.name = name
self.chinese = chinese
self.math = math
self.english = english
def total(self):
return self.chinese + self.math + self.english
def average(self):
return self.total() / 3
s = Student('小鹿', 88, 92, 79)
print(s.name, s.total(), f'{s.average():.1f}')
输出:
小鹿 259 86.3
总分、平均分不再是散落在外的普通函数,而是 Student 自带的能力——这正是第 30 篇说的封装。现在把前面「读进来 → 排名 → 打印」的主流程用类重写一遍:
import csv
class Student:
def __init__(self, name, chinese, math, english):
self.name = name
self.chinese = chinese
self.math = math
self.english = english
def total(self):
return self.chinese + self.math + self.english
def average(self):
return self.total() / 3
def __str__(self):
return f'{self.name}\t{self.total()}\t{self.average():.1f}'
# 造一份成绩单(真实使用时它已经在磁盘上)
open('scores.csv', 'w', encoding='utf-8').write(
'姓名,语文,数学,英语\n小鹿,88,92,79\n乐乐,76,72,78\n'
'阿禾,95,88,84\n果果,60,72,68\n小满,100,96,92\n小北,55,61,58\n')
students = []
with open('scores.csv', encoding='utf-8') as f:
for row in csv.DictReader(f):
students.append(Student(row['姓名'], int(row['语文']),
int(row['数学']), int(row['英语'])))
students.sort(key=lambda st: st.total(), reverse=True)
for i, st in enumerate(students, 1):
print(i, st)
输出:
1 小满 288 96.0
2 阿禾 267 89.0
3 小鹿 259 86.3
4 乐乐 226 75.3
5 果果 200 66.7
6 小北 174 58.0
两种写法跑出来的排名完全一致——类没有改变程序做什么,只是把代码摆得更整齐:「一个学生」的身份数据和它的算法(总分、平均分)从此住在一起,主流程里剩下的全是「管一群人」的活儿。
十四、常见坑
坑 1:读进来的全是字符串,忘了转数字
这是本页最致命的坑,因为它不报错:
import csv
open('scores.csv', 'w', encoding='utf-8').write(
'姓名,语文,数学,英语\n小鹿,88,92,79\n乐乐,76,72,78\n')
with open('scores.csv', encoding='utf-8') as f:
rows = list(csv.reader(f))
for r in rows[1:]:
print(r[0], '总分 =', r[1] + r[2] + r[3], ' ← 这不是加法,是拼接!')
输出:
小鹿 总分 = 889279 ← 这不是加法,是拼接!
乐乐 总分 = 767278 ← 这不是加法,是拼接!
程序跑得很欢,结果却是 '889279'。同理,用字符串比大小,'9' > '100' 会是 True。
处理办法:读进来立刻 int() / float(),并且用第 11 节那种带行号的 try 包住,坏数据一眼看得见。
坑 2:key=total 写成了 key=total()
def total(stu):
return 1
students = [{'姓名': '小鹿'}]
ranked = sorted(students, key=total()) # 多了括号
运行后会报错(逐字照抄解释器):
TypeError: total() missing 1 required positional argument: 'stu'
加了括号,就是「现在就调用它」——可这会儿还没把学生传进去,于是 TypeError。
处理办法:记住 key= 后面跟的是函数名,不是调用结果;要传参数就用 lambda,如 key=lambda s: s['数学']。
坑 3:写 csv 时忘了 newline=''
在 Mac / Linux 上你多半察觉不到,文件拷到 Windows 用 Excel 打开就每行之间多出空行。原因是 Windows 的换行是 \r\n,Python 写入时又补了一次。
open() 里跟 csv 有关的写操作,一律带上 newline=''。它不改变你文件里的内容,只是不让 Python 再多写一个换行。坑 4:json.dump 忘了 ensure_ascii=False
import json
data = [{'姓名': '小满', '等级': '优秀'}]
print('---- json.dumps 默认写法 ----')
print(json.dumps(data)) # 默认写法
print('---- json.dumps 加 ensure_ascii=False ----')
print(json.dumps(data, ensure_ascii=False)) # 中文原样保留
输出:
---- json.dumps 默认写法 ----
[{"\u59d3\u540d": "\u5c0f\u6ee1", "\u7b49\u7ea7": "\u4f18\u79c0"}]
---- json.dumps 加 ensure_ascii=False ----
[{"姓名": "小满", "等级": "优秀"}]
两种都是合法 JSON,程序读回来也一样。区别只在于人能不能直接看懂——要交给别人的文件,选第二种。
坑 5:改完文件忘记关,数据没了
文件写了一半就中断,很容易只写进一半内容。用 with open(...) as f:(第 26 篇)就不用操这份心:代码块正常走完、或者中途报错,它都会帮你把文件正确关掉。
print(open(...).read()) 看一眼——这一步能挡掉九成的「我明明存了啊」。十五、报错手册:这类程序最常撞的错
本页这类「读表格→算→存」的程序,报错基本就这几种。按报错原文对照着看:
| 报错原文(末行) | 多半是什么原因 | 怎么改 |
|---|---|---|
FileNotFoundError: [Errno 2] No such file or directory: 'scores.csv' | 文件名打错,或者程序运行目录里没有这个文件 | 用绝对路径试一次,确认文件确实在 程序所在的文件夹 里 |
TypeError: unsupported operand type(s) for +: 'int' and 'str' | 有一格没转成数字,混进了字符串 | 在 int() 那段包 try,把坏行号和坏内容打出来 |
ValueError: invalid literal for int() with base 10: '' | 表格里有空单元格,int('') 转不动 | 空值给默认分(如 0),或跳过这一行并记下来 |
KeyError: '物理' | SCORE_KEYS 里写了文件里没有的科目 | 把 SCORE_KEYS 改成文件真实的表头,别凭记忆写 |
IndexError: list index out of range | 某一行比别的行少了几列,r[3] 取不到 | 先判断 len(r),短行直接跳过并报警 |
TypeError: total() missing 1 required positional argument: 'stu' | sorted(key=total()) 多了括号 | 去掉括号,写成 key=total |
UnicodeDecodeError: 'utf-8' codec can't decode byte … | 文件其实不是 UTF-8(常见于 Excel 另存的 GBK 文件) | open(..., encoding='gbk') 试一次;先确认文件真实编码 |
PermissionError: [Errno 13] Permission denied | 文件正被 Excel / WPS 打开着,锁住了 | 关掉那个表格窗口,再运行一次 |
读报错的固定顺序,第 25 篇讲过,这里再用一次。这是本页程序在 scores.csv 丢失时吐出来的原文,一段一段拆开看:
| 报错里的这一行(照抄 IDLE) | 它在告诉你什么 |
|---|---|
Traceback (most recent call last): | 下面是一串调用链,不是一句报错 |
File "main.py", line 62, in main | 最外一层:main() 的第 62 行 |
students = load_students('scores.csv') | 那一行的代码原文——就是它把错误传上来的 |
File "main.py", line 9, in load_students | 往里一层:load_students() 的第 9 行 |
with open(path, encoding='utf-8') as f: | 出事的真正就是这一行 |
FileNotFoundError: [Errno 2] No such file or directory: 'scores.csv' | 最后一行的错误类型 + 原因:文件没找到 |
File "main.py", line N, in 函数名 正好画出调用链——顺着它就能找到是谁传了错的参数。十六、常见问题
Q:为什么非要拆成这么多函数?一个 main() 从头写到尾不行吗?
能跑,但改起来痛苦。拆开之后,「平均分算法变了」只动 average(),「报表要加一列」只动 COLS 和 row_line()。更实际的好处是:每个函数都能单独测——你可以在同一文件里随手写 print(total({'语文':88,'数学':92,'英语':79})) 验证它是 259,不用先读一遍文件。
Q:scores.csv 有三四十个学生、还有缺考的,怎么改?
核心逻辑一行都不用改——sorted、sum、字典计数都是「来多少处理多少」。要额外做的是:把 int(value) 换成「空值给 0 并标记缺考」,然后把缺考的人单独列一张表。这正是先定好数据结构换来的好处。
Q:能不能把报表直接存成 Excel(.xlsx)?
可以,但要装第三方库 openpyxl(第 27 篇讲过 pip install)。日常够用的做法是存 csv,让 Excel / WPS 自己打开——能少一个依赖就少一个,别人拿到也一定能打开。
Q:这个程序能打包成 exe 发给别人吗?
能,就是第 32 篇《打包可执行程序》讲的 pyinstaller。有一点要注意:scores.csv 要放在 exe 旁边,而不是打包进 exe 里面——数据文件本来就该是「可以随时换」的东西。
Q:代码里那些 """这是注释""" 是干什么的?
那是文档字符串(docstring),第 22 篇出现过。它和普通注释的区别是:函数里写的第一段字符串,会被 Python 记下来,help(load_students) 就能看到。给要复用的函数写一句,半年后回来自己都得谢自己。
Q:接下来该练什么?
把数据源换掉,逻辑不动:把 scores.csv 换成一份「支出记录」,把总分换成「每月合计」,就是一个记账小程序。核心套路完全一样:读 → 清洗 → 算 → 排序/统计 → 输出 → 存盘。这也是第 28 篇《常用标准库》里 json、datetime、pathlib 真正派上用场的地方。
AI 助手小贴士|项目代码让 AI 加注释、找改进点完全没问题——但建议在话术里明确「不要重写整个程序」,守住自己的作品,这是 AI 时代的作业诚信。
打开任意 AI 对话工具(网页版或 App 都可以),试试这样问:这是我写的班级成绩统计程序。请给关键代码加中文注释,指出 2 处值得改进的地方并说明理由;不要重写整个程序,改进点给片段示例即可:<粘贴代码>
十七、随堂自测(12 题)
点击选项即可判分,答错的题会立刻显示解析。
1用 csv.reader 读出来的成绩,想拿去做加法,必须先做什么?
- A. 转成
int/float - B. 先
strip()去掉空格 - C. 直接加就行,csv 模块会自动转
- D. 先
sort()排一下序
csv.reader 读出来每一格都是字符串,'88' + '92' 得到 '8892'(拼接),不会报错但结果全错,所以必须先 int()。2sorted(students, key=total, reverse=True) 里的 key=total 为什么不能写成 key=total()?
- A. 两者完全一样,写哪个都行
- B.
key要的是函数本身;加了括号会立刻调用并因缺参数报错 - C.
total()会返回一个新列表,没法排序 - D. 只有
sorted有这个限制,list.sort没
sorted 会拿着每个元素去调用你给的函数,所以传的是函数本身。写成 total() 是在定义时就调用它,此时没有 stu 参数,直接 TypeError。要传参数就用 key=lambda s: s['数学']。3enumerate(ranked, start=1) 里的 start=1 是干什么的?
- A. 从列表第二个元素开始遍历
- B. 跳过第一个元素
- C. 让编号从 1 开始,而不是默认的 0
- D. 把列表倒过来
start=1 时 enumerate 给的编号从 0 开始,名次就会是 0、1、2…… 排序不变,只是编号的起算点变了。4统计各分数段人数时写 counter[g] = counter.get(g, 0) + 1,get(g, 0) 起什么作用?
- A. 把
counter清空 - B. 判断
g是不是数字 - C. 把
g的值设成 0 - D. 取不到
g这个键时返回默认值 0,避免KeyError
counter[g] += 1 会抛 KeyError;get(g, 0) 在键不存在时给个 0,正好当计数器用。5open('report.csv', 'w', newline='', encoding='utf-8') 里 newline='' 是为了防什么?
- A. 在 Windows 上用 Excel 打开时每行多一个空行
- B. 防止中文变成乱码
- C. 防止文件被别的程序覆盖
- D. 让每一行自动对齐
\r\n,Python 写文本时还会再补一次,结果每行之间多一个空行。中文乱码靠的是 encoding='utf-8',那是另一件事。6json.dump(rows, f, ensure_ascii=False) 里的 ensure_ascii=False 有什么用?
- A. 让 JSON 文件更小
- B. 让中文按原样写进去,而不是写成
\u5c0f\u6ee1这种转义 - C. 让 JSON 允许单引号
- D. 让字典的键按字母排序
True 会把所有非 ASCII 字符转成 \uXXXX。文件合法、程序读回来也对,但人打开看不懂,所以给别人的文件一律加上这个参数。7报表里中文列对不齐,根本原因是什么?
- A. 字体没设置成等宽字体
- B. f-string 不支持中文
- C.
len()数的是字符个数,而中文实际占两个格子的宽度 - D. 必须用
tab键来对齐
len('小满') 是 2,但显示出来有 4 格宽,所以按字符数补空格对不齐。本页的 pad() 用 ord(ch) > 127 判断「非 ASCII」,把中文按 2 格计算。8程序要读的 scores.csv 有可能不存在,想让程序给一句友好提示而不是崩掉,应该捕获哪个异常?
- A.
ValueError - B.
TypeError - C.
IndexError - D.
FileNotFoundError
FileNotFoundError;ValueError 是 int('') 这类转换失败,IndexError 是 r[3] 取不到,三者要分开处理。9文件末尾这两行的作用是?if __name__ == '__main__': / main()
- A. 直接运行本文件时执行
main(),被别人import时不自动执行 - B. 让程序跑得更快
- C. 把
main注册成系统命令 - D. 让
main()里的报错被自动忽略
__name__ 是 '__main__',条件成立所以执行;被别人 import 时 __name__ 是模块名,条件不成立,就不会在别人的程序里乱打印。10表格里有一格是空的,int('') 会抛什么错?
- A.
TypeError - B.
ValueError - C.
KeyError - D. 不会报错,返回 0
int('') 抛 ValueError: invalid literal for int() with base 10: ''。空单元格是表格数据最常见的脏值,要么给默认分,要么跳过并记下来。11为什么把参数 SCORE_KEYS = ['语文', '数学', '英语'] 单独提出来,而不是到处写三个科目的名字?
- A. 因为写成常量运行更快
- B. 因为列表不能写在函数里
- C. 以后科目变了(比如加一门「物理」)只改这一处,其余代码不用动
- D. 因为
for循环只能遍历常量
total、average、load_students 都靠它工作,加一门课只改一行;散着写就要满文件找。12整篇没有新语法,那这个综合程序真正新增的能力是什么?
- A. 新的内置函数
- B. 新的循环写法
- C. 新的文件格式支持
- D. 把已有工具按正确的顺序组织起来、并让每一块各管一件事
main() 串起来,再加上异常处理兜底。