引言
这是课程 01《Python 编程基础与进阶》第十章「阶段测验与查漏补缺」的第 4 篇。前三篇的分阶段测验(第 1-3 章、第 4-7 章、第 8-9 章)检验的是「学过的知识你会不会用」,这一篇换个角度看同一批知识:把散落在前九章正文里的易错点收拢成一张 25 条的清单。编程错误的分布并不均匀——反复出现的 bug 往往来自少数几个心智模型误解,比如引用语义、可变性边界、正则的「字符 vs 模式」、以及 I/O 的隐式约定。测验做错几道题不可怕,可怕的是同一类错误在后续十个章节里反复发生。本文按五个维度整理 25 个经典陷阱,每个陷阱给出三件套:一句现象、最小复现代码、一句修复。所有代码都只用前九章学过的语法,本机 Python 3.12 实测通过,你可以逐段复制运行。
概念与原理
为什么把陷阱分成这五类
25 个坑不是按出现章节排列,而是按「弄错的心智模型」归类,因为同一个模型错误会在多个章节反复产出 bug:
- 变量与引用:Python 的对象模型核心。变量是名字不是盒子,赋值是绑定不是拷贝;可变与不可变决定「修改」的真实含义。这一类错了,函数参数、容器、文件全都会连坐。
- 流程与函数:作用域查找规则(LEGB)、默认参数求值时机、循环与迭代器的生命周期。这一类错了,程序的「控制流」与预期不符,而且常常不报错。
- 字符串与正则:字符串不可变的推论、方法返回值语义、正则引擎「字符 vs 模式」的两套世界观。这一类错了,得到的是悄悄变形的数据。
- 容器:哈希表的硬约束(键可哈希、遍历时大小不变)、可变方法的返回值约定。这一类错了,要么当场崩溃,要么数据被「半改」。
- 文件与工程:I/O 的隐式约定(编码、缓冲、关闭时机)与异常处理的边界。这一类错了,问题延迟爆发且难以复现。
现象层与机制层
每个陷阱都有两个层次:现象(可复现的输出)与机制(为什么会有这个输出)。背现象是脆弱的——同一段 is 比较在不同实现、不同优化器下可能给出不同结果;懂机制才能在情况变化时依然正确。本文每个复现代码都刻意保持最小,目的就是让你能亲手跑一遍、亲眼看到现象,再用前九章的原理解释它。请把「现象 + 机制」绑在一起记忆,而不是只记答案。
操作与实现
操作与实现
阅读下面二十五个陷阱时,请带着两个问题:“这个现象我遇到过吗”和”它背后的机制是什么”。前九章的每篇文章都给出了机制层面的解释——比如列表拷贝讲的是”槽位里是引用”,字符串编码讲的是”磁盘只有字节”,正则贪婪讲的是”回溯引擎先吞后吐”。陷阱清单的价值不在于背诵现象,而在于把这些现象和机制对上号:遇到一个报错,能立刻联想到它属于哪一类(引用?编码?容器?),然后沿着机制去排查,而不是打开搜索引擎随机试。
另一个值得注意的规律是:二十五个陷阱中有相当一部分是”看起来对”的代码——它们不报错、不崩溃,只是悄悄给出错误结果。浮点比较、浅拷贝嵌套修改、is 与 == 混淆都属于这一类。这类陷阱比报错更难发现,因为程序”正常运行”,直到某天数据对不上。对付它们的通用手段,我们在调试训练场才会系统展开,但这里可以先记住一个原则:凡是涉及”相等/拷贝/编码/时间”的代码,先写一行断言或打印去验证,再谈效率。断言是第七章讲过的工具,用它给关键不变量上锁,是工程化的第一课。
先说明使用方法。这份清单建议走三遍:第一遍通读,把 25 个现象的「一句现象」读顺,与自己的经验对号入座;第二遍动手,把五个代码块逐段复制运行,亲眼确认输出与文中给出的结果逐字一致;第三遍合上电脑复述,对每个陷阱能说出「现象 + 机制 + 修复」三句话。三遍之后,这张表才真正属于你,而不是躺在收藏夹里吃灰。下面按组展开。
第一组:变量与引用(5 个)
is与==混用:现象是int("256") is int("256")为 True、换成 257 却是 False;修复是一律用==判断值,is只留给None。- 以为赋值是拷贝:现象是
ys = xs之后ys.append(4)让 xs 也多了 4;修复是明确调用xs.copy()或切片取得独立副本。 - 试图修改不可变对象:现象是
s[0] = "H"抛 TypeError;修复是拼接生成新对象再重新绑定。 - 链式赋值配可变对象:现象是
a = b = []后往 a 里 append,b 被连坐;修复是a = []、b = []分开写,把共享意图显式化。 - 浅拷贝只隔离外层:现象是
copy.copy之后改内层列表,原字典照样被改动;修复是按需用copy.deepcopy隔离整棵对象树。
# 陷阱一:is 与 == 混用
a = int("256")
b = int("256")
print(a is b) # True:命中 -5..256 小整数缓存
x = int("257")
y = int("257")
print(x is y, x == y) # False True:缓存之外每次新建,值仍相等
# 陷阱二:以为赋值是拷贝
xs = [1, 2, 3]
ys = xs
ys.append(4)
print(xs) # [1, 2, 3, 4]:一个对象两个名字
xs = [1, 2, 3]
ys = xs.copy() # 修复:显式拷贝
ys.append(4)
print(xs, ys)
# 陷阱三:试图修改不可变对象
s = "hello"
try:
s[0] = "H"
except TypeError as e:
print("TypeError:", e)
print(s + "!") # 修复:生成新对象再重绑
# 陷阱四:链式赋值共享
a = b = []
a.append(1)
print(b) # [1]:a、b 指向同一个列表
a = []
b = [] # 修复:各建各的
a.append(1)
print(b)
# 陷阱五:浅拷贝只隔离外层
import copy
data = {"scores": [88, 92]}
shallow = copy.copy(data)
shallow["scores"].append(95)
print(data["scores"]) # [88, 92, 95]:内层仍共享
data = {"scores": [88, 92]}
deep = copy.deepcopy(data) # 修复:整棵树隔离
deep["scores"].append(95)
print(data["scores"])
True
False True
[1, 2, 3, 4]
[1, 2, 3] [1, 2, 3, 4]
TypeError: 'str' object does not support item assignment
hello!
[1]
[]
[88, 92, 95]
[88, 92]
第二组:流程与函数(5 个)
- 可变默认参数:现象是
def add_item(item, bucket=[])第二次调用时桶里还躺着第一次的苹果;修复是默认值写None、函数体内按需新建。 - 闭包延迟绑定:现象是列表推导式装三个
lambda: i,调用起来全是 2;修复是用默认参数lambda i=i: i在定义时捕获当前值。 - 函数内给同名全局变量赋值:现象是
count = count + 1抛 UnboundLocalError;修复是显式声明global count。 - 某分支漏写 return:现象是求最大值的函数返回 None、程序不报错;修复是保证每个出口都显式 return。
- 遍历列表时删除元素:现象是
for循环里remove掉所有 2,结果还剩一个 2;修复是先收集、后重建(或用推导式过滤)。
# 陷阱一:可变默认参数在定义时求值一次,之后每次调用复用
def add_item(item, bucket=[]):
bucket.append(item)
return bucket
print(add_item("apple"))
print(add_item("banana")) # 同一个默认列表被反复修改
def add_item_fixed(item, bucket=None):
if bucket is None:
bucket = []
bucket.append(item)
return bucket
print(add_item_fixed("apple"), add_item_fixed("banana"))
# 陷阱二:闭包捕获的是变量本身,不是当时的"值"
funcs = [lambda: i for i in range(3)]
print([f() for f in funcs]) # [2, 2, 2]
funcs = [lambda i=i: i for i in range(3)] # 修复:默认参数在定义时求值
print([f() for f in funcs]) # [0, 1, 2]
# 陷阱三:函数内赋值同名全局变量
count = 0
def increment():
count = count + 1
try:
increment()
except UnboundLocalError as e:
print("UnboundLocalError:", e)
def increment_fixed():
global count
count = count + 1
increment_fixed()
print(count) # 1
# 陷阱四:漏写 return,函数静默返回 None
def find_max(numbers):
for i in range(1, len(numbers)):
if numbers[i] > numbers[0]:
numbers[0] = numbers[i]
print(find_max([3, 1, 4, 1, 5])) # None
def find_max_fixed(numbers):
best = numbers[0]
for n in numbers[1:]:
if n > best:
best = n
return best
print(find_max_fixed([3, 1, 4, 1, 5])) # 5
# 陷阱五:遍历列表的同时删除元素
data = [1, 2, 2, 3, 4]
for v in data:
if v == 2:
data.remove(2)
print(data) # [1, 2, 3, 4]:第二个 2 漏网
data = [1, 2, 2, 3, 4]
data = [v for v in data if v != 2] # 修复:先收集再重建
print(data) # [1, 3, 4]
['apple']
['apple', 'banana']
['apple'] ['banana']
[2, 2, 2]
[0, 1, 2]
UnboundLocalError: cannot access local variable 'count' where it is not associated with a value
1
None
5
[1, 2, 3, 4]
[1, 3, 4]
第三组:字符串与正则(5 个)
- 把 find 的返回值当布尔:现象是
"abc".find("a")返回 0 而 0 是假值、找不到时返回 -1 反而是真值;修复是用"x" in s判断存在。 - strip 按字符集合剥:现象是
"aaabXXbaaa".strip("ab")得到 “XX”;修复是按子串剥离用removeprefix/removesuffix,全文替换用replace。 - split 传不传参数是两个世界:现象是
"a,,b".split(",")保留空串而" a b ".split()合并连续空白;修复是解析前先想清楚字段可否为空。 - isdigit 不等于十进制:现象是
"²".isdigit()为 True 而"²".isdecimal()为 False、"三".isnumeric()为 True;修复是校验数字时按业务选isdecimal/isdigit/isnumeric。 - 正则的字符 vs 模式:现象是
.不匹配换行、\w默认吞中文;修复是跨行用[\s\S]或re.DOTALL,纯 ASCII 词用re.ASCII标志。
# 陷阱一:把 find 的返回值当布尔用
s = "abc"
print(bool(s.find("a")), bool(s.find("z"))) # 0 是假值,-1 是真值
if "a" in s: # 修复:成员测试
print("找到了")
# 陷阱二:strip 按"字符集合"剥,不是按子串
s = "aaabXXbaaa"
print(s.strip("ab")) # XX
print(s.removeprefix("aaa")) # bXXbaaa:按子串才用它
# 陷阱三:split 传不传参数是两个世界
print("a,,b".split(",")) # 空串保留
print(" a b ".split()) # 连续空白合并
print("a,b,c".split(",", 1)) # maxsplit 是"最多几刀"
# 陷阱四:isdigit 不等于十进制数字
print("²".isdigit(), "²".isdecimal())
print("三".isnumeric(), "三".isdigit())
# 陷阱五:正则的"字符 vs 模式"世界观
import re
print(re.findall(r".+", "a\nb")) # 点号不匹配换行
print(re.findall(r"\w+", "我爱 Python 编程")) # \w 默认吃中文
False True
找到了
XX
bXXbaaa
['a', '', 'b']
['a', 'b']
['a', 'b,c']
True False
True False
['a', 'b']
['我爱', 'Python', '编程']
第四组:容器(5 个)
- 遍历字典时增删键:现象是
for k in d: del d[k]抛 RuntimeError;修复是遍历快照list(d)或先收集再统一删。 - get 的可变默认值共享:现象是
d.get("a", shared)里 append 之后,第二次 get 拿到的 shared 已被污染;修复是让每次求值都新建默认值。 - sort 原地返回 None:现象是
result = nums.sort()后 result 是 None;修复是要新列表就用sorted()。 - set 的 remove 与 discard:现象是
remove删不存在的元素抛 KeyError;修复是按语义选用discard(存在就删、不存在拉倒)。 - 空容器字面量陷阱与键缺失:现象是
{}是字典不是集合、下标直取缺失键抛 KeyError;修复是空集合写set()、外部数据用get兜底。
# 陷阱一:遍历字典时增删键
d = {"a": 1, "b": 2}
try:
for k in d:
del d[k]
except RuntimeError as e:
print("RuntimeError:", e)
d = {"a": 1, "b": 2}
for k in list(d): # 修复:先取快照
del d[k]
print(d) # {}
# 陷阱二:get 的默认值是"调用时求值",复用可变对象会共享
d = {}
shared = d.get("a", [])
shared.append(1)
print(d.get("b", shared)) # [1]:默认值被污染
w = d.get("a", [])
w.append(1)
print(d.get("b", [])) # []:每次新建默认值才安全
# 陷阱三:sort 是原地排序,返回 None
nums = [3, 1, 2]
print(nums.sort()) # None
print(sorted(nums)) # [1, 2, 3]:要新列表用 sorted
# 陷阱四:set 的 remove 与 discard
tasks = {"a", "b"}
try:
tasks.remove("c") # 不存在即抛 KeyError
except KeyError as e:
print("KeyError:", e)
tasks.discard("c") # 修复:存在就删、不存在拉倒
print(sorted(tasks))
# 陷阱五:{} 不是空集合;下标直取缺失键抛 KeyError
empty = {}
print(type(empty).__name__) # dict
votes = {"zhang": 3}
print(votes.get("wang", 0)) # 修复:外部数据用 get 兜底
RuntimeError: dictionary changed size during iteration
{}
[1]
[]
None
[1, 2, 3]
KeyError: 'c'
['a', 'b']
dict
0
第五组:文件与工程(5 个)
'w'静默清空:现象是想读配置却用了'w'打开,文件内容在打开瞬间被清空且不报错;修复是纯读用'r'、必须新建用'x'。- 编码不匹配:现象是往 ascii 编码的文件写中文当场抛 UnicodeEncodeError,且磁盘上留下 0 字节文件;修复是读写一律显式
encoding='utf-8'。 - writelines 不补换行:现象是
writelines(["苹果", "香蕉"])写出一整行粘连文本;修复是显式拼"\n".join(...) + "\n"。 - 裸 except 捕获一切:现象是裸
except:连 KeyboardInterrupt(Ctrl+C)都吞掉;修复是只捕获预期的精确类型。 - 依赖垃圾回收自动关闭文件:现象是脚本短命时看不出问题,长服务迟早
Too many open files;修复是每个open都配with。
from pathlib import Path
# 陷阱一:'w' 打开已有文件,内容被静默清空
p = Path("conf.txt")
p.write_text("重要配置", encoding="utf-8")
with open(p, "w", encoding="utf-8") as f: # 想读配置却用了 'w'
pass
print(p.read_text(encoding="utf-8") == "") # True:文件已空
# 陷阱二:编码不匹配,写入当场抛错
try:
with open("out.txt", "w", encoding="ascii") as f:
f.write("中文")
except UnicodeEncodeError as e:
print(e)
# 陷阱三:writelines 不补换行
items = ["苹果", "香蕉"]
with open("lines.txt", "w", encoding="utf-8") as f:
f.writelines(items)
print(repr(open("lines.txt", encoding="utf-8").read()))
with open("lines2.txt", "w", encoding="utf-8") as f:
f.write("\n".join(items) + "\n") # 修复:显式拼换行
print(repr(open("lines2.txt", encoding="utf-8").read()))
# 陷阱四:裸 except 会吞掉 KeyboardInterrupt
try:
raise KeyboardInterrupt()
except:
print("裸 except 连 Ctrl+C 都吞掉")
try:
raise ValueError("解析失败")
except (ValueError, TypeError) as e: # 修复:指定精确类型
print("精确捕获:", e)
# 陷阱五:修复——每个 open 都配 with,不依赖垃圾回收
with open("demo.txt", "w", encoding="utf-8") as f:
f.write("数据")
print("with 保证 close 被调用")
True
'ascii' codec can't encode characters in position 0-1: ordinal not in range(128)
'苹果香蕉'
'苹果\n香蕉\n'
裸 except 连 Ctrl+C 都吞掉
精确捕获: 解析失败
with 保证 close 被调用
易错点与陷阱
复习这张清单本身也有三个坑,提醒在先:
- 只背输出、不背机制。同一段代码的输出可能随解释器实现浮动:小整数缓存是 CPython 的实现细节(-5..256),字符串驻留更无统一边界;本文陷阱一(is/==)的价值不在记住哪个整数范围,而在记住「is 看身份、== 看值」这一机制。真到验收面试、换到 PyPy 上跑,机制不变,输出却可能变。
- 把实现细节当语言规范。set 的打印顺序不稳定、
locals()返回副本、re内部有编译缓存——这些都不是语言承诺。凡是「依赖顺序、依赖缓存、依赖特定版本行为」的代码,一律按脆弱的写。 - 只看不跑。25 个复现代码全部可以直接复制运行(第二、五组会生成临时文件,记得放在临时目录里跑)。亲手跑一遍的现象记忆,比读十遍文字都牢;跑完再对照输出块里的结果,逐字不同之处就藏着你没理解的点。
小结
本文把前九章的易错点按五类心智模型收拢为 25 条清单:变量与引用(is/==、赋值非拷贝、不可变性、链式赋值、浅拷贝边界)、流程与函数(可变默认参数、闭包延迟绑定、UnboundLocalError、漏写 return、遍历中删除)、字符串与正则(find 布尔、strip 字符集、split 参数、isdigit 家族、正则字符观)、容器(遍历增删、get 可变默认、sort 返回 None、remove 与 discard、空容器字面量)、文件与工程(‘w’ 清空、编码错配、writelines 换行、裸 except、with 关闭)。注意这些名称本身不构成记忆负担——每一类背后的心智模型才是:引用语义、求值时机、字符与模式、哈希与迭代约束、环境与资源约定。能对着任何一个陷阱说出「现象 + 机制 + 修复」三句话,就说明模型到位了。掌握这张表,后续 7-9 篇的调试训练、项目冲刺与收官测验就站在了坚实的地基上。
练习与思考题
- 预测输出并运行验证:
a = [0]; b = a; b = b + [1]; print(a, b)——注意与b.append(1)的差异,用「绑定 vs 原地修改」解释。 - 用一句话回答:为什么
try/except必须包住整个for x, y in pairs:循环,而不是包在循环体内? - 审查这段代码,找出至少 4 处本文提到过的陷阱,并逐一修复:
def merge(a, b=[]): a.append(b); return a以及for k in d: d[k] = d.get(k, 0) + 1(d 是待统计的字典)。 - 思考题:
re.findall(r"\w+", "一 2 ٣")会返回什么?如果业务要求「只识别 ASCII 单词」,应该怎么改?运行验证你的答案。