Python 循环中删除元素为什么崩了?——迭代器内部状态与你不知道的事

📖 文章简介: Python初学者几乎都会遇到这个错误:用 for 循环遍历列表的同时删除元素,结果要么漏删、要么越界、要么直接报 RuntimeError。本文从迭代器的内部状态机讲起,拆解 for i in lst 底层到底做了什么——__iter____next__StopIteration 的完整链路。逐一分析五种常见边遍历边删除的场景和它们为什么会翻车,然后给出三种安全的解决方案:倒序遍历、列表推导式生成新列表、while循环手动控制索引。文末附一个真实案例——从日志清洗脚本中因为漏删导致的统计偏差,帮你彻底解决这个高频痛点。


在这里插入图片描述

🎬 个人主页: 源码骑士

专栏传送门: 《Android开发基础》《python基础课程》

⭐️热衷从源码视角拆解技术底层原理,将复杂架构讲得通俗易懂


🎬 源码骑士的简介:
5年Android Framework系统开发经验,曾主导多项系统级性能优化专项
技术栈覆盖Android系统全链路(Binder/Handler/AMS/WMS/启动流程)及Java后端全家桶(Spring + MyBatis + Redis + Oracle)
累计产出原创技术文章100+篇,文章以源码拆解为特色,被读者评价为"看一篇胜过啃一周文档"


导入语

你不一定知道"迭代器协议"这个概念,但我敢说你一定写过这种代码:

# 删除列表中的所有偶数
nums = [1, 2, 3, 4, 5, 6]
for num in nums:
    if num % 2 == 0:
        nums.remove(num)
print(nums)  # 你是不是期望得到 [1, 3, 5]?

实际输出:[1, 3, 5, 6]。6 没被删掉——漏了。

这个 Bug 我在一次日志清洗脚本中踩过。当时线上生成了几万条告警日志,需要过滤掉某些信息类的,结果清洗完发现数据量不对——漏删了大概几百条。排查了大半天才意识到是"边遍历边删元素"导致的。

根本原因不是"Python 设计有缺陷",而是你不清楚 for 循环在底层怎么和迭代器交互。 这篇文章把这个交互过程和你踩过的坑从头串一遍。


1 ~> for 循环的底层——你在写 for i in lst 时 Python 在干什么

1.1 现象

先看最基础的遍历,一切正常:

nums = [1, 2, 3]
for n in nums:
    print(n)    # 依次输出:1 2 3

1.2 原因分析

for i in lst 本质上是一个语法糖。Python 在底层做了三件事:

# 这是 for 循环背后的真正执行过程
nums = [1, 2, 3]

# 第一步:对可迭代对象调用 iter(),拿到迭代器
iterator = iter(nums)        # 等价于 nums.__iter__()

# 第二步:反复调用 next() 获取下一个元素
print(next(iterator))  # 输出:1
print(next(iterator))  # 输出:2
print(next(iterator))  # 输出:3

# 第三步:没有更多元素时,next() 抛出 StopIteration
print(next(iterator))  # StopIteration 异常

所以完整的 for 等价于:

iterator = iter(nums)
while True:
    try:
        n = next(iterator)  # 靠内部游标逐个取元素
    except StopIteration:
        break               # 游标走到末尾,停止
    # 你的循环体代码
    if n % 2 == 0:
        nums.remove(n)      # 但它在这里修改了列表本身!

1.3 核心问题

迭代器基于"游标"逐个取元素。它在取第 i 个元素时,并不知道——也不关心——列表本身是否被修改了。你用 remove 删了元素,列表缩短、元素前移,但迭代器内部游标继续往前走——最终跳过了一些元素。


2 ~> 五种边遍历边删的场景逐一拆解

2.1 场景一:remove()——漏删经典案例

nums = [1, 2, 3, 4, 5, 6]
for num in nums:
    if num % 2 == 0:
        nums.remove(num)
print(nums)  # [1, 3, 5, 6]  ← 6 漏了

执行过程逐步推演:

初始列表:[1, 2, 3, 4, 5, 6]
游标位置:↑

第1轮:游标=0,num=nums[0]=1 → 奇数,不删 → 游标移到 1
第2轮:游标=1,num=nums[1]=2 → 偶数,删除 2
       → 列表变成 [1, 3, 4, 5, 6],元素被前移,3 占据了原来 2 的位置
       → 游标=1,但 nums[1] 现在已经是 3 了 → 游标移到 2

第3轮:游标=2,num=nums[2]=4 → 不是 3!3 被跳过了!
       → 4 是偶数,删除 → 列表变成 [1, 3, 5, 6] → 游标移到 3

第4轮:游标=3,num=nums[3]=65 又被跳过了!
       → 6 是偶数,删除 → 列表 [1, 3, 5]

第5轮:游标=4,超出范围 → for 循环结束

最终:3 和 5 被跳过了(因为上游元素前移),6 没处理到。

关键发现:删除元素后,当前索引位置的元素已经是"下一个元素"了(前移过来的),但迭代器内部游标还是加 1,导致这个元素被跳过一次检查。 连续两个偶数靠在一起时,第二个必然漏删。

2.2 场景二:pop()——越界异常

nums = [1, 2, 3, 4, 5]
for i in range(len(nums)):
    if nums[i] % 2 == 0:
        nums.pop(i)

这种情况会直接崩:

初始:nums = [1, 2, 3, 4, 5],len=5
第1轮:i=0,nums[0]=1 → 奇数
第2轮:i=1,nums[1]=2 → 偶数,pop(1)
        nums变成 [1, 3, 4, 5],len=4
第3轮:i=2,nums[2]=4 → 偶数,pop(2)
        nums变成 [1, 3, 5],len=3
第4轮:i=3,nums[3] → IndexError: list index out of range

根因:for i in range(len(nums)) 在循环开始时确定了 range(len(nums)) 的范围(比如 range(5)),之后即使列表变短,i 仍按 range(5) 递增。当 i 大于当前列表的实际长度时,越界异常。 这跟 Java 的 for (int i = 0; i < list.size(); i++) 如果循环内 remove 也会 ConcurrentModificationException 的道理本质是一样的——遍历过程中,你操作的是正在被迭代的容器。

2.3 场景三:遍历字典时删除 key

d = {"a": 1, "b": 2, "c": 3}
for key in d:                        # ❌ RuntimeError
    if d[key] % 2 == 0:
        del d[key]
RuntimeError: dictionary changed size during iteration

Python 对字典做了保护——迭代过程中字典大小改变,直接抛 RuntimeError。列表没有这个保护,反而更容易埋下隐性 Bug。

2.4 场景四:for item in lst[:]——可以但浪费内存

nums = [1, 2, 3, 4, 5, 6]
for num in nums[:]:   # 遍历的是切片(一份拷贝)
    if num % 2 == 0:
        nums.remove(num)
print(nums)  # [1, 3, 5]  ← 对了!

nums[:] 创建了整个列表的浅拷贝,然后在这份拷贝上遍历。你对原始的 nums 做删除不会影响正在遍历的拷贝。能跑,但如果列表有几万条数据,拷贝一份内存开销翻倍。 内存敏感场景慎用。

2.5 场景五:同时删多个——彻底乱套

nums = [1, 2, 2, 3, 2, 4]
for num in nums:
    if num == 2:
        nums.remove(num)
print(nums)  # [1, 2, 3, 4]  ← 有一个 2 漏了

remove 只删找到的第一个匹配项,而且每次删除后列表结构都在变。结果完全不可预测。


3 ~> 三种安全的解决方案

3.1 方案一:列表推导式(最 Pythonic,推荐)

nums = [1, 2, 3, 4, 5, 6]
nums = [num for num in nums if num % 2 != 0]  # 保留奇数
print(nums)  # [1, 3, 5]

原理: 不是"边遍历边删除",而是"从原列表过滤出你想要的元素,构建一个新列表"。不修改旧容器,逻辑干净。

字典同理:

d = {"a": 1, "b": 2, "c": 3}
d = {k: v for k, v in d.items() if v % 2 != 0}

3.2 方案二:倒序遍历 + 删除

nums = [1, 2, 3, 4, 5, 6]
for i in range(len(nums)-1, -1, -1):   # 从最后一个往前遍历
    if nums[i] % 2 == 0:
        del nums[i]
print(nums)  # [1, 3, 5]

原理: 倒着删——删除后面的元素不会影响前面元素的下标。即使列表变短,还没遍历到的元素位置不受影响。

适用场景:当你确实需要原地修改原列表(不想创建新对象)。

3.3 方案三:while + 手动控制索引

nums = [1, 2, 3, 4, 5, 6]
i = 0
while i < len(nums):
    if nums[i] % 2 == 0:
        del nums[i]   # 删除后不增加 i,因为下一个元素自动移到了当前位置
    else:
        i += 1        # 不删才加 i
print(nums)  # [1, 3, 5]

原理: 手动管理游标——什么时候改索引你说了算。删了元素就停在原地(因为下一个元素前移过来了),不删才推进。


4 ~> 真实踩坑:日志清洗的统计偏差

2020 年写了一个日志清洗脚本,大概长这样:

# 清洗掉信息级日志,只保留 Warning 和 Error
log_entries = [
    "INFO: service started",
    "WARNING: disk 80%",
    "INFO: connection pool",
    "ERROR: database timeout",
    "INFO: gc completed",
    "WARNING: memory 90%"
]

for entry in log_entries:
    if entry.startswith("INFO:"):
        log_entries.remove(entry)

print(f"清洗后日志数: {len(log_entries)}")
# 期望:3 条(1错误2警告) 实际:4 条?

被跳过的 INFO 统计进去后 Warning 的占比下降,报警阈值一直没触发——这就是漏删引发的问题。改成了列表推导式一行搞定之后就没再出现过。从那以后,我强迫自己在看到 for item in list: list.remove(item) 时直接停下来重构。


思考 && 总结

三句话总结:

  1. for 循环底层是迭代器协议: iter()next()StopIteration。迭代器内部游标是顺序递增的,不管遍历过程中列表怎么变。
  2. 边遍历边删最安全的方案是列表推导式——不修改原容器,直接构建新容器,逻辑最干净。需要原地删除的场景用倒序遍历while 手动控制索引
  3. Python 字典有跑迭代时改大小的保护(RuntimeError),但列表没有——这反而让列表的边遍历边删更危险,因为不会崩溃,只会产生错误的逻辑。

结尾

各位小伙伴,本文的内容到这里就全部结束了,源码骑士在这里再次感谢您的阅读!

源码骑士 — Python 全栈 & 系统架构

👀 关注:跟博主一起从源码视角深耕底层原理,见证每一次成长

❤️ 点赞:让优质内容被更多人看见,让知识传递更有力量

收藏:把核心知识点存好,在需要时随时查、随时用

💬 评论:分享你的经验或疑问,评论区一起交流避坑

🔄 一键四连:不要忘记给博主"一键四连"哦!今日源码拆解达成!

🗡️ 寄语:技术之路难免有困惑,但同行的人会让前进更有方向

结语:下一讲进入字典的世界——Python 3.6+ 中的字典到底是无序还是有序?hash 表怎么存你的 key?我们从头拆到底。不要忘记给博主"一键四连"哦!

🗡博主在这里放了一只小狗,大家看完了摸摸小狗放松一下吧!🗡૮₍ ˶ ˊ ᴥ ˋ˶₎ა


本文来自 CSDN,仅供学习参考。
力量

收藏:把核心知识点存好,在需要时随时查、随时用

💬 评论:分享你的经验或疑问,评论区一起交流避坑

🔄 一键四连:不要忘记给博主"一键四连"哦!今日源码拆解达成!

🗡️ 寄语:技术之路难免有困惑,但同行的人会让前进更有方向

结语:下一讲进入字典的世界——Python 3.6+ 中的字典到底是无序还是有序?hash 表怎么存你的 key?我们从头拆到底。不要忘记给博主"一键四连"哦!

更多推荐