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

调试第一课:print 与 pdb 的实战打法

## 引言 这是第一章的第 8 篇。到目前为止,我们有了能跑代码的机器(第 2 篇)、理解了执行原理(第 3 篇)、会隔离环境与装包(第 4、5 篇)、能在 REPL 里快速验证想法(第 6 篇)、知道怎么写规范代码(第 7 篇)。但所有这些技能都默认一个前提:**代码是错的,得先找到错在哪**。写程序的主要时间其实花在"找出为什么不符合预期"上,这门手艺就叫调试(debugging),而 Python 的调试第一课,就从 print 和 pdb 开始。 本文解决的具体问题:程序报错了(或结果不对),我照着什么流程去定位?读者应已掌握第 6 篇的 REPL 操作(pdb 的交互风格与 REPL 一脉相承,但它是面向调试专用);本文的所有演示都基于 Python 3.10+ 的标准库行为,不安装任何第三方包——pdb 和 breakpoint 都是内置能力。 ## 调试的本质:控制流 × 状态 任何 bug 都可以归结为两个问题的组合:**程序执行到了哪一行**(控制流,control flow)?**执行到那一行时,各个变量的值是什么**(状态,state)?当"实际的控制流或状态"与"你头脑中的预期"出现偏差时,bug 就产生了。调试的全部工作,就是不断缩小"实际"与"预期"之间的偏差区间: 1. 观察到症状(报错信息、错误输出); 2. 定位到可疑代码区间(通常是最近的几次函数调用); 3. 在区间内检查关键变量的状态; 4. 修正偏差,回到第 1 步,直到症状消失。 理解这个框架,你就明白为什么"看代码找 bug"效率低:人脑在模拟控制流时不可靠,尤其是循环和嵌套调用。工具的价值在于**把控制流和状态变成可见的**。 ## print 调试:最朴素也最直接的武器 print 调试就是在代码里插入打印语句,观察关键位置的变量值。它的优点无可替代:零学习成本、任何环境都能用、不会引入新工具。但它的缺点会随代码规模放大:打印语句是**侵入式**的,插进源码就要从源码里删掉,而"忘了删"的 print 会污染真正的输出;print 也**看不见控制流**——你只知道某个值打印出来了,却不知道它是从哪条路径走到的;循环量大时,print 会疯狂刷屏。 在深入 pdb 之前,先把 print 调试的打法学规范。下面是一个典型场景:写一个统计学生成绩的脚本 `scores.py`,它计算相邻成绩的差值,运行即报错: ```python # scores.py —— 运行时抛出 IndexError def diff_with_next(scores): diffs = [] for i in range(len(scores)): diffs.append(scores[i + 1] - scores[i]) return diffs scores = [78, 92, 45] print(diff_with_next(scores)) ``` 运行 `python scores.py`,报错: ```text Traceback (most recent call last): File "scores.py", line 8, in <module> print(diff_with_next(scores)) File "scores.py", line 4, in diff_with_next diffs.append(scores[i + 1] - scores[i]) IndexError: list index out of range ``` 回溯信息(traceback)已经指出了症状位置:第 4 行,`scores[i + 1]` 越界。但"为什么越界"——`i` 最后一次取值是多少、列表有多长——print 看不出。用 print 打法,你会改成: ```python for i in range(len(scores)): print("i =", i, ",列表长度 =", len(scores)) diffs.append(scores[i + 1] - scores[i]) ``` 输出 `i = 0`、`i = 1`、`i = 2`,然后异常。定位到:`i` 取到 2 时(最后一个下标),`i + 1 = 3` 已超出列表。结论:循环应该 `range(len(scores) - 1)`。print 完成了任务,但可以看出流程别扭:要插一行、跑一次、读输出、删除——更麻烦的是,如果循环在深层函数里,你根本不知道"此刻打印的 i 到底是哪次调用的"。这正是 pdb 登场的时机。 ## pdb:标准库自带的交互式调试器 **pdb**(The Python Debugger)是 Python 标准库里的交互式调试器,官方文档定位为"Python 调试器的互动源代码浏览器"。它复用第 6 篇讲过的交互式命令行风格:输入命令、回车、立刻得到反馈。它的底层机制值得知道一句:pdb 通过 **`sys.settrace`** 在解释器层面注册一个追踪函数(trace function),每当执行流跨过一行代码(或函数调用/返回事件)时,追踪函数就被回调,pdb 借此知道"现在执行到哪一行",并决定是否停下来等待你输入命令。也就是说,pdb 不是"慢速 Python",它就是 Python 本身,只是多了一个监控者。 pdb 有两种启动方式,行为略有差异,必须分清: - **`python -m pdb 脚本.py`**:以"调试模式"启动脚本,交互提示符在**第一行执行前**出现,你逐步命令控制它;如果让它继续运行到异常发生,pdb 会切换进 **post-mortem 模式**(死后诊断,拉丁语 post mortem 意为"死后"),停在异常发生的帧上,供你勘察现场; - **在代码中调用 `breakpoint()`**:程序运行到这一行时,如果环境允许(默认允许),就地进入调试器。这是内置函数,由 PEP 553 引入(Python 3.7+),等价于旧写法 `import pdb; pdb.set_trace()`,但更简洁且可被环境变量控制。 ### 真实调试会话一:post-mortem 勘察异常现场 我们先用第一种方式处理刚才的 `scores.py`: ```bash python -m pdb scores.py ``` 敲 `c`(continue,继续运行),脚本跑起来并抛异常,pdb 自动进入 post-mortem。以下是本文作者在 Python 3.12 上实际运行的完整会话(为版面清晰,文件路径做了精简;你机器上的输出路径不同,但格式与数值一致): ```text (Pdb) c Traceback (most recent call last): File "scores.py", line 8, in <module> print(diff_with_next(scores)) File "scores.py", line 4, in diff_with_next diffs.append(scores[i + 1] - scores[i]) IndexError: list index out of range Uncaught exception. Entering post mortem debugging Running 'cont' or 'step' will restart the program > scores.py(4)diff_with_next() -> diffs.append(scores[i + 1] - scores[i]) (Pdb) p i 2 (Pdb) p scores [78, 92, 45] (Pdb) p scores[i + 1] *** IndexError: list index out of range (Pdb) w pdb.py(1960)main() pdb.py(1754)_run() bdb.py(627)run() <string>(1)<module>() scores.py(8)<module>() -> print(diff_with_next(scores)) > scores.py(4)diff_with_next() -> diffs.append(scores[i + 1] - scores[i]) (Pdb) q ``` 逐条解读这个会话,它展示了 pdb 的核心命令: - `p 表达式`(print 的缩写):求值并打印表达式。`p i` 得到 2——**异常前最后一个 `i` 值**;`p scores` 得到整个列表——长度 3,最大下标 2。两个信息一拼:i=2 时 i+1=3 越界,真相大白; - `w`(where):打印调用栈(call stack),从最底层往上直到当前帧。箭头 `>` 标出当前位置,正是异常帧第 4 行; - `q`(quit):退出调试器。post-mortem 模式下退出前 pdb 会提示"程序将被重新启动",直接回车确认即可。 在 post-mortem 现场,你要回答的问题固定为三个:**异常发生在哪个函数、哪一行;那一刻的关键变量是什么;调用链是怎么走过来的**。`p`、`w` 恰好一一对应。这就是为什么"post-mortem 勘察"是 pdb 最高频的用法——比打 print 少改一行代码。 ### 真实调试会话二:breakpoint() 单步进入函数 第二个场景:结果不对(不报错)的 bug 用 post-mortem 没用,因为根本没有异常。此时用 `breakpoint()` 在可疑位置"钉"一个断点,然后单步观察。把脚本改名 `scores_step.py`,在调用处之前插入断点: ```python def average(scores): total = 0 for s in scores: total += s return total / len(scores) breakpoint() # 运行到这里停下,进入 pdb scores = [78, 92, 45, 63] print(average(scores)) ``` 直接运行 `python scores_step.py`:程序执行 `breakpoint()` 那一行之后,在下一行执行前停住,出现 `(Pdb)` 提示符。以下为实际会话: ```text > scores_step.py(8)<module>() -> scores = [78, 92, 45, 63] (Pdb) s > scores_step.py(9)<module>() -> print(average(scores)) (Pdb) s --Call-- > scores_step.py(1)average() -> def average(scores): (Pdb) n > scores_step.py(2)average() -> total = 0 (Pdb) p total *** NameError: name 'total' is not defined (Pdb) n > scores_step.py(3)average() -> for s in scores: (Pdb) p s *** NameError: name 's' is not defined (Pdb) n > scores_step.py(4)average() -> total += s (Pdb) p total 0 (Pdb) c 69.5 ``` 这段会话信息量很大,留意三个细节: - **`s`(step)与 `n`(next)的区别**:`s` 会**进入**函数内部(`--Call--` 事件标明"即将调用另一个函数"),`n` 则停留在当前函数内执行下一行。在 `print(average(scores))` 这一行上,`s` 带我们进入了 `average()` 的内部,之后 `n` 一行一行推进它的循环; - **NameError 也是有效信息**:在 `total = 0` 执行前查 `total` 报 `NameError`,说明变量确实还没被赋值——这确认了执行顺序;在 `for s in scores:` 这一行上 `s` 尚未定义,说明循环体还没开始执行(`for` 行的取值发生在进入循环体之前)。**能确认"此刻不该有值",与能确认"此刻有某个值"同样有价值**; - **断点行语义**:起始提示符跟在 `breakpoint()` 之后的 `-> scores = ...`,即**断点打在某一行,停住的位置是"这一行执行之前"**,所以该行代码尚未执行。 最后敲 `c`,程序跑完循环打印 69.5。 ### 断点管理:b 与 cl `breakpoint()` 是写死在代码里的断点;pdb 还能在会话中**动态下断点**:`b 行号`(break 的缩写)在指定行设置断点,`b` 单独输入列出所有断点,`c` 运行到下一个断点停下,`cl 编号`(clear)按编号清除。搭配 `python -m pdb` 从第一行开始,就是"想在哪停在哪停": ```text (Pdb) b 4 Breakpoint 1 at scores.py:4 (Pdb) b 1 breakpoint keep yes at scores.py:4 (Pdb) c > scores.py(4)diff_with_next() -> diffs.append(scores[i + 1] - scores[i]) (Pdb) p i 0 (Pdb) cl 1 Deleted breakpoint 1 at scores.py:4 ``` (以上为同一演示的实际输出。)注意 `cl 1` 是按编号清除;不带编号的 `cl` 会弹出 "Clear all breaks?" 询问,回答 `y` 确认全部清除——这是个容易把人问住的交互细节。 ### 常用命令速查 至此你已见过 pdb 六大核心命令。完整常用表如下,全部真实存在:`n`(next 单步不进入函数)、`s`(step 单步进入函数)、`c`(continue 继续到下一断点)、`q`(quit 退出)、`p 表达式`(print 求值)、`pp 表达式`(pretty-print 美化输出)、`l`(list 显示附近源码)、`w`(where 打印调用栈)、`u/d`(up/down 在调用栈帧间上下移动)、`b [行号]`(break 设置/列出断点)、`cl [编号]`(clear 清除断点)、`until`(继续到当前行号之后)、`r`(return 执行到当前函数返回)、`h`(help 查看命令帮助)、`!语句`(执行任意 Python 语句,如 `!x = 42` 修改变量值)。记不住没关系:`h` 可以随时查,还记得第 6 篇 REPL 吗?pdb 的提示符就是"面向调试的 REPL"。 ## 易错点与陷阱 **1. print 调试的两宗罪:缓冲与残留。** 第一宗:print 写向 stdout,在非交互环境下是**块缓冲**(block buffering),输出不会立即落盘/落屏,而异常与日志走 stderr 是无缓冲的,于是常见"打印顺序错乱"的假象——正确做法是 `print("debug:", x, flush=True)`。第二宗:忘删的 print 混进正式输出,甚至可能被下游程序误解析;调试期在 print 前加 `DEBUG:` 等前缀,收尾时按前缀批量清理,是工程上的惯例。 **2. pdb 命令名遮蔽变量。** pdb 的每个命令首字母都有含义:`p`、`c`、`l`、`n`、`s`……如果你的变量恰好叫 `c` 或 `n`,直接敲变量名回车,pdb 会把它当成 continue/next 命令,程序"莫名其妙"跑飞了。对策:查任何变量一律用 `p 变量名`(pdb 会把 `p` 之后的文本当表达式求值),赋值用 `!变量 = 值`。这是新手在 pdb 中最经典的"灵异事件"。 **3. post-mortem 与调试模式的混淆。** `python -m pdb 脚本.py` 从**第一行**开始等你指挥(调试模式),不是"等异常再进";异常发生后它才进入 post-mortem,且提示 "Uncaught exception. Entering post mortem debugging"。反过来,调试"结果不对但不报错"的逻辑 bug,post-mortem 帮不上忙——因为根本没有异常可进,这时要主动 `breakpoint()` 或 `b 行号` 预设断点。把两个模式分开记,能少踩一半坑。 **4. breakpoint() 的"静默失效"。** `breakpoint()` 受环境变量 `PYTHONBREAKPOINT` 控制:置为 `0` 时它被完全禁用(直接返回,不停住)——这是为 CI 和生产环境准备的"一键关调试"开关。于是"调试时插了断点忘了删,代码却莫名其妙正常跑过了"的谜案,往往就是某处设了 `PYTHONBREAKPOINT=0`。发布前用 `git grep breakpoint` 扫描全仓,确保断点清零,是值得养成的习惯(第 7 篇讲过 flake8,F 类错误同样能帮你抓未删除的调试残留)。 ## 小结 调试的本质是同时盯住控制流与状态两个维度。print 是零成本的探针,适合快速确认"某个值在此刻是什么",但它是侵入式的,且对控制流无能为力;pdb 是标准库自带的交互式调试器,基于 `sys.settrace` 追踪函数的机制工作在解释器层面。本文通过两段真实会话演示了最核心的打法:`python -m pdb` 触发异常后进入 post-mortem 模式,用 `p` 查变量、`w` 看调用栈、`q` 退出;`breakpoint()`(PEP 553,3.7+)在代码中钉断点,用 `s`/`n` 单步、`b`/`cl` 管理断点。四个常见坑:print 的缓冲与残留、pdb 命令名遮蔽变量、两种启动模式的混淆、`PYTHONBREAKPOINT=0` 使断点静默失效。下一篇我们把调试阵地搬进图形界面——VS Code 的 Python 开发环境配置。 ## 练习与思考题 1. 新建 `bug.py`,让 `average()` 接收**空列表**:`average([])` 会抛什么异常?为什么?尝试用 `python -m pdb bug.py` 在 post-mortem 中确认异常类型。(答案导向:`ZeroDivisionError: division by zero`,空列表使 `len(scores)` 为 0。) 2. 在上面的 `scores.py` 中,用 `b 4` 设置断点并 `c` 运行,连续多次停在断点后,用 `p i` 观察 `i` 从 0 递增到 2 的过程。解释为什么最后一次停在断点时程序随后即异常。(答案导向:每次进循环体都会命中断点,i=2 时 i+1 越界,继续运行即抛异常。) 3. 写出 `n` 与 `s` 在 `print(average(scores))` 这一行上的行为差异,并说明为什么调试"结果不对"的逻辑 bug 时 `s` 比 `n` 更有用。(答案导向:s 进入被调函数内部逐行跟踪,n 把整个调用当一步。) 4. 思考:把 `breakpoint()` 放进一个万次循环里会发生什么?如何避免"每轮迭代都停"?(答案导向:每轮迭代都会停下;把断点移到循环外,或在循环内用 `p i` 加上条件后手动 `c`,更工程化的做法是第 9 篇 VS Code 里的条件断点。)