第6章 6.3 调试技巧:print/pdb/logging
🎯 开场 3 分钟:代码跑不动比写代码更让人崩溃
上一章我们学会了自定义异常,知道了怎么用 raise 主动抛出错误、用 raise ... from ... 把多个异常串成一条链。
但问题是:异常是跑完代码之后才告诉你的。
如果你写了一段代码,它运行了很久(比如 10 分钟),最后突然崩了——这时候你只知道「最后一行报错了」,但前面发生了什么、数据是怎么一步步变成这个样子的,对不起,异常不会告诉你。
这就好像你点了一份外卖,外卖小哥说「送到了」,但你开门发现没有外卖。你只能打电话给客服投诉,但客服也只能说「我们核实过了,确实送到了」。——到底是谁的问题?不知道。
调试(Debugging)就是你的「监控摄像头」,让你能看到代码执行过程中间发生了什么。
本章学完,你能:
1. 用 print 快速定位简单问题(5 秒定位)
2. 用 pdb 逐行深入排查复杂逻辑
3. 用 logging 记录关键数据,方便事后复盘
4. 用 assert 做防御性检查
🧱 基础 25 分钟:三种武器 + 一个保镖
武器一:print——最原始但最有效的「监控摄像头」
是什么:在代码里插入 print(),把变量值打印出来。
生活类比:想象你在玩「大家来找茬」,你在 A 图和 B 图之间来回切换,眼睛都不知道哪次是哪次。突然你灵机一动——在 A 图上用红笔做个标记「这是第一轮」,B 图上做个标记「这是第二轮」。现在你能追踪每次的状态了。print 就是你给代码做的「红笔标记」。
为什么用:简单粗暴,0 成本,5 秒出结果。
怎么用:
def calculate_total(price, quantity, discount):
print(f"输入参数:price={price}, quantity={quantity}, discount={discount}")
subtotal = price * quantity
print(f"小计(未打折):{subtotal}")
discounted = subtotal * (1 - discount)
print(f"打折后金额:{discounted}")
tax = discounted * 0.13
print(f"税费:{tax}")
total = discounted + tax
print(f"最终总价:{total}")
return total
calculate_total(100, 3, 0.1)
运行结果:
输入参数:price=100, quantity=3, discount=0.1
小计(未打折):300
打折后金额:270.0
税费:35.1
最终总价:305.1
什么时候用 print:
- 代码逻辑不复杂,变量不多
- 想快速知道某一步的值是什么
- 不需要频繁用,长期用还是要升级到 logging
武器二:pdb——程序员的「时间暂停器」
是什么:pdb 是 Python 内置的调试器(debugger),让你能在代码运行时暂停,然后一行一行地执行,随时查看任意变量的值。
生活类比:你有没有看过《奇异博士》?古一法师能让你「进入时间循环」,在那一瞬间你可以反复尝试、观察每一步的后果。pdb 就类似这个能力——让你的程序暂停,你可以「走进」代码里,一步一步看它怎么运行。
为什么用:print 只能看最终结果,pdb 能让你看到每一步发生了什么,特别适合排查「为什么这个 if 分支没进去」这种问题。
怎么用:
在可疑代码前插入 import pdb; pdb.set_trace():
def find_user(users, user_id):
for user in users:
if user["id"] == user_id:
return user
return None
user_list = [
{"id": 1, "name": "张三", "age": 25},
{"id": 2, "name": "李四", "age": 30},
{"id": 3, "name": "王五", "age": 28},
]
import pdb; pdb.set_trace() # 程序在这里暂停
result = find_user(user_list, 2)
print(f"找到的用户:{result}")
运行这段代码,终端会变成这样:
> /Users/apple/workspace/test.py(11)<module>()
-> result = find_user(user_list, 2)
(Pdb)
此时程序暂停在第 11 行,等待你输入指令。常用指令:
| 指令 | 缩写 | 作用 |
|---|---|---|
n (next) |
n |
执行下一行 |
s (step) |
s |
进入函数内部 |
p 变量名 |
p x |
打印变量值 |
l (list) |
l |
查看当前代码上下文 |
c (continue) |
c |
继续运行到下一个断点 |
q (quit) |
q |
退出调试 |
输入 p user_list 看看列表内容,输入 n 往下走,输入 s 进入 find_user 函数内部看它怎么遍历。

武器三:logging——专业的「监控录像系统」
是什么:logging 是 Python 的日志模块,能把程序运行过程中的信息记录下来,写入文件或控制台,方便事后查看。
生活类比:print 像是在墙上贴便签纸(「小王今天来了」「小李走了」),贴完你自己都不知道哪张是哪天的。logging 则是公司前台的专业访客登记系统——自动记录谁、什么时候、来干什么、什么时间离开,还能按严重程度分类(普通记录、警告、严重错误),还能写进文件保存几个月。
为什么用:
- print 只能打印到屏幕,logging 能写文件
- 能分级别(DEBUG/INFO/WARNING/ERROR),方便过滤
- 生产环境代码不能用 print,但 logging 是标配
怎么用:
import logging
# 配置日志:写入文件,级别是 DEBUG(最详细)
logging.basicConfig(
level=logging.DEBUG,
format='%(asctime)s - %(levelname)s - %(message)s',
filename='app.log',
filemode='w'
)
def divide(a, b):
logging.debug(f"divide 被调用:a={a}, b={b}")
if b == 0:
logging.error("除数不能为零!")
return None
result = a / b
logging.debug(f"divide 结果:{result}")
return result
divide(10, 2)
divide(10, 0)
运行后在 app.log 文件里看到:
2024-01-15 10:30:00 - DEBUG - divide 被调用:a=10, b=2
2024-01-15 10:30:00 - DEBUG - divide 结果:5.0
2024-01-15 10:30:00 - ERROR - 除数不能为零!
日志级别从小到大:DEBUG < INFO < WARNING < ERROR < CRITICAL。设置 level=logging.INFO 时,DEBUG 级别的日志会被过滤掉不显示。
保镖:assert——代码的「自动安检门」
是什么:assert 是断言语句,用来假设检查——「我假设这个条件为真,如果不为真,就报错」。
生活类比:就像地铁安检——你假设所有乘客都不会带易燃易爆品。如果有人带了(条件为假),安检门就会响(抛出 AssertionError)。
为什么用:在关键地方加一层检查,提前发现「不该发生的事」,让 bug 在源头就被抓住。
怎么用:
def withdraw(balance, amount):
assert amount > 0, "取款金额必须为正"
assert amount <= balance, f"余额不足!余额={balance},取款={amount}"
return balance - amount
print(withdraw(1000, 200)) # 正常:800
print(withdraw(1000, -50)) # 报错:AssertionError: 取款金额必须为正
print(withdraw(1000, 2000)) # 报错:AssertionError: 余额不足!余额=1000,取款=2000
注意:AssertionError 应该是「理论上不可能发生」的情况。如果用户输入可能是负数,应该用 if 判断 + 抛出自定义异常,而不是 assert(因为 Python 运行时会用 -O 参数跳过所有 assert)。

🔥 实战 35 分钟:三个递进项目
项目 1(5 分钟):用 print 调试「计算器异常」
场景:你写了一个计算器,但输入某些值时会得到奇怪的结果。
def calculate_bmi(weight_kg, height_m):
print(f"=== 开始计算 BMI ===")
print(f"输入:体重={weight_kg}kg, 身高={height_m}m")
bmi = weight_kg / (height_m ** 2)
print(f"计算得到 BMI={bmi}")
if bmi < 18.5:
category = "偏瘦"
elif bmi < 24:
category = "正常"
elif bmi < 28:
category = "偏胖"
else:
category = "肥胖"
print(f"分类结果:{category}")
return bmi, category
# 测试几个数据
calculate_bmi(70, 1.75)
calculate_bmi(0, 1.75) # 体重为 0 会怎样?
calculate_bmi(70, 0) # 身高为 0 会怎样?(除零错误!)
calculate_bmi(-70, 1.75) # 体重为负数会怎样?
预期输出:
=== 开始计算 BMI ===
输入:体重=70kg, 身高=1.75m
计算得到 BMI=22.857142857142858
分类结果:正常
=== 开始计算 BMI ===
输入:体重=0kg, 身高=1.75m
计算得到 BMI=0.0
分类结果:偏瘦
=== 开始计算 BMI ===
输入:体重=70kg, 身高=0m
分类结果:肥胖 # 等等,这明显是除零错误导致的inf
=== 开始计算 BMI ===
输入:体重=-70kg, 身高=1.75m
计算得到 BMI=-22.857142857142858
分类结果:偏瘦 # 负数体重居然被分类了,这不对
通过 print,你一眼就发现了两个问题:
1. 身高为 0 时 BMI 显示为「肥胖」而不是报错
2. 负数体重居然也能计算出 BMI
项目 2(15 分钟):用 logging 记录「用户登录日志」
场景:你需要排查为什么某些用户登录失败,需要分析登录日志。
完整代码:
import logging
from datetime import datetime
# 配置日志
logging.basicConfig(
level=logging.DEBUG,
format='%(asctime)s - %(levelname)s - %(message)s',
filename='login.log',
filemode='a'
)
# 模拟用户数据库(真实场景是从数据库读取)
USERS = {
"alice": {"password": "123456", "age": 25, "city": "北京"},
"bob": {"password": "password", "age": 30, "city": "上海"},
"charlie": {"password": "abc123", "age": 28, "city": "深圳"},
}
def log_login_attempt(username, success, reason=""):
if success:
logging.info(f"登录成功 - 用户:{username}")
else:
logging.warning(f"登录失败 - 用户:{username},原因:{reason}")
def validate_age(age):
if age < 0 or age > 150:
logging.error(f"年龄异常:{age}")
return False
return True
def login(username, password):
logging.debug(f"尝试登录 - 用户名:{username}")
# 检查用户是否存在
if username not in USERS:
log_login_attempt(username, False, "用户不存在")
return False, "用户不存在"
user = USERS[username]
# 检查密码
if user["password"] != password:
log_login_attempt(username, False, "密码错误")
return False, "密码错误"
# 检查年龄
if not validate_age(user["age"]):
log_login_attempt(username, False, "年龄数据异常")
return False, "年龄数据异常"
log_login_attempt(username, True)
return True, "登录成功"
# 模拟一系列登录尝试
test_cases = [
("alice", "123456"), # 正确密码
("alice", "wrong"), # 错误密码
("unknown", "123"), # 不存在的用户
("bob", "password"), # 正确
]
print("=== 模拟用户登录 ===")
for username, password in test_cases:
success, message = login(username, password)
print(f"结果:{username} -> {message}")
print("-" * 30)
print("\n=== 日志文件内容 ===")
with open('login.log', 'r') as f:
print(f.read())
预期输出:
=== 模拟用户登录 ===
结果:alice -> 登录成功
------------------------------
结果:alice -> 密码错误
------------------------------
结果:unknown -> 用户不存在
------------------------------
结果:bob -> 登录成功
------------------------------
=== 日志文件内容 ===
2024-01-15 14:30:00 - INFO - 登录成功 - 用户:alice
2024-01-15 14:30:00 - WARNING - 登录失败 - 用户:alice,原因:密码错误
2024-01-15 14:30:00 - WARNING - 登录失败 - 用户:unknown,原因:用户不存在
2024-01-15 14:30:00 - INFO - 登录成功 - 用户:bob
现在运维同事可以直接查看 login.log 文件,分析登录失败的原因,而不需要你在场。
项目 3(15 分钟):组合工具——「数据清洗脚本」
场景:你从 CSV 文件读取一批用户数据,需要清洗后输出。数据中可能有空值、异常值,需要记录每一步处理了多少条。
import logging
import csv
from datetime import datetime
# 配置日志,同时输出到控制台和文件
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(levelname)s - %(message)s',
filename='data_cleaning.log',
filemode='w',
handlers=[
logging.StreamHandler() # 同时输出到屏幕
]
)
def log_summary(message):
logging.info(message)
print(message) # 也在 print 一份,方便看
def clean_user_data(input_file, output_file):
log_summary("=" * 40)
log_summary("开始清洗用户数据")
log_summary(f"输入文件:{input_file}")
log_summary(f"输出文件:{output_file}")
log_summary("=" * 40)
total_count = 0
valid_count = 0
invalid_count = 0
# 模拟数据(真实场景是从 CSV 读取)
raw_data = [
{"name": "张三", "age": "25", "email": "zhangsan@example.com"},
{"name": "李四", "age": "", "email": "lisi@example.com"}, # 空年龄
{"name": "王五", "age": "abc", "email": "wangwu@example.com"}, # 非数字年龄
{"name": "赵六", "age": "-5", "email": "zhaoliu@example.com"}, # 负数年龄
{"name": "钱七", "age": "30", "email": "invalid-email"}, # 无效邮箱
{"name": "孙八", "age": "28", "email": "sunba@example.com"}, # 正常
]
cleaned_data = []
for record in raw_data:
total_count += 1
name = record["name"]
age_str = record["age"]
email = record["email"]
logging.debug(f"处理:{name}, age={age_str}, email={email}")
# 验证年龄
try:
age = int(age_str)
if age <= 0 or age > 120:
logging.warning(f"{name} 年龄异常:{age},跳过")
invalid_count += 1
continue
except ValueError:
logging.warning(f"{name} 年龄格式错误:{age_str},跳过")
invalid_count += 1
continue
# 验证邮箱(简单检查)
if "@" not in email or "." not in email:
logging.warning(f"{name} 邮箱格式错误:{email},跳过")
invalid_count += 1
continue
cleaned_data.append({"name": name, "age": age, "email": email})
valid_count += 1
logging.debug(f"{name} 验证通过")
# 写入输出文件(模拟)
log_summary(f"\n处理完成!")
log_summary(f"总记录数:{total_count}")
log_summary(f"有效记录:{valid_count}")
log_summary(f"无效记录:{invalid_count}")
log_summary(f"有效率:{valid_count/total_count*100:.1f}%")
return cleaned_data
# 运行
if __name__ == "__main__":
result = clean_user_data("users.csv", "users_cleaned.csv")
print("\n清洗后的数据:")
for user in result:
print(f" {user}")
预期输出:
========================================
开始清洗用户数据
输入文件:users.csv
输出文件:users_cleaned.csv
========================================
处理完成!
总记录数:6
有效记录:2
无效记录:4
有效率:33.3%
清洗后的数据:
{'name': '张三', 'age': 25, 'email': 'zhangsan@example.com'}
{'name': '孙八', 'age': 28, 'email': 'sunba@example.com'}
日志文件(data_cleaning.log)内容:
2024-01-15 15:00:00 - INFO - 开始清洗用户数据...
2024-01-15 15:00:00 - WARNING - 李四 年龄格式错误:,跳过
2024-01-15 15:00:00 - WARNING - 王五 年龄格式错误:abc,跳过
2024-01-15 15:00:00 - WARNING - 赵六 年龄异常:-5,跳过
2024-01-15 15:00:00 - WARNING - 钱七 邮箱格式错误:invalid-email,跳过
2024-01-15 15:00:00 - INFO - 处理完成!总记录数:6...
这个脚本把 print 的快速反馈、logging 的文件记录、if/try 的异常处理全都串起来了。
💪 进阶 20 分钟:常见坑 + 性能小贴士
坑 1:logging 在函数里配置了,但文件没生成
❌ 错误:
def process():
logging.basicConfig(level=logging.INFO, filename='app.log')
logging.info("开始处理")
# ...
✅ 正确:basicConfig 要放在所有 logging 调用之前,而且只调用一次。正确做法:
import logging
# 模块顶部配置,只配置一次
logging.basicConfig(level=logging.INFO, filename='app.log', format='%(message)s')
def process():
logging.info("开始处理")
坑 2:pdb.set_trace() 忘了删,上线后卡住
❌ 错误:直接提交代码:
def calculate():
result = expensive_operation()
import pdb; pdb.set_trace() # 忘了删!
return result
✅ 正确:用环境变量控制,或者用 breakpoint()(Python 3.7+):
def calculate():
result = expensive_operation()
# 只有设置了 DEBUG=1 才会停
if os.environ.get("DEBUG"):
import pdb; pdb.set_trace()
return result
坑 3:print 调试多线程代码,输出乱成一团
❌ 错误:多线程用 print,日志会混在一起:
Thread-1: 开始处理 Thread-2: 开始处理
Thread-1: 完成 Thread-2: 完成
✅ 正确:用 logging,它自带线程安全:
import logging
logging.basicConfig(format='%(threadName)s - %(message)s')
坑 4:assert 用于用户输入验证
❌ 错误:
def withdraw(balance, amount):
assert amount > 0 # Python -O 会跳过 assert!
✅ 正确:用 if 判断 + 异常:
def withdraw(balance, amount):
if amount <= 0:
raise ValueError("取款金额必须为正")
坑 5:logging 级别设错,看不到想要的日志
❌ 错误:设了 WARNING 但想看 DEBUG:
logging.basicConfig(level=logging.WARNING)
logging.debug("这是调试信息") # 不会输出!
✅ 正确:根据需求设对级别:
logging.basicConfig(level=logging.DEBUG) # 开发阶段开 DEBUG
性能小贴士:字符串拼接别用 + 号
❌ 慢:大量日志拼接用 +:
log_msg = "用户:" + username + ",年龄:" + str(age) + ",城市:" + city
✅ 快:用 f-string 或 logging 的格式参数:
logging.info("用户:%s,年龄:%d,城市:%s", username, age, city)
# 或者
logging.info(f"用户:{username},年龄:{age},城市:{city}")
logging 的参数化日志(%s/%d)在日志级别被过滤时不执行 f"{username}" 的拼接,性能更好。
✏️ 练习题 + 作业题
练习题(5 道,10 分钟)
练习 1(1 分钟):print 改错
- 输入:运行以下代码,发现 result 值为 None,找出原因
def add(a, b):
print(a + b)
result = add(3, 5)
print(f"结果是:{result}")
- 预期输出:
结果是:None - 提示:
print和return是不一样的
练习 2(2 分钟):加个 if 判断
- 输入:在练习 1 的基础上,在 add 函数里加一个判断,当 a 或 b 为负数时返回 "负数不支持"
- 预期输出:
8
-2
结果是:None
结果是:负数不支持
- 提示:先用
add(-1, -1)测试
练习 3(2 分钟):用 logging 记录温度转换
- 输入:写一个华氏度转摄氏度的函数,用 logging.DEBUG 记录转换过程
- 预期输出(查看日志):
公式:C = (F - 32) * 5/9
100°F = 37.78°C
50°F = 10.0°C
- 提示:
logging.info(f"公式:...")放函数外面,logging.debug放里面
练习 4(2 分钟):串起两个项目
- 输入:把「BMI 计算器」和「数据清洗」的思想结合,验证一组体重数据,过轻/过重/正常分别记录不同级别日志
- 预期输出:看到 INFO 记录正常体重,WARNING 记录过轻/过重
- 提示:正常 BMI 范围是 18.5-24
练习 5(3 分钟):分析 assert 报错
- 输入:运行以下代码,说明为什么会报错,以及如何修复
def get_first_item(items):
assert items, "列表不能为空"
return items[0]
print(get_first_item([]))
- 预期输出:
AssertionError: 列表不能为空 - 提示:这个报错说明了什么假设被打破了?应该用 try/except 还是改进代码逻辑?
作业题(30 分钟-2 小时)
作业:做一个「个人财务记账日志工具」
需求描述:记录每天的收入和支出,自动计算余额,所有操作都要记录日志。
功能点:
1. 初始化一个起始余额(比如 10000 元)
2. 支持「收入」和「支出」两种操作,每次操作记录金额和备注
3. 余额为负时记录 ERROR 级别日志(警告)
4. 每次操作后显示当前余额和最近 5 条交易记录
5. 程序结束时生成一份「财务报告」(统计总收入、总支出、结余)
加分项:
1. 支持从 JSON 文件加载和保存数据
2. 用 pdb 调试模式,运行时输入 DEBUG=1 python main.py 会停在关键步骤
验收标准:
- 能跑起来不报错
- 余额为负时能看到 ERROR 日志
- 最终输出的财务报告数字对得上
提交方式:评论区贴代码或 GitHub 链接
📚 总结 + 资源
本文学了 3 件事:
1. print 调试——最快的「贴便签」方式,适合 5 秒定位
2. pdb 断点——时间暂停器,能一步步走进代码里
3. logging 模块——专业的监控录像,生产环境必备
4. assert 断言——代码的自动安检门,防御性编程
延伸学习:
- 官方文档:Logging HOWTO——logging 高级用法
- 书籍:《Python Crash Course》第 2 版,第 10 章「调试技巧」
- 视频:B 站「莫烦 Python」的 logging 和 pdb 教程系列
互动钩子:你在排查 bug 时,是 print 派还是 pdb 派?有没有被 logging 的多行配置折磨过的经历?评论区聊聊,老粉优先回复!
下一章我们要学习 Python 的「面向对象」基础——class 和 __init__。你可能会问:为什么要把代码「包装」成对象?它跟之前的函数有什么区别?先卖个关子——想象一下,你之前学的函数像是一张张「说明书」,而下一章要教的 class,像是一个「会自己管理自己的机器人」。敬请期待。

评论(0)