41. 代码调试与测试
写代码只是成功的一半。另一半是确保你的代码能正确工作,并在它不能正确工作时找到问题。每个程序员,从新手到专家,写出的代码都会有 bug。区别在于,有经验的程序员已经发展出系统化的方法来定位并修复这些 bug。
在本章中,你将学习实用的调试(debugging)技巧,它们能帮助你理解你的代码实际上在做什么、快速定位问题,并验证你的代码是否按预期工作。这些技能会让你成为更自信、更高效的程序员。
41.1) 通过阅读 Traceback 定位错误(快速回顾)
正如我们在第 24 章学到的那样,当出现问题时,Python 会提供名为 traceback(tracebacks) 的详细错误信息。让我们回顾一下如何高效地阅读它们,因为这就是你调试时的第一道防线。
41.1.1) Traceback 的结构
当 Python 遇到错误时,它会准确告诉你问题发生的位置以及错误类型。下面是一个典型的 traceback:
def calculate_average(numbers):
total = sum(numbers)
count = len(numbers)
return total / count
def process_student_grades(grades):
average = calculate_average(grades)
return f"Average: {average:.1f}"
# 这会导致错误
student_grades = []
result = process_student_grades(student_grades)
print(result)输出:
Traceback (most recent call last):
File "grades.py", line 12, in <module>
result = process_student_grades(student_grades)
File "grades.py", line 7, in process_student_grades
average = calculate_average(grades)
File "grades.py", line 4, in calculate_average
return total / count
~~~~~~^~~~~~~
ZeroDivisionError: division by zero让我们拆解一下这个 traceback 告诉了我们什么:
从下往上阅读:
- 错误类型与信息(底部):
ZeroDivisionError: division by zero精确说明发生了什么 - 发生错误的确切代码行:第 4 行的
return total / count - 调用链 展示我们如何到达这里:从第 12 行开始,经过第 7 行,最终到第 4 行
41.1.2) 使用 Traceback 找到根本原因
Traceback 会展示 症状(错误发生的位置),但你需要找到 原因(为什么会发生)。让我们追踪一下这个问题:
# 错误发生在这里
return total / count # count 为 0
# 但真正的问题在这里
student_grades = [] # 传给函数的是空列表除以零是因为我们传入了一个空列表。Traceback 指向第 4 行,但修复需要更早发生——要么验证输入,要么处理空列表的情况:
def calculate_average(numbers):
"""返回 numbers 的平均值,如果列表为空则返回 None。"""
if not numbers:
return None
return sum(numbers) / len(numbers)
def process_student_grades(grades):
"""处理学生成绩并返回格式化字符串。"""
average = calculate_average(grades)
if average is None:
return "No grades to process"
return f"Average: {average:.1f}"
# 现在可以安全运行
student_grades = []
result = process_student_grades(student_grades)
print(result) # Output: No grades to process
# 这个也可以
student_grades = [85, 92, 78, 90]
result = process_student_grades(student_grades)
print(result) # Output: Average: 86.2关键要点:
- 从下往上阅读 traceback
- 错误位置(症状)不一定是根本原因
- 尽早验证输入,防止后续出错
- 使用防御式编程(
.get()、长度检查)让代码更安全
不同类型的错误会产生不同的 traceback,但阅读过程始终相同:从底部开始看发生了什么,然后向上追踪以理解你是如何到达那里的。如果你需要复习特定的异常类型,请回到第 24 章。
现在你已经能有效阅读 traceback 了,让我们学习如何在脑海中追踪代码,逐步理解它在做什么。
41.2) 在脑海中追踪代码执行
有时你会遇到一个 bug,但无法立刻运行代码——也许你在纸上审阅代码、阅读别人的 pull request,或者试图理解某个函数为什么行为异常。在这些情况下,心智执行(mental execution)——在脑海里逐行执行代码、跟踪每个变量发生了什么——就会变得非常宝贵。
即使是有经验的程序员也会经常使用这种技巧。在添加 print 语句或运行调试器之前,他们往往会先在脑海里追踪几次迭代,以形成一个关于问题可能出在哪里的假设。这比试错更快,并且能帮助你更深入地理解代码。
心智执行在以下情况特别有用:
- 阅读不熟悉的代码 来理解它做什么
- 审阅小函数(5-15 行)在运行前先过一遍
- 调试逻辑错误:代码能运行但结果不对
- 理解循环(loop)行为:模式并不直观时
- 代码评审:你无法轻易亲自运行代码时
对于更大或更复杂的代码,你会把心智追踪与本章后面会讲到的其他技术结合使用。但掌握这项技能会让你成为更高效的调试者。
41.2.1) 心智执行的流程
当你在脑海中执行代码时,你要像 Python 解释器一样,遵循与 Python 相同的规则。让我们用一个简单例子练习:
def find_maximum(numbers):
max_value = numbers[0]
for num in numbers:
if num > max_value:
max_value = num
return max_value
result = find_maximum([3, 7, 2, 9, 5])
print(result) # Output: 9下面是如何追踪这段代码:
逐步追踪:
Initial state:
numbers = [3, 7, 2, 9, 5]
max_value = 3 (numbers[0])
Iteration 1: num = 3
Check: 3 > 3? → False
max_value remains 3
Iteration 2: num = 7
Check: 7 > 3? → True
max_value = 7 ✓
Iteration 3: num = 2
Check: 2 > 7? → False
max_value remains 7
Iteration 4: num = 9
Check: 9 > 7? → True
max_value = 9 ✓
Iteration 5: num = 5
Check: 5 > 9? → False
max_value remains 9
Return: 941.2.2) 创建追踪表
对于更复杂的代码,创建一个 追踪表(trace table) 来展示变量如何随时间变化。这对循环和嵌套结构尤其有帮助:
def calculate_running_totals(numbers):
totals = []
running_sum = 0
for num in numbers:
running_sum += num
totals.append(running_sum)
return totals
result = calculate_running_totals([10, 20, 30, 40])
print(result) # Output: [10, 30, 60, 100]追踪表:
该表展示每一步变量的状态。注意 running_sum 在每次相加时如何从“之前”变为“之后”:
| 迭代 | num | running_sum(之前) | running_sum(之后) | totals |
|---|---|---|---|---|
| 开始 | - | 0 | 0 | [] |
| 1 | 10 | 0 | 10 | [10] |
| 2 | 20 | 10 | 30 | [10, 30] |
| 3 | 30 | 30 | 60 | [10, 30, 60] |
| 4 | 40 | 60 | 100 | [10, 30, 60, 100] |
创建这张表能帮助你清楚看到数据如何在代码中流动。如果输出与你期望的不一致,你就能精确定位到底是在哪一步出错的。
41.2.3) 追踪条件逻辑
条件语句需要特别注意到底执行了哪个分支。让我们追踪一个更复杂的例子:
def categorize_grade(score):
if score >= 90:
category = "Excellent"
bonus = 10
elif score >= 80:
category = "Good"
bonus = 5
elif score >= 70:
category = "Satisfactory"
bonus = 0
else:
category = "Needs Improvement"
bonus = 0
final_score = score + bonus
return category, final_score
result = categorize_grade(85)
print(result) # Output: ('Good', 90)对 score = 85 的心智追踪:
- 检查
85 >= 90→ False,跳过第一个代码块 - 检查
85 >= 80→ True,进入第二个代码块 - 设置
category = "Good"和bonus = 5 - 跳过剩余的 elif 与 else(已经匹配到一个分支)
- 计算
final_score = 85 + 5 = 90 - 返回
("Good", 90)
41.2.4) 追踪函数调用与返回
当函数调用其他函数时,你需要跟踪 调用栈(call stack)——函数调用的顺序以及各自的局部变量:
def calculate_tax(amount, rate):
tax = amount * rate
return tax
def calculate_total(price, quantity, tax_rate):
subtotal = price * quantity
tax = calculate_tax(subtotal, tax_rate)
total = subtotal + tax
return total
result = calculate_total(50, 3, 0.08)
print(f"Total: ${result:.2f}") # Output: Total: $162.00带调用栈的追踪:
┌─ calculate_total(50, 3, 0.08)
│ price = 50, quantity = 3, tax_rate = 0.08
│ subtotal = 150
│
│ ┌─ calculate_tax(150, 0.08)
│ │ amount = 150, rate = 0.08
│ │ tax = 12.0
│ │ return 12.0
│ └─
│
│ tax = 12.0 (from calculate_tax)
│ total = 162.0
│ return 162.0
└─
result = 162.0这种逐步追踪能清楚展示数据如何在函数之间流动。调试时,如果最终结果不正确,你可以回溯看看是哪个函数产生了错误的中间值。
心智追踪很强大,但对于复杂代码它可能很繁琐。在下一节中,我们将学习如何有策略地使用 print 语句来观察代码运行时到底发生了什么,这通常比单靠心智执行更快也更可靠。
41.3) 使用 Print 调试:f"{var=}" 与 repr()
虽然心智执行适用于小函数,但对更大或更复杂的代码就不现实了。当你不确定循环内部发生了什么,或者某个计算得到意外结果时,最快的调查方式往往是添加一些有策略的 print() 语句。
Print 调试有一些相对其他技巧的优势:
- 不需要特殊工具:在任何 Python 环境都能用
- 实现快:几秒钟就能添加一个 print
- 输出清晰:你会看到你要求显示的内容
- 容易移除:完成后把 print 删除即可
专业开发者也经常使用 print 调试——这不是“新手技巧”。让我们学习如何有效使用它。
41.3.1) 基础 Print 调试
最简单的调试方法是在代码的关键位置打印变量的值:
def process_order(items, discount_rate):
print(f"Starting process_order")
print(f"Items: {items}")
print(f"Discount rate: {discount_rate}")
subtotal = sum(item['price'] * item['quantity'] for item in items)
print(f"Subtotal: {subtotal}")
discount = subtotal * discount_rate
print(f"Discount amount: {discount}")
total = subtotal - discount
print(f"Final total: {total}")
return total
order_items = [
{'name': 'Book', 'price': 25.99, 'quantity': 2},
{'name': 'Pen', 'price': 3.50, 'quantity': 5}
]
result = process_order(order_items, 0.10)输出:
Starting process_order
Items: [{'name': 'Book', 'price': 25.99, 'quantity': 2}, {'name': 'Pen', 'price': 3.5, 'quantity': 5}]
Discount rate: 0.1
Subtotal: 69.47999999999999
Discount amount: 6.9479999999999995
Final total: 62.53199999999999这些 print 语句向你展示了执行流程以及每一步的数值。如果最终结果不对,你可以清楚看到到底是在哪一步计算偏离了轨道。
41.3.2) 使用 f"{var=}" 快速查看
Python 3.8 引入了一个方便的调试语法:f"{var=}"。它会同时打印变量名和变量值:
def calculate_compound_interest(principal, rate, years):
# 传统写法
print(f"principal: {principal}")
print(f"rate: {rate}")
print(f"years: {years}")
# 使用 f"{var=}" 的更简洁写法
print(f"{principal=}")
print(f"{rate=}")
print(f"{years=}")
# 不仅能用变量,也能用表达式
print(f"{principal * rate=}")
print(f"{(1 + rate) ** years=}")
amount = principal * (1 + rate) ** years
print(f"{amount=}")
return amount
result = calculate_compound_interest(1000, 0.05, 10)输出:
principal: 1000
rate: 0.05
years: 10
principal=1000
rate=0.05
years=10
principal * rate=50.0
(1 + rate) ** years=1.628894626777442
amount=1628.89462677744241.3.3) 使用 repr() 看清数据的真实形态
有时你打印出来看到的并不是你以为的那样。repr() 函数会显示对象的 精确表示,包括隐藏字符:
# 这些字符串打印时看起来一样
text1 = "Hello"
text2 = "Hello\n" # 末尾有一个换行符
print("Using print():")
print(f"text1: {text1}")
print(f"text2: {text2}")
print("\nUsing repr():")
print(f"text1: {repr(text1)}")
print(f"text2: {repr(text2)}")输出:
Using print():
text1: Hello
text2: Hello
Using repr():
text1: 'Hello'
text2: 'Hello\n'repr() 的输出显示 text2 末尾有一个隐藏的换行符。这在调试字符串处理时非常关键:
def clean_user_input():
# 用户输入常常带有隐藏的空白字符
username = input("Enter username: ") # 用户输入 "Alice "
print(f"Username with print(): {username}")
print(f"Username with repr(): {repr(username)}")
# 清理输入
cleaned = username.strip()
print(f"Cleaned with repr(): {repr(cleaned)}")
return cleaned如果用户输入 "Alice" 后跟一些空格并按下 Enter,你可能会看到:
输出:
Enter username: Alice
Username with print(): Alice
Username with repr(): 'Alice '
Cleaned with repr(): 'Alice'repr() 的输出揭示了 print() 不容易清晰显示的尾部空格。
何时使用 repr() 与 str():
repr() 是为开发者设计的——它展示“官方”的字符串表示,理论上可以用来重建该对象。str()(print() 默认使用)是为终端用户设计的——它展示可读、友好的版本。
在调试时,repr() 通常更有用,因为它揭示了数据的真实结构。
41.3.4) 有策略地放置 Print
不要把 print 语句到处乱撒。要把它们放在关键位置:
def calculate_shipping_cost(weight, distance, express=False):
print(f"=== calculate_shipping_cost called ===")
print(f"Input: {weight=}, {distance=}, {express=}")
# 计算基础费用
base_rate = 0.50
base_cost = weight * distance * base_rate
print(f"Calculated: {base_cost=}")
# 应用加急附加费
if express:
surcharge = base_cost * 0.50
print(f"Express surcharge: {surcharge=}")
total = base_cost + surcharge
else:
print("No express surcharge")
total = base_cost
print(f"Final: {total=}")
print(f"=== calculate_shipping_cost returning ===\n")
return total
# 测试不同场景
cost1 = calculate_shipping_cost(10, 500, express=True)
cost2 = calculate_shipping_cost(5, 200, express=False)输出:
=== calculate_shipping_cost called ===
Input: weight=10, distance=500, express=True
Calculated: base_cost=2500.0
Express surcharge: surcharge=1250.0
Final: total=3750.0
=== calculate_shipping_cost returning ===
=== calculate_shipping_cost called ===
Input: weight=5, distance=200, express=False
Calculated: base_cost=500.0
No express surcharge
Final: total=500.0
=== calculate_shipping_cost returning ===清晰的标记(===)和有组织的输出让你更容易跟踪执行流程。
41.3.5) 移除调试 Print
一旦你找到并修复了 bug,记得移除调试用的 print。这里有一些策略:
策略 1:使用明显的前缀
# 便于用搜索/替换快速找到并删除
print(f"DEBUG: {total=}")
print(f"DEBUG: {items=}")策略 2:使用调试开关
DEBUG = True
def calculate_total(items):
if DEBUG:
print(f"Processing {len(items)} items")
total = sum(item['price'] for item in items)
if DEBUG:
print(f"{total=}")
return total
# 一次性关闭所有调试输出
DEBUG = False策略 3:注释掉但保留
def process_data(data):
# print(f"DEBUG: {data=}") # 便于未来调试
result = transform(data)
# print(f"DEBUG: {result=}")
return result如果你需要更复杂、可保留在生产代码中的日志记录,Python 有一个 logging 模块,但在开发期间做快速调试时,简单的 print 语句非常合适。
Print 调试展示的是变量的值,但有时你需要理解对象的 结构——它有什么方法、是什么类型、能做什么。下一节我们将学习如何使用 type() 和 dir() 来检查对象。
41.4) 检查对象:type() 与 dir()
Print 调试能让你看到变量的 值,但有时问题不在值,而在你正在处理的对象 类型。你可能以为拿到的是列表(list)却收到字符串,或者你在使用一个不熟悉的对象却不知道它支持哪些方法。
Python 提供了内置工具来检查对象:type() 告诉你对象是什么类型,dir() 展示它支持哪些操作。在以下场景中,这些函数非常关键:
- 调试与类型相关的错误(TypeError、AttributeError)
- 使用不熟悉的库或 API
- 理解第三方代码返回的对象
- 验证你的代码是否接收到了预期的类型
让我们学习如何有效使用这些检查工具。
41.4.1) 使用 type() 识别对象类型
type() 函数会准确告诉你对象的类型。在调试与类型相关的错误时,这非常关键:
def process_data(data):
print(f"Received data: {data}")
print(f"Data type: {type(data)}")
if isinstance(data, list):
print("Processing as list")
return sum(data)
elif isinstance(data, dict):
print("Processing as dictionary")
return sum(data.values())
else:
print("Unexpected type!")
return None
# 用不同类型测试
result1 = process_data([10, 20, 30])
print(f"Result: {result1}\n")
result2 = process_data({'a': 10, 'b': 20, 'c': 30})
print(f"Result: {result2}\n")
result3 = process_data("123")
print(f"Result: {result3}")输出:
Received data: [10, 20, 30]
Data type: <class 'list'>
Processing as list
Result: 60
Received data: {'a': 10, 'b': 20, 'c': 30}
Data type: <class 'dict'>
Processing as dictionary
Result: 60
Received data: 123
Data type: <class 'str'>
Unexpected type!
Result: None41.4.2) 调试类型混淆
类型混淆是 bug 的常见来源,尤其当函数可能从多个来源接收数据时——用户输入、读取文件、API 响应或其他函数。你可能期望得到一个数字列表,却不小心拿到了字符串,或者期望字典却得到列表。
使用 type() 能帮助你识别是否拿到了错误的类型。通过在函数开头打印类型,你可以在它们导致更深处令人困惑的错误信息之前,就立刻发现类型不匹配:
def calculate_average(numbers):
print(f"{type(numbers)=}")
print(f"{numbers=}") # 展示我们实际拿到的内容
# 如果 numbers 不是数字列表,这里会失败
total = sum(numbers)
count = len(numbers)
return total / count
# 常见错误:忘了把字符串转换为列表
scores = "85" # 应该是 [85] 或者直接是 85
try:
avg = calculate_average(scores)
print(f"Average: {avg}")
except TypeError as e:
print(f"TypeError: {e}")
print(f"Expected list of numbers, got {type(scores)}")
print(f"The string contains: {repr(scores)}")输出:
type(numbers)=<class 'str'>
numbers='85'
TypeError: unsupported operand type(s) for +: 'int' and 'str'
Expected list of numbers, got <class 'str'>
The string contains: '85'type() 检查立即揭示了问题:我们传入了字符串,但我们需要的是列表。没有这些调试输出的话,你可能会花时间去理解为什么 sum() 失败,而真正的问题是:进入函数的数据类型本身就错了。
41.4.3) 使用 dir() 发现可用方法
当你处理不熟悉的对象时——无论来自你正在学习的库、API 响应,还是 Python 内置类型——你常常需要知道:“我能对这个对象做什么?”dir() 函数通过列出对象可用的全部属性与方法来回答这个问题。
它在以下场景中特别有价值:
- 你在探索一个新库,想看看对象提供哪些方法
- 你从第三方代码拿到了一个对象,需要理解它的能力
- 你忘了想用的方法的确切名称
- 你在调试时,想确认对象是否具有你期望的方法
让我们看看字符串有哪些方法:
# 探索字符串有哪些方法
text = "Python Programming"
print(f"Type: {type(text)}")
print(f"\nAvailable string methods (showing first 10):")
methods = [m for m in dir(text) if not m.startswith('_')]
for method in methods[:10]: # Show first 10
print(f" {method}")
print(f" ... and {len(methods) - 10} more")输出:
Type: <class 'str'>
Available string methods (showing first 10):
capitalize
casefold
center
count
encode
endswith
expandtabs
find
format
format_map
... and 37 more现在你可以看到字符串可用的所有操作。如果你不确定字符串是否有 count 方法或 endswith 方法,dir() 会告诉你它们确实存在。然后你可以使用 Python 的 help() 函数了解任何特定方法的更多信息:
# 了解某个方法的更多信息
help(text.count)它会显示 count 方法的文档:
Help on built-in function count:
count(sub[, start[, end]], /) method of builtins.str instance
Return the number of non-overlapping occurrences of substring sub in string S[start:end].
Optional arguments start and end are interpreted as in slice notation.dir() 函数就像 Python 内置的文档——它展示了你手上这个对象可以做什么。
41.4.4) 检查自定义对象
当你使用自定义类时,type() 和 dir() 能帮助你理解你正在处理什么。此外,Python 还提供 hasattr(),用于在访问某个属性之前检查对象是否具备该属性——这能避免 AttributeError 异常。
class Student:
def __init__(self, name, grade):
self.name = name
self.grade = grade
def get_status(self):
return "Passing" if self.grade >= 60 else "Failing"
student = Student("Alice", 85)
print(f"Object type: {type(student)}")
print(f"\nAvailable attributes and methods:")
for attr in dir(student):
if not attr.startswith('_'):
print(f" {attr}")
# 检查特定属性是否存在
print(f"\nHas 'name' attribute: {hasattr(student, 'name')}")
print(f"Has 'age' attribute: {hasattr(student, 'age')}")
print(f"Has 'get_status' method: {hasattr(student, 'get_status')}")
# 现在我们可以安全访问我们确定存在的属性
if hasattr(student, 'name'):
print(f"\nStudent name: {student.name}")
else:
print("\nNo name attribute found")
if hasattr(student, 'get_status'):
print(f"Status: {student.get_status()}")
else:
print("No get_status method found")
# 这可以防止出现如下错误:
# print(student.age) # Would raise AttributeError!输出:
Object type: <class '__main__.Student'>
Available attributes and methods:
get_status
grade
name
Has 'name' attribute: True
Has 'age' attribute: False
Has 'get_status' method: True
Student name: Alice
Status: Passinghasattr() 函数对于编写防御式代码至关重要——也就是在执行操作前先检查是否安全的代码。该函数在属性存在时返回 True,不存在时返回 False——这样你就能在尝试访问属性之前做出判断。这在处理外部库对象或用户输入时尤其重要,因为你无法保证会有哪些属性存在。
41.4.5) 使用 getattr() 安全访问属性
当你不确定某个属性是否存在时,使用带默认值的 getattr():
def display_student_info(student):
"""即使缺少某些属性,也能安全显示学生信息。"""
print(f"Type: {type(student)}")
# 使用默认值进行安全属性访问
name = getattr(student, 'name', 'Unknown')
grade = getattr(student, 'grade', 0)
age = getattr(student, 'age', 'Not specified')
print(f"Name: {name}")
print(f"Grade: {grade}")
print(f"Age: {age}")
# 调用方法前先检查方法是否存在
if hasattr(student, 'get_status'):
status = student.get_status()
print(f"Status: {status}")
# 使用上面相同的 Student 类
student = Student("Bob", 72)
display_student_info(student)输出:
Type: <class '__main__.Student'>
Name: Bob
Grade: 72
Age: Not specified
Status: Passing这种做法能在处理可能没有所有预期属性的对象时避免 AttributeError 异常。getattr() 在以下场景特别有用:
- 处理来自外部 API 的对象(可能有不同版本)
- 在你自己的类中处理可选属性
- 构建能优雅处理缺失数据的防御式代码
理解你拿到的对象是什么类型、支持哪些方法,对调试至关重要。但有时你需要验证的不仅是代码能运行,还要验证它产生了 正确的结果。下一节我们将学习如何使用 assert 语句来测试你的假设,并尽早捕获 bug。
41.5) 使用 assert 语句进行测试
我们已经学习了在出问题时如何调试代码——阅读 traceback、在脑海中追踪执行、使用 print 语句、以及检查对象。但还有一种比 bug 出现后再修复更好的方式:通过测试在一开始就预防它们。
assert 语句是 Python 最简单的测试工具。它让你通过在关键点检查假设来验证代码行为是否正确。当断言失败时,Python 会立即准确告诉你哪里出了问题以及发生的位置,这使得你更容易在早期捕获 bug——通常在你运行主程序之前就能发现。
断言在以下方面特别有价值:
- 验证函数(function)是否产生预期结果
- 检查输入是否满足你的要求
- 测试可能破坏代码的边界情况
- 记录你的代码依赖的假设
把断言看作 自动化检查,它会持续验证你的代码是否按预期工作。让我们学习如何有效使用它们。
41.5.1) assert 做了什么
assert 语句会检查一个条件是否为真。如果条件为真,什么也不会发生——代码正常继续执行。如果为假,Python 会抛出 AssertionError 并停止执行。
语法:
assert condition, "Optional error message"condition:任何会求值为 True 或 False 的表达式"Optional error message":断言失败时显示的提示文本
下面是它在实践中的工作方式:
# 简单断言
x = 10
assert x > 0 # 静默通过(x 确实 > 0)
assert x < 5 # 失败!抛出 AssertionError
# 带错误信息(更有帮助!)
assert x > 0, f"x must be positive, got {x}"
assert x < 5, f"x must be less than 5, got {x}" # 失败并给出清晰信息现在让我们在一个真实函数中看看断言:
def calculate_discount(price, discount_percent):
# 验证输入有效
assert price >= 0, "Price cannot be negative"
assert 0 <= discount_percent <= 100, "Discount must be between 0 and 100"
discount_amount = price * (discount_percent / 100)
final_price = price - discount_amount
# 验证输出合理
assert final_price >= 0, "Final price cannot be negative"
return final_price
# 有效输入可正常工作
result = calculate_discount(100, 20)
print(f"Price after 20% discount: ${result}") # Output: Price after 20% discount: $80.0
# 无效输入会触发断言
try:
result = calculate_discount(-50, 20)
except AssertionError as e:
print(f"Assertion failed: {e}") # Output: Assertion failed: Price cannot be negative
try:
result = calculate_discount(100, 150)
except AssertionError as e:
print(f"Assertion failed: {e}") # Output: Assertion failed: Discount must be between 0 and 10041.5.2) 使用断言验证函数行为
断言非常适合测试函数是否产生预期结果:
def calculate_average(numbers):
if not numbers:
return 0.0
return sum(numbers) / len(numbers)
# 用多种输入测试
result = calculate_average([10, 20, 30])
assert result == 20.0, f"Expected 20.0, got {result}"
print(f"Test 1 passed: average of [10, 20, 30] = {result}")
result = calculate_average([5, 5, 5, 5])
assert result == 5.0, f"Expected 5.0, got {result}"
print(f"Test 2 passed: average of [5, 5, 5, 5] = {result}")
result = calculate_average([])
assert result == 0.0, f"Expected 0.0 for empty list, got {result}"
print(f"Test 3 passed: average of [] = {result}")
result = calculate_average([100])
assert result == 100.0, f"Expected 100.0, got {result}"
print(f"Test 4 passed: average of [100] = {result}")输出:
Test 1 passed: average of [10, 20, 30] = 20.0
Test 2 passed: average of [5, 5, 5, 5] = 5.0
Test 3 passed: average of [] = 0.0
Test 4 passed: average of [100] = 100.0如果任何断言失败,你会立刻知道是哪一个测试用例暴露了问题。
41.5.3) 测试边界情况
边界情况是处在函数可处理范围边界上的输入。测试这些输入能发现普通输入可能遗漏的 bug:
def get_first_and_last(items):
"""从序列中返回第一个和最后一个元素。"""
assert len(items) > 0, "Cannot get first and last from empty sequence"
return items[0], items[-1]
# 测试正常情况
result = get_first_and_last([1, 2, 3, 4, 5])
assert result == (1, 5), f"Expected (1, 5), got {result}"
print(f"Normal case: {result}")
# 测试边界情况:单个元素
result = get_first_and_last([42])
assert result == (42, 42), f"Expected (42, 42), got {result}"
print(f"Single item: {result}")
# 测试边界情况:两个元素
result = get_first_and_last([10, 20])
assert result == (10, 20), f"Expected (10, 20), got {result}"
print(f"Two items: {result}")
# 测试边界情况:空序列(应该失败)
try:
result = get_first_and_last([])
print("ERROR: Should have raised AssertionError for empty list")
except AssertionError as e:
print(f"Empty list correctly rejected: {e}")输出:
Normal case: (1, 5)
Single item: (42, 42)
Two items: (10, 20)
Empty list correctly rejected: Cannot get first and last from empty sequence41.5.4) 测试数据转换
当函数对数据进行转换时,用断言验证转换是否正确:
def remove_duplicates(items):
"""在保留顺序的同时去除重复项。"""
seen = set()
result = []
for item in items:
if item not in seen:
seen.add(item)
result.append(item)
return result
# 测试基础去重
input_data = [1, 2, 2, 3, 1, 4, 3, 5]
result = remove_duplicates(input_data)
expected = [1, 2, 3, 4, 5]
assert result == expected, f"Expected {expected}, got {result}"
print(f"Test 1 passed: {input_data} -> {result}")
# 测试顺序是否保留
input_data = [3, 1, 2, 1, 3, 2]
result = remove_duplicates(input_data)
expected = [3, 1, 2]
assert result == expected, f"Expected {expected}, got {result}"
print(f"Test 2 passed: {input_data} -> {result}")
# 测试没有重复项的情况
input_data = [1, 2, 3, 4, 5]
result = remove_duplicates(input_data)
assert result == input_data, f"Expected {input_data}, got {result}"
print(f"Test 3 passed: {input_data} -> {result}")
# 测试全部重复的情况
input_data = [5, 5, 5, 5]
result = remove_duplicates(input_data)
expected = [5]
assert result == expected, f"Expected {expected}, got {result}"
print(f"Test 4 passed: {input_data} -> {result}")输出:
Test 1 passed: [1, 2, 2, 3, 1, 4, 3, 5] -> [1, 2, 3, 4, 5]
Test 2 passed: [3, 1, 2, 1, 3, 2] -> [3, 1, 2]
Test 3 passed: [1, 2, 3, 4, 5] -> [1, 2, 3, 4, 5]
Test 4 passed: [5, 5, 5, 5] -> [5]41.5.5) 创建一个简单的测试函数
随着代码增长,把 assert 语句散落在主代码中会变得杂乱且难以管理。更好的方法是 把测试组织到专门的测试函数中。这能将测试代码与生产代码分离,并让你更容易一次运行所有测试。
为什么要使用专门的测试函数?
- 组织性:某个函数的所有测试集中在一个地方
- 可复用:任何时候修改代码都可以运行测试
- 文档性:测试展示了函数应该如何行为
- 调试性:测试失败时,你立刻知道是哪种场景出错
- 开发流程:先测试,再实现或修复代码
让我们看看实际做法:
def calculate_grade(score):
"""把数值分数转换为字母等级。"""
if score >= 90:
return 'A'
elif score >= 80:
return 'B'
elif score >= 70:
return 'C'
elif score >= 60:
return 'D'
else:
return 'F'
def test_calculate_grade():
"""测试 calculate_grade 函数。
该函数测试所有预期行为:
- 每个等级区间(A, B, C, D, F)
- 边界值(90, 80, 70, 60)
- 边界情况(每个边界值的下方一点)
"""
print("Testing calculate_grade...")
# 测试 A 等级
assert calculate_grade(95) == 'A', "95 should be A"
assert calculate_grade(90) == 'A', "90 should be A (boundary)"
print(" ✓ A grades: passed")
# 测试 B 等级
assert calculate_grade(85) == 'B', "85 should be B"
assert calculate_grade(80) == 'B', "80 should be B (boundary)"
print(" ✓ B grades: passed")
# 测试 C 等级
assert calculate_grade(75) == 'C', "75 should be C"
assert calculate_grade(70) == 'C', "70 should be C (boundary)"
print(" ✓ C grades: passed")
# 测试 D 等级
assert calculate_grade(65) == 'D', "65 should be D"
assert calculate_grade(60) == 'D', "60 should be D (boundary)"
print(" ✓ D grades: passed")
# 测试 F 等级
assert calculate_grade(55) == 'F', "55 should be F"
assert calculate_grade(0) == 'F', "0 should be F"
print(" ✓ F grades: passed")
# 测试边界边缘情况(每个阈值下方 1 分)
assert calculate_grade(89) == 'B', "89 should be B (just below A)"
assert calculate_grade(79) == 'C', "79 should be C (just below B)"
assert calculate_grade(69) == 'D', "69 should be D (just below C)"
assert calculate_grade(59) == 'F', "59 should be F (just below D)"
print(" ✓ Boundary cases: passed")
print("All tests passed! ✓\n")
# 运行测试
test_calculate_grade()
# 现在你可以放心使用该函数
student_score = 87
grade = calculate_grade(student_score)
print(f"Student score {student_score} = Grade {grade}")输出:
Testing calculate_grade...
✓ A grades: passed
✓ B grades: passed
✓ C grades: passed
✓ D grades: passed
✓ F grades: passed
✓ Boundary cases: passed
All tests passed! ✓
Student score 87 = Grade B这种方法的好处:
- 清晰的测试组织:你可以一眼看到所有测试用例
- 易于运行:每次修改函数后只要调用
test_calculate_grade()即可 - 渐进式反馈:函数运行时你能看到哪些测试组通过了
- 自解释:测试函数准确展示了
calculate_grade()应该如何工作
何时运行测试:
- 修改之前:确保当前代码的测试能通过
- 修改之后:验证你没有破坏任何东西
- 添加功能时:先为新功能写测试(测试驱动开发)
- 修复 bug 时:先添加一个能复现 bug 的测试,再修复它
这个简单模式——用断言写测试函数——是专业软件测试的基础。随着你进步,你会学习 pytest、unittest 等测试框架,但核心思想不变:编写函数来验证你的代码确实能正确工作。
41.5.6) 何时使用断言 vs 异常
理解何时使用断言与何时使用异常至关重要。它们的目的本质不同:
断言用于在开发阶段发现 bug:
- 它们检查一些如果你的代码写对了就 绝不 应为假的事情
- 它们验证你自己代码内部的假设与逻辑
- 它们帮助你在编写与测试代码时捕获编程错误
- 例子:“在我的函数执行到这里时,这个列表不可能为空”
- 例子:“这个列表中的所有项都应该是整数,因为我刚刚过滤过它们”
异常用于处理正常运行中可能发生的错误:
- 它们处理你无法控制的外部条件
- 它们处理即使代码完美也可能发生的情况
- 它们允许程序优雅恢复或给出清晰的失败信息
- 例子:用户输入了文本,但你期望的是数字
- 例子:你尝试打开的文件不存在
- 例子:网络请求超时
关键区别:断言表示“这不可能发生”,而异常表示“这可能发生,我们需要处理它”。
让我们看一个实际例子:
# 示例 1:用于 USER INPUT 的函数
# 用户可能输入任何内容,包括 0
def calculate_user_ratio(numerator, denominator):
"""根据用户提供的数字计算比值。"""
# 用户可能输入 0,因此使用异常处理
if denominator == 0:
raise ValueError("Denominator cannot be zero")
return numerator / denominator
# 示例 2:内部计算场景,0 应该不可能出现
def calculate_percentage(part, total):
"""计算 'part' 占 'total' 的百分比。"""
# 在调用这里之前我们已经验证过 total > 0
# 如果 total 为 0,那是我们代码里的编程 bug
assert total > 0, "total must be positive - check calling code"
return (part / total) * 100每种方式应处理的更多例子:
| 情况 | 使用断言 | 使用异常 |
|---|---|---|
| 用户输入无效 | ❌ 否 | ✅ 是 |
| 文件不存在 | ❌ 否 | ✅ 是 |
| 网络请求失败 | ❌ 否 | ✅ 是 |
| 函数从你自己的代码拿到了错误的参数类型 | ✅ 是 | ❌ 否 |
| 列表按理应有元素,但因逻辑错误而为空 | ✅ 是 | ❌ 否 |
| 因 bug 导致数据结构处于异常状态 | ✅ 是 | ❌ 否 |
| 数据库连接失败 | ❌ 否 | ✅ 是 |
| API 返回了意外格式 | ❌ 否 | ✅ 是 |
| 你的算法产生了数学上不可能的结果 | ✅ 是 | ❌ 否 |
断言的关键限制:
当 Python 以优化模式运行时,断言可以被完全禁用:
python -O script.py # All assert statements are ignored!当断言被禁用时,它们会直接消失——Python 根本不会检查它们。这意味着:
- ❌ 永远不要 用断言验证用户输入
- ❌ 永远不要 用断言做安全检查
- ❌ 永远不要 用断言做任何在生产环境必须始终生效的事情
# DANGEROUS - DON'T DO THIS:
def process_payment(amount):
assert amount > 0, "Amount must be positive" # WRONG! Gets disabled with -O
# Process payment...
# CORRECT - DO THIS:
def process_payment(amount):
if amount <= 0:
raise ValueError("Amount must be positive") # Always checked!
# Process payment...总结:
-
断言 = “我在开发阶段检查我自己的代码是否有 bug”
- 理解为:“如果我写对了,这就不可能发生”
- 它们帮助你发现逻辑上的错误
-
异常 = “我在处理现实世界中确实可能发生的情况”
- 理解为:“正常使用时可能发生,我需要应对它”
- 它们帮助程序处理不可预测的情况
断言是开发与调试工具,帮助你写出正确的代码。异常是生产工具,帮助你的程序应对用户输入、文件系统、网络等你无法控制的外部因素带来的复杂现实。
你现在已经学习了贯穿整个编程旅程都会用到的关键调试与测试技巧:
- 阅读 traceback(tracebacks),快速定位错误发生的位置
- 在脑海中追踪代码,逐步理解代码行为
- 有策略地使用 print 语句,观察运行时的数值与流程
- 用
type()与dir()检查对象,理解你正在处理的内容 - 用断言(assertions) 测试代码,验证结果并尽早捕获 bug
这些技能共同构成一套完整的调试工具箱。当你遇到问题时:
- 读取 traceback,找到失败位置
- 使用 print 调试或心智追踪理解原因
- 当你不确定对象能做什么时,用 type/dir 检查
- 编写断言,防止 bug 再次出现
通过练习,你会逐渐形成直觉,知道在不同情况下该用哪种技巧。记住:每个程序员都会调试代码——区别在于,有经验的程序员会系统且高效地调试。这些技巧会让你成为其中之一。