一、你的代码与 AI 的代码失败方式相同

  • 这一篇对应官方 Lecture 37(Software Testing)与 Lecture 38(Software Tracing):前半段讲怎么证明「它对」,后半段讲当它不对时怎么找到原因。

AI 生成的代码常常「读起来对、边界常错」。人写的代码同样如此——失败往往不是语法错,而是边界错。

「让所有 doctest 通过」之所以有价值,是因为它把「我认为正确」换成了「一组客观用例判定它正确」。这一篇讨论如何编写这些用例。

二、doctest:把例子当测试

Python 的标准库 doctest 会把 docstring 里的交互式例子真的跑一遍:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import doctest

def square(x):
"""返回 x 的平方。

>>> square(3)
9
>>> square(-2)
4
>>> square(0)
0
"""
return x * x

result = doctest.testmod()
print("失败数:", result.failed, "用例数:", result.attempted)

输出:

1
失败数: 0 用例数: 3

三个例子全部通过。docstring 不再只是注释,而是合同。

合同被违反时,它真的会报:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import doctest

class Sink:
"""把 doctest 的失败详情丢掉,只看计数。out 需要是可调用对象。"""
def __call__(self, s):
return len(s)

def square_bad(x):
"""
>>> square_bad(3)
10
"""
return x * x

runner = doctest.DocTestRunner(verbose=False)
for t in doctest.DocTestFinder().find(square_bad):
runner.run(t, out=Sink())
print("失败数:", runner.failures, "用例数:", runner.tries)

输出:

1
失败数: 1 用例数: 1

3 * 3 = 9,文档里写的是 10——立刻暴露。这就是为什么要先写例子:例子就是对「正确」的定义。

一个必须知道的细节:doctest 的期望输出要逐字符一致,多一个空格、少一个引号都算失败。这不是刁难,是它必须机械可判。

三、assert:把「预期是这样的」写进代码

assert 是代码内部的契约,不成立就当场抛 AssertionError:

1
2
3
4
5
6
7
8
9
def average(nums):
assert nums, 'average of empty sequence' # 前置条件
return sum(nums) / len(nums)

print(average([1, 2, 3]))
try:
average([])
except AssertionError as e:
print("AssertionError:", e)

输出:

1
2
2.0
AssertionError: average of empty sequence
用法 位置 作用
前置条件 函数开头 参数必须满足什么
后置条件 return 之前 结果必须满足什么

assert 比注释强在「它会停」。注释里的「这里不该为空」没人管;assert 里的,一旦为空程序立刻停在那一行。

四、边界值:四类,逐项列清

Lecture 17 的四步流程里,第 ① 步就是「列边界」。具体列什么:

类型 例子 为什么必测
零 0、[]、空字符串 除零、max([]) 的老家
单元素 [x] 循环/递归只跑一次,最容易漏
空容器 []、空树、Link.empty 递归基的唯一入口
极值 负数、极大值 符号与溢出
1
2
3
4
5
6
7
def first_or_none(xs):
if not xs:
return None
return xs[0]

for case in [[], [7], [7, 8, 9]]:
print(case, "→", first_or_none(case))

输出:

1
2
3
[] → None
[7] → 7
[7, 8, 9] → 7

覆盖率不等于测得好。 可以让每行代码都被执行到,却一次都没测过空列表——因为空列表那条路径恰好被前面的 if 挡住了。

五、异常:控制流的一种

try / except / finally 是控制流,不是「容错开关」:

1
2
3
4
5
6
7
8
9
10
11
def divide(n, d):
try:
result = n / d
except ZeroDivisionError:
result = None
finally:
print(" finally 一定会跑")
return result

print("10/2 =", divide(10, 2))
print("10/0 =", divide(10, 0))

输出:

1
2
3
4
  finally 一定会跑
10/2 = 5.0
finally 一定会跑
10/0 = None

finally 里的那句话在两条路径上都打印了——无论是否出错,它都执行。

1
2
3
4
5
6
7
def f():
try:
return 1
finally:
return 2

print(f())

输出:

1
2

finally 里的 return 会覆盖 try 里的 return。 这一行为反直觉,也很容易被忽略。原因是 return 1 只是「准备返回」,而 finally 必须执行完才能真正返回——它一并把返回值改掉了。

多个 except 的顺序也有讲究:具体的在前,宽泛的在后。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class MyError(Exception):
pass

def risky(flag):
if flag == 1:
raise MyError("自己的错")
if flag == 2:
raise ValueError("值错")
return "没事"

for flag in [1, 2, 3]:
try:
print(flag, risky(flag))
except MyError:
print(flag, "抓到 MyError")
except ValueError:
print(flag, "抓到 ValueError")

输出:

1
2
3
1 抓到 MyError
2 抓到 ValueError
3 没事

Python 从上往下试 except,第一个匹配的赢。所以具体异常必须写在前面,否则会被宽泛的(比如 except Exception)先截胡。

六、最坏的调试习惯:裸 except

1
2
3
4
5
6
7
def buggy():
return 1 + "a" # 类型错误,本该报错

try:
buggy()
except: # ✗ 裸 except 吞掉一切
print("被吞掉了,程序看起来一切正常")

输出:

1
被吞掉了,程序看起来一切正常

1 + "a" 是一个真实的 TypeError——代码里的 bug。但裸 except 把它直接吞掉,程序看似正常运行,错误无声无息。

这类 bug 最难排查,因为它不留痕迹。只会看到最终结果不对,却不知道错在哪里。

写法 问题
except: 吞掉一切,包括自己的逻辑 bug
except Exception: 略好,但仍然掩盖了预期之外的错误
except ValueError: ✓ 只捕获明确预期的那种

判据:except 后面写具体异常名。写不具体,说明还没有想清楚这里可能出什么错。

七、读懂 traceback:从下往上看

报错时那一大坨 traceback,很多人的第一反应是从上往下读——错了。

1
2
3
4
5
6
7
8
Traceback (most recent call last):
File "a.py", line 10, in <module>
main()
File "a.py", line 6, in main
helper()
File "a.py", line 2, in helper
1 + "a"
TypeError: unsupported operand type(s) ...
位置 是什么
最上面 调用链的起点(<module>)
中间几层 一路调下来的过程
最下面 真正抛出的那一行 ← 先看这里
最后一行 异常类型 + 消息

most recent call last 这个提示已经明说了:最近的一次调用在最后。所以先读最下面,找到出错的那一行,再往上回溯「是谁调用了它」。

先看最下面那一行。

八、小结

概念 一句话 证据
doctest docstring 里的例子会真跑 失败数: 0 用例数: 3
doctest 严格 期望输出逐字符一致 写 10 立刻失败
assert 契约,不成立就停 AssertionError: average of empty sequence
边界四类 零 / 单元素 / 空容器 / 极值 [] → None、[7] → 7
finally 无论如何都执行 两条路径都打印
finally + return 覆盖 try 的返回值 f() → 2
except 顺序 具体的在前 MyError 先被抓住
裸 except 吞掉自己的 bug 1 + "a" 被静默吞掉
traceback 从下往上看 最下面是真正的抛出点

三条能带走的:

  1. docstring 里的例子就是合同。 先写例子再写实现,测试与文档一次完成;而且它机械可判,不留下主观判断的空间。
  2. except 后面写具体异常名。 裸 except 会把自己的 bug 一起吞掉,这类错误最难排查——因为它不留痕迹。
  3. traceback 从下往上看。 最下面那一行才是真正抛出的地方,上面只是「是谁调过来的」。