去年双十一前夕,我们有个订单统计系统,需要在遍历订单字典时,根据某些规则重新整理订单数据。代码大概是这样的:
|
1 2 3 4 5 6 7 8 9 10 11 |
orders = { "A001": {"status": "paid", "amount": 299}, "A002": {"status": "unpaid", "amount": 150}, "A003": {"status": "paid", "amount": 399}, }
# 把状态为paid的订单统一改个编号前缀 for key in orders.keys(): if orders[key]["status"] == "paid": new_key = f"PAID_{key}" orders[new_key] = orders.pop(key) |
运行时直接报错:
|
1 |
RuntimeError: dictionary changed size during iteration |
我当时就卡住了:为什么不能改?我只是修改键名而已,又没有增加或减少元素数量。改完一个删一个,字典大小保持不变,为什么不让遍历继续?
后来我仔细研究了Python字典的底层实现,才明白为什么会有这个限制。今天把这些理解讲清楚,顺便聊聊那些"变通方案"背后的隐患。
这个错误不仅仅是"Python不准你这样做"那么简单。它背后有个重要的设计考量:字典在迭代过程中需要保持内部结构的一致性。
先看几个会触发错误的典型操作:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
d = {"a": 1, "b": 2, "c": 3}
# 操作1:直接遍历并删除 for key in d: if key == "b": del d[key] # RuntimeError
# 操作2:用keys()遍历并删除 for key in d.keys(): if key == "b": del d[key] # RuntimeError
# 操作3:遍历时新增键 for key in list(d.keys()): d[f"{key}_new"] = d[key] # RuntimeError
# 操作4:遍历时修改键名(先增后删) for key in d.keys(): new_key = f"new_{key}" d[new_key] = d.pop(key) # RuntimeError |
以上四种操作,Python都会禁止。
但有一个例外:在遍历时修改现有键的值,是允许的。
|
1 2 |
for key in d: d[key] = d[key] * 2 # 可以,值变了,但键和大小都没变 |
为什么改值可以,改键(增删)就不行?因为改值不改变字典的结构(哈希表的大小和排列),而增删键会触发字典的重新哈希或表变化,导致迭代器失效。
要理解为什么不能一边遍历一边改,得先了解Python字典的底层实现。
Python字典本质上是一张哈希表。你可以想象成一个大桌子,桌子上有N个位置(槽位),每个键通过哈希算法算出一个数字,然后放到对应的槽位上。
当字典里的元素太多,桌子上的位置不够用时,字典会扩容——换一张更大的桌子,把所有元素重新放一遍(这个操作叫rehash)。
这就带来两个问题:
为了安全和简单,Python设计者决定:一旦字典在迭代期间发生变化(增删键),直接抛出异常。
既然不能直接改,那怎么达到"修改键"的目的呢?有三种常见方案,各有优缺点。
这是最简单、最直观的做法。
|
1 2 3 4 5 6 7 8 9 10 11 12 13 |
orders = { "A001": {"status": "paid", "amount": 299}, "A002": {"status": "unpaid", "amount": 150}, "A003": {"status": "paid", "amount": 399}, }
for key in list(orders.keys()): # 复制一份键列表 if orders[key]["status"] == "paid": new_key = f"PAID_{key}" orders[new_key] = orders.pop(key)
print(orders) # {'A002': {'status': 'unpaid', 'amount': 150}, 'PAID_A001': {'status': 'paid', 'amount': 299}, 'PAID_A003': {'status': 'paid', 'amount': 399}} |
list(orders.keys())会生成一个独立的列表,包含字典当前所有的键。迭代的是这个列表,而不是字典本身,所以字典在迭代过程中怎么改都没事。
优点:简单、安全、代码可读性好。 缺点:需要复制一份键列表,如果字典很大(百万级键),复制会占用额外内存和时间。
不修改原字典,而是构造一个新字典。
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
orders = { "A001": {"status": "paid", "amount": 299}, "A002": {"status": "unpaid", "amount": 150}, "A003": {"status": "paid", "amount": 399}, }
new_orders = {} for key, value in orders.items(): if value["status"] == "paid": new_key = f"PAID_{key}" else: new_key = key new_orders[new_key] = value
orders = new_orders print(orders) |
优点:没有修改原字典,更安全;适合函数式编程风格。 缺点:同样需要额外内存,而且如果字典很大,复制开销不小。
在Python 3.7+,字典天然有序。如果你利用这个特性,配合方案2,可以保证新字典的顺序符合预期。
但不要尝试在迭代时使用for key in d:再加del或新增,无论版本多少都报错。
|
1 2 3 |
d = {"a": 1} for key in d: d[key] = d[key] + 1 # 改值,安全 |
改值安全,是因为没有触发表结构变化。
但有一种情况要小心:如果值是可变对象,修改它不会触发表结构变化,但可能影响后续逻辑。
|
1 2 3 |
d = {"a": [1, 2, 3]} for key in d: d[key].append(4) # 列表内容变了,但字典结构没变,安全 |
一个常见的需求:删除字典中所有值小于5的键。
|
1 2 3 4 5 |
# 错误写法 d = {"a": 1, "b": 2, "c": 3, "d": 4} for key in d: if d[key] < 5: del d[key] # RuntimeError |
正确写法:
|
1 2 3 4 5 6 7 |
# 方法1:复制键列表 for key in list(d.keys()): if d[key] < 5: del d[key]
# 方法2:字典推导式 d = {key: value for key, value in d.items() if value >= 5} |
|
1 2 3 4 |
d = {"a": 1, "b": 2, "c": 3} for key in d: if d[key] % 2 == 0: del d[key] # RuntimeError |
|
1 2 3 4 |
d = {"A": 1, "B": 2} for key in d: if key == "A": d["A_new"] = d.pop("A") # RuntimeError |
这两种都会触发报错,而且很难通过日志定位。
有些人想到用while d:加popitem()处理:
|
1 2 3 4 |
d = {"a": 1, "b": 2} while d: key, value = d.popitem() # 处理... |
这样虽然不会报错,但popitem()会随机弹出键值对(实际上按后进先出顺序),你很难控制处理顺序。
| 操作 | 是否安全 | 原因 |
|---|---|---|
| 遍历时修改键的值 | ? 安全 | 不改变表结构 |
| 遍历时修改可变值的内容 | ? 安全 | 不改变表结构 |
| 遍历时删除键 | ? 报错 | 改变表大小 |
| 遍历时新增键 | ? 报错 | 可能触发rehash |
| 遍历时修改键名(先删后增) | ? 报错 | 相当于删+增 |
| 复制键列表后遍历修改 | ? 安全 | 迭代的是独立列表 |
| 创建新字典后赋值 | ? 安全 | 原字典没被修改 |
| 用字典推导式创建新字典 | ? 安全 | 不修改原字典 |
如果你遇到"在遍历字典时需要修改键"的问题,按这个优先级选择:
记住这个原则:迭代器迭代的是字典当前的"视图",迭代期间改变视图本身,迭代器就失效了。
那次双十一之后,我把所有类似代码都改成了"先收集要修改的键,统一处理"的模式。从那以后,再没因为改字典键出过线上事故。