我在 Python 里一边遍历字典一边删掉满足条件的键,本以为天经地义,结果程序直接抛 RuntimeError 说字典在迭代时改变了大小:一次遍历时修改容器、误以为可以边用边改的深度复盘

我有个字典 cache,想把所有过期条目删掉,顺手写了 for key in cache: if is_expired(cache

我在 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 时,刻进骨子里的几条铁律:

  1. 不能在遍历 dict/set 的同时增删它的键/元素(改变大小),会抛 RuntimeError。
  2. list 正向遍历边删不报错,但会索引错乱、悄悄漏删,更危险。
  3. 迭代器依赖容器结构稳定;边遍历边改结构破坏了这个前提。
  4. 正解一:遍历副本 for k in list(d.keys()),改原容器不影响快照。
  5. 正解二(推荐):用推导式新建容器,思路从"删掉不要的"变"留下要的"。
  6. 正解三:两阶段——先只读收集要删的,遍历结束后再统一删。
  7. 只修改 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
声明:本站所有文章,如无特殊说明或标注,均为本站原创发布。任何个人或组织,在未征得本站同意时,禁止复制、盗用、采集、发布本站内容到任何网站、书籍等各类媒体平台。如若本站内容侵犯了原著者的合法权益,可联系我们进行处理 邮箱1846861578@qq.com。
技术教程

我给 AI Agent 写了个查数据库的工具,某次它查出了几万行结果原封不动塞进了对话上下文,当场超出 token 上限报错,就算没报错模型也被海量数据淹没得抓不住重点:一次工具返回过大塞爆上下文的深度复盘

2026-6-3 0:25:50

技术教程

我图省事调了个异步函数发通知,既没 await 也没 catch,想着失败了无所谓,结果它一旦 reject 整个 Node 进程就因为未处理的 Promise 拒绝直接崩溃退出:一次 unhandled rejection 拖垮服务的深度复盘

2026-6-3 0:36:59

0 条回复 A文章作者 M管理员
    暂无讨论,说说你的看法吧
个人中心
购物车
优惠劵
今日签到
有新私信 私信列表
搜索