04-Python循环中删除元素为什么崩了-迭代器内部状态揭秘
文章目录
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]=6 → 5 又被跳过了!
→ 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) 时直接停下来重构。
思考 && 总结
三句话总结:
- for 循环底层是迭代器协议:
iter()→next()→StopIteration。迭代器内部游标是顺序递增的,不管遍历过程中列表怎么变。 - 边遍历边删最安全的方案是列表推导式——不修改原容器,直接构建新容器,逻辑最干净。需要原地删除的场景用倒序遍历或 while 手动控制索引。
- Python 字典有跑迭代时改大小的保护(RuntimeError),但列表没有——这反而让列表的边遍历边删更危险,因为不会崩溃,只会产生错误的逻辑。
结尾
各位小伙伴,本文的内容到这里就全部结束了,源码骑士在这里再次感谢您的阅读!
源码骑士 — Python 全栈 & 系统架构
👀 关注:跟博主一起从源码视角深耕底层原理,见证每一次成长
❤️ 点赞:让优质内容被更多人看见,让知识传递更有力量
⭐ 收藏:把核心知识点存好,在需要时随时查、随时用
💬 评论:分享你的经验或疑问,评论区一起交流避坑
🔄 一键四连:不要忘记给博主"一键四连"哦!今日源码拆解达成!
🗡️ 寄语:技术之路难免有困惑,但同行的人会让前进更有方向
结语:下一讲进入字典的世界——Python 3.6+ 中的字典到底是无序还是有序?hash 表怎么存你的 key?我们从头拆到底。不要忘记给博主"一键四连"哦!
🗡博主在这里放了一只小狗,大家看完了摸摸小狗放松一下吧!🗡૮₍ ˶ ˊ ᴥ ˋ˶₎ა
本文来自 CSDN,仅供学习参考。
力量
⭐ 收藏:把核心知识点存好,在需要时随时查、随时用
💬 评论:分享你的经验或疑问,评论区一起交流避坑
🔄 一键四连:不要忘记给博主"一键四连"哦!今日源码拆解达成!
🗡️ 寄语:技术之路难免有困惑,但同行的人会让前进更有方向
结语:下一讲进入字典的世界——Python 3.6+ 中的字典到底是无序还是有序?hash 表怎么存你的 key?我们从头拆到底。不要忘记给博主"一键四连"哦!
更多推荐

所有评论(0)