分解、测试与调试
引入| English | 释义 | 拼音 |
|---|---|---|
| decomposition/ˌdiːkɒmpəˈzɪʃn/ | 分解 | fēn jiě |
| testing/ˈtestɪŋ/ | 测试 | cè shì |
| debugging/ˈdiːbʌɡɪŋ/ | 调试 | tiáo shì |
300 行代码里,bug 在哪里?
- 一个商店程序打印的总价是错的。全部 300 行写在一整块里,错误可能在任何地方,每次修改都可能带来新的错误。
- 把同一个程序拆成小函数,每个都单独检查,问题就缩小为“是哪一块?”。这节课讲怎样构建可以检查、可以修复的程序。
一个函数只做一件事
- **分解(decomposition)**就是把问题不断拆分,直到每一块只做一件清楚的事。好的一块小到可以单独测试。
- 给每个函数写一段简短的文档字符串,说明它的契约:输入什么、输出什么、单位是什么。
def add_fee(amount):
"""Return amount (pence) plus a fixed 3 pence fee."""
return amount + 3
def order_total(unit_pence, quantity):
"""Return the order total in pence, fee included."""
return add_fee(unit_pence * quantity)
把“打印收据”程序拆分,哪种方式是最好的分解?
难度 1/5 · 今日首次完成可获 100 金币及少量额外奖励
每一块只做一件事,输入输出清楚,所以每一块都可以单独测试。按代码位置拆分得到的块没有清楚的契约。
中间用 return,边缘才 print
- 返回结果的函数可以被其他函数使用,也可以被测试检查。只会打印的函数做不到:调用它得到的是
None。 - 把
input()和print()放在程序的边缘。中间应该是接收值、返回值的函数。
追踪 order_total(250, 4)
add_fee(1000) 运行时 order_total 暂停,然后使用它返回的值。因为两个函数都有返回值,所以各自都可以单独测试。
def show(x): print(x + 1)。result = show(4) 在 result 中存放什么值?
难度 1/5 · 今日首次完成可获 100 金币及少量额外奖励
show 打印 5,但没有 return,所以调用得到 None。没有任何东西可以使用或测试这个 5。
测试比较实际结果与预期结果
- **测试(testing)**就是在已经知道正确答案的输入上运行代码。
assert order_total(250, 4) == 1003通过时不出声,失败时引发AssertionError。 - 在运行代码之前手工算出预期值;否则你只会照抄代码打印出来的东西。
运行之前手工算出:order_total(120, 3) 应该返回多少便士?
难度 2/5 · 今日首次完成可获 140 金币及少量额外奖励
$120 \times 3 = 360$ 便士,加上 3 便士手续费,得 $363$。这个数写在 assert order_total(120, 3) == 363 的右边。
测试边界
- bug 藏在边缘:零、一、空、最大值,以及规则的精确边界。只测一个典型情况是不够的。
- 测试
average([])会发现函数除以零,引发ZeroDivisionError。只测average([4, 6])永远发现不了这一点。
- 为
order_total(unit_pence, quantity)选择测试。 - 典型情况:
order_total(250, 4) == 1003。 - 数量为零:
order_total(250, 0) == 3。仍然收取手续费;这是商店想要的吗?边界测试迫使你作出决定。 - 单价为零:
order_total(0, 4) == 3。
你在测试 average(xs),它返回 sum(xs) / len(xs)。哪些输入是值得加入的边界情况?选出所有正确项。
难度 2/5 · 今日首次完成可获 140 金币及少量额外奖励
空列表能发现除以零;一项和全零测试规则的边缘。换个名字重复典型情况,没有测试任何新东西。
先写测试
- 先写一个会失败的测试,运行它并看到它失败,再写能通过的最简单的代码,然后在测试保持通过的情况下整理代码。
- 测试就是写成代码的契约。如果之后的修改破坏了契约,测试会立刻失败。

把“先写测试”的循环按顺序排列。
难度 3/5 · 今日首次完成可获 200 金币及少量额外奖励
先看到测试失败,证明它确实在检查东西;一个从不失败的测试可能什么也没测。
用对半查找来调试
- 调试(debugging)就是找出你的想法和代码的实际行为在哪里分道扬镳。先用最小的失败输入重现 bug。
- 然后在可疑代码的中间打印一个值。如果它是对的,bug 在后一半;否则在前一半。每运行一次搜索范围减半,所以 8 个阶段只需要运行 3 次。
bug 在一个 16 个阶段的流水线中的某处。每次运行时放一个合适的 print,就能把搜索范围减半。最多需要运行几次才能找到出错的阶段?
难度 2/5 · 今日首次完成可获 140 金币及少量额外奖励
$16 \to 8 \to 4 \to 2 \to 1$:减半四次,因为 $2^4 = 16$。
测试全部通过,输出却错了?检查连接
- 当每个函数都通过了测试,程序却仍然出错时,要看函数之间:参数顺序错了,或者返回值被丢掉了。
discount(80, 25)是60.0,但写成discount(25, 80)的调用得到5.0。调用add_fee(sub)而不保存结果,sub不会改变。
如果每个函数都通过了自己的测试,整个程序就一定能给出正确的输出。
难度 1/5 · 今日首次完成可获 100 金币及少量额外奖励
函数之间的调用仍然可能出错:写成 discount(25, 80) 而不是 discount(80, 25),或者返回值从未被保存。
容易丢分的地方
- 用打印代替返回。 调用者得到
None,什么也测试不了。 - 只测典型情况。 一定要加上零、空和边界情况。
- 从输出照抄预期值。 先手工算出来。
- 一次改很多地方。 改一处,重新运行全部测试,再继续。
你掌握了
- 分解:一个函数只做一件事,有文档字符串契约,中间返回值,只在边缘打印。
- 测试:
assert actual == expected,预期值先算好;覆盖零、一、空和边界情况。 - 调试:用最小的失败输入重现,每次运行用一个 print 把搜索范围减半,并检查已测试函数之间的连接。