我在 Python 里一边遍历字典一边删掉满足条件的键,本以为天经地义,结果程序直接抛 RuntimeError 说字典在迭代时改变了大小:一次遍历时修改容器、误以为可以边用边改的深度复盘
那个 RuntimeError,是我写一段"看起来再正常不过"的清理代码时撞上的。我有个字典 cache,想把其中所有过期的条目删掉,于是顺手写了:for key in cache: if is_expired(cache[key]): del cache[key]——遍历每个键,过期的就删掉,多直白。可一运行就崩:RuntimeError: dictionary changed size during iteration(字典在迭代过程中改变了大小)。我当时就愣住了:删个字典元素怎么还犯法了?这不是最基本的操作吗?复盘 Python 迭代器的机制,我才彻底明白,后背发凉:问题出在我"一边遍历一个容器、一边改变它的结构(大小)"。当你 for key in cache 时,Python 会创建一个迭代器来逐个产出字典的键,这个迭代器内部依赖字典当前的结构(大小、内部布局)来追踪"遍历到哪了";而我在遍历的同时 del cache[key] 改变了字典的大小,破坏了迭代器赖以工作的前提——它没法再可靠地知道"下一个该产出谁、有没有遗漏或重复";所以 Python 主动检测到了这种"迭代中结构被改"的危险情况,直接抛 RuntimeError 阻止你——这其实是一种保护(总比悄悄地遗漏元素、行为诡异要好);这背后是一条普遍的规则:不能在遍历一个集合(dict/set 等)的同时,改变它的大小/结构。根本原因是:遍历字典时,迭代器依赖字典结构的稳定来正确工作;我在遍历的同时 del 改变了字典大小,破坏了迭代器的前提,Python 检测到便抛 RuntimeError;不能边遍历一个容器边改变它的结构。问题的根,是在迭代字典的过程中修改了它的大小——迭代器依赖结构稳定,边遍历边增删会破坏它、被 Python 检测到而报错。这篇就把这次"遍历时修改容器"的坑,从头到尾复盘一遍。
故障现场:边遍历边删,直接 RuntimeError
问题在于在迭代过程中改变了字典的大小:
# 我想删掉所有过期的缓存条目(看起来天经地义):
cache = {"a": 1, "b": 2, "c": 3, ...}
for key in cache: # 创建迭代器, 遍历键
if is_expired(cache[key]):
del cache[key] # ✗ 遍历中删除 → 改变了字典大小!
# 运行:
# RuntimeError: dictionary changed size during iteration
# (字典在迭代过程中改变了大小)
"""
为什么会报错(迭代器的机制):
- for key in cache 时, Python创建一个迭代器, 逐个产出字典的键;
- 这个迭代器内部记录着"遍历进度", 依赖字典当前的结构(大小/内部哈希布局)来推进;
- 你 del 一个键 → 字典大小变了、内部布局可能重组(rehash) → 迭代器"懵了":
它没法可靠判断下一个该给谁, 可能遗漏元素、重复产出、或读到无效位置;
- Python为了不让你陷入"行为诡异、难以察觉"的bug, 主动检测大小变化、直接抛RuntimeError;
- 这是"快速失败(fail-fast)": 与其默默出错, 不如当场报错让你知道。
★ 普遍规则: 不能在遍历一个集合(dict、set)的同时, 改变它的大小(增、删)。
- dict/set: 迭代中增删 → RuntimeError(明确报错, 算"友好");
- list: 迭代中增删 → 不报错, 但会"跳过元素/索引错乱"(更阴险, 见后文);
- 本质都是: "一个东西正在被遍历(依赖它稳定), 你却动了它的根基"。
注意: 只是"修改已有键的值"(cache[key] = new_value)不改变大小, 一般是允许的;
改变"大小/结构"(增加新键、删除键)才是问题。
"""
看着那行 RuntimeError,我先是错愕,然后释然又警醒:"删个字典元素而已,怎么会报错?……哦,原来是我在'边走边拆桥'——迭代器正踩着字典的结构往前走,我却把它脚下的结构改了。Python 报错其实是在救我,免得我遇到更隐蔽的乱序 bug。"这个坑给我的提醒是:有些"看起来天经地义"的操作,其实暗含了一个"被操作的东西在过程中保持稳定"的隐含前提;一旦你打破这个前提(边遍历边改结构),轻则报错(dict/set,算幸运),重则悄无声息地出错(list,更可怕)。下面就来拆解,遍历时需要增删,到底该怎么做。
第一件事:搞懂迭代时不能改结构的原理
我顺着这次事故,把"遍历与修改"的机制彻底理清了。
为什么遍历时不能改容器结构? 各容器的行为有何不同?
【核心: 迭代器依赖容器结构稳定来追踪进度; 遍历中改变大小(增删)会破坏它——dict/set直接RuntimeError
(fail-fast保护), list则悄悄跳元素/错乱(更阴险); 正解是遍历副本、或新建容器、或两阶段(先收集后改)】
1. 迭代器为什么怕"结构变化":
- 迭代器要逐个产出元素, 内部维护"当前位置"; 它依赖容器在遍历期间结构不变;
- 一旦增删导致大小变化(dict/set可能rehash重组、list元素移位), "当前位置"就失去意义;
- 结果: 遗漏元素、重复、读到错误位置——这种bug极难发现。
2. 各容器在"遍历中修改"下的表现:
- dict / set: 改变大小 → 抛 RuntimeError(明确报错, fail-fast, 算"友好的失败");
(只改已有键的"值"不改大小, 通常允许)
- list: 遍历中 del/insert → 不报错, 但索引错乱:
for x in lst: if cond(x): lst.remove(x) # 删了一个, 后面元素前移, 迭代器索引跳过下一个! 漏删!
- 本质相同: 都是"在依赖某结构稳定的过程中, 改了那个结构"。
3. 三种正确做法:
① 遍历副本(简单直接): 对副本遍历, 对原容器修改, 两者独立:
for key in list(cache.keys()): # list()拷贝一份键的快照
if is_expired(cache[key]): del cache[key] # 改原dict, 不影响已快照的遍历
② 构建新容器(更函数式、更清晰): 用推导式筛出要保留的, 生成新容器:
cache = {k: v for k, v in cache.items() if not is_expired(v)} # 保留没过期的
③ 两阶段(先收集后修改): 先遍历收集要删的key, 遍历结束后再统一删:
to_del = [k for k in cache if is_expired(cache[k])]
for k in to_del: del cache[k]
4. list 的删除还要额外注意:
- 别正向遍历边删(索引错乱); 用列表推导 lst = [x for x in lst if keep(x)];
- 或倒序遍历删(从后往前, 删除不影响前面的索引): for i in range(len(lst)-1, -1, -1): ...
5. 更广的原则: 读和写分开, 别在"用"的过程中改"被用的东西"
- 遍历(读)和增删(写结构)分到两个阶段, 或在快照上读、在本体上写;
- 这是"迭代器失效(iterator invalidation)"问题, 在C++/Java等很多语言都有(只是表现不同)。
一句话: 迭代器依赖容器结构稳定; 遍历中增删会破坏它(dict/set报RuntimeError、list悄悄错乱);
正解是遍历副本list(d)、或字典推导新建、或先收集后删——把"遍历"和"改结构"分开。
这套认知,是整个坑的根。迭代器为什么怕结构变化:它内部维护"当前位置"、依赖容器遍历期间结构不变;增删导致大小变化(dict/set 可能 rehash、list 元素移位)就让位置失去意义,遗漏/重复/错位。各容器表现:dict/set 改大小直接 RuntimeError(fail-fast,友好);list 边删不报错但索引错乱漏删(更阴险);只改 dict 已有键的值不改大小则允许。三种正确做法:①遍历副本 list(d.keys());②字典推导新建保留项;③两阶段先收集 key 后统一删。list 删除额外注意:用列表推导或倒序遍历删。更广原则:读和写分开,别在"用"的过程中改"被用的东西"(迭代器失效问题,多语言通用)。一句话:迭代器依赖容器结构稳定;遍历中增删会破坏它(dict/set 报 RuntimeError、list 悄悄错乱);正解是遍历副本 list(d)、或字典推导新建、或先收集后删——把"遍历"和"改结构"分开。
第二件事:正解——遍历副本、推导新建、或两阶段
知道了不能边遍历边改结构,正解就清楚了:把"遍历"和"改结构"分开。
# 正解1: 遍历副本(最直接, 改动最小)
for key in list(cache.keys()): # list()拍一份键的快照, 遍历快照
if is_expired(cache[key]):
del cache[key] # 改原dict, 不影响已快照的遍历 → 不报错
# 注意: 是 list(cache.keys()) 拷贝快照; 直接 for key in cache 才会报错。
# 正解2: 字典推导式构建新容器(最清晰, 推荐)
cache = {k: v for k, v in cache.items() if not is_expired(v)} # 保留没过期的, 生成新dict
# 思路从"删掉不要的"变成"留下要的", 无副作用、不改正在遍历的对象, 更不容易错。
# 正解3: 两阶段——先收集要删的, 再统一删
to_delete = [k for k in cache if is_expired(cache[k])] # 阶段1: 只读, 收集key
for k in to_delete: # 阶段2: 遍历结束后再删
del cache[k]
# --- list 的情况 ---
# 错误: 正向遍历边删 → 索引错乱、漏删
# for x in items:
# if should_remove(x): items.remove(x) # ✗ 删后元素前移, 跳过下一个
# 正解A: 列表推导(最推荐)
items = [x for x in items if not should_remove(x)] # 留下要保留的
# 正解B: 倒序遍历删(从后往前, 删除不影响前面索引)
for i in range(len(items) - 1, -1, -1):
if should_remove(items[i]):
del items[i]
# --- set 同理 ---
to_remove = {x for x in s if cond(x)}
s -= to_remove # 或 s.difference_update(to_remove); 别在 for x in s 里 s.discard(x)
# 核心: 把"遍历"和"改结构"分开——遍历副本(快照)、或用推导式新建容器、或先收集后修改;
# 思路上多倾向"留下要的"(推导式)而非"删掉不要的"(原地改), 更安全清晰。
这套正解的关键,是让"遍历的对象"和"被修改的对象"不是同一个正在迭代中的东西。遍历副本:list(cache.keys()) 拍一份快照来遍历,对原 dict 增删互不影响,改动最小。字典/列表推导新建:把思路从"删掉不要的"换成"留下要的",生成新容器,无副作用、最清晰,推荐。两阶段:先只读地收集要删的 key,遍历结束后再统一删。list 额外:用列表推导或倒序遍历删,避免正向删的索引错乱。核心是:把"遍历"和"改结构"分开,并多倾向"留下要的"而非"原地删不要的"。
第三件事:其他几个"边用边改"的坑
顺着这次遍历时修改,我把"在使用一个东西的过程中改变它"的几类坑也一并理了:
几类"边用边改"的坑(核心都是"动了正在被依赖的东西"):
坑1: 遍历list时remove/append(同上)——索引错乱、漏处理元素;
正解: 列表推导新建、倒序删、或遍历副本。
坑2: 多线程下一个线程遍历、另一个线程改(同Java CME 545篇)——
并发修改, Java抛ConcurrentModificationException, Python行为也不确定;
正解: 加锁、用线程安全结构、或遍历前拷贝快照。
坑3: 遍历时修改"已有键的值" vs "增删键"——前者通常安全, 后者报错;
for k in d: d[k] = f(d[k]) # 改值, OK; 但 d[new]=v 或 del d[k] 就报错。
坑4: 在迭代生成器/惰性序列时改其数据源——可能拿到意外结果(同587 LINQ延迟、542生成器);
正解: 需要稳定就先物化成list。
坑5: 递归遍历目录时增删文件——os.walk期间改目录树, 行为依赖实现, 易出错;
正解: 先收集要处理的路径, 遍历结束后再操作。
坑6: 修改正在迭代的对象的"间接"形式——比如遍历d.keys()视图(它是动态的, 随d变);
for k in d.keys(): del d[k] # keys()是视图不是快照, 一样报错; 要 list(d.keys())。
共同的根: "遍历/使用一个结构"时, 默认依赖它在过程中保持稳定; 一旦在过程中改变它的结构,
就破坏了这个依赖——要么报错(友好), 要么悄悄出错(危险); 通用解法是"读写分离/先快照"。
这些坑看似不同,根却是同一个:"遍历/使用一个结构"这个动作,默认依赖它在整个过程中保持稳定;一旦你在"正用着它"的时候改变了它的结构,就像在自己正站着的那根树枝上锯口子——破坏了你赖以进行的前提。认清这个根("别动正在被遍历/依赖的东西的结构,读写要分开"),很多语言、很多场景的类似坑就都能预判了。
第四件事:遍历中修改的行为 / 解法——两张对照表
我把各容器"遍历中修改"的行为、以及对应解法,整理成对照表,贴在了团队的 Python 规范里:
| 容器 / 操作 | 遍历中做会怎样 | 是否报错 |
|---|---|---|
| dict 遍历中 del/新增键 | RuntimeError 改变大小 | 是(fail-fast) |
| dict 遍历中改已有键的值 | 通常正常(大小没变) | 否 |
| set 遍历中 add/discard | RuntimeError 改变大小 | 是 |
| list 正向遍历中 remove/del | 索引错乱,跳过/漏删元素 | 否(更阴险) |
| list 遍历中 append | 可能无限循环或多处理 | 否 |
| 遍历 d.keys() 视图删键 | 视图随 d 变,仍 RuntimeError | 是 |
| 需求 | 推荐做法 | 说明 |
|---|---|---|
| dict 删满足条件的键 | {k:v for ... if 保留} 新建 | 最清晰,留下要的 |
| dict 删少量键 | for k in list(d): ... | 遍历快照,改原 dict |
| list 过滤元素 | [x for x in lst if 保留] | 列表推导,首选 |
| list 原地删 | 倒序遍历 del | 从后往前不影响索引 |
| 逻辑复杂/有顺序依赖 | 两阶段:先收集后删 | 读写彻底分开 |
这两张表的核心,第一张是dict/set 改大小会"友好地报错"、list 边删会"阴险地错乱"——后者更危险因为不报错;第二张是解法的首选都是"推导式新建(留下要的)",其次是遍历副本或两阶段。记住一条:需要边遍历边改时,优先想"能不能用推导式生成一个新的",而不是"怎么在原地边遍历边改"。
第五件事:关于遍历与修改的几组容易想当然的认知
这次事故也让我厘清了几组关于"遍历时修改"的、容易想当然的概念:
| 直觉以为 | 实际上 |
|---|---|
| 遍历时删元素是基本操作,没问题 | 边遍历边改结构会破坏迭代器 |
| dict 报错是 Python 的毛病 | 是 fail-fast 保护,免你遇到隐蔽 bug |
| list 不报错说明边删没问题 | 不报错但索引错乱、悄悄漏删,更危险 |
| 改 dict 的值和删键差不多 | 改值不变大小(OK),删键变大小(报错) |
| for k in d.keys() 遍历的是快照 | keys() 是动态视图,随 d 变,仍报错 |
| 遍历副本是多余的拷贝开销 | 它恰恰是把读写分开的关键 |
| 这是 Python 独有的怪问题 | 迭代器失效在很多语言都有 |
这张表里,我栽的是第一行和第二行:把"遍历时删元素"当成理所当然的基本操作,报错时还觉得是 Python 多事,没意识到这是它在保护我免于更隐蔽的乱序 bug。厘清这些,核心是一个意识:"遍历一个容器"这个动作,默认依赖它在过程中结构稳定;想边遍历边增删,要么在副本上读、在本体上写,要么用推导式生成新的——别在"正用着"的时候改它的"根基"。
第六件事:要边遍历边改容器时,我现在的自检习惯
现在每当我要在遍历一个容器的同时增删它,我都会先按这张图问自己:
这张图的精髓,是"优先推导式新建、必须原地改就遍历副本或倒序、只改值才安全"。先问能否新建一个(推导式留下要的)、不能就遍历副本/倒序/两阶段、只改值不增删通常安全。这套习惯,让我从"边遍历边删,天经地义"变成了"遍历和改结构必须分开"——核心始终是:迭代器依赖容器结构稳定;遍历中增删会破坏它(dict/set 报错、list 错乱);正解是遍历副本、推导式新建、或先收集后删,把遍历和改结构分开。
我立下的几条规矩
这场"遍历字典时删键报 RuntimeError"的事故,换来了我写 Python 时,刻进骨子里的几条铁律:
- 不能在遍历 dict/set 的同时增删它的键/元素(改变大小),会抛 RuntimeError。
- list 正向遍历边删不报错,但会索引错乱、悄悄漏删,更危险。
- 迭代器依赖容器结构稳定;边遍历边改结构破坏了这个前提。
- 正解一:遍历副本
for k in list(d.keys()),改原容器不影响快照。 - 正解二(推荐):用推导式新建容器,思路从"删掉不要的"变"留下要的"。
- 正解三:两阶段——先只读收集要删的,遍历结束后再统一删。
- 只修改 dict 已有键的值(不增删)通常安全;keys() 是动态视图不是快照。
附:一个安全增删的小工具
借这次的坑,我写了几个小工具函数,把"遍历时安全增删"的几种正确姿势固化下来,团队直接用。
# 安全地按条件从 dict 删除键(返回新dict, 不改原对象, 最安全)
def dict_filter(d: dict, keep) -> dict:
"""保留 keep(k, v) 为真的项, 返回新dict"""
return {k: v for k, v in d.items() if keep(k, v)}
# 安全地原地删除(确需改原对象时, 用快照遍历)
def dict_remove_inplace(d: dict, should_remove) -> int:
"""原地删除 should_remove(k, v) 为真的项, 返回删除数量"""
to_del = [k for k, v in d.items() if should_remove(k, v)] # 先收集(只读)
for k in to_del: # 后删除
del d[k]
return len(to_del)
# 安全地按条件过滤 list(返回新list)
def list_filter(lst: list, keep) -> list:
return [x for x in lst if keep(x)]
# 用法示例:
cache = dict_filter(cache, lambda k, v: not is_expired(v)) # 留下没过期的
# 或原地:
removed = dict_remove_inplace(cache, lambda k, v: is_expired(v))
# 原则: 把"遍历时安全增删"的正确姿势封装成函数, 既避免每次手写出错,
# 也让调用处的意图更清晰("过滤/删除"而非一坨易错的for循环)。
这几个小工具本身很简单,但它们让"正确的做法"变成了"最顺手的做法":默认提供"返回新容器(不改原对象)"的版本,把更安全、更函数式的方式作为首选;确需原地改时,也用"先收集后删"的安全实现兜住——让团队成员不必每次都重新踩一遍"遍历时删"的坑。
写在最后
回头看,这场由"遍历字典时删键"引发的 RuntimeError,真正教给我的,远不止"遍历副本或用推导式"这一个技巧。它让我对"当你正依赖某个东西的稳定来进行一项活动时, 去改变那个东西本身, 会从根上动摇你这项活动的基础——'使用'和'改变'同一个东西, 如果不分开, 就会互相破坏",有了一次刻骨的体会。我栽跟头,是因为我让"读(遍历)"和"写(改结构)"纠缠在了同一个动作里——我一边踩着字典的结构往前走(遍历依赖它稳定), 一边又把这个结构改了(删键);这就像站在一根树枝上, 同时锯断这根树枝; 又像照着一张地图走路, 却一边走一边改地图——我活动的根基(被遍历的结构), 正是我亲手在动摇的东西;Python 报的那个错, 本质是在喊: "你不能一边用着它、一边拆着它!"。这让我领悟到一个关于"使用与变更、读与写"的深刻认知:"使用/读取/依赖一个东西的当前状态" 和 "修改/变更那个东西本身",是两种会互相干扰的活动;当它们在同一个过程中交织、且没有妥善隔离时,前者赖以成立的"稳定前提"会被后者打破,导致混乱(报错、错乱、不一致);这就是为什么"读写分离"是个如此普遍而重要的原则——从迭代器、到数据库的读写分离、到并发的读写锁、到不可变数据结构、到"先拍快照再操作": 本质都是把"使用"和"变更"在时间或空间上隔开, 让"使用"能基于一个稳定的视图进行, 而不被同时进行的"变更"扰乱。这给了我一种处理"动态变化的东西"时的清醒:每当我要"一边使用一个东西、一边改变它"时,要警觉地问"我使用它的过程, 是否依赖它保持稳定?我的改变会不会破坏这个稳定?"——如果会,就把'使用'和'改变'分开:要么先在快照/副本上读、再对本体写, 要么先收集决策、再统一执行, 要么干脆生成一个新的而不动旧的;"识别'使用'对'稳定'的隐含依赖, 并把会破坏稳定的'变更'从'使用'过程中隔离出去",是避免'在自己脚下拆桥'式混乱的关键。认清使用与变更同一个东西会互相干扰、读写分离之所以普遍正是为隔开二者、要在快照上读或先收集后改——这,是我用一次遍历字典报错的事故,换来的、关于 Python、也关于如何处理使用与变更之关系的、最朴素也最深刻的领悟。如果这篇复盘,能让你下次想"边遍历字典边删键"时,顺手把它改成一个字典推导式、或 for k in list(d),那我对着那行 dictionary changed size during iteration 错愕的那一刻,就值了。
—— 别看了 · 2026