我在 for 循环里一边遍历列表一边删元素,结果有些该删的没删掉、还漏处理了一些,我盯着这个忽好忽坏的结果排查了大半天的深度复盘
这是一个让我对"遍历时修改集合"刻骨铭心的故事。我有一个列表,要遍历它,把其中满足某个条件的元素,删掉。我很自然地,写了一个最直觉的 for 循环:遍历这个列表,遇到该删的,就调 list.remove() 把它从列表里删掉。在我朴素的认知里,这天经地义——一边遍历、一边把不要的删掉,多直接。
可运行结果,处处透着诡异:有些明明满足删除条件的元素,竟然没被删掉,还留在列表里;而且,我用来"处理每个元素"的逻辑,似乎漏处理了一些元素——遍历像是"跳着走"的,有些元素被悄悄地跳过了。最气人的是,这个问题,还和具体的数据排布有关:如果两个该删的元素挨在一起,就必有一个删不掉;数据一变,表现就变,忽好忽坏。我当时百思不得其解:我明明遍历了每一个元素啊,也明明对该删的调了 remove 啊,怎么会有的没删掉、有的没遍历到?我反复检查删除条件,逻辑没错。直到我去深究 for 循环遍历列表的底层机制,才恍然大悟,补上了关于"遍历"最重要的一课:问题的核心,是我在"遍历一个列表"的同时,又去"修改(删除元素)"这个列表——而这,会导致遍历的"位置"和列表的"实际内容",发生错位!具体来说:Python 的 for 循环遍历列表,底层是靠一个"索引(下标)"在往后走的——它先处理索引 0、再索引 1、再索引 2……可当我在遍历到索引 i 的元素、并把它 remove 删掉时,这个删除,会让它后面的所有元素,整体往前挪一位(原来索引 i+1 的,现在变成了索引 i)!可循环下一步,会去处理索引 i+1——而原来在 i+1 的那个元素,已经被挪到了 i,于是它就被跳过、没被遍历到了!这,就解释了我所有的困惑:为什么有的该删的没删掉?——因为它恰好"挪"到了被跳过的位置上,循环没遍历到它,自然没删。为什么有的元素漏处理了?——同样,因为删除导致后面元素前移,而循环索引继续后走,就跳过了那个前移过来的元素。我那个"一边遍历一边删"的直觉写法,根本性地违背了一条规则:你不能在"迭代一个集合"的同时,去"修改(增删)"这个集合——因为修改,会破坏迭代器赖以工作的、关于"位置/结构"的假设,让遍历乱套。
故障现场:删一个元素,后面的就整体前移、被跳过
我把这个"遍历时删除"的现场,用代码摊开给你看:
# ✗ 灾难: 一边遍历列表, 一边 remove 它的元素
nums = [1, 2, 2, 3, 4]
for x in nums:
if x == 2:
nums.remove(x) # ✗ 遍历时删除, 会导致后面元素前移、被跳过!
print(nums) # [1, 2, 3, 4] ← ??? 还有一个 2 没删掉!
# 为什么? for 循环遍历列表, 底层是"靠索引往后走"的:
# 遍历过程(索引 i 在走):
# - i=0: nums[0]=1, 不删。 i→1
# - i=1: nums[1]=2, 删! → nums 变成 [1,2,3,4], 后面的元素整体前移!
# (原本 i=2 的那个 2, 现在被挪到了 i=1)
# - i=2: 循环走到 i=2, nums[2]=3 —— 那个被挪到 i=1 的 2, 被跳过了!没遍历到!
# - i=3: nums[3]=4, 不删。
# → 第二个 2, 因为前移到了"已经走过"的位置, 被漏掉了, 没删成。
# 同理, "处理每个元素"的逻辑也会漏: 删一个 → 后面前移 → 索引后走 → 跳过一个。
# 与数据排布有关: 两个该删的挨在一起 → 必漏一个; 不挨着 → 可能碰巧没事。
# → 忽好忽坏, 难复现, 极易上线后才暴露。
# 根因: 在"遍历(迭代)"一个列表的同时, "修改(删除)"了它。
# 迭代靠索引/迭代器走, 而删除改变了元素的位置和长度 → 遍历错位、跳过元素。
# (注意: 对 dict/set 遍历时增删, Python 会直接抛 RuntimeError, 更严格)
看着这段代码和那个索引走位的过程,我才算真正理解了这个"忽好忽坏"的根源。问题的核心,是我在"遍历一个列表"的同时,又去"修改(删除元素)"了它——而这,导致了遍历的"位置",和列表的"实际内容",发生了错位。要理解这一点,得知道 Python 的 for 循环遍历列表,底层是靠一个"索引"在往后走的(处理完索引 0,就去索引 1,再索引 2……)。而当我遍历到索引 1 的那个 2、并把它 remove 删掉时,关键的事情发生了:这个删除,让它后面的所有元素,整体往前挪了一位——原本在索引 2 的那第二个 2,现在被挪到了索引 1。可循环下一步,会去处理索引 2(而不是再看一次索引 1)——于是,那个被挪到索引 1 的第二个 2,就被彻底跳过、再也没被遍历到了!这就完美解释了我所有的困惑:为什么有的该删的没删掉?——因为它(第二个 2)恰好被"挪"到了循环已经走过的位置上,循环没再遍历到它,自然就没删。为什么有的元素漏处理了?——同理,删除导致后面元素前移、而循环索引继续后走,就跳过了那个前移过来的元素。这也解释了为什么它"和数据排布有关、忽好忽坏":两个该删的元素如果挨在一起,就必然漏掉一个;如果不挨着,可能就碰巧没出问题——所以它表现不稳定、难复现,极易在上线后、数据变了才暴露。归根结底:我那个"一边遍历、一边删"的直觉写法,根本性地违背了一条规则:你不能在"迭代一个集合"的同时,去"修改(增删)"这个集合。因为,迭代器(或索引)是依赖于集合的"结构/位置"来工作的;而你的增删,破坏了这个结构,让迭代器赖以前进的假设失效,于是遍历就乱套了——有的元素被跳过、有的被重复。(顺便一提:对列表,Python 是"悄悄地"出错、跳过元素;而对字典或集合,如果你在遍历时增删,Python 会更严格地直接抛出 RuntimeError,反而更容易发现。)
第一件事:搞懂"遍历时修改集合"会让迭代错位
定位到根源,我必须把"为什么遍历时不能改集合"这件事,彻底搞清楚:
为什么"遍历一个集合的同时修改它"会出错
# 迭代(遍历)是怎么工作的?
# - 它靠一个"游标"(对列表是索引, 对其它是迭代器内部状态)在集合上移动。
# - 这个游标, 依赖集合当前的"结构/长度/元素位置"来决定"下一个是谁"。
# 你在遍历时增删元素, 就破坏了这个依赖:
# - 删元素: 后面的元素位置变了(前移), 游标却照常后走 → 跳过元素。
# - 加元素: 可能重复遍历, 甚至无限循环。
# - 改变长度: 游标可能越界, 或漏掉、或重复。
# → 游标的"位置假设", 和集合的"实际状态", 错位了。
# 不同集合, 表现不同:
# - list: Python 不报错, 但"静默地"跳过/重复元素(本文, 最坑——错了还不知道)。
# - dict / set: 遍历时增删 → 直接抛 RuntimeError: dictionary changed size...
# (Python 主动检测并报错, 反而帮你发现问题)
# - 其它语言: Java 的 ArrayList 遍历时改 → ConcurrentModificationException。
# → 很多语言/集合, 都明确禁止"遍历时修改"(因为它本质就是危险的)。
# 一个通用规则: "别在迭代一个集合的同时, 修改这个集合的结构(增删)"。
# (只改元素的"值"、不改"结构"通常没问题; 但增删元素一定要小心)
# 怎么办? 把"遍历"和"修改"分开:
# - 遍历"副本", 修改"原集合"(遍历的和改的不是同一个)。
# - 或: 不改原集合, 而是"产出一个新集合"(过滤/推导)。
# - 或: 倒序遍历删除(删后面不影响前面的索引)。
# 核心: 迭代依赖集合结构稳定; 遍历时增删会破坏它, 导致跳过/重复/报错。
# 要么"遍历副本", 要么"产出新集合", 别原地边遍历边增删。
原理终于刻进脑子里了。要理解这个坑,得先知道迭代(遍历)是怎么工作的:它靠一个"游标"(对列表是索引,对其它集合是迭代器的内部状态),在集合上移动;而这个游标,依赖于集合当前的"结构、长度、元素位置",来决定"下一个该处理谁"。所以,当你在遍历时增删元素,就破坏了这个依赖:删元素,会让后面的元素位置变化(前移),而游标却照常后走,就跳过了元素;加元素,可能导致重复遍历、甚至无限循环;总之,改变了集合的结构,就让游标的"位置假设",和集合的"实际状态",发生了错位。而不同的集合,表现还不一样:对 list,Python 不报错,但会"静默地"跳过或重复元素(这是最坑的——你错了,却还不知道);对 dict/set,遍历时增删会直接抛 RuntimeError(Python 主动检测并报错,反而帮你及早发现);在其它语言里,比如 Java 的 ArrayList,遍历时改会抛 ConcurrentModificationException——可见,很多语言和集合,都明确禁止"遍历时修改",因为这件事,本质上就是危险的。由此,我得到了一条通用的规则:"别在迭代一个集合的同时,修改这个集合的结构(增删元素)"(只改元素的"值"、不改"结构",通常没问题;但增删元素,一定要小心)。而正确的做法,核心是把"遍历"和"修改"分开:要么遍历"副本"、修改"原集合"(让遍历的和修改的,不是同一个东西);要么不改原集合,而是"产出一个新集合"(用过滤/推导);要么倒序遍历删除(从后往前删,删后面的不影响前面元素的索引)。归根结底:迭代,依赖集合结构的稳定;而遍历时增删,会破坏它,导致跳过、重复、或报错。所以,要么遍历副本,要么产出新集合,绝不要原地"边遍历边增删"——这,是我用一个"忽好忽坏、漏删漏处理"的 bug,补上的、关于遍历最关键的一课。
第二件事:正解——产出新列表,而不是原地边删边遍历
搞懂了根因——"遍历时增删破坏迭代"——正解就清晰了:最推荐、也最 Pythonic 的做法,是不去原地修改原列表,而是用"列表推导式"产出一个新的、只含要保留元素的列表;此外,也可以遍历原列表的一个副本(改原列表)、或倒序遍历来删除。核心,是把"遍历"和"修改"分开。
# 正解1(最推荐, 最 Pythonic): 用列表推导式, 产出"新列表"(只留要保留的)
nums = [1, 2, 2, 3, 4]
nums = [x for x in nums if x != 2] # ✓ 产出新列表, 只含 != 2 的
print(nums) # [1, 3, 4] ✓ 全对!
# 思路: 不"删掉不要的", 而是"留下要的"——产出一个全新的、正确的列表。
# (filter 也行: nums = list(filter(lambda x: x != 2, nums)))
# 正解2: 遍历"副本", 修改"原列表"(遍历的和改的不是同一个)
nums = [1, 2, 2, 3, 4]
for x in nums[:]: # nums[:] 是副本! 遍历副本, 改原 nums
if x == 2:
nums.remove(x) # ✓ 遍历的是副本(结构不变), 删的是原列表, 不会错位
print(nums) # [1, 3, 4] ✓
# (list(nums)、nums.copy() 都能得到副本)
# 正解3: 倒序遍历删除(从后往前, 删后面不影响前面的索引)
nums = [1, 2, 2, 3, 4]
for i in range(len(nums) - 1, -1, -1): # 从最后一个索引往前
if nums[i] == 2:
del nums[i] # ✓ 删 i 只影响 i 之后的, 而 i 之后的已经遍历过了
print(nums) # [1, 3, 4] ✓
# 字典/集合同理(它们遍历时改会直接 RuntimeError):
d = {"a": 1, "b": 0, "c": 2}
d = {k: v for k, v in d.items() if v != 0} # ✓ 字典推导, 产出新字典
# 或遍历 key 的副本: for k in list(d): if d[k]==0: del d[k]
# 选择建议:
# - 首选"产出新集合"(列表/字典推导、filter)——清晰、安全、Pythonic。
# - 必须"原地改"时, 用"遍历副本"或"倒序删除"。
# - 绝不"原地正序边遍历边增删"。
# 核心: 把"遍历"和"修改"分开。优先"产出新集合", 别原地边遍历边改。
这套正解,核心是把"遍历"和"修改"这两件互相干扰的事,分开来做。正解1(产出新列表,最推荐、最 Pythonic):换一个思路——不去"删掉不要的",而是"留下要的";用一个列表推导式,产出一个全新的、只包含你想保留的元素的列表(nums = [x for x in nums if x != 2])。这个写法清晰、安全(它在遍历旧列表、构建新列表,两者不冲突)、而且非常 Pythonic(filter 也是同样的思路)。正解2(遍历副本,修改原列表):如果你必须原地修改原列表,那就遍历它的一个副本(for x in nums[:],nums[:] 是副本)——这样,你遍历的副本(结构不变)和修改的原列表,不是同一个东西,就不会错位了。正解3(倒序遍历删除):从最后一个索引往前遍历删除——因为删除索引 i 的元素,只会影响 i 之后的元素位置,而 i 之后的,你已经遍历过了,所以不受影响。字典和集合也是同样的道理(它们遍历时改会直接抛 RuntimeError):用字典推导产出新字典,或遍历 key 的副本(for k in list(d))再删。而选择建议是:首选"产出新集合"(列表/字典推导、filter)——它最清晰、安全、Pythonic;必须"原地改"时,用"遍历副本"或"倒序删除";而绝不要"原地正序地、边遍历边增删"。归根结底:把"遍历"和"修改"分开;优先"产出新集合",而不是在原集合上边遍历边改。我那次的错误,正是把这两件事搅在了一起;而正解,就是把它们干净利落地分开。
下面这张图,对比了"原地边遍历边删"和"产出新集合"两条路径:
这张图的对比很清楚:左边红色那条,原地正序边遍历边 remove,删一个后面就前移、索引继续后走跳过前移来的元素,导致有的没删掉、有的漏处理、忽好忽坏;右边绿色那条,用推导式/filter 产出新集合——遍历旧的、构建只含要保留元素的新的,遍历和修改分离、互不干扰,结果全对、清晰安全。两条路的根本分野,在于你有没有把"遍历"和"修改"分开。
第三件事:其它"遍历时改集合"的坑
填平了列表这个坑,我系统排查了一遍"在迭代时改动东西"的其它常见坑:
# 其它"迭代时改动"的坑:
# 1. 遍历 list 时 remove/append(本文): 静默跳过/重复元素(列表不报错, 最坑)。
# 2. 遍历 dict 时增删 key: 直接抛 RuntimeError!
for k in d: # ✗
if cond: del d[k] # RuntimeError: dictionary changed size during iteration
# ✓ 遍历副本: for k in list(d): if cond: del d[k]
# ✓ 或字典推导产出新字典。
# 3. 遍历 set 时增删: 同样 RuntimeError。
# 4. 遍历时 append, 可能无限循环(边遍历边加, 永远加不完)。
# 5. 多个引用指向同一列表, 一处遍历、另一处(如别的线程/函数)修改 → 同样错位。
# (并发场景下尤其危险)
# 6. 遍历"生成器"时, 改它依赖的数据源 → 行为难预料(生成器是惰性的)。
# 7. 修改"嵌套结构"时, 也要小心(改外层的同时遍历内层等)。
# 通用安全模式:
# - 要过滤 → 推导式/filter, 产出新集合(首选)。
# - 要原地删 → 遍历副本 list(x), 或倒序。
# - 要原地加 → 先收集到另一个列表, 遍历完再 extend 回去。
# - 并发 → 加锁, 或用并发安全的集合, 或遍历快照。
# 核心: "迭代一个东西" 和 "修改这个东西", 是天生冲突的两件事。
# 把它们分开(遍历副本 / 产出新的), 是处理这类问题的通用安全模式。
这一排查,让我对"迭代时改动"的各种坑,有了全面的警觉。除了遍历 list 时 remove(本文,列表不报错、静默出错,最坑),还有:遍历 dict 时增删 key(直接抛 RuntimeError: dictionary changed size during iteration——要遍历 list(d) 副本、或用字典推导);遍历 set 时增删(同样 RuntimeError);遍历时 append(可能无限循环,边遍历边加、永远加不完);多个引用指向同一列表,一处遍历、另一处(如别的线程、别的函数)修改(同样会错位,并发场景下尤其危险);遍历"生成器"时改它依赖的数据源(行为难预料,因为生成器是惰性的);以及修改嵌套结构时也要小心。而通用的安全模式是:要过滤就用推导式/filter 产出新集合(首选);要原地删就遍历副本 list(x) 或倒序;要原地加就先收集到另一个列表、遍历完再 extend 回去;并发场景就加锁、或用并发安全的集合、或遍历快照。归根结底:"迭代一个东西"和"修改这个东西",是天生冲突的两件事;而把它们分开(遍历副本、或产出新的),正是处理这一整类问题的通用安全模式。把这个模式刻进习惯,这类静默又诡异的"遍历时改动"bug,就再也绊不倒你了。
第四件事:不可变式思维——产出新值,而非原地修改
这次踩坑,把我引向了一个更有价值的编程思想:与其"原地修改"一个东西,不如"产出一个新的"。我把它系统地梳理了出来:
# 不可变式思维: 优先"产出新值", 而非"原地修改"
# 两种风格:
# A. 原地修改(mutate in place): 在原对象上改(remove/append/改字段)。
# nums.remove(x) # 改的就是 nums 本身
# B. 产出新值(produce new): 不改原对象, 而是基于它算出一个新对象。
# new = [x for x in nums if x != target] # nums 不变, 产出 new
# 为什么"产出新值"往往更好?
# 1. 没有"遍历时修改"的冲突(本文): 遍历旧的、产出新的, 天然分离。
# 2. 不会"意外改到"别人共享的对象(原地改会影响所有引用它的地方)。
# 3. 更可预测、易推理: 输入→输出, 没有"副作用"埋伏在某处。
# 4. 对并发更友好: 不共享可变状态, 就少很多竞态。
# 5. 易于"回溯/对比": 旧值还在, 能对比前后、能撤销。
# 这正是"函数式编程"和"不可变数据"推崇的核心理念:
# - 用 map/filter/reduce、推导式, 从旧数据"算出"新数据。
# - 而不是 for 循环里, 对一个共享的可变对象, 修修补补。
# 当然, 原地修改也有它的位置:
# - 性能敏感(大数据, 不想复制)、或确实需要"就地"语义时。
# - 但默认, 优先"产出新值"——更安全、更清晰。
# 一句话: "别改它, 而是基于它造一个新的"——
# 这个简单的思维转变, 能避开"遍历时修改""共享对象被意外改"等一大类坑。
# 核心: 优先"产出新值"而非"原地修改"。
# 遍历时修改的坑, 本质是"原地修改"惹的祸; 换成"产出新值", 它就消失了。
这一思考,让我从一个具体的 bug,上升到了一个更普适的编程思想。处理数据,有两种风格:A. 原地修改(mutate in place)——在原对象上直接改(remove/append/改字段);B. 产出新值(produce new)——不改原对象,而是基于它,算出一个新对象(如用推导式 new = [x for x in nums if x != target],nums 本身不变)。而为什么"产出新值"往往更好?有好几个理由:第一,没有"遍历时修改"的冲突(本文)——遍历旧的、产出新的,两者天然分离;第二,不会"意外改到"别人共享的对象(原地改,会影响所有引用着它的地方);第三,更可预测、易推理——输入到输出,清清楚楚,没有"副作用"埋伏在某个角落;第四,对并发更友好——不共享可变状态,就少了很多竞态;第五,易于回溯/对比——旧值还在,你能对比前后、能撤销。而这,正是"函数式编程"和"不可变数据"所推崇的核心理念:用 map/filter/reduce、用推导式,从旧数据"算出"新数据;而不是,在一个 for 循环里,对一个共享的可变对象,反复地修修补补。当然,原地修改也有它的位置:在性能敏感(大数据、不想复制一份)、或确实需要"就地"语义时,原地改是合理的;但默认情况下,应优先"产出新值"——它更安全、更清晰。归根结底,我领悟到一个简单却强大的思维转变:"别改它,而是基于它,造一个新的"——这个转变,能让你一举避开"遍历时修改""共享对象被意外改"等一大类坑。遍历时修改的坑,本质上,是"原地修改"惹的祸;一旦换成"产出新值"的思路,这个坑,就自然而然地消失了。把"原地修改"和"产出新值"两种风格对比成一张表:
| 维度 | 原地修改(mutate) | 产出新值(produce new) |
|---|---|---|
| 遍历时改 | 会冲突,跳过/报错 | 遍历旧、产出新,无冲突 |
| 共享对象 | 会意外改到别处 | 原对象不变,安全 |
| 可推理性 | 副作用埋伏各处 | 输入→输出,清晰 |
| 并发 | 共享可变状态,易竞态 | 少竞态,更友好 |
| 适用 | 性能敏感/需就地 | 默认首选,更安全 |
第五件事:别在"观察一个东西"的同时"改变它"
这次踩坑,在认知层面给了我最大的纠偏——它让我领悟到一个超越编程的、普遍的原则。我把这层反思,沉淀了下来:
认知纠偏: 别在"依赖某状态遍历/观察"的同时, 去"改变那个状态"
# 我的误解(错误的):
# 我想当然地以为"一边遍历一边改"是直觉、自然的; 没意识到这两件事
# 会互相干扰——我"观察(遍历)"所依赖的东西, 被我自己"改变"了。
# 真相: "观察一个东西"和"改变这个东西", 同时进行, 会互相干扰
# - 遍历依赖"集合结构稳定", 而你改了结构 → 遍历乱套(本文)。
# - 这其实是个普遍现象: 当 A 依赖 B 的某个状态, 而你在 A 进行时改了 B,
# A 就可能基于"已失效的假设"出错。
# 这个原则, 在很多地方都有体现:
# - 遍历集合时改集合(本文)。
# - 迭代器失效(C++ 容器增删使迭代器失效)。
# - 回调/事件处理中, 改变正在被遍历的监听器列表。
# - 渲染(读取状态)的同时改状态(很多 UI 框架要求别在渲染中改 state)。
# - 读一个文件的同时写它。
# → 共同模式: "基于某状态做事"时, 那个状态被"同时"改变了 → 出错。
# 正确的习惯:
# 1. 警惕"一边读/遍历 X, 一边写/改 X"的代码——它往往有坑。
# 2. 把"读/遍历"和"写/改"在时间上分开, 或操作不同的副本。
# 3. 大原则: 在依赖一个东西的稳定性做事时, 别同时破坏它的稳定性。
核心: 别在"遍历/依赖一个状态"的同时, 去"改变那个状态"。
把"读"和"写"分开——这是避开一大类"自己干扰自己"的 bug 的通用智慧。
这层反思,是这次踩坑给我最高维度的收获。复盘我的误解,根源是:我想当然地以为,"一边遍历、一边改",是个直觉的、自然的写法;却没意识到,这两件事会互相干扰——我"观察(遍历)"所依赖的那个东西,被我自己"改变"了。可真相是:"观察一个东西"和"改变这个东西",如果同时进行,就会互相干扰——遍历,依赖"集合结构的稳定",而你改了结构,遍历就乱套了(本文);这其实是一个普遍的现象:当 A 依赖 B 的某个状态、而你在 A 进行的过程中改了 B,A 就可能基于"已经失效的假设"而出错。而这个原则,在许许多多的地方,都有体现:遍历集合时改集合(本文);C++ 容器增删导致的迭代器失效;在回调/事件处理中,改变正在被遍历的监听器列表;渲染(读取状态)的同时改状态(很多 UI 框架,都明确要求"别在渲染过程中改 state");读一个文件的同时写它——它们的共同模式,都是:"基于某个状态做事"的时候,那个状态,被"同时"改变了,于是出错。由此,我立下了几条习惯:第一,警惕"一边读/遍历 X、一边写/改 X"的代码——它往往藏着坑;第二,把"读/遍历"和"写/改",在时间上分开,或者让它们操作不同的副本;第三,记住那个大原则:在依赖一个东西的稳定性做事时,别同时去破坏它的稳定性。归根结底:别在"遍历/依赖一个状态"的同时,去"改变那个状态";把"读"和"写"分开——这,是避开一大类"自己干扰自己"的 bug 的、通用的智慧。我那个忽好忽坏的漏删 bug,正是一次典型的"自己干扰自己"。
| 维度 | 边读边改(踩坑) | 读写分开(智慧) |
|---|---|---|
| 遍历集合 | 边遍历边增删 | 遍历副本/产出新集合 |
| UI | 渲染中改 state | 渲染只读,改在别处 |
| 文件 | 边读边写同一个 | 读写分离/写到新文件 |
| 本质 | 破坏所依赖的稳定性 | 依赖时不破坏它 |
| 结果 | 基于失效假设出错 | 稳定可靠 |
一套"遍历时要增删该怎么做"的决策流程
把这次踩坑的全部教训,我浓缩成了一张"想在遍历一个集合时增删元素、该怎么做"的决策图,贴在了团队的规范里:
这张图,把我"血泪换来"的整套方法论,串成了一条可执行的路径:想遍历集合并增删,先问目的是什么——是过滤(留下满足条件的)就用推导式/filter 产出新集合(首选);必须原地改原集合的,删元素就遍历副本或倒序删、加元素就先收集到新列表、遍历完再 extend 回去。而如果操作的是 dict/set,绝不能直接遍历时增删(会 RuntimeError),要遍历副本或用推导。这条"先想目的、优先产出新集合"的决策链,现在是我们团队处理每一个"遍历时增删"需求时的准则。
我立下的几条遍历与修改规矩
这次"遍历时删元素漏删"的踩坑,让我把遍历与修改的注意事项,认真地立成了几条规矩:
- 绝不原地正序边遍历边增删。列表会静默跳过/重复元素(不报错最坑),dict/set 会 RuntimeError。
- 过滤优先用推导式/filter 产出新集合。最清晰、安全、Pythonic;不改原集合。
- 必须原地删就遍历副本或倒序。
for x in lst[:]改原 lst,或range(len-1,-1,-1)倒序 del。 - 必须原地加就先收集后 extend。别在遍历中 append,可能无限循环。
- dict/set 遍历改 key 要遍历
list(d)副本。或用字典推导产出新字典。 - 优先"产出新值"而非"原地修改"。更安全、可预测、并发友好,避开一大类共享对象坑。
- 别在依赖一个状态做事时改变它。读和写分开——遍历集合时改集合、渲染时改 state 都是反面。
写在最后
这次"我一边遍历列表一边删元素、结果漏删漏处理"的经历,是我在 Python 路上,一次很经典、也很受用的成长。它教给我的,远不止"遍历时别改集合"这一条具体的技术经验,更是一个超越编程语言的、普遍的智慧——别在"依赖一个状态做事"的同时,去"改变那个状态";把"读"和"写"分开。我那个忽好忽坏的漏删 bug,根源就在于,我在"遍历(读)"一个列表的同时,又去"删除(写)"了它,亲手破坏了遍历所依赖的那个结构的稳定性,让自己干扰了自己。
所以,当你写下"一边遍历一个东西、一边修改这个东西"的代码时,请警觉起来——无论是遍历集合时增删、渲染时改状态、还是读文件时写它,这类"边读边写同一个对象"的写法,往往都藏着坑。更好的思路,是把"读"和"写"在时间上分开,或者干脆,换一种"产出新值"的思维:别去原地改它,而是基于它,造一个新的。就像从列表里删元素,你只要用一句 [x for x in nums if ...] 产出一个新列表,就再也不会经历那种"漏删漏处理、忽好忽坏"的抓狂。从"原地修改"的直觉,到"产出新值"的清醒,从"边读边写"的隐患,到"读写分离"的稳健,是从一个"会写循环"的开发,走向一个"懂数据、思维严谨"的工程师,必经的修炼。愿你写的每一次遍历,都干净利落、不漏不重;也愿你我,永远记得,别在依赖一个东西的时候,亲手去动摇它。共勉。
—— 别看了 · 2026