在Python开发中,字典是最常用的数据结构之一。但在遍历字典的同时修改键(例如重命名键、添加新键或删除键),会触发一个典型的RuntimeError:"dictionary changed size during iteration"。这个错误并非Python不可理喻,而是哈希表内部机制的安全保护。
一、错误触发场景
以下四种操作都会报出RuntimeError:- d = {"a": 1, "b": 2, "c": 3}
- # 场景1:for key in d 直接删除
- for key in d:
- if key == "b":
- del d[key]
- # 场景2:for key in d.keys() 删除
- for key in d.keys():
- if key == "b":
- del d[key]
- # 场景3:遍历时新增键
- for key in list(d.keys()):
- d[f"{key}_new"] = d[key]
- # 场景4:先增后删(修改键名)
- for key in d.keys():
- new_key = f"new_{key}"
- d[new_key] = d.pop(key)
复制代码 唯一安全的是修改已有键的值(不改变键集):- for key in d:
- d[key] = d[key] * 2 # 安全,不改变表结构
复制代码
二、底层原因:哈希表与迭代器失效
Python字典底层是一张哈希表(散列表)。每个键通过哈希函数映射到槽位。当槽位不够时,字典会扩容(rehash),将原有元素重新放置到新表。迭代器持有的是旧表的指针,一旦字典大小在迭代过程中变化(增删键),迭代器可能指向错误的槽位(空洞或越界)。因此Python设计者直接选择了“检测到大小变化就抛异常”,避免更隐晦的数据错误。
三、四种安全解决方案及对比
方案1:复制键列表
将当前所有键复制为一个独立列表,迭代这个列表而不是字典本身。- 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)
复制代码 优点:简单直观,代码可读性好。
缺点:对于数百万键的大字典,复制列表会消耗额外内存和创建时间。
方案2:创建新字典
不修改原字典,而是构造一个符合要求的新字典。- 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)
复制代码 优点:原字典不变,适合函数式风格,适合后续只读场景。
缺点:同样需要额外内存,如果字典极大可能会有性能压力。
方案3:字典推导式(适合过滤场景)
直接生成新字典,适合删除不符合条件的键。- d = {"a": 1, "b": 2, "c": 3, "d": 4}
- d = {key: value for key, value in d.items() if value >= 5}
- print(d)
复制代码 优点:语法简洁,表达力强。
缺点:不适合需要修改键名的场景(可以结合条件表达式变通)。
方案4:先收集要修改的键,统一处理
遍历前用列表收集所有需要修改的旧键,再在循环外统一进行添加/删除。- keys_to_modify = [key for key, value in orders.items() if value["status"] == "paid"]
- for old_key in keys_to_modify:
- new_key = f"PAID_{old_key}"
- orders[new_key] = orders.pop(old_key)
复制代码 本质也是复制了键列表,但逻辑分离,适合复杂规则。
四、实战:过滤字典中值<5的键
错误写法(会报错):- d = {"a": 1, "b": 2, "c": 3, "d": 4}
- for key in d:
- if d[key] < 5:
- del d[key] # RuntimeError
复制代码 正确写法(方案2的变种):- d = {key: value for key, value in d.items() if value >= 5}
复制代码 或使用复制键列表:- for key in list(d.keys()):
- if d[key] < 5:
- del d[key]
复制代码
五、避坑:不要在遍历时使用popitem()
有人试图用while d: key, value = d.popitem() 来避免错误,但popitem()弹出顺序是后进先出(LIFO),无法控制处理顺序,容易导致业务逻辑错误。
六、总结与最佳实践
操作安全性对照表:
- 修改键的值(不增删键):安全
- 修改可变值的内容(如列表append):安全
- 删除键:不安全
- 新增键:不安全
- 修改键名(先删后增):不安全
- 复制键列表后遍历修改:安全
- 创建新字典后赋值:安全
- 字典推导式创建新字典:安全
- 用while+popitem:安全但顺序不可控
推荐优先级:
1. 能用字典推导式(过滤)或新建字典(重命名)时,优先使用,代码简洁。
2. 需要原址修改且内存受限时,复制键列表list(d.keys())是最稳妥的方案。
3. 永远不要在遍历原字典时直接增删键,即使看起来大小不变(如pop配合add),底层仍会被视为大小变化。
记住:迭代器迭代的是字典当前的“视图”,视图改变则迭代器失效。将“修改键”的操作与“遍历”分离开,就能避免这个线上常见的坑。 |