今天你 AI 赋能了吗?打开浏览器,在地址栏输入 xuefulu.ai ,按下 Enter 即可访问学府路1号!
2026年10月14日 星期三 10:40:33
常用网址: | | 学府路1号 https://xuefulu.ai | 下载 Python https://xuefulu.ai/Python.html | 下载 TRAE https://xuefulu.ai/Trae.html

一、这个程序要做什么:一个真实的小需求

前面十几篇把工具一件件攒齐了:变量、判断、循环、列表、字典、函数、文件、异常、标准库。这一篇不教新语法,只干一件事——把这些零件拼成一个真能用的小程序。

需求来自老师的一句话:

「我这有个 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 篇):

Python
# 一个学生 = 一个字典
xiaolu = {'姓名': '小鹿', '语文': 88, '数学': 92, '英语': 79}
print(xiaolu)
print(xiaolu['数学'])          # 按名字取,比 stu[2] 好读得多

输出:

输出
{'姓名': '小鹿', '语文': 88, '数学': 92, '英语': 79}
92

一个班就是很多个这样的字典,用列表装起来(第 16 篇):

Python
# 一个班 = 字典的列表
students = [
    {'姓名': '小鹿', '语文': 88, '数学': 92, '英语': 79},
    {'姓名': '乐乐', '语文': 76, '数学': 72, '英语': 78},
]
for stu in students:
    print(stu['姓名'], '的数学是', stu['数学'])

输出:

输出
小鹿 的数学是 92
乐乐 的数学是 72

再想一层:三个科目的名字会在代码里反复出现(算总分要用、算平均分要用、统计要用)。把它们收进一个变量,改科目时只改一处:

Python
SCORE_KEYS = ['语文', '数学', '英语']

stu = {'姓名': '小鹿', '语文': 88, '数学': 92, '英语': 79}
print(sum(stu[key] for key in SCORE_KEYS))

输出:

输出
259
这就是「数据结构先行」的意思:先定「一个学生是字典、一个班是列表、科目名放常量」,后面写函数时就能一直用 stu['语文'] 这种叫得出名字的写法,而不是 row[1] row[2] 猜位置。真实项目里改数据结构,远比改几十行代码便宜。

三、准备一份成绩单:scores.csv

程序要读的文件长这样(csv 就是「用逗号分隔的文本表」):

Python
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 读出来的是「一行一个列表」。注意——所有字段都是字符串,这大概是读表格时最容易翻车的地方:

Python
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。所以第一步必须把它们转成整数:

Python
# 承上: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 篇):

Python
# 准备数据
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 告诉它就行:

Python
# 准备数据
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 步:统计各分数段有多少人

先把「分数 → 等级」这条规则单独写成函数,规则变了只改这一处:

Python
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:

Python
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(名字, 总分) 出来的东西,列是歪的。原因是中文只算一个字符,却占两个格子的宽度:

Python
rows = [('小满', 288), ('小鹿', 259), ('小北', 174)]
for name, score in rows:
    print(f'{name:<6}{score:>5}')

输出:

输出
小满      288
小鹿      259
小北      174

看上去 小鹿 那一行比 小满 多挤了一点。因为 len('小满') == 2,两个字符宽 4 格——脚本按「字符数」补空格,实际宽度就对不上。

解决办法也简单:自己写一个按显示宽度补齐的小函数(中文算 2 格):

Python
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  优秀  

下面这段是这三个函数里最值得抄走的一行:

Python
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()。

这一手只解决等宽字体下的大致对齐(终端、IDLE、本页的代码块都是等宽字体)。真要生成给别人看的报表,更稳的做法是存成 csv 让对方用表格软件排版——下一节就做这件事。

AI 助手小贴士|对齐打印是这类程序的门面,f 字符串的对齐写法值得专问。

打开任意 AI 对话工具(网页版或 App 都可以),试试这样问:f 字符串里 {name:<6} 和 {avg:>8.1f} 是什么意思?请解释左对齐、右对齐和保留一位小数的写法,给一张对齐整齐的成绩表示例

九、第 6 步:把结果存成 csv 与 json

打印在屏幕上,关掉窗口就没了。存成文件才叫「交差」。两种格式各有各的用处:

格式存法适合给谁看
csvcsv.writer(f).writerows(列表)给 Excel / WPS,能直接双击打开
jsonjson.dump(数据, f, ensure_ascii=False, indent=2)给程序读,也能给人看(indent 让它带缩进)
Python
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())

输出:

Python
---- 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 篇《模块与包》里讲过的入口约定:

Python
if __name__ == '__main__':
    main()

它保证:直接运行这个文件时程序会跑;但如果别人 import 你的文件,main() 不会自作主张地执行。少了这两行,别人一 import 你的模块,你的程序就在他那儿闷头打印一通报表。

看到没——整篇没有一句新语法。这次真正的新东西只有「顺序」:先定数据结构,再读数据,再算,再排,再统计,再输出,最后存盘。顺序对了,代码就是可读的。

AI 助手小贴士|把脚本收成完整程序最容易在缩进和作用域上翻车。

打开任意 AI 对话工具(网页版或 App 都可以),试试这样问:把六步脚本合并成一个带 main() 的完整程序时,常见的报错有哪些?出 2 道「这段为什么报 NameError」的排错题让我练

十一、加一层保险:文件不在、格式不对怎么办

写到这里程序「能跑」了,但只在你自己的电脑上、文件正好在、格式正好对的时候能跑。给别人用之前,先想清楚三种最可能出问题的情况:

情况不处理会怎样捕获哪个异常
文件不存在或路径写错程序带着一屏 Traceback 直接退出FileNotFoundError
文件里有空行 / 非数字,int() 转不动同样整段崩掉ValueError
文件里缺一列(比如只有两科)zip 少配一个键,后面代码悄悄算错主动检查列数

先看「不处理」是什么样子:

Python
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 篇),让它变成一句人话:

Python
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() 失败时要知道是哪一行出的问题,不然文件一大根本找不到:

Python
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):

Python
# 为了让下面这段能单独运行,开头先把 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 长这样:

Python
---- report.json ----
[
  {
    "名次": 1,
    "姓名": "小满",
    "总分": 288,
    "平均分": 96.0,
    "等级": "优秀"
  },
  {
    "名次": 2,
    "姓名": "阿禾",
    "总分": 267,
    "平均分": 89.0,
    "等级": "良好"
  }
]
注意上面这段完整程序开头多了 3 行造数据的代码——那是为了让本页每段都能单独跑。你实际交付的 main.py 不需要它们,直接从 import csv 开始就行。

十三、进阶:用类重写数据层(选学)

到此为止,程序里的「一个学生」都是用字典装的:{'姓名': '小鹿', '语文': 88, ...}。字典够用,但有个隐患——字段名是字符串,手一抖写成 row['数學'],写入的时候一声不吭,要等到取值运算那一刻才炸。学完《30-面向对象编程》之后,可以做得更好:把「一个学生」升级成一个 Student 对象,让数据和对数据的操作住进同一个类。

Python
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 篇说的封装。现在把前面「读进来 → 排名 → 打印」的主流程用类重写一遍:

Python
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

两种写法跑出来的排名完全一致——类没有改变程序做什么,只是把代码摆得更整齐:「一个学生」的身份数据和它的算法(总分、平均分)从此住在一起,主流程里剩下的全是「管一群人」的活儿。

什么时候不必用类?字段只有两三个、写完就丢的一次性脚本,字典照样轻快。当字段越来越多、每个字段都要配算法(算平均、判及格、评等级),或者同一个「学生」要在好几个函数之间传来传去时,类的收益才真正显出来。
类版还有个实用的小好处:字典的键打错了,编辑器毫无反应;而对象的属性打错了,TRAE 当场就画红线(第 4 篇的 AI 助手也能一眼看出来)。错误暴露得越早,越好找。

十四、常见坑

坑 1:读进来的全是字符串,忘了转数字

这是本页最致命的坑,因为它不报错:

Python
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()

Python
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

Python
import json

data = [{'姓名': '小满', '等级': '优秀'}]
print('---- json.dumps 默认写法 ----')
print(json.dumps(data))                              # 默认写法
print('---- json.dumps 加 ensure_ascii=False ----')
print(json.dumps(data, ensure_ascii=False))          # 中文原样保留

输出:

Python
---- json.dumps 默认写法 ----
[{"\u59d3\u540d": "\u5c0f\u6ee1", "\u7b49\u7ea7": "\u4f18\u79c0"}]
---- json.dumps 加 ensure_ascii=False ----
[{"姓名": "小满", "等级": "优秀"}]

两种都是合法 JSON,程序读回来也一样。区别只在于人能不能直接看懂——要交给别人的文件,选第二种。

坑 5:改完文件忘记关,数据没了

文件写了一半就中断,很容易只写进一半内容。用 with open(...) as f:(第 26 篇)就不用操这份心:代码块正常走完、或者中途报错,它都会帮你把文件正确关掉。

顺带一句:要把结果文件交给别人,先确认它真的写出去了。最简单的办法就是像第 9 节那样,写完立刻 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 题)

点击选项即可判分,答错的题会立刻显示解析。

已答 0 / 12 · 正确 0 —

1用 csv.reader 读出来的成绩,想拿去做加法,必须先做什么?

  • A. 转成 int / float
  • B. 先 strip() 去掉空格
  • C. 直接加就行,csv 模块会自动转
  • D. 先 sort() 排一下序
答案:A。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 没
答案:B。sorted 会拿着每个元素去调用你给的函数,所以传的是函数本身。写成 total() 是在定义时就调用它,此时没有 stu 参数,直接 TypeError。要传参数就用 key=lambda s: s['数学']。

3enumerate(ranked, start=1) 里的 start=1 是干什么的?

  • A. 从列表第二个元素开始遍历
  • B. 跳过第一个元素
  • C. 让编号从 1 开始,而不是默认的 0
  • D. 把列表倒过来
答案:C。不写 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
答案:D。第一次遇到某个等级时字典里还没有这个键,直接 counter[g] += 1 会抛 KeyError;get(g, 0) 在键不存在时给个 0,正好当计数器用。

5open('report.csv', 'w', newline='', encoding='utf-8') 里 newline='' 是为了防什么?

  • A. 在 Windows 上用 Excel 打开时每行多一个空行
  • B. 防止中文变成乱码
  • C. 防止文件被别的程序覆盖
  • D. 让每一行自动对齐
答案:A。Windows 的换行是 \r\n,Python 写文本时还会再补一次,结果每行之间多一个空行。中文乱码靠的是 encoding='utf-8',那是另一件事。

6json.dump(rows, f, ensure_ascii=False) 里的 ensure_ascii=False 有什么用?

  • A. 让 JSON 文件更小
  • B. 让中文按原样写进去,而不是写成 \u5c0f\u6ee1 这种转义
  • C. 让 JSON 允许单引号
  • D. 让字典的键按字母排序
答案:B。默认值 True 会把所有非 ASCII 字符转成 \uXXXX。文件合法、程序读回来也对,但人打开看不懂,所以给别人的文件一律加上这个参数。

7报表里中文列对不齐,根本原因是什么?

  • A. 字体没设置成等宽字体
  • B. f-string 不支持中文
  • C. len() 数的是字符个数,而中文实际占两个格子的宽度
  • D. 必须用 tab 键来对齐
答案:C。len('小满') 是 2,但显示出来有 4 格宽,所以按字符数补空格对不齐。本页的 pad() 用 ord(ch) > 127 判断「非 ASCII」,把中文按 2 格计算。

8程序要读的 scores.csv 有可能不存在,想让程序给一句友好提示而不是崩掉,应该捕获哪个异常?

  • A. ValueError
  • B. TypeError
  • C. IndexError
  • D. FileNotFoundError
答案:D。文件不存在 / 路径写错抛的是 FileNotFoundError;ValueError 是 int('') 这类转换失败,IndexError 是 r[3] 取不到,三者要分开处理。

9文件末尾这两行的作用是?
if __name__ == '__main__': /     main()

  • A. 直接运行本文件时执行 main(),被别人 import 时不自动执行
  • B. 让程序跑得更快
  • C. 把 main 注册成系统命令
  • D. 让 main() 里的报错被自动忽略
答案:A。直接运行该文件时 __name__ 是 '__main__',条件成立所以执行;被别人 import 时 __name__ 是模块名,条件不成立,就不会在别人的程序里乱打印。

10表格里有一格是空的,int('') 会抛什么错?

  • A. TypeError
  • B. ValueError
  • C. KeyError
  • D. 不会报错,返回 0
答案:B。int('') 抛 ValueError: invalid literal for int() with base 10: ''。空单元格是表格数据最常见的脏值,要么给默认分,要么跳过并记下来。

11为什么把参数 SCORE_KEYS = ['语文', '数学', '英语'] 单独提出来,而不是到处写三个科目的名字?

  • A. 因为写成常量运行更快
  • B. 因为列表不能写在函数里
  • C. 以后科目变了(比如加一门「物理」)只改这一处,其余代码不用动
  • D. 因为 for 循环只能遍历常量
答案:C。这叫把「会变的东西」集中到一处。total、average、load_students 都靠它工作,加一门课只改一行;散着写就要满文件找。

12整篇没有新语法,那这个综合程序真正新增的能力是什么?

  • A. 新的内置函数
  • B. 新的循环写法
  • C. 新的文件格式支持
  • D. 把已有工具按正确的顺序组织起来、并让每一块各管一件事
答案:D。综合实战练的就是「组织」:先定数据结构,再读、算、排、统计、输出、存盘,每步收成一个函数,最后用 main() 串起来,再加上异常处理兜底。