本节摘要:调试是初学者最缺训练、又最被低估的能力。本节把"读报错、缩小范围、验证假设"这套流程拆开练:先学会从 Traceback 最后一行提取关键信息,再用打印与二分法定位问题,最后用几个精心埋 bug 的练习把整套动作走熟。做完本节,你面对一段不工作的代码时将不再慌乱,而是有一套确定的动作可执行。
阅读完本节,你应当能够:
想象这个场景:你写了四十行的成绩统计程序,运行后输出"平均分 4.6"——明显不对,但程序没报任何错。你的第一反应是什么?多数初学者会从头到尾重读代码,靠眼睛找。这个方法在三十行以内偶尔有效,代码一长就变成体力活,而且找到的往往是"看起来可疑"的地方,不是真正出错的地方。
有经验的调试者做法完全不同:先问"哪个环节最可能出错",在平均分计算的上游打印中间变量——总分对不对?人数对不对?如果总分错了,再往上看每条成绩是怎么累加的。每一次打印都把怀疑范围砍掉一块,几轮之后 bug 被逼进三五行的小区域,眼睛一看就穿。调试的本质不是"找错",而是"系统地排除没错的部分"。
这个能力和做练习题直接相关:做题卡住时,你缺的常常不是语法知识,而是把"不工作"变成"我知道哪里不工作"的能力。本节就把这套动作练成习惯。
报错信息从下往上读:最后一行是错误类型和说明,往上是出错的行号和调用链。看一个典型例子:
def get_average(scores): total = sum(scores) return total / len(scores) def main(): data = ["85", "92", "78"] print(get_average(data)) main() # TypeError: unsupported operand type(s) for +: 'int' and 'str'
从最后一行能读出三层信息:错误类型是 TypeError(类型不匹配);操作是加法;涉及 int 和 str。再往上看出错位置在 sum 内部,而调用来自 get_average、又被 main 调用。组合起来推断:传入的 scores 里有字符串。对照 main 里的 data——果然是从某处读来的成绩没转成数字。一条 Traceback 读到位,问题基本已经定位。
常见错误类型先认识这五个,覆盖初学阶段九成报错:SyntaxError(语法错,程序根本没跑起来)、NameError(用了不存在的名字,多为拼写错)、TypeError(类型不匹配)、ValueError(类型对但值不合法,如 int("abc"))、IndexError/KeyError(索引或键越界)。
| 问题类型 | 表现 | 对策 |
|---|---|---|
| 语法错误 | 程序不运行,直接指出行号 | 看行号,检查该行及上一行的括号、冒号、缩进 |
| 运行时异常 | 跑到某处崩溃,有 Traceback | 读 Traceback,定位出错行,检查该行的数据 |
| 逻辑错误 | 能跑完,结果不对 | 打印中间变量,缩小范围,假设-验证 |
前两类程序会"自己说话",难度不大;真正考验人的是第三类。后面两小节专门对付它。
逻辑 bug 的克星是打印。原则有三:打印在"怀疑点之后"而不是之前(确认数据流到此处变成了什么样);打印带标签(print("累加后 total =", total),多个打印混在一起才分得清);打印完要删(调试打印是脚手架,房子盖好要拆)。
def load_scores(lines): records = [] for line in lines: name, score = line.split(",") records.append((name, score)) # 疑点:score 没转 int? print("DEBUG 读入:", name, repr(score)) return records
repr 比 str 更适合调试——字符串会带引号,85 和 "85" 一眼分清。上面这个疑点是初学阶段的高发 bug:从文件读来的都是字符串,忘了转数字,后面求和要么报错要么拼接。
代码长、怀疑点多时,别一个点一个点打印,用二分:在数据流的中间位置打印一次,结果对,问题在后半段;结果错,问题在前半段。一段一百行的处理流程,三四次打印就能把范围压到十行以内。这个思路和 5.2 节的二分查找同源——都是在有序的结构(这里是数据流)上每次砍掉一半。

bug 修完不等于结束。把暴露这个 bug 的输入固化成一条 assert,防止它换件衣服回来。积累下来的测试用例,就是这段代码的"体检套餐"——以后任何改动,跑一遍就知道有没有伤到旧功能。
⚠️ 常见坑:改了代码只用手头那个出错输入试一下就收工。很多修复是"针对这个输入特调"的,换一组数据又坏。修复后至少跑三类输入:原来报错的、正常的、边界的。
调试前先想办法把问题"做小":能触发 bug 的最短输入是什么?能复现的最小程序片段是哪几行?把无关代码一行行注释掉(或删掉),bug 依然出现的最小版本,就是最理想的调试对象。在最小版本上调通了,再放回原程序验证。这个过程本身就是一轮排除法。
💡 关键直觉:向别人求助前先做最小复现。一半以上的 bug 在做最小复现的过程中自己就看出来了——因为删代码时你被迫逐行思考每行的作用。
调试时眼睛会顺带看到别的"可疑代码",忍住别改。一次只修一个 bug,修完验证一个。同时改三处,其中一处引入新问题,你就分不清是修复无效还是改坏了别处——两团乱麻搅在一起,调试成本翻倍。
题面:下面五段代码各有一步错误,先不运行,逐段写出错误类型和修复方案,再上机验证。
# A print("hello" # B total = totl + 1 # C age = int("十二岁") # D nums = [1, 2, 3] print(nums[3]) # E d = {"a": 1} print(d["b"])
思路点拨:A 缺右括号是语法错;B 是拼写不一致的 NameError;C 是值不合法的 ValueError;D、E 分别是索引和键越界。先猜后验证,比直接运行印象深得多。
参考答案:A 补右括号;B 统一变量名;C 改 int("12") 或从输入清洗;D 改 nums[2] 或先判断长度;E 用 d.get("b") 或先判断键存在。
题面:下面的成绩统计代码埋了三个 bug,程序能跑但结果错误。用打印法定位并修复。
def average(scores): total = 0 for s in scores: total = s # 疑点在此区域 return total / len(scores) def main(): raw = "85,92,78,90" scores = raw.split() print(f"平均分:{average(scores):.1f}") main()
思路点拨:三个 bug 分别是——累加写成了赋值(total = s 应为 total += s);按空白 split 但数据是逗号分隔(应 split(","));字符串没转数字(累加处会先暴露)。建议先在 average 入口打印 scores,再在循环内打印 total,一步步逼近。
参考答案:修复后平均分应为 86.2。三处修完后,把每个 bug 的输入固化成 assert,例如空列表、单元素列表各补一条用例。
题面:找一段你两周前写的旧练习(或让同学给你一段五十行内、确认能跑的代码),请人埋两三个逻辑 bug(或自己改乱后隔一天再调)。限时三十分钟,记录:每个 bug 用了几次打印定位、有没有走弯路、哪次假设错了。
思路点拨:重点是过程记录而非速度。做完写下"我通常会先怀疑哪类位置"——这个自我观察会告诉你调试直觉的偏向,有人总先怀疑计算、有人总先怀疑输入,知道偏向才能在弱侧多设检查点。
⚠️ 常见坑:调试超过二十分钟仍无进展时,硬盯代码效率极低。起来倒杯水,或者把代码讲给"橡皮鸭"听——把每行的作用说出声,说不出口的那行往往就是问题所在。
最后谈谈心态,因为它实际影响调试效率。第一个要点是"接受 bug 是常态":写代码出错不是能力问题,是编程活动的固有部分。把每次调试当成一次侦探推理而非对自己的审判,注意力才能保持在证据上。第二个要点是"记录现场再动手":改代码前把当前的错误现象、行号、输入数据记下来(哪怕记在注释里),改动后对照。不少人在连续尝试中忘了最初的现象,绕一圈又回到起点。
第三个要点是"二十分钟规则":同一思路下卡超过二十分钟就强制换方法——从"读代码"换成"打印",从"往后追"换成"往前截",或者干脆向别人描述问题。切换的成本远低于在死胡同里加时。橡皮鸭调试法是其中成本最低的一种:对着任何一个物件把代码逻辑一行行讲清楚,讲到卡壳的那行,问题常常自己浮出来。这招有效的原理很朴素——讲给别人听的版本必须连你自己也说服,含混处藏不了。
还有两个高频误区值得点名。一是"改动恐惧":怕改坏而不敢动代码,于是只敢在旁边加打印。其实配合版本管理或至少留一份副本,大胆修改是安全的,很多 bug 只有删掉一段代码才看得清。二是"迷信式编程":不清楚原理但"上次这么改就好了",于是反复试各种改法碰运气。碰运气偶尔命中,但没理解的修复留不下任何经验。每次修复后花一分钟问自己"为什么这样改能好",这一分钟才是调试经验真正的复利来源。
配一张常见报错的"翻译对照"收尾,调试时直接按类型索引:NameError 九成是拼写不一致或忘了定义先用;UnboundLocalError 多半是函数里先读后写一个全局名字(回看 2.1 的作用域规则);TypeError 出现在"字符串和数字混算"或"给 None 调方法"——后者通常是某个函数忘了写 return,返回了默认的 None;AttributeError 说"这个对象没有这个方法",先打印 type(x) 确认它到底是你以为的类型还是别的什么。把这张对照记进错题本的第一页,前三个月的调试速度会明显不同。
85 与 "85",多个打印不打架。