如何入门代码调试(Python)

前言

你是否盯着一行行 Python 代码,反复检查逻辑,却始终找不到问题所在?是否觉得“调试”是高手才拥有的超能力?

其实,调试不是魔法,而是一套有条理的“排除法”。你不需要复杂的 IDE 调试器,最强大的工具就在你的指尖——阅读错误信息插入 print() 语句。这篇文章将带你用最朴素的方法,定位并修复绝大多数 Python 错误,让你从“怕报错”变成“懂调试”。

错误信息是你的朋友,不是敌人

很多初学者看到一大串红色 Traceback 就心慌。其实,Python 已经精确地告诉了你哪里出了错。请记住一个口诀:从下往上看,先看最后一行

经典错误:缩进错误(IndentationError)

# buggy1.py
def greet(name):
print(f"Hello, {name}")   # 忘记缩进

greet("Alice")

运行后得到:

  File "buggy1.py", line 2
    print(f"Hello, {name}")
    ^
IndentationError: expected an indented block

错误信息指出在第 2 行需要一个缩进块。解决方案:在 print 那一行前面加四个空格(或一个 Tab)。每当你复制粘贴代码时,缩进最容易错乱,记得立刻检查。

经典错误:名称错误(NameError)

# buggy2.py
count = 10
print(counter)   # 变量名写错

输出:

NameError: name 'counter' is not defined

Python 告诉你 counter 没有被定义。此时检查拼写,改为 count 即可。这类错误还包括使用未导入的模块、函数未定义等,错误信息都会直接给出“未定义”的名字。

经典错误:类型错误(TypeError)

# buggy3.py
result = "2" + 3

输出:

TypeError: can only concatenate str (not "int") to str

错误信息直白:字符串只能和字符串拼接,不能和整数直接相加。改正方法:"2" + str(3)int("2") + 3这条错误信息顺便教会了你 Python 是强类型语言。

经典错误:索引错误(IndexError)

scores = [72, 85, 90]
print(scores[3])   # 试图访问第4个元素

IndexError: list index out of range 告诉你列表索引超出范围。最大合法索引是 len(scores)-1,也就是 2。遇到它,应检查循环边界,或确认索引变量是否被意外修改。

经典错误:属性错误(AttributeError)

text = "hello"
text.append("!")   # 字符串没有 append 方法

AttributeError: 'str' object has no attribute 'append',翻译过来就是“字符串对象没有 append 这个属性”。这意味着你可能把对象的数据类型搞错了。

错误信息不止于报错,用好 Traceback

当一个错误发生在函数深处,Traceback 会列出整个调用栈。比如:

def divide(a, b):
    return a / b

def calculate():
    return divide(10, 0)

print(calculate())

输出:

Traceback (most recent call last):
  File "test.py", line 6, in <module>
    print(calculate())
  File "test.py", line 4, in calculate
    return divide(10, 0)
  File "test.py", line 2, in divide
    return a / b
ZeroDivisionError: division by zero

从下往上看:错误是零除错误,发生在 divide 函数的 return a / b。谁调用了它?calculate 函数传入了 0。这样一层层追踪,错误根源一目了然。

练习:试着阅读下面这个 Traceback,你能定位到问题吗?

AttributeError: 'NoneType' object has no attribute 'append'

这通常意味着你试图在一个 None 上调用 append。可能是某个函数忘记写 return,导致变量接收到了 None

当程序能运行但结果不对时,说明逻辑有误,而错误信息无法帮你。这时候,就需要深入代码内部“探路”。Python 中灵活的工具就是 print()

监控变量的生命轨迹

在关键位置打印变量值,验证它们是否按你的预期变化。

def process(data):
    result = []
    for item in data:
        if item > 0:
            result.append(item * 2)
    return result

nums = [5, -1, 3, 0]
output = process(nums)
print(output)  # 只看到最终结果,中间发生了什么?

插入探测语句:

def process(data):
    result = []
    for item in data:
        print(f"DEBUG: 当前 item={item}, 处理前 result={result}")
        if item > 0:
            result.append(item * 2)
        print(f"DEBUG: 处理后 result={result}")
    return result

现在运行,输出变为:

DEBUG: 当前 item=5, 处理前 result=[]
DEBUG: 处理后 result=[10]
DEBUG: 当前 item=-1, 处理前 result=[10]
DEBUG: 处理后 result=[10]
...

每一步的变化清清楚楚。如果你发现某个数字应该被加入却没有加入,可以立刻找到条件判断哪句话说谎了。

检查循环是否执行了正确的次数

x = 20
count = 0
while x > 1 and count < 5:
    x = x // 3
    count += 1
print(f"x={x} count={count}")

你预期只循环 2 次,但看到输出是 x=0 count=3,想不通为什么还有第三次。插入中途打印:

x, count = 20, 0
while x > 1 and count < 5:
    print(f"进入循环: x={x}, count={count}")
    x = x // 3
    count += 1
    print(f"退出本次循环: x={x}, count={count}")

输出暴露了 x=22 > 1 仍然成立,于是多循环了一次。真相大白。

观察递归函数的调用和返回

def collect_evens(n):
    if n <= 0:
        return []
    if n % 2 == 0:
        return collect_evens(n-1) + [n]
    return collect_evens(n-1)

print(collect_evens(8))

想知道递归怎么工作的?加一点 print

def collect_evens(n):
    print(f"进入 collect_evens({n})")
    if n <= 0:
        result = []
    elif n % 2 == 0:
        result = collect_evens(n-1) + [n]
    else:
        result = collect_evens(n-1)
    print(f"离开 collect_evens({n}), 返回 {result}")
    return result

输出像一串嵌套的俄罗斯套娃,递归过程瞬间可视化。

定位程序“崩溃”之前的最后一步

当程序抛出未捕获的异常而停止时,你可以在可能出错的代码前后放置 print,判断程序死在哪一句之前。

def update_value(d, key):
    print("DEBUG: 进入 update_value")
    d[key] = d[key] + 1   # 如果 key 不存在,会怎样?
    print("DEBUG: 更新完成")

data = {"count": 5}
update_value(data, "total")  # 'total' 不存在

运行结果只打印了“进入 update_value”,然后抛出 KeyError: 'total'。最后一行告诉我们字典没有这个键。追踪到错误后,就可在代码里加上 if key in d: 做保护。

理解“短路求值”的奇怪输出

a, b = 0, 5
print(a and b)
print(a or b)
print(b and a)
print(b or a)

很多初学者会认为 a and b 应该返回 TrueFalse,但实际是 05。这时用 print 拆解逻辑无济于事,因为这是语言特性。但你可以写一段探测代码来验证自己的理解

for val1, val2 in [(0,5), (3,0), (0,0), (4,5)]:
    print(f"{val1} and {val2} -> {val1 and val2}")
    print(f"{val1} or  {val2} -> {val1 or val2}")

由此可知:and 返回第一个假值,否则返回最后一个;or 返回第一个真值,否则返回最后一个。

常见 Bug 的实战示范

下面我们通过几个从真实练习题中提炼的场景,展示上述知识的妙用。

场景一:计算平均分,结果总是不对

需求:写一个函数,接收成绩列表,返回平均分。

有 Bug 的代码

def average(scores):
    total = 0
    for s in scores:
        total = total + s
    return total // len(scores)

print(average([72, 85, 60]))   # 期待 72.333...

运行总得到结果 72,而不是 72.333333!如果你在程序里发现平均值总是整数,可在 return 前打印:

print(f"total={total}, len={len(scores)}, 整除结果={total // len(scores)}")

立即发现 // 的锅。修改为 / 即可。

场景二:列表在遍历时被修改,元素悄悄消失

需求:移除列表中所有的负数。

错误写法

nums = [4, -1, -3, 7, 2]
for x in nums:
    if x < 0:
        nums.remove(x)
print(nums)

实际输出可能是 [4, -3, 7, 2]-3 没被删掉!为什么会这样?

调试:在循环内打印当前元素和列表状态。

for x in nums:
    print(f"检查元素 {x}, 当前列表: {nums}")
    if x < 0:
        nums.remove(x)
        print(f"  删除后列表: {nums}")

输出:

检查元素 4, 当前列表: [4, -1, -3, 7, 2]
检查元素 -1, 当前列表: [4, -1, -3, 7, 2]
  删除后列表: [4, -3, 7, 2]
检查元素 7, 当前列表: [4, -3, 7, 2]   # 7 不满足条件,无删除输出
检查元素 2, 当前列表: [4, -3, 7, 2]   # 2 同样不满足
最终结果: [4, -3, 7, 2]

发现问题:删除 -1 后列表缩短,循环索引却依然后移,导致原本在后一个位置的 -3 被跳过,而 7 前移补位,于是被“漏检”。修复方法:遍历列表的副本 for x in nums[:]:

这个案例告诉我们:绝对不要在遍历列表的过程中直接增删原列表元素,除非你清楚知道自己在做什么。

场景三:可变对象的“分身术”

a = [1, 2, 3]
b = a             # b 是 a 的引用,不是副本
b.append(4)
print(a)          # 预期 [1,2,3],实际 [1,2,3,4]

print 监视 id(a)id(b)

a = [1,2,3]
b = a
print(f"a 的 id: {id(a)}, b 的 id: {id(b)}")  # 完全相同
b.append(4)
print(a)  # [1,2,3,4]

如果希望 b 独立,应使用 b = a[:]b = a.copy()调试口诀:当你发现修改一个变量却影响到另一个,立刻打印它们的 id() 看看是不是同一个对象。

场景四:字符串切片负步长理解偏差

s = "python"
print(s[4:1:-1])   # 你预测什么?

如果你的预测是 "tyh""noh",实际输出了 "oht",说明切片规则没掌握。你可以用 print 逐个字符拆解:

for i in range(4, 1, -1):
    print(f"索引 {i} 的字符是 '{s[i]}'")

输出:

索引 4 的字符是 'o'
索引 3 的字符是 'h'
索引 2 的字符是 't'

一目了然:从索引 4 开始,到索引 1 结束(不包含),步长 -1,所以得到 'o','h','t'将抽象规则变成可观察的输出,是理解语言机制的捷径。

场景五:忘记 return 的函数

def double(n):
    result = n * 2
    # 忘记 return

print(double(3) + double(4))   # 报错 TypeError

错误信息:unsupported operand type(s) for +: 'NoneType' and 'NoneType',因为函数默认返回 None。如果在你原以为该有数值的地方出现了 None,立刻在函数末尾加 print(result) 并检查是否有 return 语句。

调试思维——从一团乱麻到精准切除

当你积累了足够的错误诊断经验,就需要一套系统的排查流程。

二分注释法

当程序很长,不知道哪部分出错时,注释掉后半部分代码(或用 if False: 包裹),先运行前半部分,看结果是否符合预期。如果前半部分就错了,Bug 在前一半;否则 Bug 在后一半。如此反复,能迅速将问题缩小到几行之内。

构造最小可运行示例

将怀疑有问题的函数单独取出来,写一个极简的测试代码调用它。例如:

def my_buggy_func(data):
    # ... 很多代码
    pass

# 测试
print(my_buggy_func([1,2,3]))

如果依然出错,Bug 就在这个函数内部,排除了外部干扰。这个能力在向他人求助时也极其重要——别人更愿意帮你分析 20 行的精简代码,而不是 500 行的工程。

创建“预期 vs 实际”变量对照表

在纸上或注释里,手写出每一步变量应该等于什么。然后在代码里插入大量 print,把实际值打印出来进行对比。一旦出现偏离,病根就在那儿。比如:

x = 15
result = []
# 预期:第一个 if 成立,result 增加 "A"
if x > 10:
    result.append("A")
    print(f"1. 预期 ['A'], 实际 {result}")
# 预期:由于是独立 if,同样成立,增加 "B"
if x > 5:
    result.append("B")
    print(f"2. 预期 ['A','B'], 实际 {result}")

这种做法强迫你逐行验证假设,错误无处可藏。

永远不要相信任何“显而易见”

即使再简单的代码,也要用打印验证以下四点:

  • 变量是否被正确初始化 (比如 sum = 0
  • 循环边界是否准确 (打印循环索引和终止条件)
  • 函数参数和返回值 (进入函数立刻打印参数,返回前打印返回值)
  • 数据结构的修改是否产生了副作用 (列表、字典的 id 或长度前后比较)

结语:调试是一种心态

99% 的初学者 Bug,都可以通过细致阅读错误信息和大胆使用 print 来搞定。不要害怕那串红色英文,每一条错误信息都是 Python 在努力和你沟通。每当你陷入困惑,做三件事:

  1. 把完整的错误信息读出来,并尝试翻译成自己的话。
  2. 在可疑位置前后插入 print,让程序自己“说话”。
  3. 用最小代码重现问题,逐一排除假设。

当你养成了这种习惯,会发现调试不再痛苦,而是像侦探破案一样,每一条输出都是线索,每一次修复都带来小小的成就感。

当然,等你对这些基本方法驾轻就熟后,也值得尝试 IDE 提供的断点调试、变量监视等工具——它们能让你更高效地完成同样的“侦探工作”。但无论工具如何变化,读错误信息和探测内部状态的核心思维永远不会过时。

posted @ 2026-05-10 22:20  AFewMoon  阅读(2)  评论(0)    收藏  举报