博客

Python 调试技巧:从错误现象到根因修复

以一个学生管理脚本为案例,整理可重复的排错流程、调试工具的选择、8 类典型 Bug 的根因与修复,以及防复发策略。

约 21 分钟读完

下载 Markdown

以 student_manager.py 为案例,保留最实用的调试方法、8 类典型 Bug、工具命令和防复发策略。


📌 阅读优先级

调试能力贯穿模型调用、异步任务、数据处理和线上排障。优先学习可重复的诊断流程,再补充具体工具。

标记 学习要求 常见使用位置
🔥 重点掌握 能按步骤复现、定位、验证并补回归测试 API 报错、模型输出异常、异步失败、数据边界问题
🧩 补充掌握 能按需选工具并识别 Python 常见陷阱 复杂回归、性能诊断、共享状态与日志治理
👀 了解即可 作为速查材料保留,需要时再翻阅 命令速查和案例附件

🔥 重点掌握

  1. 证据驱动的调试流程(第 1~2 章):明确期望、最小复现、读 Traceback、验证假设、修根因和补测试。
  2. 常用调试工具(第 3.1、3.2、3.4 节):短期验证用 print(),长期诊断用日志,复杂状态用断点。
  3. 典型错误模式(第 4 章):重点学习空值、缺字段、重复键、排序方向和连锁失败背后的共性。
  4. 复杂问题定位(第 6 章):二分定位、正常/异常对比和 git bisect 对回归问题很常用。
  5. 异常、日志与检查清单(第 8~9 章):避免吞异常、滥用断言,并形成上线前自检习惯。

🧩 补充掌握

  1. 变量、调用栈和异常速查(第 5 章):能提高现场排障速度。
  2. Python 常见陷阱(第 7 章):可变默认参数、浅拷贝、闭包晚绑定和真假值混淆很容易制造隐蔽问题。
  3. pdb 与性能分析(第 3.3、6.5 节):远程环境或性能瓶颈出现时再重点使用。
  4. 一页速记(第 10 章):适合作为复习入口。

👀 了解即可

  1. 附录案例代码:知道位置即可,需要复现案例时再打开。

1. 调试的核心:用证据缩小问题范围 ★★★★★

🔥 学习优先级:重点掌握

调试不是“不断改代码直到能跑”,而是一个可验证的推理过程:

明确期望
→ 稳定复现
→ 阅读错误与调用栈
→ 提出一个假设
→ 用最小实验验证
→ 修复根因
→ 增加回归测试

每次只验证一个假设。例如程序计算平均分时报错,不要同时修改文件读取、排序和异常处理;先确认参与除法的数据到底是什么。

1.1 科学方法与正确心态

可以把调试看成一次小型科学实验:

  1. 观察现象:记录输入、期望结果、实际结果和异常信息;
  2. 提出假设:把“可能有问题”改写成可验证的原因,例如“scores 为空”;
  3. 设计实验:只增加一条打印、一个断点或一个最小测试;
  4. 收集证据:运行实验,观察值、类型、长度和调用栈;
  5. 分析结果:判断假设成立还是被证伪;
  6. 修复并验证:修改根因,再运行失败样例和回归测试。

调试时要保持耐心和可重复性:相信错误信息提供的线索,不要一次改动很多地方,也不要因为“现在能跑了”就跳过测试。记录“现象、证据、结论”还能避免下一次重新走同一条弯路。

1.2 三类 Bug

类型 表现 优先线索
语法错误 程序无法启动,如缺少冒号、括号不匹配 Python 指出的文件、行号和 SyntaxError
运行时错误 执行途中抛出异常,如 KeyError、TypeError Traceback 最后一行和最底部业务栈帧
逻辑错误 不报错但结果不符合预期,如排名方向相反 期望结果、实际结果和中间状态对比

1.3 最容易浪费时间的做法

  • 一次修改很多地方,最后不知道哪项改动有效;
  • 只看异常最后一句,不看调用栈;
  • 用宽泛 except Exception 把系统错误伪装成正常结果;
  • 看到空值就随意返回 0,混淆“没有数据”和“数据确实为零”;
  • 修完当前样例却不补测试,导致问题再次出现。

2. 一套可重复使用的排错流程 ★★★★★

🔥 学习优先级:重点掌握

第 1 步:写清楚期望和实际结果

不要只记录“程序坏了”,而要写成可比较的陈述:

输入:一个尚未录入成绩的学生
期望:平均分显示“暂无成绩”
实际:抛出 ZeroDivisionError

第 2 步:创建最小复现

只保留触发问题所需的最少代码:

def reproduce_empty_scores() -> None:
student = Student("001", "测试学生", 18, "高一")
print(student.get_average_score())
reproduce_empty_scores()

最小复现应当:

  • 运行快;
  • 结果稳定;
  • 不依赖无关页面或操作步骤;
  • 能在修复前失败、修复后通过。

第 3 步:从下往上读 Traceback

Traceback (most recent call last):
File "student_manager.py", line 196, in main
avg = student.get_average_score()
File "student_manager.py", line 29, in get_average_score
average = total / len(self.scores)
ZeroDivisionError: division by zero

阅读顺序:

  1. 最后一行:异常类型和直接原因;
  2. 最靠近底部的业务代码:真正失败的位置;
  3. 向上回溯:错误输入从哪个调用者传来。

这段 Traceback 已经告诉我们:get_average_score() 中的除数为零。下一步应检查 self.scores,而不是先改报告格式。

第 4 步:提出可证伪的假设

不好的假设:

平均分函数可能有问题。

更好的假设:

self.scores 为空,导致 len(self.scores) == 0。

后者可以通过断点或一条打印立即验证。

第 5 步:选择最小工具验证

print({
"scores": self.scores,
"count": len(self.scores),
})

如果问题涉及循环中某一个特定对象,则使用条件断点:

len(self.scores) == 0

第 6 步:修复根因并明确函数契约

“无成绩”与“平均分为 0”不是同一个状态。更清晰的契约是返回 None:

def get_average_score(self) -> float | None:
"""返回平均分;尚无成绩时返回 None。"""
if not self.scores:
return None
return sum(self.scores.values()) / len(self.scores)

展示层再决定如何显示:

average = student.get_average_score()
if average is None:
print("平均分: 暂无成绩")
else:
print(f"平均分: {average:.2f}")

第 7 步:增加回归测试

def test_empty_scores_have_no_average() -> None:
student = Student("001", "测试学生", 18, "高一")
assert student.get_average_score() is None

测试不是修复后的附属品,而是“这个 Bug 不应再次出现”的可执行记录。


3. 调试工具怎么选 ★★★★★

🔥 学习优先级:重点掌握

场景 首选工具 原因
小函数、一次性确认变量 print() / repr() 快速直接
多模块或长期运行服务 logging 可分级、筛选、持久化
需要逐行观察状态变化 breakpoint() / pdb 可暂停、单步、查看栈
本地复杂对象和循环 VSCode 调试器 变量面板、条件断点、调用栈更直观
只在异常发生后检查现场 post-mortem 调试 直接停在失败现场
不知道哪个提交引入问题 git bisect 二分定位回归提交
代码慢但不报错 timeit / profiler 用数据定位瓶颈

3.1 print():适合快速验证假设

print(f"student_id={student_id!r}")
print(f"scores={scores!r}, type={type(scores).__name__}")
print(">>> entered calculate_class_average")

!r 使用 repr(),可以区分空字符串、空格、换行等不容易肉眼识别的值。

限制:大量 print() 难以筛选,也容易遗留在生产代码中。问题跨越多个请求或模块时,应切换到日志或调试器。

3.2 logging:适合可重复诊断

import logging
logger = logging.getLogger(__name__)
logging.basicConfig(
level=logging.DEBUG,
format=(
"%(asctime)s %(levelname)s "
"%(name)s [%(filename)s:%(lineno)d] %(message)s"
),
)
logger.debug("calculating average: subject=%s", subject)
logger.info("student added: id=%s", student_id)
logger.warning("duplicate student: id=%s", student_id)
logger.exception("failed to load student data")

日志级别:

级别 用途
DEBUG 详细诊断信息,通常只在开发或排障时开启
INFO 正常的重要业务事件
WARNING 异常情况,但功能仍可继续
ERROR 当前功能失败
CRITICAL 系统级严重故障,可能无法继续服务

生产注意事项:

  • 不记录密码、Token、完整隐私数据;
  • 不用 f-string 提前拼接大量未启用的 DEBUG 日志;
  • 避免重复添加 Handler,导致每条日志输出多次;
  • Web 服务优先记录稳定字段,如 request_id、操作名、状态和耗时。

同时写入文件和控制台

需要长期复现问题时,可以让详细日志写入文件,只把重要日志显示在控制台:

logger = logging.getLogger("StudentManager")
logger.setLevel(logging.DEBUG)
logger.propagate = False
file_handler = logging.FileHandler(
"student_manager.log",
encoding="utf-8",
)
file_handler.setLevel(logging.DEBUG)
console_handler = logging.StreamHandler()
console_handler.setLevel(logging.INFO)
formatter = logging.Formatter(
"%(asctime)s - %(name)s - %(levelname)s - %(message)s"
)
file_handler.setFormatter(formatter)
console_handler.setFormatter(formatter)
logger.addHandler(file_handler)
logger.addHandler(console_handler)
logger.debug("这条只写入文件")
logger.info("这条同时显示在控制台和文件")

如果初始化代码可能被执行多次,应先检查 logger.handlers,否则每条日志会重复输出。

3.3 pdb 与 breakpoint()

Python 3.7+ 推荐直接写:

def get_average_score(self) -> float | None:
breakpoint()
...

常用命令:

命令 作用
l 显示附近代码
n 执行下一行,不进入函数
s 进入当前行调用的函数
r 执行完当前函数并返回调用处
c 继续到下一个断点
p expr / pp expr 查看表达式,后者格式更友好
w 查看调用栈
u / d 在调用栈中上移/下移
b 29 在第 29 行设置断点
b 29, condition 设置条件断点
cl 1 删除编号为 1 的断点
q 退出调试器

命令行启动:

终端窗口
python -m pdb student_manager.py

异常发生后进入现场:

终端窗口
python -m pdb -c continue student_manager.py

3.4 VSCode 调试器

核心操作:

操作 Windows/Linux 说明
启动/继续 F5 运行到断点
切换断点 F9 在当前行添加或移除断点
Step Over F10 执行当前行,不进入函数
Step Into F11 进入函数内部
Step Out Shift+F11 执行完当前函数
重启 Ctrl+Shift+F5 重新开始调试

推荐使用:

  • 条件断点:仅当 student_id == "2024001" 时暂停;
  • 异常断点:异常抛出时立即暂停;
  • 日志点:不暂停程序,只输出表达式;
  • 监视表达式:持续观察 len(self.scores)、subject in student.scores;
  • 调用栈:查看谁调用了当前函数,以及每一层的局部变量。

现代 Python 扩展通常可直接调试当前文件;只有需要固定参数、环境变量或工作目录时才配置 .vscode/launch.json。示例:

{
"version": "0.2.0",
"configurations": [
{
"name": "Python: 当前文件",
"type": "debugpy",
"request": "launch",
"program": "${file}",
"console": "integratedTerminal",
"justMyCode": true
}
]
}

在 VSCode 中的基本流程是:打开要运行的 Python 文件,点击行号左侧设置红点,按 F5 启动;程序停下后观察“变量”“监视”“调用堆栈”面板,用 F10/F11/Shift+F11 单步执行。遇到只在特定数据出现的问题,右键断点选择“编辑条件”,例如 student_id == "2024001";想在异常抛出瞬间停下,则在“运行和调试”面板启用 Python 异常断点。


4. 8 个典型 Bug:根因、修复与防复发 ★★★★★

🔥 学习优先级:重点掌握

Bug 表面现象 根因 推荐契约/修复 防复发测试
1. 空成绩求平均 ZeroDivisionError len(scores) == 0 无数据返回 None,不要伪装成 0 分 空成绩学生
2. JSON 缺少 scores KeyError: 'scores' 输入数据不完整或旧版本字段缺失 可选字段使用默认值;必填字段显式校验 缺字段、错类型、损坏 JSON
3. 重复学号 原学生被静默覆盖 字典相同键会替换旧值 添加前检查唯一性,抛领域异常 连续添加相同 ID
4. 查询不存在学生 KeyError 函数没有定义“找不到”行为 返回 `Student None或抛StudentNotFoundError`
5. 学生缺少某科成绩 KeyError: subject 直接访问不存在的科目键 只统计有该科成绩的学生 部分学生缺科目
6. 空班级/无人有该科成绩 ZeroDivisionError 统计数量为 0 返回 None,由展示层显示“暂无数据” 空班级、未知科目
7. Top N 顺序相反 最低分排第一 sorted() 默认升序 reverse=True,测试完整顺序 [95, 78, 90] → [95, 90, 78]
8. 报告生成失败 报告调用平均分时崩溃 Bug 1 沿调用链传播 在平均分函数修复根因,展示层处理 None 报告含无成绩学生

下面每个案例都按同一条学习链路展开:先看会失败的代码,再稳定复现,接着用打印、日志、pdb 或 VSCode 找证据,最后修根因并补回归测试。这样你学到的是“怎么定位”,而不只是“最后应该怎么写”。

4.1 Bug 1:空成绩列表导致平均分除零

问题代码

def get_average_score(self):
"""计算学生平均分"""
total = sum(self.scores.values())
# Bug:没有处理 scores 为空的情况
return total / len(self.scores)

现象:先记录期望和实际

输入:刚创建、尚未录入成绩的学生“赵六”
期望:显示“暂无成绩”
实际:ZeroDivisionError: division by zero

最小复现:

student = Student("001", "赵六", 18, "高一")
print(student.get_average_score())

调试证据

Traceback 的关键部分:

File "student_manager.py", line 29, in get_average_score
average = total / len(self.scores)
ZeroDivisionError: division by zero

用 print 验证假设,而不是直接猜:

def get_average_score(self):
print(f"[DEBUG] scores = {self.scores!r}")
print(f"[DEBUG] len(scores) = {len(self.scores)}")
total = sum(self.scores.values())
return total / len(self.scores)

输出:

[DEBUG] scores = {}
[DEBUG] len(scores) = 0

根因已经确定:空字典没有任何成绩,除数为零。这里不要继续修改报告格式或文件读取代码。

修复:明确“无数据”的函数契约

无成绩和 0 分不是一回事。推荐返回 None,由展示层决定文案:

def get_average_score(self) -> float | None:
"""返回平均分;没有任何成绩时返回 None。"""
if not self.scores:
return None
return sum(self.scores.values()) / len(self.scores)

调用方:

average = student.get_average_score()
if average is None:
print("平均分:暂无成绩")
else:
print(f"平均分:{average:.2f}")

if not self.scores 已经覆盖空字典,不需要再写 or len(self.scores) == 0。

回归测试与预防

def test_empty_scores_have_no_average():
student = Student("001", "测试学生", 18, "高一")
assert student.get_average_score() is None
def test_average_score_uses_all_subjects():
student = Student("002", "测试学生", 18, "高一")
student.scores = {"数学": 90, "英语": 80}
assert student.get_average_score() == 85

以后遇到平均分问题,优先检查集合是否为空、统计数量是否正确,以及函数是否明确约定空集合的返回值。

4.2 Bug 2:JSON 缺少 scores 字段导致 KeyError

问题代码

def load_data(self):
if os.path.exists(self.data_file):
with open(self.data_file, "r", encoding="utf-8") as file:
data = json.load(file)
for student_data in data:
student = Student(
student_data["student_id"],
student_data["name"],
student_data["age"],
student_data["grade"],
)
# Bug:scores 字段可能不存在
student.scores = student_data["scores"]
self.students[student.student_id] = student

现象:先检查真实输入

旧数据或手工编辑的 JSON 可能是:

[
{
"student_id": "2024001",
"name": "张三",
"age": 18,
"grade": "高一"
}
]

运行时:

KeyError: 'scores'

注意:JSON 标准不允许注释;如果你在 JSON 示例中写 // 没有 scores,那份文件本身也会触发 JSONDecodeError。

调试证据:在出错行前停下

for student_data in data:
breakpoint()
student.scores = student_data["scores"]

调试会话:

(Pdb) p student_data
{'student_id': '2024001', 'name': '张三', 'age': 18, 'grade': '高一'}
(Pdb) p 'scores' in student_data
False
(Pdb) p student_data.get('scores')
None

证据说明:不是 scores 的值错误,而是这个键根本不存在。

修复:默认值只用于真正可选的字段

如果业务允许新学生暂时没有成绩,可以这样兼容旧数据:

student.scores = student_data.get("scores", {})

完整写法:

def load_student(student_data: dict) -> Student:
student = Student(
student_data["student_id"],
student_data["name"],
student_data["age"],
student_data["grade"],
)
student.scores = student_data.get("scores", {})
return student

student_id、name 等必填字段不应一律使用默认值,否则坏数据会被静默掩盖。真实项目可以用 Pydantic 或 JSON Schema 校验必填字段、类型和范围。

回归测试与预防

def test_load_student_without_optional_scores():
student = load_student({
"student_id": "001",
"name": "张三",
"age": 18,
"grade": "高一",
})
assert student.scores == {}
def test_load_student_without_required_id_fails():
try:
load_student({"name": "张三", "age": 18, "grade": "高一"})
except KeyError as exc:
assert exc.args[0] == "student_id"
else:
raise AssertionError("缺少必填字段时应该失败")

4.3 Bug 3:重复学号静默覆盖旧学生

问题代码

def add_student(self, student_id, name, age, grade):
# Bug:没有检查学号是否已经存在
student = Student(student_id, name, age, grade)
self.students[student_id] = student
print(f"成功添加学生:{name}")
return student

现象

【第一次添加】成功添加学生:张三
【第二次添加同一学号】成功添加学生:重复学生
结果:张三被重复学生覆盖

程序不报错,所以这属于数据完整性问题,往往比直接崩溃更危险。

调试证据:用日志观察写入前后的键

logger.debug("尝试添加学生:id=%s, name=%s", student_id, name)
logger.debug("当前学生 ID:%s", list(self.students))
student = Student(student_id, name, age, grade)
self.students[student_id] = student

第二次调用时:

DEBUG 当前学生 ID:['2024001']

根因是 Python 字典的规则:给已有键赋值会替换旧值。问题不在 Student 构造函数,而在写入前缺少唯一性检查。

修复

推荐用领域异常让调用方明确处理:

class DuplicateStudentError(ValueError):
pass
def add_student(
self,
student_id: str,
name: str,
age: int,
grade: str,
) -> Student:
if student_id in self.students:
raise DuplicateStudentError(
f"学号 {student_id} 已存在"
)
student = Student(student_id, name, age, grade)
self.students[student_id] = student
return student

简单脚本也可以返回 None,但要在函数文档和类型提示中写清楚;不要一边打印错误,一边返回看似成功的对象。

回归测试与预防

def test_duplicate_student_id_is_rejected():
manager = StudentManager()
manager.add_student("001", "张三", 18, "高一")
try:
manager.add_student("001", "重复学生", 18, "高一")
except DuplicateStudentError:
pass
else:
raise AssertionError("重复学号应该被拒绝")
assert manager.students["001"].name == "张三"

4.4 Bug 4:查询不存在的学生

问题代码与现象

def get_student(self, student_id):
# Bug:直接访问未知键
return self.students[student_id]
查询学号 9999999
KeyError: '9999999'

调试证据

在 VSCode 第 3 行设置断点,变量面板可能显示:

student_id: "9999999"
self.students: {"2024001": <Student>, "2024002": <Student>}
student_id in self.students: False

用 Python 交互验证字典行为:

d = {"a": 1}
d["b"] # KeyError
d.get("b") # None
d.get("b", "不存在") # "不存在"

修复:先选择清楚函数契约

如果“找不到”是正常业务结果,返回可选值:

def get_student(self, student_id: str) -> Student | None:
return self.students.get(student_id)

如果调用方必须拿到学生,则使用明确的领域异常:

class StudentNotFoundError(LookupError):
pass
def require_student(self, student_id: str) -> Student:
try:
return self.students[student_id]
except KeyError as exc:
raise StudentNotFoundError(
f"学号 {student_id} 不存在"
) from exc

不要让调用方猜测函数会返回 None、打印警告还是抛异常。三种做法都可以,但必须固定一种契约。

回归测试

def test_unknown_student_returns_none():
manager = StudentManager()
assert manager.get_student("9999999") is None

4.5 Bug 5:学生缺少科目成绩导致 KeyError

问题代码

def calculate_class_average(self, subject):
total = 0
count = 0
for student in self.students.values():
# Bug:假设每个学生都有该科成绩
total += student.scores[subject]
count += 1
return total / count

现象

张三:{'数学': 95, '英语': 88, '物理': 92}
李四:{'数学': 78, '英语': 85}
计算物理平均分
KeyError: '物理'

调试证据:日志定位到具体学生

for student in self.students.values():
logger.debug("检查学生 %s:%r", student.name, student.scores)
total += student.scores[subject]

随后用 pdb 停在循环内部:

(Pdb) p student.name
'李四'
(Pdb) p subject
'物理'
(Pdb) p student.scores
{'数学': 78, '英语': 85}
(Pdb) p subject in student.scores
False

根因不是科目名称拼写,而是统计代码没有定义“缺少该科成绩”的处理规则。

修复:只统计实际有该科成绩的学生

def calculate_class_average(
self,
subject: str,
) -> float | None:
scores = [
student.scores[subject]
for student in self.students.values()
if subject in student.scores
]
if not scores:
return None
return sum(scores) / len(scores)

这里的统计口径是“只统计已录入成绩的学生”。如果业务要求缺考按 0 分处理,应把规则写进需求、代码和测试,不能让 dict.get(subject, 0) 偷偷决定业务含义。

回归测试

def test_class_average_ignores_missing_subject_scores():
manager = StudentManager()
first = manager.add_student("001", "张三", 18, "高一")
second = manager.add_student("002", "李四", 18, "高一")
first.scores = {"物理": 92}
second.scores = {"数学": 78}
assert manager.calculate_class_average("物理") == 92

4.6 Bug 6:空班级或无人有该科成绩导致除零

Bug 5 的修复解决了缺科目问题,但还必须单独测试 scores 集合最终为空的场景。

问题与复现

manager = StudentManager()
manager.calculate_class_average("数学") # ZeroDivisionError
manager.add_student("001", "张三", 18, "高一")
# 学生存在,但没有任何数学成绩
manager.calculate_class_average("数学") # 仍然是 ZeroDivisionError

调试证据

在返回语句前查看两个统计变量:

print({"total": total, "count": count})

输出:

{'total': 0, 'count': 0}

这说明循环没有收集到任何有效成绩,真正的除数是 count,不是某个学生的分数。

修复与边界测试

统一返回 None,让展示层显示“暂无数据”:

if count == 0:
return None
return total / count

测试至少覆盖:空班级、学生存在但没有该科成绩、正常有一条成绩、多个学生部分缺科目。

def test_empty_class_has_no_average():
manager = StudentManager()
assert manager.calculate_class_average("数学") is None
def test_class_with_no_scores_has_no_average():
manager = StudentManager()
manager.add_student("001", "张三", 18, "高一")
assert manager.calculate_class_average("数学") is None

开发阶段可以用断言帮助发现违反内部假设的情况,但不要用断言代替对外部输入的正常处理,详见第 8 节。

4.7 Bug 7:Top N 排序方向相反

问题代码

def get_top_students(self, subject, n=3):
students_with_scores = [
student
for student in self.students.values()
if subject in student.scores
]
# Bug:sorted 默认升序
sorted_students = sorted(
students_with_scores,
key=lambda student: student.scores[subject],
)
return sorted_students[:n]

现象

输入成绩:[95, 78, 90]
期望排名:[95, 90, 78]
实际排名:[78, 90, 95]

调试证据

先验证 sorted 默认行为:

>>> sorted([3, 1, 4])
[1, 3, 4]
>>> sorted([3, 1, 4], reverse=True)
[4, 3, 1]

再分别打印排序前后的成绩,确认筛选阶段没有丢数据,错误只出在排序方向:

print("排序前:", [s.scores[subject] for s in students_with_scores])
print("排序后:", [s.scores[subject] for s in sorted_students])

修复与回归测试

def get_top_students(
self,
subject: str,
n: int = 3,
) -> list[Student]:
candidates = [
student
for student in self.students.values()
if subject in student.scores
]
return sorted(
candidates,
key=lambda student: student.scores[subject],
reverse=True,
)[:n]
def test_top_students_order():
# 构造 95、78、90 三个成绩
top = manager.get_top_students("数学", 3)
scores = [student.scores["数学"] for student in top]
assert scores == [95, 90, 78]

只断言“第一名是 95”不够,因为后两名仍可能顺序错误。排序类 Bug 要检查完整序列、n 大于人数、n 为 0 等边界。

4.8 Bug 8:生成报告时的连锁除零

问题代码

def generate_report(self):
for student_id, student in self.students.items():
print(f"学号:{student_id}")
for subject, score in student.scores.items():
print(f" {subject}: {score} 分")
# Bug:无成绩学生会在这里触发 Bug 1
average = student.get_average_score()
print(f"平均分:{average:.2f}")

现象与调用链

当报告包含没有成绩的“赵六”时,错误表面上出现在报告生成,但调用链是:

main()
└─ generate_report()
└─ get_average_score()
└─ 空 scores 导致除零

这是一个连锁 Bug:报告层调用了一个对空数据没有明确契约的底层函数。不要看到报告崩溃,就在每个报告分支里复制一套 try/except ZeroDivisionError。

调试证据:异常现场与调用者状态

可以使用 post-mortem 调试停在失败现场:

import pdb
import sys
def debug_excepthook(exc_type, exc_value, traceback):
pdb.post_mortem(traceback)
sys.excepthook = debug_excepthook

在调试器中查看:

(Pdb) p student.name
'赵六'
(Pdb) p student.scores
{}
(Pdb) w

证据表明根因仍然是 get_average_score() 的空集合处理,而不是报告的字符串格式。

修复:底层修契约,展示层处理 None

def generate_report(self):
for student_id, student in self.students.items():
print(f"\n学号:{student_id}")
print(f"姓名:{student.name}")
if not student.scores:
print("成绩:暂无")
print("平均分:暂无")
continue
print("成绩:")
for subject, score in student.scores.items():
print(f" {subject}: {score} 分")
average = student.get_average_score()
if average is None:
print("平均分:暂无")
else:
print(f"平均分:{average:.2f}")

由于 get_average_score() 已经修复,报告层不应捕获同一个 ZeroDivisionError 来掩盖根因。只有在边界层确实知道如何处理异常时,才捕获具体异常并记录完整上下文。

回归测试

def test_report_includes_student_without_scores(capsys):
manager = StudentManager()
manager.add_student("001", "赵六", 18, "高一")
manager.generate_report()
output = capsys.readouterr().out
assert "成绩:暂无" in output
assert "平均分:暂无" in output

4.9 八个案例的共同模式

这 8 个 Bug 看起来不同,但都可以用同一套问题拆解:

  1. 数据是否为空、缺字段、缺键或类型不对?
  2. 函数是否明确约定了边界输入的结果?
  3. Traceback 指向的行是否只是“最后爆炸点”,真正的错误状态是否更早产生?
  4. 修复是否改变了错误语义,例如把缺失数据伪装成 0?
  5. 是否增加了最小回归测试,证明这个场景不会再次失败?

5. 断点、变量和调用栈速查 ★★★

🧩 学习优先级:补充掌握

5.1 断点类型

  • 普通断点:执行到该行时暂停;
  • 条件断点:条件为真时暂停;
  • 日志点:不暂停,只输出表达式;
  • 异常断点:指定异常抛出时暂停;
  • 函数断点:进入指定函数时暂停。

条件示例:

student_id == "2024001"
subject not in student.scores
len(self.scores) == 0

5.2 查看变量时优先检查什么

  1. 实际值:是否与假设相同;
  2. 类型:"18" 与 18 完全不同;
  3. 长度:容器是否为空;
  4. 成员关系:键或元素是否存在;
  5. 对象标识:两个变量是否引用同一可变对象;
  6. 上一层调用者传入了什么。

常用表达式:

type(value)
repr(value)
len(items)
key in mapping
vars(obj)
id(obj)

5.3 调用栈怎么读

Traceback 的最后一层通常是失败点,但根因也可能来自更上层传入的错误状态。使用 w 查看栈,再用 u、d 切换栈帧,检查每层局部变量。

例如报告生成失败时:

main()
└─ generate_report()
└─ get_average_score()
└─ ZeroDivisionError

失败发生在最底层;“为什么会传入无成绩学生”则可能需要到 generate_report() 或数据加载层检查。

5.4 常见异常速查

异常 常见原因 首要检查
NameError 变量未定义或作用域不对 拼写、定义顺序、作用域
TypeError 运算或调用的类型不兼容 type()、函数签名
ValueError 类型可接受,但值非法 原始输入和范围
KeyError 字典键不存在 key in mapping、数据版本
IndexError 序列索引越界 len(sequence)、循环边界
AttributeError 对象没有该属性 实际对象类型、属性名
ZeroDivisionError 除数为零 数据集合是否为空、计数逻辑
FileNotFoundError 路径错误或文件缺失 当前工作目录、绝对路径
JSONDecodeError JSON 格式损坏 文件内容、编码、非法注释/逗号

6. 高效定位复杂问题 ★★★★★

🔥 学习优先级:重点掌握

6.1 二分定位

当流程很长但不知道从哪一步开始错误时,在中点检查一次:

step1()
step2()
check_state("after step2")
step3()
step4()
check_state("after step4")
step5()

如果 step2 后正确、step4 后错误,问题范围已经从五步缩小到两步。继续二分,而不是每一行都打印。

6.2 对比正确与错误输入

同时准备一个正常样例和一个失败样例,对比:

  • 输入值和类型;
  • 容器长度;
  • 配置和环境变量;
  • 进入关键函数前的状态;
  • 外部依赖返回值。

这比只盯着失败样例更容易发现“失败样例缺了什么”。

6.3 用 git diff 和 git bisect 查回归

先看最近改动:

终端窗口
git diff
git log --oneline -10

已知一个好提交和一个坏提交时:

终端窗口
git bisect start
git bisect bad
git bisect good v1.0
# 每次测试当前提交后标记:
git bisect good
# 或
git bisect bad
git bisect reset

若有稳定测试命令,可自动二分:

终端窗口
git bisect run python -m unittest tests.test_average

开始前确保工作区修改已妥善保存,避免切换提交时混入未提交变更。

6.4 解释代码

把代码逐步解释给同学或写成文字:

先收集成绩
→ 再计算长度
→ 如果长度为零……

当你无法清晰解释某一步的输入、输出和前置条件时,那里往往就是值得检查的地方。

6.5 性能问题要测量

小表达式可用 timeit:

import timeit
elapsed = timeit.timeit(
"sum(range(1000))",
number=10_000,
)
print(f"总耗时: {elapsed:.6f}s")

完整程序应使用 cProfile 等 profiler:

终端窗口
python -m cProfile -s cumulative student_manager.py

不要根据一次 print(time.time()) 就断言性能瓶颈。性能测试应多次运行,并区分 CPU、I/O、网络和初始化开销。


7. 五个常见 Python 陷阱 ★★★

🧩 学习优先级:补充掌握

7.1 可变默认参数

默认参数只在函数定义时创建一次,因此多个调用会共享同一个字典:

# 错误
def normalize_scores(scores={}):
scores["数学"] = 95
return scores

正确方式:

def normalize_scores(
scores: dict[str, float] | None = None,
) -> dict[str, float]:
if scores is None:
scores = {}
scores["数学"] = 95
return scores

7.2 浅拷贝与深拷贝

import copy
original = {"scores": {"数学": 95}}
shallow = original.copy()
shallow["scores"]["数学"] = 100
print(original) # 内层字典也变成 100
deep = copy.deepcopy(original)
deep["scores"]["数学"] = 60
print(original) # 不受这次修改影响

浅拷贝只创建第一层容器,嵌套可变对象仍被共享。深拷贝也不是永远正确:大型对象可能很昂贵,文件句柄、连接等资源也不适合随意复制。

7.3 循环中的闭包晚绑定

functions = []
for index in range(3):
functions.append(lambda: index)
print([func() for func in functions]) # [2, 2, 2]

闭包在调用时读取变量 index,循环结束后它已经是 2。可在定义时绑定当前值:

functions = [
(lambda value=index: value)
for index in range(3)
]

或使用 functools.partial。

7.4 字符串与数字

data = json.loads('{"age": "18"}')
age = data["age"]
print(repr(age), type(age)) # '18' <class 'str'>

不要在业务深处到处 int(...);更好的方式是在输入边界集中校验和转换,并对非法值返回明确错误。

7.5 None、False、0 和空容器

它们都可能在布尔判断中为假,但业务含义不同:

score = get_score("001")
if score is None:
print("学生或成绩不存在")
elif score == 0:
print("成绩确实为 0 分")

当 0、False 或空字符串是合法值时,不要用笼统的 if not value 判断“缺失”。


8. 断言、异常和日志不要混用 ★★★★★

🔥 学习优先级:重点掌握

8.1 assert 用于内部不变量

def calculate_average(scores: list[float]) -> float:
if not scores:
raise ValueError("scores 不能为空")
if not all(0 <= score <= 100 for score in scores):
raise ValueError("分数必须在 0~100 之间")
average = sum(scores) / len(scores)
assert 0 <= average <= 100
return average
  • 外部输入错误:显式抛 ValueError 或领域异常;
  • 理论上不可能被破坏的内部不变量:可使用 assert;
  • 正常但值得记录的事件:使用日志。

Python 使用优化选项运行时可能移除 assert,因此不能依赖它执行安全、权限或用户输入校验。

8.2 不要捕获后只打印

# 不推荐
try:
load_data()
except Exception as exc:
print(exc)

这会丢失调用栈,还可能让程序带着不完整状态继续运行。边界层需要记录未知异常时:

try:
load_data()
except (OSError, json.JSONDecodeError):
logger.exception("failed to load data")
raise

只捕获你知道如何处理或如何转换的具体异常。

8.3 用装饰器自动记录函数调用

当多个函数都需要记录“谁被调用、传入什么、返回什么”时,可以用装饰器减少重复日志代码:

import functools
import logging
def debug_log(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
logging.debug(
"调用 %s,参数:args=%r, kwargs=%r",
func.__name__,
args,
kwargs,
)
result = func(*args, **kwargs)
logging.debug(
"%s 返回:%r",
func.__name__,
result,
)
return result
return wrapper

使用方式:

@debug_log
def add(a: int, b: int) -> int:
return a + b

这个装饰器适合开发和排障阶段。生产环境要注意脱敏,避免把密码、Token 或完整用户数据写入日志;如果函数会抛异常,也可以在装饰器中用 logger.exception() 记录后重新抛出。


9. 调试检查清单 ★★★★★

🔥 学习优先级:重点掌握

复现与证据

  • 写清输入、期望和实际结果;
  • 能稳定复现,最好有最小测试;
  • 完整阅读异常类型、失败行和调用栈;
  • 检查最近改动:git diff、配置、依赖和数据版本;
  • 用打印、日志或断点验证假设,而不是凭感觉修改。

边界与状态

  • 检查 None、空容器、0、负数、最大值、重复值;
  • 检查实际类型、编码、路径和当前工作目录;
  • 检查字典键、列表边界和对象是否共享可变状态;
  • 检查循环第一次、最后一次和零次执行;
  • 检查错误是当前函数产生,还是由上游输入传入。

修复与验证

  • 一次只改一个原因;
  • 修复根因,不在每个调用处重复兜底;
  • 明确“找不到”“没有数据”“值为 0”的不同语义;
  • 运行原失败样例、正常样例和相邻边界样例;
  • 增加回归测试;
  • 删除临时断点和无用打印;
  • 检查日志没有泄露敏感数据。

10. 一页速记 ★★★

🧩 学习优先级:补充掌握

1. 先复现,不要先修改。
2. Traceback 从最后一行开始读,再沿调用栈向上找输入来源。
3. 把猜测写成可验证假设。
4. 小问题用 print,大流程用 logging,状态变化用断点。
5. 检查值、类型、长度、成员关系和对象引用。
6. 空值、0、False、None 是不同业务状态。
7. 修复函数契约和根因,不要在所有调用方重复捕获。
8. 修复后增加回归测试。
9. 性能问题用 profiler,不靠感觉。
10. 不要用 assert 校验外部输入,不要用宽泛 except 隐藏错误。

调试能力的提升路径:

  • 初级:会读 Traceback,用最小打印定位值;
  • 中级:会用条件断点、调用栈、日志和最小复现;
  • 进阶:能通过测试、类型契约、可观测性和代码设计预防 Bug。

本文由作者整理,AI 辅助润色。