AI 技术博客
返回首页
Python 基础 28 分钟阅读

调试训练场:六个真实 Bug 的定位过程

Python调试实战易错点经验

引言

第十章是课程 01 的收官章。第一章到第九章把语法、数据模型、文件、正则、面向对象、命令行与测试全部铺完,前四篇分别安排了阶段测验(第 1–3 篇)、全书的坑清单(第 4 篇)、代码风格审查(第 5 篇)和性能初体验(第 6 篇)。它们解决的是”见过”的问题:见过文档、见过清单,遇到时能对上号。本篇补上另一项能力——“没见过的病,也能自己查出来”。调试(debugging)不是凭感觉碰运气,而是一条可重复的流程:描述症状、提出假设、设计实验定位、修复、回归验证。

本篇用六个真实发生过、且每个初学者迟早都会踩到的 bug 当训练场,覆盖六个知识点:函数默认参数的引用共享、闭包的迟绑定、文件编码、正则的贪婪量词、文件句柄的生命周期、深浅拷贝。每一个都会按”症状 → 假设 → 定位 → 修复 → 回归”完整走一遍,定位手段各不相同:print 与 is 身份检查、pdb 交互式断点、二分注释法、读字节与读源码。第 8 篇的里程碑项目会立刻用到这套方法——项目做大了,bug 是自己写出来的,调试就不是可选项了。读者应先掌握:第四章的函数与闭包、第五章的 re 与编码、第六章的引用与 copy、第七章的 with 与异常。

调试方法论:五步法与四件工具

五步法

症状(symptom):把”现象”写下来,包括完整的报错文本或错误输出——报错文本里的文件名、行号、异常类型全都有信息量,不要只截最后一句话。

假设(hypothesis):针对症状提出一个可证伪的解释,一次只提一个。写下来的假设比脑子里的假设可靠,因为它逼你先想清楚”如果这个假设成立,那么观察 X 应该成立”。

定位(localize):用实验验证假设。定位不是找答案,是缩小范围:哪一段代码、哪一个值、哪一个环节。范围缩小到一行时,病根通常自己就显形了。

修复(fix):改最少的代码消除病根,而不是消除症状。给症状打补丁而不管根源,是”治标”——它会让真正的问题潜伏到更难看穿的地方。

回归(regression):修复后重跑原始场景,确认症状消失、输出符合预期;再把相邻场景也跑一遍,确保修复没有引入新问题。

四件工具

第一件是 print 与身份检查:在怀疑的位置打印变量的值、类型,以及 id()/is 的身份对比。“能不能打印”这个问题本身常能给出线索——该有的变量没定义、该有的属性是别的类型,一打印就露馅。

第二件是 pdb(Python Debugger,标准库自带):python -m pdb 脚本.py 从第一行进入调试器,也可以在代码里调用 breakpoint() 就地打断。常用命令:b 行号 设置断点、c 继续执行到下一个断点、p 表达式 打印、s 单步进入、n 单步跳过、q 退出。pdb 的优势是在程序暂停的那一刻查看现场,不用改代码、不用重跑整个流程。

第三件是 二分注释法:把怀疑的代码段注释掉一半,看症状是否消失,从而把嫌疑范围每次砍半。它治的是”不知道问题在哪一段”的盲区,比逐行读代码快得多。

第四件是 读字节与读源码:报错信息会直接指出文件名和行号,有时候还要再往下挖一层——看它背后的对象结构(__closure__、__defaults__、原始字节)或查标准库文档。同样一段代码,官方文档说清了契约之后,很多”玄学”就变成必然。

本文的复现环境

所有脚本在 Python 3.12.10 上逐字实测(UTF-8 模式运行,保证各平台中文输出一致)。每个文件都自包含,保存后即可独立运行。为排版整洁,traceback 中的文件绝对路径统一简写为文件名,其余内容逐字保留;你在自己的机器上运行时,路径会显示为你实际的保存位置。

Bug 1:引用共享——两个报名单串了门

症状

场景:给两个培训班分别登记报名,函数把活动名追加进一个”桶”里。collect_signup 的第二个参数 bucket 带着默认值 [],本意是”不传桶就新建一个”。文件 bug1_wrong.py:

# bug1_wrong.py:错误版本——默认参数被"共享"
def collect_signup(activity, bucket=[]):
    bucket.append(activity)
    return bucket

python_class = collect_signup("Python 基础")
ds_class = collect_signup("线性代数")
print("Python 班报名:", python_class)
print("线代班报名:", ds_class)

运行结果:

Python 班报名: ['Python 基础', '线性代数']
线代班报名: ['Python 基础', '线性代数']

症状:两个班级的报名单彼此”串门”——线代班的名单里出现了 Python 班的记录,而且两个变量指向同一份数据。预期的行为是每个班只登记自己的活动。

假设

假设:两次调用复用了同一个默认列表。依据是 Python 函数定义的执行模型:def 是一条语句,执行 def 时创建函数对象,并把默认值求值一次后绑定到函数对象上;此后每次调用不带实参时,都直接复用这个已存在的对象,而不是重新求值 []。所以第一次调用往默认列表里追加了内容,第二次调用拿到的还是那个”被污染”的列表。

定位

验证假设只需问一个问题:两次调用返回的是不是同一个对象?用 is 检查身份(第六章讲过,is 比较的是对象身份而非内容)。文件 bug1_debug.py:

# bug1_debug.py:定位——两次调用拿到的是不是同一个列表
def collect_signup(activity, bucket=[]):
    bucket.append(activity)
    return bucket

python_class = collect_signup("Python 基础")
ds_class = collect_signup("线性代数")
print("两个班级用的是同一个列表对象:", python_class is ds_class)

运行结果:

两个班级用的是同一个列表对象: True

假设被证实:两次调用拿回的是同一个列表对象,追加自然就互相污染。顺带说一句,默认值的绑定位置可以内省:collect_signup.__defaults__[0] 就是那个被共享的列表——它甚至可以在函数外被直接改,这正是”默认值必须不可变”这条铁律的来历。

修复与回归

修复方法是经典的 None 哨兵(sentinel):不给默认值传可变对象,而是传不可变的 None,在函数体内”需要新桶时再新建”。文件 bug1_fix.py:

# bug1_fix.py:修复版本——None 哨兵,每次调用新建列表
def collect_signup(activity, bucket=None):
    if bucket is None:
        bucket = []
    bucket.append(activity)
    return bucket

python_class = collect_signup("Python 基础")
ds_class = collect_signup("线性代数")
print("Python 班报名:", python_class)
print("线代班报名:", ds_class)
print("两个桶是不同对象:", python_class is not ds_class)

回归验证——重跑原始场景:

Python 班报名: ['Python 基础']
线代班报名: ['线性代数']
两个桶是不同对象: True

症状消失:每个班级只有自己的记录,且两个桶确为不同对象。这个 bug 也解释了为什么默认参数必须用不可变值(数字、字符串、None):函数定义只求值一次,可变默认值等于”全局隐藏状态”。

Bug 2:闭包迟绑定——三个问候函数都喊”王五”

症状

场景:循环生成三个问候函数,分别向张三、李四、王五问好,再依次调用。文件 bug2_wrong.py:

# bug2_wrong.py:错误版本——闭包迟绑定
def make_greeters():
    greeters = []
    for name in ["张三", "李四", "王五"]:
        greeters.append(lambda: print(f"你好,{name}!"))
    return greeters

greeters = make_greeters()
for greet in greeters:
    greet()

运行结果:

你好,王五!
你好,王五!
你好,王五!

症状:三个函数全部问候”王五”。预期是每人各被问候一次。

假设

假设:三个 lambda 共享了同一个变量 name,而循环结束后 name 的终值是”王五”,所以调用时全读到这个终值。背后是闭包的迟绑定(late binding)语义:闭包记住的是”外层作用域里的那个变量”,不是”定义那一刻变量的值”。循环变量 name 在 make_greeters 的栈帧里只有一个单元,三次迭代只是反复往同一个单元里写值。

定位

验证”共享同一个变量”可以直接内省闭包单元。第四章讲过,闭包实现为单元(cell),函数通过 __closure__ 元组访问它们,cell_contents 是单元当前持有的值。文件 bug2_debug.py:

# bug2_debug.py:定位——闭包单元里到底存着什么
def make_greeters():
    greeters = []
    for name in ["张三", "李四", "王五"]:
        greeters.append(lambda: print(f"你好,{name}!"))
    print("第一个函数闭包单元里存的是:", greeters[0].__closure__[0].cell_contents)
    print("三个函数共享同一个闭包单元:", greeters[0].__closure__[0] is greeters[2].__closure__[0])
    return greeters

make_greeters()

运行结果:

第一个函数闭包单元里存的是: 王五
三个函数共享同一个闭包单元: True

两层证据都拿到了:第一个函数读到的是循环终值”王五”;三个函数持有的甚至是同一个单元对象。迟绑定假设成立。

修复与回归

修复思路是把当前值提前绑定:利用”默认参数在定义时求值”(Bug 1 的教训在这里变成了武器),用 lambda name=name: 把本次循环的值定格成默认参数,函数体不再去查外层单元。文件 bug2_fix.py:

# bug2_fix.py:修复版本——默认参数在定义瞬间把当前值"绑"进函数
def make_greeters():
    greeters = []
    for name in ["张三", "李四", "王五"]:
        greeters.append(lambda name=name: print(f"你好,{name}!"))
    return greeters

greeters = make_greeters()
for greet in greeters:
    greet()

回归验证:

你好,张三!
你好,李四!
你好,王五!

三声问候各归其位。除了默认参数技巧,标准库 functools.partial 也是常用解法(lambda 闭包迟绑定的另一个高频场景是 GUI 按钮回调与线程回调,症状一模一样:所有回调都拿到最后一轮的值)。

Bug 3:编码错误——中文文件读不进来

症状

场景:程序要读取一个”早期系统导出”的文本文件,notes_gbk.txt,内容是一行中文。文件以 GBK 编码写入(Windows 简体中文的默认编码,或由旧系统生成),而读取时代码写死了 UTF-8。文件 bug3_wrong.py:

# bug3_wrong.py:错误版本——用 utf-8 去读 gbk 编码的文件
with open("notes_gbk.txt", "w", encoding="gbk") as f:
    f.write("备注:本学期 Python 课程作业")

with open("notes_gbk.txt", encoding="utf-8") as f:
    text = f.read()
print(text)

运行结果:

Traceback (most recent call last):
  File "bug3_wrong.py", line 6, in <module>
    text = f.read()
           ^^^^^^^^
  File "<frozen codecs>", line 322, in decode
UnicodeDecodeError: 'utf-8' codec can't decode byte 0xb1 in position 0: invalid start byte

症状:读取直接抛 UnicodeDecodeError,提示第 0 个字节 0xb1 不是合法的 UTF-8 起始字节。

假设

假设:文件里的字节是 GBK 编码,而解码器按 UTF-8 解释。推理链:UTF-8 里一个字符的起始字节只能是 0x00–0x7F(ASCII)、0xC0–0xDF(两字节)、0xE0–0xEF(三字节)、0xF0–0xF7(四字节);而 0xb1(二进制 1011 0001)落在”续字节”区段,出现在字节流开头就是非法起始字节。GBK 的”备”字恰好由 0xb1 0xb8 两个字节构成——文件开头正是它,所以错误出现在 position 0。

定位

先用二分注释法缩小范围:把 f.read() 那一行注释掉再跑——open 不报错,说明打开文件这步没问题,问题发生在解码环节。open 成功只代表拿到了文件句柄,不代表内容可解,这个区分本身就是知识点。

再往下挖一层,直接读原始字节验证编码猜想。文件 bug3_debug.py:

# bug3_debug.py:定位——先看原始字节,再验证编码猜想
with open("notes_gbk.txt", "w", encoding="gbk") as f:
    f.write("备注:本学期 Python 课程作业")

raw = open("notes_gbk.txt", "rb").read(4)
print("文件前 4 个字节:", raw)
print("按 GBK 解码是:", raw[:2].decode("gbk") + raw[2:4].decode("gbk"))

运行结果:

文件前 4 个字节: b'\xb1\xb8\xd7\xa2'
按 GBK 解码是: 备注

证据闭合:前两个字节 b1 b8 正是勾引 UTF-8 报错的字节,而按 GBK 解码它们就是”备”字。文件确实是 GBK 字节流——假设成立。

修复与回归

修复:读文件时声明正确的编码。文件 bug3_fix.py:

# bug3_fix.py:修复版本——用正确的编码读取
with open("notes_gbk.txt", "w", encoding="gbk") as f:
    f.write("备注:本学期 Python 课程作业")

with open("notes_gbk.txt", encoding="gbk") as f:
    text = f.read()
print(text)

回归验证:

备注:本学期 Python 课程作业

文本完整读回。需要警惕的是编码 bug 的两种形态:像本例这样”解不动”直接报错,其实是善良的形态;更阴险的是解码器也能消化非法字节组合(比如把 GBK 字节流按 Latin-1 解码),程序不报错、输出却是乱码——静默错误比报错难查十倍。所以工程上才有”全项目统一 UTF-8”的纪律:源头统一,两边都不用猜。

Bug 4:正则贪婪——配置被吞成一条

症状

场景:解析一段配置文件,里面用分号分隔多个 server= 条目,正则想抽出每条服务器名。文件 bug4_wrong.py:

# bug4_wrong.py:错误版本——正则贪婪匹配
import re

config = "server=web-01; server=web-02; server=web-03;"
items = re.findall(r"server=(.*);", config)
print(items)

运行结果:

['web-01; server=web-02; server=web-03']

症状:三段配置被吞成了一整条,findall 只返回一个结果。预期是 ['web-01', 'web-02', 'web-03']。

假设

假设:量词 * 默认贪婪(greedy)——(.*) 会尽量多吞字符,直到”还能让整个表达式匹配成功”才收手。于是它从第一个 server= 之后一路吞到最后一个分号之前;表达式并不要求”每组只吃到第一个分号”,所以贪婪行为完全合法,是我们没有表达清楚意图。

定位

用二分注释法做对照实验:先把配置缩到只剩一段,症状消失;恢复成两段,症状复现。文件 bug4_debug.py:

# bug4_debug.py:定位——二分注释,只留一段配置做对照
import re

config = "server=web-01;"  # 其余两段先注释掉
items = re.findall(r"server=(.*);", config)
print("只剩一段时:", items)

config = "server=web-01; server=web-02;"
items = re.findall(r"server=(.*);", config)
print("恢复为两段时:", items)

运行结果:

只剩一段时: ['web-01']
恢复为两段时: ['web-01; server=web-02']

范围锁定:问题与配置条数无关,是模式本身在跨段吞内容。再读一遍 re 文档确认语义——*、+ 等量词默认贪婪,加 ? 可转为非贪婪(non-greedy):在保证整体匹配成功的前提下尽可能早地结束。

修复与回归

修复:把 (.*) 改成 (.*?)。文件 bug4_fix.py:

# bug4_fix.py:修复版本——非贪婪量词 ? 让匹配尽早收手
import re

config = "server=web-01; server=web-02; server=web-03;"
items = re.findall(r"server=(.*?);", config)
print(items)

回归验证:

['web-01', 'web-02', 'web-03']

三条配置各归其位。两个补充提醒:其一,非贪婪不是”匹配最短”,而是”从当前位置尽量早地结束并保证整体成功”,理解这一点才能解释某些”非贪婪仍多吞”的边角;其二,凡是用正则抽取分隔结构,优先想清楚分隔符与内容的关系,本场景更稳的写法是把配置拆成”条目列表逐条匹配”,正则只负责单条目内的字段。

Bug 5:文件残留句柄——删除文件被拒绝

症状

场景:程序写完一个临时数据文件后要把它删掉。文件 bug5_wrong.py:

# bug5_wrong.py:错误版本——写完文件忘了关闭
data = open("data.txt", "w", encoding="utf-8")
data.write("第一行")
# 忘记调用 data.close()

import os
os.remove("data.txt")
print("已删除 data.txt")

运行结果(Windows,Python 3.12.10):

Traceback (most recent call last):
  File "bug5_wrong.py", line 7, in <module>
    os.remove("data.txt")
PermissionError: [WinError 32] 另一个程序正在使用此文件,进程无法访问。: 'data.txt'

症状:os.remove 抛 PermissionError(WinError 32)。Windows 默认禁止删除仍被打开的文件:句柄还握着,操作系统拒绝解除。顺便说明平台差异:在 Linux/macOS 这类 POSIX 系统上,删除一个仍打开的文件通常不会报错(文件被 unlink,但句柄仍可读写直到关闭)——那边的隐患则是另一副面孔:data.write 的数据还在缓冲里没落盘,close 被跳过意味着可能静默丢数据。两种平台,同一个根源。

假设

假设:data 这个文件对象(句柄)没有被关闭,导致文件被占用。依据:代码里 open 之后没有任何 close,而缓冲区是否落盘、句柄是否释放都由关闭动作决定。

定位

这里用 pdb 在程序暂停的瞬间看现场。在 os.remove 那一行之前打断点,打印句柄对象的状态。命令序列:b 7(在第 7 行设断点)、c(跑过去)、p data(打印句柄对象)、p data.closed(打印关闭状态)、q(退出):

Breakpoint 1 at bug5_debug.py:7
<_io.TextIOWrapper name='data.txt' mode='w' encoding='utf-8'>
False

第一次输出是句柄对象本身:mode='w' 说明写通道还活着;第二次 data.closed 的 False 是决定性证据——文件对象确实没关。接下来把”关闭”这一步补上,删除应该就能成功。

修复与回归

修复:用 with 语句表明句柄的生存期(第七章的核心知识点),离开代码块自动 close。文件 bug5_fix.py:

# bug5_fix.py:修复版本——用 with 语句保证及时关闭
with open("data.txt", "w", encoding="utf-8") as data:
    data.write("第一行")

import os
os.remove("data.txt")
print("已删除 data.txt,文件不存在:", not os.path.exists("data.txt"))

回归验证:

已删除 data.txt,文件不存在: True

删除成功。把这条经验固化成习惯:凡是 open,一律 with。两个反面教材要记住:其一,指望垃圾回收(GC)替你关门不可靠——CPython 的引用计数通常能兜底,但时机不确定,换实现(PyPy 等)或异常路径下都会翻车;其二,在循环里频繁 open 不关,Windows 上最终会攒出一堆占用句柄,症状表现为”删不掉文件”甚至”文件被占用无法重命名”,和本例是同一个病根。

Bug 6:深浅拷贝——改备份动了原数据

症状

场景:要给联系人列表做一份”备份”,然后往备份里加一条座机号码。文件 bug6_wrong.py:

# bug6_wrong.py:错误版本——浅拷贝当备份,改备份却动了原数据
import copy

contacts = [
    {"name": "张三", "phones": ["13800000000"]},
    {"name": "李四", "phones": ["13900000000"]},
]
backup = copy.copy(contacts)  # 自认为"完整的备份"
backup[0]["phones"].append("010-12345678")  # 给备份里的张三加座机
print("contacts 里的张三:", contacts[0])

运行结果:

contacts 里的张三: {'name': '张三', 'phones': ['13800000000', '010-12345678']}

症状:只改了 backup,但 contacts 里张三的电话也被加了座机。预期的备份应当与原件互不相干。

假设

假设:copy.copy 是浅拷贝(shallow copy)——只复制最外层容器,内层对象仍是引用共享。contacts 是”列表套字典套列表”的三层结构,浅拷贝只造了一个新外层列表,两个内层字典、以及字典里的 phones 列表,全都还是原对象。

定位

验证假设就是逐层检查身份:哪一层换了新对象、哪一层还是老引用,is 一测便知。文件 bug6_debug.py:

# bug6_debug.py:定位——逐层检查复制到了哪一层
import copy

contacts = [{"name": "张三", "phones": ["13800000000"]}]
backup = copy.copy(contacts)
print("外层列表已更换:", contacts is not backup)
print("内层字典仍是同一个:", contacts[0] is backup[0])
print("字典里的列表仍是同一个:", contacts[0]["phones"] is backup[0]["phones"])

运行结果:

外层列表已更换: True
内层字典仍是同一个: True
字典里的列表仍是同一个: True

三层结论一目了然:外层换了、内层没换。所以往 backup[0]["phones"] 里 append,改的正是原数据的列表——症状与假设完全吻合。

修复与回归

修复:给”必须完全独立”的副本用深拷贝(deep copy),copy.deepcopy 递归复制每一层。文件 bug6_fix.py:

# bug6_fix.py:修复版本——深拷贝,连内层一起复制
import copy

contacts = [
    {"name": "张三", "phones": ["13800000000"]},
    {"name": "李四", "phones": ["13900000000"]},
]
backup = copy.deepcopy(contacts)
backup[0]["phones"].append("010-12345678")
print("contacts 里的张三:", contacts[0])
print("backup 里的张三:", backup[0])
print("备份与原数据互不影响:", contacts[0]["phones"] is not backup[0]["phones"])

回归验证:

contacts 里的张三: {'name': '张三', 'phones': ['13800000000']}
backup 里的张三: {'name': '张三', 'phones': ['13800000000', '010-12345678']}
备份与原数据互不影响: True

原件干净、备份独立。选型原则:浅拷贝快、省内存,适合”只读共享”或”只改最外层”的场景(第 8 篇通讯录里 store.load() 返回新列表但内层字典只读,就是浅层语义);凡是”副本要可独立修改”的,必须深拷贝。数据量大时深拷贝有成本,工程上常用”重建代替复制”——比如 [c.to_dict() for c in contacts] 每次从原始数据重新构建,比复制一个巨大对象树更可控。

小结

六只 bug,六个知识点,一条方法论:描述症状 → 提出假设 → 定位验证 → 最小修复 → 回归确认。引用共享的病根在”默认参数只求值一次”,迟绑定在”闭包记变量不记值”,编码在”字节流与解读协议要匹配”,贪婪在”量词默认吃到不能再吃”,文件句柄在”资源要有明确的生存期”,深浅拷贝在”复制要复制到多深”。注意两个规律:其一,多数 bug 都不是”语法不会”,而是”语义与直觉不符”,所以定位的关键动作永远是验证假设而不是重读代码;其二,六个定位实验里四个只用了 print 加 is,说明小工具的调试不需要仪式感——先让现场可见。第 8 篇的通讯录项目里,凡是出了错,请把本文的五步法原样搬过去用。

练习与思考题

  1. 在 bug1_wrong.py 上追加两行:打印 collect_signup.__defaults__ 观察它储存的对象,再尝试在函数外执行 collect_signup.__defaults__[0].append("被篡改") 后重新调用函数,解释为什么函数外改动也会影响调用结果。(答案导向:证明默认值对象就是共享本体,且它存放在函数对象的 __defaults__ 里。)

  2. 把 bug2_fix.py 的默认参数解法换成 functools.partial 重写,运行确认输出一致,并说明两种解法的等价性依据(都在定义时把值绑定进可调用对象)。

  3. 手工构造一个”静默乱码”实验:用 gbk 写文件,再用 latin-1 编码读取。观察它不报错但输出乱码,然后解释为什么”不报错”比”报错”更危险。(答案导向:Latin-1 能解码任意字节序列。)

  4. 思考题:把 bug4_fix.py 的配置最后一段去掉结尾分号(server=web-01; server=web-02; server=web-03)再运行,预测非贪婪模式的结果,然后用实验验证,并解释原因。再想想:为什么这里”拆成条目列表、逐条解析”的设计比正则一次性抽取更稳?(答案导向:非贪婪要求遇见 ; 才收手,没有分隔符的最后一条会被静默丢弃,正则应回到”分隔结构不宜用单条正则硬抽”的结论。)

  5. 综合题:把六个 bug 的”定位实验”合并成一张自查单——每个 bug 一句话写出”哪个实测输出宣判了病根”。例如 Bug 1 是 is 返回 True。核对后你的自查单应当能在一分钟内复现任一 bug。