← 返回知识卡片总目录

DS1号 · 自学教材系列 | 编程

第 12 章 动手做一个小项目

知识如果不用,就只是「知道」。这一章,我们把前 11 章的所有武器——变量、类型、运算符、分支、循环、函数、列表、文件、异常——全部上阵,从零写一个完整可运行的小程序:「成绩统计小工具」。你会看到,那些看似零散的知识点,是如何拼成一个真正有用的东西的。

🎯 本章学习目标

  1. 体验「把一个大需求拆成小步骤」的思考方式。
  2. 实践「先最小可用,再逐步增强」的编程方法。
  3. 综合运用变量、循环、分支、函数、列表、文件、异常。
  4. 读懂一个完整程序的整体结构和数据流向。

12.1项目是什么:成绩统计小工具

我们的项目是:读入一组学生成绩,计算并打印统计报告(平均分、最高分、最低分、及格人数),并把报告保存到文件。

选它是因为:它不大不小,刚好能覆盖我们学过的几乎每一个知识点,而且结果清晰可验证——你一眼就能看出算得对不对。

这个项目用到哪些知识点
知识点在项目里的用途
变量与数据类型存成绩、存统计结果
运算符算平均分、比较大小
分支 if判断是否及格、是否更新最高分
循环 for遍历所有成绩
函数把「读文件」「算统计」「打印」各封装成函数
列表装下所有成绩
文件读写从文件读成绩、把报告写进文件
异常处理文件不存在时不崩溃

核心思想 一个好的练手项目,应该「麻雀虽小,五脏俱全」——能串起你学过的知识,又不至于复杂到无从下手。成绩统计正是这样的项目。

把「做项目」变成一套工作流:需求 → 设计 → 编码 → 测试

写作业程序,是「想到哪写到哪」;做项目,要用一套成熟的工作流,一共四步,每步都有明确的产出物:

项目四步工作流
步骤回答的问题产出物
① 需求程序要做成什么样?一句话目标 + 验收标准(可测的条目)
② 设计拆成哪几块?块与块怎么连接?子任务清单 + 函数契约(输入/输出)
③ 编码按设计把每一块写出来能运行的代码(按版本逐步长大)
④ 测试跑出来的结果符合验收标准吗?测试记录 + 修 bug 后的回归

新手最大的坑,是把四步压成一步——拿到题目直接敲代码。后果通常是:写到一半发现需求没想清(到底要不要保存文件?)、块与块缠在一起(读文件的代码里混着算平均)。后面 12.2 到 12.7 的四个版本,恰好就是这套工作流的现场演示:先定需求(12.1),再拆解定契约(12.2),然后按版本增量编码(12.3~12.6),最后整合并测试(12.7)。

📐 例题 1 给项目写验收标准

为「成绩统计小工具」写出 5 条可验证的验收标准;已知示例数据 scores = [88, 76, 95, 61, 82],预先算出每条标准应得到什么数字。

思路 验收标准要满足两点:一可测(能明确判断过/不过),二带数字(预期值先算好)。数字按第一版逻辑手工预估——12.4 节会验证。

解 5 条标准与预估:

验收标准与预估结果
#标准预估([88, 76, 95, 61, 82])
1能从 scores.txt 读入全部成绩读入 5 个数
2平均分计算正确88+76+95+61+82=402,402÷5=80.4
3最高分、最低分正确最高 95、最低 61
4及格人数(≥60)正确5 个成绩全部 ≥60,及格 5 人
5报告写入 report.txt文件含标题在内 6 行文本

答:5 条标准如上,预估数字 5 人 / 80.4 分 / 95 / 61 / 5 人 / 6 行。 ⚠ 易错点:① 标准里不能出现「运行流畅」「界面好看」这类没法判定过不过的说法——验收标准必须是一句能回答「是/否」的话;② 预估数字要有依据(402÷5=80.4 是手算),测试时才有「对账单」;③ 注意第 4 条:61 分也及格(≥60 含 60 本身),别凭感觉数成 3 人。

📖 工程视角:验收单是工程世界的通用语言 设备进场要有验收单:逐条项目、逐条实测、逐条签字——「通电自检正常」「量程校准合格」。软件交付同样如此:需求方与开发方先对验收标准达成一致,写代码的人才知道「做到什么程度算完」。把本章的 5 条标准想象成一张验收单:程序交付前,每条都要实测打钩。这套「先定验收、再做东西、做完对单」的节奏,是所有工程行业的共同纪律。

辨析:需求不是「想法」 「做个好用的成绩工具」是想法,人人会说;「读入成绩文件、输出四项统计、报告落盘」才是需求。从想法到需求的距离,就是给每个形容词换成可测的句子:好用到什么程度?算多快?保存成什么?想清这三问,需求就算定了。往后无论是接活还是自学做项目,先逼自己写出验收标准,等于把一半的返工消灭在动笔之前。

12.2拆解需求:把大问题切成小问题

新手最大的误区,是一上来就想写完整代码。正确的方法是:先拆解,再动手。

「成绩统计小工具」这个需求,可以拆成 4 个清晰的子任务:

需求拆解 把总需求拆成读成绩、算统计、打印报告、存报告四个子任务。 成绩统计小工具 ① 读成绩 从文件读入列表 ② 算统计 平均/最高/最低/及格 ③ 打印报告 显示到屏幕 ④ 存报告 写入文件 数据像流水一样:读进来 → 算一算 → 打印 → 存起来 每个子任务对应一个函数,各司其职
图 12-1把大需求拆成 4 个子任务,每个对应一个函数,数据像流水一样依次流过。

🧠 一句话记忆 大问题拆小问题,小问题写成函数。拆得越清楚,写得越容易。

拆解的验收标准:每个子任务「输入输出一句话说得清」

把需求切成 4 块只是第一步,拆解合格与否有个硬标准:每个子任务的输入是什么、输出是什么,都能用一句话说清。说清输入输出的那张表,就是函数的契约。契约定了,4 个函数就可以分开写、分开测:负责「读」的先把成绩列表造出来,负责「算」的拿现成列表开工,谁也不等谁;将来某个环节要改(比如报告加一栏),只要契约不变,其他函数一行不动。这就是「大问题拆小问题」的全部价值——小问题之间只靠契约咬合,不靠内部细节纠缠。

📐 例题 2 写出四个函数的契约,再手动追踪一遍核心函数

① 为 load_scores、compute_stats、make_report、save_report 各写一行契约(输入 → 输出);② 手动执行 compute_stats([88, 76, 95, 61, 82]),把循环每一轮结束后四个变量的值填进追踪表。

思路 契约从「数据流」读出来:读文件吃进文件名、吐出列表;算统计吃进列表、吐出四元组;拼报告吃进「列表+四元组」、吐出一段文字;存报告吃进「文字+文件名」、不吐东西(写出文件本身)。追踪表则铺开草稿纸逐行抄写(第 10 章例题 5 用过的方法)。

解 ① 契约表:

四个函数的契约
函数输入输出一句话职责
load_scores文件名整数列表把每行成绩读成数字
compute_stats整数列表(平均, 最高, 最低, 及格数)一趟循环算出四项统计
make_report列表 + 四元组报告文本把数字拼成人能读的报告
save_report文本 + 文件名(写出文件)把报告写到磁盘

② 追踪表(每轮结束时):循环开始前 total=0、highest=88、lowest=88、passed=0。

compute_stats 的逐轮追踪
本轮 stotalhighestlowestpassed
88(第1轮)8888881
76(第2轮)16488762
95(第3轮)25995763
61(第4轮)32095614
82(第5轮)40295615

循环结束后:average = 402 ÷ 5 = 80.4,返回 (80.4, 95, 61, 5)。核对几处变化:第 3 轮 highest 被更新为 95;第 2 轮 lowest 降到 76、第 4 轮再降到 61;passed 每轮 +1,因为 5 个成绩全部 ≥60。

答:契约见表;追踪后 compute_stats 返回 (80.4, 95, 61, 5)。 ⚠ 易错点:① 契约写「输入文件内容」是错的——函数吃的是文件名,打开文件是函数内部的事;② 追踪表里第 2 轮 total 是 88+76=164,别抄成 76;③「最高分 95 在第 3 轮才出现」提醒我们:最值是遍历完才能确定的结论,中途任何一个值都只是「暂时的最高」。

📖 工程视角:先定契约,再分头干活 真实软件几乎都是多人合写的。团队怎么做到互不踩脚?答案就是本节的契约表:接口先谈定(给什么、还什么),然后各自领一块回去写。只要人人守约,最后拼起来就能跑;谁悄悄改了契约(比如多返回一个值),下游全部遭殃。学生项目虽然一个人写,「今天的你和明天的你」也是协作——契约表贴在桌边,明天接着写时不用重新读代码。

辨析:拆解按「数据流」切,不按「代码行数」切 「这个项目 100 行,每人写 50 行」不是拆解——两半代码搅在一起,谁也没法独立测试。正确的切法沿数据流下刀:上一块的输出正好是下一块的输入(文件 → 列表 → 统计 → 报告文本 → 文件),每块自然独立、自然可测。判断拆解好坏就一句话:切完之后,能不能一块一块单独验证?

12.3第一版:硬编码,先跑起来

拆解完后,不要急着写完整版。先写一个最简版本:把成绩「硬编码」(直接写在代码里),只做最核心的「算平均分」,先让程序跑起来。

scores = [88, 76, 95, 61, 82]   # 先直接写死在代码里

total = 0
for s in scores:
    total = total + s
average = total / len(scores)

print("平均分:", average)        # 输出:平均分:80.4

这个 7 行的版本,已经能算出平均分了。它虽然简陋,但验证了核心逻辑是对的——这就是「先跑起来」的意义:尽早看到结果,尽早发现错误。

核心思想 编程不要「一口吃成胖子」。先写最小可用版本(MVP)跑通核心逻辑,再一点点加功能。每一小步都可见、可验证,比一次性写完再调试要高效得多。

「最小」指最小闭环,不是最小行数

MVP(最小可用版本)里的「最小」常被误读成「写得越少越好」。它真正的含义是最小闭环:输入 → 处理 → 输出这一整条链路被完整打通,哪怕每个环节都简陋。第一版硬编码成绩、只算平均分,看似偷工减料,其实意义重大——它第一次证明了「数据进得来、算得出、打得印」,核心通路是通的。之后每一版只加一个能力(v2 函数、v3 文件、v4 异常),加完就整版再验一次,这叫增量验证。反面教材是「一次性写完再调」:四五个新能力混在一起出问题,你根本说不清 bug 藏在哪一层——这正是下一章调试要还的债,与其事后还,不如增量时就规避。

📐 例题 3 第一版的边界:空数据会怎样?

第一版代码在 scores = [88, 76, 95, 61, 82] 下输出「平均分:80.4」。如果 scores = [](空列表),会发生什么?先预测,再说修复思路。

思路 模拟执行:total 累加一轮都不发生,保持 0;len([]) = 0;然后 total ÷ len(scores) = 0 ÷ 0。第 11 章例题 4 的四类异常里,正好有它。

解 

scores = []           # 边界:一个成绩都没有

total = 0
for s in scores:      # 循环体一次也不执行,total 还是 0
    total = total + s
average = total / len(scores)   # 0 / 0 → 崩!

print("平均分:", average)      # 这行永远执行不到

实测:程序在 average 那行抛出 ZeroDivisionError: division by zero,print 那行根本轮不到——又一次印证第 11 章「异常行之后的代码被跳过」。修复思路是在做除法之前先判空:if len(scores) == 0: 就提前返回(12.7 的完整代码里,compute_stats 开头那句防御正是这么写的,还顺手避免了 scores[0] 越界)。

答:抛 ZeroDivisionError 崩溃;修复 = 除法前判断 len(scores) == 0 提前返回。 ⚠ 易错点:① 「正常数据跑通」不等于「所有数据跑通」——边界(空列表、单元素、全部同分)最会拆穿想当然;② 空列表时 scores[0] 也是雷(IndexError),判空一举挡住两颗雷;③ 「先跑起来」的原则要和「敢测边界」配套:MVP 阶段可以暂不处理边界,但要知道它在那里,并在版本演进中补上——12.7 完整版正是这么做的。

📖 工程视角:里程碑式的版本节奏 工程研发惯用「里程碑」管理进度:每个版本冻结一个可用状态,通过一轮验证再进入下一个。好处有两个——任何时刻手里都有一个「能演示的版本」,以及出问题时能快速锁定「上一版还好好的,问题一定出在这一版新加的东西里」。本书项目的 v1→v4 就是这条节奏的微缩版:每一版都可运行、可验收,就像登山途中一个个营地,退可守、进可攻。

辨析:MVP 是路径,不是终点 有人把 MVP 误当「糊弄版」:能跑就行,不打算再改。健康的姿势恰恰相反——每一版都从「打算被下一版替换」开始写:变量命名认真、结构清楚,替换时才不疼。MVP 与「半途而废」的区别,就看它有没有站在四步工作流里:需求验收在前、增量验证在后。第一版只算平均分,不是偷懒,是把「及格人数、最高最低分」诚实地排进了下一个版本。

12.4第二版:用函数封装

第一版只有一个「算平均」,太零散。现在用第 9 章学的函数,把「算统计」封装成一个独立函数,顺便把最高分、最低分、及格人数都算出来。

def compute_stats(scores):
    total = 0
    highest = scores[0]    # 先假设第一个是最高分
    lowest = scores[0]     # 先假设第一个是最低分
    passed = 0

    for s in scores:
        total = total + s          # 累加求和
        if s > highest:             # 遇到更大的就更新
            highest = s
        if s < lowest:              # 遇到更小的就更新
            lowest = s
        if s >= 60:                # 及格(≥60)
            passed = passed + 1

    average = total / len(scores)
    return average, highest, lowest, passed

scores = [88, 76, 95, 61, 82]
avg, high, low, p = compute_stats(scores)
print(avg, high, low, p)   # 输出:80.4 95 61 4

逐行看几个关键点:

要点 找最值套路:先拿第一个当基准,遍历比较替换。这是「变量 + 循环 + 分支」三件套的经典组合,务必吃透。

找最值的「基准」为什么必须是第一个元素

12.4 的套路是「先假设第一个最大/最小,遇到更强的就替换」。为什么基准不干脆写个 0,还非要 scores[0]?因为0 不是中立起点,它自己就是候选值:一旦所有成绩都是负数(换个场景:全是零下的温度、全为负的偏差),写死 0 的「最大值」会永远是 0——可数据里根本没有 0!基准必须来自数据内部,第一个元素正是「既在数据内、又天然参与比较」的成员。这条教训可以推广成一句口诀:初始化要选「不会说谎」的值:计数器从 0 起(没计数就是 0,诚实);找最值从第一个元素起(它真的在数据里);累加器从 0 起(没累加就是 0)。

📐 例题 4 两个版本的找最大值,谁在说谎?

下面两个函数都想找最大值,基准不同。用两组数据各测一遍:[88, 76, 95, 61, 82] 与 [-12, -30, -7],判断哪个函数正确。

解 

def bad_max(scores):
    m = 0                     # 基准写死 0
    for s in scores:
        if s > m:
            m = s
    return m

def good_max(scores):
    m = scores[0]             # 基准取第一个元素
    for s in scores:
        if s > m:
            m = s
    return m

print(bad_max([88, 76, 95, 61, 82]))   # 95  ——碰巧对
print(good_max([88, 76, 95, 61, 82]))  # 95
print(bad_max([-12, -30, -7]))        # 0   ——说谎!数据里没有 0
print(good_max([-12, -30, -7]))       # -7  ——正确的最大值

第一组全是正数,0 打不过任何人,两个函数殊途同归;第二组全是负数,0 稳赢所有负数——bad_max 于是「凭空造」出一个最大值。危险的恰恰是第一组:用错了基准,正常成绩怎么测都是对的。这提示一条测试铁律:测试数据要「毒」一点——专门挑能拆穿假设的数据(全负数、空列表、全部同分)。

答:good_max 正确;bad_max 在全负数据下错误地返回 0。 ⚠ 易错点:① 「正数数据测不出这个 bug」是本题最深的坑——测试用例选不好,错误代码也能全绿;② good_max 遇到空列表会先在 scores[0] 上 IndexError,所以完整版在函数开头加了 len==0 防御(12.7);③ 套路可以镜像复制:找最小值同一套代码,把 > 换成 <。

⚠ 亲手验算教材:注释也是输出,照验不误 本节代码末行注释写着「输出:80.4 95 61 4」。把这段代码原样跑一遍,实测输出是 80.4 95 61 5——逐个数:88、76、95、61、82 五个成绩全部 ≥60,及格人数是 5,注释里的「4」是笔误。average、95、61 与追踪核对无误(见例题 2)。这不影响代码本身正确,但留下一课:注释也是要验算的——它是人写给人看的文档,同样会手滑;以实测为准、随手验算,是读任何教材、接任何旧代码时都该带着的姿态。

📖 工程视角:测试数据要「毒」一点 专业测试人员的思维方式和开发相反:开发想「怎么让它跑起来」,测试想「怎么把它弄坏」。空文件、全负数、超长输入、重复值、恰好等于阈值的值(60 分这个及格线,59、60、61 都要各测一次)……这类「毒数据」专治想当然。12.7 的测试三件套会把这个习惯落成方法:正常、边界、异常,一类都不能少。

辨析:函数是无副作用的黑箱 compute_stats 只吃参数、只返回结果,不打印、不改全局、不写文件——这样的函数像纯净水:谁来测、测几遍、什么时候测,结果都一样。对照看 main(12.7):它负责调度读写文件与打印,是「有动作」的。分层经验:算东西的函数保持纯净,与外界打交道的动作集中到少数几个函数里,程序才好测、好改、好排错。

12.5第三版:加文件,持久化

成绩不能总写在代码里——它应该从文件读进来。我们假设有一个 scores.txt 文件,每行一个成绩:

88
76
95
61
82

用第 11 章的文件读写,写一个 load_scores 函数,从文件读入成绩列表:

def load_scores(filename):
    scores = []
    f = open(filename, "r")      # 打开文件(读模式)
    for line in f:               # 逐行读
        scores.append(int(line.strip()))
    f.close()
    return scores

这里有几个新细节:

同样,把报告「写」进文件也用函数封装:

def save_report(text, filename):
    f = open(filename, "w")      # 打开文件(写模式)
    f.write(text)
    f.close()

要点 从文件读出来的永远是字符串,要转成数字必须 int() 或 float()。这是文件处理的常见坑,务必记住。

读文件的三段流水:读 → 清洗 → 转换

load_scores 只有 6 行,却串着一条三段式流水线:读(for line in f 拿到一行字符串)→ 清洗(line.strip() 去掉换行符与空白)→ 转换(int(...) 变成整数)。真实世界的成绩文件很少干净:末尾可能多按了回车(多出空行)、录入员可能敲错一个字母、不同系统导出的换行风格还可能不同。三段流水就是为这些「脏东西」准备的防线。先看各段到底在对抗什么:

📐 例题 5 脏数据文件的逐行容错解析

scores.txt 的实际内容是六行:88、76、abc、95、(空行)、61。写一个容错版 load_scores:能解析的行进成绩列表,解析不了的跳过并计数,最后报告「有效几条、跳过几条」。原来那版(不带 try/except)会怎样?

思路 把「转换」这段流水包进 try/except:int 抛 ValueError 就说明这行不干净,跳过它、计数 +1,继续下一行——一行中毒,整文件不崩。原版遇到 abc 直接崩,后面 95、61 全丢。

解 

def load_scores_robust(filename):
    scores = []
    skipped = 0
    f = open(filename, "r")
    for line in f:
        clean = line.strip()          # 清洗:去换行与空白
        try:
            scores.append(int(clean))  # 转换:行得通就收下
        except ValueError:
            skipped = skipped + 1     # 行不通:跳过并计数
    f.close()
    return scores, skipped

scores, skipped = load_scores_robust("scores.txt")
print("有效成绩:", scores)   # 有效成绩: [88, 76, 95, 61]
print("跳过行数:", skipped)  # 跳过行数: 2

六行里 88、76、95、61 四行有效;abc 与空行各触发一次 ValueError(空行 strip 后是空串,int("") 转不动),skipped = 2。逐行容错保住了四条数据;原版在 abc 那行当场崩溃,一条也保不住。

答:scores = [88, 76, 95, 61],跳过 2 行(abc 行与空行);原版会崩在 abc 行。 ⚠ 易错点:① 跳过要计数、事后要报告——静默吞掉脏行等于对数据质量说谎(呼应第 11 章「兜住要有出路」);② int(clean) 必须放在 try 里、append 紧跟其后——一旦抛异常,append 不会执行,毒数据进不了列表;③ 报告里写「2 行」而不是「abc 和空行」时,要记得 skipped 是条数,想报内容还得另外记录。

⚠ 亲手验算教材:int() 对空白到底多宽容? 本章思考题第 3 题问「直接 int(line) 行不行」,原提示称 int("88\n") 会报错。拿真机试一下会发现更微妙的事实:int("88\n") 并不报错,返回 88——int() 对字符串首尾的空白(含换行)是容忍的,会先剥掉再转换(int(" 88 ") 同样返回 88)。真正让 int() 翻脸的是内容:int("88.5") 与 int("") 都抛 ValueError。那 strip 是不是白写?不是——它的价值在于把清洗做显:空行 strip 后变成空串,if clean == "": 一眼可判;显式清洗让「这行是什么」在代码里明明白白,而不是依赖 int() 内部的隐含容忍。结论照旧成立:正常成绩行 int(line) 恰好能转,但遇到空行、脏行立刻崩——容错版才是能上战场的版本;而教材提示里「int("88\n") 会报错」这句,照「以实测为准」的姿态修正即可。

📖 工程视角:真实数据没有「干净」的 传感器导出的 CSV、人工录入的台账、多年积攒的历史文件——工程世界的数据源几乎都有杂质:缺测、乱码、单位混用、重复记录。所以专业程序在读入数据的第一站就修「安检门」:能修的修(strip、单位换算),不能修的隔离(跳过并记日志),绝不放毒数据进核心计算。本节 6 行的小安检门,就是真实数据管线的前 10 米。

辨析:容错不是掩盖 跳过脏行时把 2 这个数字报告出来,与「装作没看见」是两种境界:前者让用户知道 6 行里有 2 行没被统计,可以回头修数据;后者让统计结果带着看不见的缺口流出去。容错的目标是让好的数据活下来,同时让坏的数据留痕——两件事一起做,才叫健壮;只做第一件,那叫瞒报。

12.6第四版:加异常,变健壮

现在程序有个隐患:如果 scores.txt 不存在,open 会直接抛异常、程序崩溃。用第 11 章的 try/except 兜住它:

def load_scores(filename):
    scores = []
    try:
        f = open(filename, "r")
        for line in f:
            scores.append(int(line.strip()))
        f.close()
    except FileNotFoundError:
        print("找不到文件", filename, ",返回空列表")
    return scores

这样即使文件不存在,程序也不会崩,而是打印一句提示,返回空列表,继续往下跑。

核心思想 真实程序必须健壮——预测可能出错的地方(如文件不存在、格式不对),用 try/except 兜住。这一步让程序从「玩具」变成「能用的工具」。

兜住之后:三选一的「出路」

第 11 章说过 except 兜住后必须给出路。具体到「文件读不到」这个场景,出路有三种:

文件缺失的三种兜底策略
策略做法适用场合
放任崩溃不兜,异常冒到顶层开发调试期:让问题尽早暴露
兜住 + 默认值返回空列表,程序继续缺省数据不影响大局时(本项目)
兜住 + 明确报告后退出提示用户,终止数据是必需品(如配置文件)

本项目选第二种,但只兜一半就撒手是不够的:load_scores 兜住文件缺失、返回空列表之后,main 还得接得住这份「空」——这正是完整代码里 if not scores: print("没有数据,程序结束"); return 的由来。异常防线和业务防线首尾相接,程序才真正站得稳。

📐 例题 6 数一数这条链上有几道防线

完整版程序运行时 scores.txt 不存在。① 写出从启动到退出经过的每一道防线;② 屏幕最终打印什么?③ 如果第二道防线(main 的判空)忘了写,程序会停在哪一步、报什么错?

思路 沿数据流走:load_scores 里 open 抛 FileNotFoundError → except 接住 → 返回 [] → main 拿到 [] → 判空 → 打印提示 → return 结束。

解 

def load_scores(filename):
    scores = []
    try:
        f = open(filename, "r")
        for line in f:
            scores.append(int(line.strip()))
        f.close()
    except FileNotFoundError:                     # 防线一
        print("找不到文件", filename, ",返回空列表")
    return scores

def main():
    scores = load_scores("scores.txt")   # 拿到 []
    if not scores:                        # 防线二
        print("没有数据,程序结束")
        return
    # ……后面的计算不会执行到

main()

实测屏幕输出两行:找不到文件 scores.txt ,返回空列表 与 没有数据,程序结束。第③问:若 main 忘了判空,空列表会流进 compute_stats,先在 scores[0] 上撞出 IndexError(第 10 章例题 2 讲过越界)——兜底兜了个寂寞。这说明防线要成链:异常防线保住「读」,业务防线保住「用」,缺一环,毒数据照样流到深处爆雷。

答:两道防线(except FileNotFoundError + main 判空);屏幕打印「找不到文件……」与「没有数据,程序结束」;若缺判空,程序崩在 compute_stats 的 scores[0],报 IndexError。 ⚠ 易错点:① print("找不到文件", filename, ",返回空列表") 用逗号拼接输出,逗号处自动补空格——所以「文件名」后面有个空格再接逗号句,别当成排版错误;② return scores 写在 try/except 结构之后,兜住后照样执行到它;③ except 只兜 FileNotFoundError——若文件存在但某行是脏数据,ValueError 不会被这道防线拦下(那属于例题 5 的行级防线),两道防线各管各的类型。

📖 工程视角:优雅降级 你用的地图 App 断网后并不闪退:先弹「离线模式」,缓存里有的地图照用——这就是优雅降级(graceful degradation):核心能力保住,缺失的部分用默认值顶上、并明明白白告诉用户缺了什么。工程系统按「缺什么、能不能用默认顶」把每个外部依赖分好级,写程序时才知道每个 except 之后该走哪条出路。本项目的「空列表 + 提示」就是最小号的优雅降级。

辨析:健壮不是「永不失败」 追求健壮,目标不是让程序永远不倒——那做不到,也不必要。目标是失败得有尊严:意外发生时,程序要么带着默认值继续,要么把原因讲清楚再体面退出,绝不一声不吭地崩给用户看,也绝不带着毒数据假装一切正常。判断一段兜底代码好不好,就看它失败时用户看到的是什么:一句人话,还是一屏天书。

12.7完整代码与逐行回顾

把四个版本合并、串起来,就是最终的程序。我们把整体流程放进一个 main() 函数,让结构清晰:

def load_scores(filename):
    """从文件读取成绩,返回列表"""
    scores = []
    try:
        f = open(filename, "r")
        for line in f:
            scores.append(int(line.strip()))
        f.close()
    except FileNotFoundError:
        print("找不到文件", filename, ",返回空列表")
    return scores


def compute_stats(scores):
    """计算统计信息,返回(平均, 最高, 最低, 及格人数)"""
    if len(scores) == 0:
        return None          # 空列表,没有统计意义
    total = 0
    highest = scores[0]
    lowest = scores[0]
    passed = 0
    for s in scores:
        total = total + s
        if s > highest:
            highest = s
        if s < lowest:
            lowest = s
        if s >= 60:
            passed = passed + 1
    average = total / len(scores)
    return average, highest, lowest, passed


def make_report(scores, stats):
    """把统计结果拼成一段报告文字"""
    avg, high, low, passed = stats
    lines = []
    lines.append("=== 成绩统计报告 ===")
    lines.append("人数:" + str(len(scores)))
    lines.append("平均分:" + str(avg))
    lines.append("最高分:" + str(high))
    lines.append("最低分:" + str(low))
    lines.append("及格人数:" + str(passed))
    return "\n".join(lines)


def save_report(text, filename):
    """把报告写入文件"""
    f = open(filename, "w")
    f.write(text)
    f.close()


def main():
    scores = load_scores("scores.txt")       # ① 读
    if not scores:
        print("没有数据,程序结束")
        return
    stats = compute_stats(scores)             # ② 算
    report = make_report(scores, stats)       # ③ 拼报告
    print(report)                             # 打印到屏幕
    save_report(report, "report.txt")         # ④ 存文件


main()

整个程序的数据流非常清晰,像一条流水线:

程序的数据流 数据从文件读入为列表,经计算得统计,拼成报告,最后打印并保存。 scores.txt (磁盘) scores 列表 (内存) stats 统计 (内存) report 报告 (屏幕 + 文件) 读文件 计算 输出
图 12-2程序的数据流:文件 → 列表 → 统计结果 → 报告,每一步都有清晰的输入和输出。

复盘:这个项目串起了什么 变量与列表(存数据)、循环(遍历成绩)、分支(判断及格/更新最值)、函数(4 个子任务各成一个函数)、文件(读入与保存)、异常(文件不存在不崩)。前 11 章的知识,在这里全部「用上了」。

🧠 一句话记忆 程序 = 数据 + 逻辑。数据从文件来,逻辑用函数组织,结果回文件去——这就是编程的通用骨架。

代码跑不通怎么办:调试方法论

四个版本合并成一个程序之后,总有一天它跑得通,但结果不对——这比直接崩溃更难受。崩溃有 Traceback 指路(第 11 章),「静悄悄算错」才真考验功夫。调试有两条基本功,都简单得不像技术:

基本功一:打印定位。别盯着代码猜,让程序自己交代中间过程——在关键位置插一行 print(变量),看看中间量到底等于几。比如某个版本算出平均分 16.4,而验收标准说是 80.4(例题 1 的对账单起作用了)。别乱翻代码,先在循环结束处打一行 print(total):预期 402,实际打出 82——正好是最后一个成绩!累加环节出了问题,循着 82 这条线索回看循环体,一眼就能看到 total = total + s 被多缩进了一层,根本没进循环,退出循环后才执行了一次(s 停在最后一个值 82)。修复只需删一个缩进——但若没有 print 拍下的 82,这个 bug 够你盯半小时。

基本功二:二分排除。程序长、线索少时,不知道该在哪打点?在程序中段随便打一个:打印值正确,说明前半段无罪,bug 藏在后半段;打印值不对,bug 在前半段。每打一个点就排除一半代码:12 行的程序,第一点后剩 6 行,再一点剩 3 行,三点之内锁定到那一行。这两个基本功与第 11 章「异常即跳转」是一对:异常告诉你程序死在哪,调试打印告诉你程序活到哪、算到哪。图 12-3 画出了这套循环。

打印定位与二分排除的调试循环 发现结果不对后,在程序中段打印中间值:值不符合预期说明bug在前半段,符合则在后半段,每轮排除一半代码。 程序跑通但结果不对:平均分 16.4,预期 80.4 在程序中段插一行 print(中间量) 打印的值符合 预期吗? 不符合 → bug 在 print 之前 接下来对前半段继续二分 符合 → bug 在 print 之后 接下来对后半段继续二分 不符合 符合 每轮排除一半:12 行 → 6 行 → 3 行 → 1 行,修一处、验一次
图 12-3调试循环:发现结果不对 → 中段打印中间量 → 按预期是否相符排除一半代码 → 反复缩小包围圈,直到锁定那一行;每修一处立刻全量重测。

测试三件套:正常、边界、异常

调试是「出了事再查」,测试是「没事时先备好对账单」。给这个项目备三组用例,就能把大部分意外拦在上线前:正常用例(常规数据,对答案)、边界用例(空数据、单元素、全同分、刚好压线的 60 与 59)、异常用例(文件不存在、脏数据行)。三件套一旦写好,每次改代码后全量重跑——这叫回归测试:专门防「修好东墙、塌了西墙」。

📐 例题 7 给完整程序跑一遍测试三件套

用三组用例测试完整程序:① 正常:scores.txt 五个成绩 [88, 76, 95, 61, 82];② 边界:只有一个成绩 [100];③ 异常:scores.txt 不存在。分别写出屏幕应出现的核心结果,并实测验证。

思路 ①②直接调 compute_stats 核对四元组;③走例题 6 的防线链。预期:正常 → (80.4, 95, 61, 5);单人 → 平均 100.0、最值都是 100、及格 1;异常 → 两行提示后「没有数据,程序结束」。

解 

# ① 正常用例
print(compute_stats([88, 76, 95, 61, 82]))   # (80.4, 95, 61, 5)
# ② 边界用例:单人、空
print(compute_stats([100]))                   # (100.0, 100, 100, 1)
print(compute_stats([]))                       # None(防线:len==0 提前返回)
# ③ 异常用例:文件不存在 → 留给 main 的防线链
# 屏幕依次打印:
#   找不到文件 scores.txt ,返回空列表
#   没有数据,程序结束

实测与预期逐条相符:正常 (80.4, 95, 61, 5);单人 (100.0, 100, 100, 1)——注意单人班上最高分、最低分、平均分三者同为 100,这是边界用例特有的「反直觉对」;空列表返回 None,由 main 的判空接住。对照例题 1 的验收标准:5 条逐条打钩,项目验收通过。

答:① (80.4, 95, 61, 5);② (100.0, 100, 100, 1) 与 None;③ 两行提示后体面退出。 ⚠ 易错点:① 单人用例的「最高=最低=平均=100」不是 bug——先想清楚预期,再对照实测,测试比的从来是这两者;② 边界用例要包含「刚好压线」的 59 与 60(及格线两侧各一测);③ 回归是全量重跑——只重跑你改过的那条,等于没跑。

📖 工程视角:改一处、全量重跑 专业团队的代码库里躺着成百上千条测试用例,谁改了代码,提交前自动把全部用例跑一遍,任何一条变红就禁止合入。这套机制(回归测试)的价值在第 N 次修改时会显现:你以为只动了一行,全量重跑却亮了三个红灯——三处你根本没想到的连带影响被当场拦下。学生项目里,把三件套存成一个 test.py,每次改完 python test.py 一敲,就是最小号的回归体系。

辨析:测试不能证明「没有 bug」 测试只能证明「在这些用例下表现正确」,它是在有限用例里逼近真相,不是宣判程序无罪。所以测试讲究「用例选得毒」(例题 4 的负数教训)、讲究覆盖三件套,而不是求多求全。调试和测试是一对搭档:测试拦住大部分错,漏网的交给打印与二分去追。至此,四步工作流闭环:需求(例题 1)→ 设计(例题 2)→ 编码(v1~v4)→ 测试与调试(本节)——每一步的产出,都是下一步的原料。

本章小结(也是全书小结)

  1. 写程序的正道:先拆解需求,再写最小可用版,最后逐步增强。
  2. 「找最值」「累加」「计数」是循环里最经典的三件套模式。
  3. 函数把 4 个子任务各封装成一个,程序结构清晰、易改易维护。
  4. 文件让数据持久化,异常让程序健壮——这两点让程序从玩具变成工具。
  5. 回顾 12 章:从「什么是编程」到「动手写程序」,你已经完成了从零到一的跨越。
  6. 项目四步工作流:需求(验收标准)→ 设计(契约表)→ 编码(增量版本)→ 测试(三件套 + 回归),每步产出物是下一步的原料。
  7. 找最值的基准必须取第一个元素:0 不是中立起点,全负数据下会「凭空夺冠」。
  8. 调试两基本功:打印定位让程序交代中间量,二分排除每打一点砍掉一半嫌疑代码;修一处、验一次。
  9. 容错解析让好数据活下来、坏数据留痕;注释、提示与教材答案,一律以实测为准。

读完本章,你应该能……

思考题(动手挑战)

1. 把项目改成「猜数字游戏」:程序随机一个 1-100 的数,用户循环猜,提示大了/小了,猜中结束。

提示:用 random 模块的 randint 生成随机数,用 while 循环 + if 判断 + input 输入。

2. 给成绩统计加一个功能:打印「优秀人数(≥90)」。

提示:在 compute_stats 的循环里,再加一个 if s >= 90: excellent += 1,并返回它。

3. 为什么 int(line.strip()) 要先 strip 再 int?直接 int(line) 行不行?

提示:文件每行末尾有换行符 \n,int("88\n") 会报错;先 strip 去掉空白再转整数。

4. 如果 scores.txt 是空的(0 行),程序会怎样?如何让 compute_stats 正确处理?

提示:scores 会是空列表,compute_stats 里 len==0 时返回 None,避免除以 0 报错。

5. 12 行代码结果不对,你在第 6 行末尾加了 print,输出符合预期——bug 在前半段还是后半段?下一个 print 加在哪里?

提示:第 6 行的输出还正确,说明 bug 在它之后(第 7~12 行);下一步对后半段取中点,在第 9 行附近再打一个,把范围再砍一半。

6. 找最大值时基准为什么不能写死 0?用 [-12, -30, -7] 实测会暴露什么?

提示:0 不是中立起点,全负数据里它稳赢——bad_max 会返回数据里根本不存在的 0,而正确答案是 -7;基准要取第一个元素。

7. 修完一个 bug 之后,除了看它修没修好,还必须做什么?为什么?

提示:全量回归——把测试三件套整套重跑一遍;因为你只验证了「这一处好了」,拦不住「别处被这处修改带坏」(修好东墙、塌了西墙)。