Python 调试技巧:从错误现象到根因修复
以一个学生管理脚本为案例,整理可重复的排错流程、调试工具的选择、8 类典型 Bug 的根因与修复,以及防复发策略。
以
student_manager.py为案例,保留最实用的调试方法、8 类典型 Bug、工具命令和防复发策略。
📌 阅读优先级
调试能力贯穿模型调用、异步任务、数据处理和线上排障。优先学习可重复的诊断流程,再补充具体工具。
| 标记 | 学习要求 | 常见使用位置 |
|---|---|---|
| 🔥 重点掌握 | 能按步骤复现、定位、验证并补回归测试 | API 报错、模型输出异常、异步失败、数据边界问题 |
| 🧩 补充掌握 | 能按需选工具并识别 Python 常见陷阱 | 复杂回归、性能诊断、共享状态与日志治理 |
| 👀 了解即可 | 作为速查材料保留,需要时再翻阅 | 命令速查和案例附件 |
🔥 重点掌握
- 证据驱动的调试流程(第 1~2 章):明确期望、最小复现、读 Traceback、验证假设、修根因和补测试。
- 常用调试工具(第 3.1、3.2、3.4 节):短期验证用
print(),长期诊断用日志,复杂状态用断点。 - 典型错误模式(第 4 章):重点学习空值、缺字段、重复键、排序方向和连锁失败背后的共性。
- 复杂问题定位(第 6 章):二分定位、正常/异常对比和
git bisect对回归问题很常用。 - 异常、日志与检查清单(第 8~9 章):避免吞异常、滥用断言,并形成上线前自检习惯。
🧩 补充掌握
- 变量、调用栈和异常速查(第 5 章):能提高现场排障速度。
- Python 常见陷阱(第 7 章):可变默认参数、浅拷贝、闭包晚绑定和真假值混淆很容易制造隐蔽问题。
pdb与性能分析(第 3.3、6.5 节):远程环境或性能瓶颈出现时再重点使用。- 一页速记(第 10 章):适合作为复习入口。
👀 了解即可
- 附录案例代码:知道位置即可,需要复现案例时再打开。
1. 调试的核心:用证据缩小问题范围 ★★★★★
🔥 学习优先级:重点掌握
调试不是“不断改代码直到能跑”,而是一个可验证的推理过程:
明确期望→ 稳定复现→ 阅读错误与调用栈→ 提出一个假设→ 用最小实验验证→ 修复根因→ 增加回归测试每次只验证一个假设。例如程序计算平均分时报错,不要同时修改文件读取、排序和异常处理;先确认参与除法的数据到底是什么。
1.1 科学方法与正确心态
可以把调试看成一次小型科学实验:
- 观察现象:记录输入、期望结果、实际结果和异常信息;
- 提出假设:把“可能有问题”改写成可验证的原因,例如“
scores为空”; - 设计实验:只增加一条打印、一个断点或一个最小测试;
- 收集证据:运行实验,观察值、类型、长度和调用栈;
- 分析结果:判断假设成立还是被证伪;
- 修复并验证:修改根因,再运行失败样例和回归测试。
调试时要保持耐心和可重复性:相信错误信息提供的线索,不要一次改动很多地方,也不要因为“现在能跑了”就跳过测试。记录“现象、证据、结论”还能避免下一次重新走同一条弯路。
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阅读顺序:
- 最后一行:异常类型和直接原因;
- 最靠近底部的业务代码:真正失败的位置;
- 向上回溯:错误输入从哪个调用者传来。
这段 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.py3.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_dataFalse(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 studentstudent_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]查询学号 9999999KeyError: '9999999'调试证据
在 VSCode 第 3 行设置断点,变量面板可能显示:
student_id: "9999999"self.students: {"2024001": <Student>, "2024002": <Student>}student_id in self.students: False用 Python 交互验证字典行为:
d = {"a": 1}d["b"] # KeyErrord.get("b") # Noned.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 None4.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.scoresFalse根因不是科目名称拼写,而是统计代码没有定义“缺少该科成绩”的处理规则。
修复:只统计实际有该科成绩的学生
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("物理") == 924.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 Nonereturn 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 pdbimport 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 output4.9 八个案例的共同模式
这 8 个 Bug 看起来不同,但都可以用同一套问题拆解:
- 数据是否为空、缺字段、缺键或类型不对?
- 函数是否明确约定了边界输入的结果?
- Traceback 指向的行是否只是“最后爆炸点”,真正的错误状态是否更早产生?
- 修复是否改变了错误语义,例如把缺失数据伪装成
0? - 是否增加了最小回归测试,证明这个场景不会再次失败?
5. 断点、变量和调用栈速查 ★★★
🧩 学习优先级:补充掌握
5.1 断点类型
- 普通断点:执行到该行时暂停;
- 条件断点:条件为真时暂停;
- 日志点:不暂停,只输出表达式;
- 异常断点:指定异常抛出时暂停;
- 函数断点:进入指定函数时暂停。
条件示例:
student_id == "2024001"subject not in student.scoreslen(self.scores) == 05.2 查看变量时优先检查什么
- 实际值:是否与假设相同;
- 类型:
"18"与18完全不同; - 长度:容器是否为空;
- 成员关系:键或元素是否存在;
- 对象标识:两个变量是否引用同一可变对象;
- 上一层调用者传入了什么。
常用表达式:
type(value)repr(value)len(items)key in mappingvars(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 diffgit log --oneline -10已知一个好提交和一个坏提交时:
git bisect startgit bisect badgit 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 scores7.2 浅拷贝与深拷贝
import copy
original = {"scores": {"数学": 95}}
shallow = original.copy()shallow["scores"]["数学"] = 100print(original) # 内层字典也变成 100
deep = copy.deepcopy(original)deep["scores"]["数学"] = 60print(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 functoolsimport 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_logdef 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 辅助润色。