1 前言
我记得之前在python
使用for
遍历list
时不建议删除和插入内部元素,原因之一是可能导致原来迭代器失效,python
报错抛出异常。但最近写的时候发现又没有这个问题了,一时之间不清楚是我记错了现象还是记错了用法。
于是我做了一些实验,翻了一些源码,分析一下原因。
2 使用迭代器遍历时插入
迭代器遍历在python
里就很简单,举一个简单的例子:
输出:
2.1 尾部插入
那在上面的基础上,我们改一下代码,希望在访问到元素7的时候在尾部追加元素10,我们先这么写:
输出:
5
6
7
8
9
10
一个非常有意思的事情发生了,我们看到元素10
被迭代器访问并输出到控制台中。由此我们得到一个现象:
list
使用迭代器访问时,在尾部插入元素,迭代器也可以访问到该元素。
也就是说,list
的迭代器在插入后它的下限(end)在某种机制下更新了,以至于能访问到最后一个新插入的元素。
2.2 头部插入
那我们换一个位置插入试试,这次我们把目标放到了头部:
输出:
5
6
7
7
7
7
7
7
...
这个时候我们发现迭代器好像到7就不走了,这就有点反直觉了,一般我认为迭代器是指向元素7
的,那么它在下一次遍历时应该会next
指向8
,与列表前是否插入元素无关才对。
2.3 源码分析
为了明白上面头插和尾插的异常现象,我去翻了一下python3.7.9
的源码(https://www.python.org/ftp/python/3.7.9/Python-3.7.9.tgz),并翻到了list
的迭代器实现。
https://github.com/python/cpython/blob/13c94747c74437e594b7fc242ff7da668e81887c/Objects/listobject.c#L3109
首先,我们需要知道python
的所有类型、对象都是PyTypeObject
类型衍生出来的,就比如list
的迭代器类型定义:
我们注意到该迭代器的tp_iternext
部分赋值了一个iternextfunc
类型的listiter_next
函数,我们看一下它的实现:
这部分逻辑很简单,就是从it
迭代器中获取it_index
(这个就是list
数组元素下标),如果it_index
比list
的size
小,那么通过下标访问获取到元素item
,将it_index
加1,最终将item
返回。
我们可以注意到,这里的迭代器访问本质上就是下标访问,这也能解释上面尾插和头插的奇异现象了:
- 对于尾插来说,每次
for
迭代器遍历时都会判断if (it->it_index < PyList_GET_SIZE(seq))
,也就是每次遍历都会校验当前下标和list
的大小,这样当遍历的时候插入元素,也可以正常访问到最后一个元素。
- 对于头插来说,每次迭代器遍历都是下标访问,那么原来元素7的下标是2,头插后下标变成3,此时迭代器中的
it_index
仍是3,还是访问元素7,这样一直死循环。
3 举一反三
3.1 删除元素
那基于原理,我们可以认为从list
中删除元素理论上也会出现类似的问题,我们来一个试一试:
输出
我们注意到元素8
没了,说明到7的时候,我们把元素7删除,此时下标2的元素变成了8,迭代器在下次遍历时又+1,最终指向了9。
4 总结
总而言之,python
的list
迭代器是一个基于下标随机访问的封装产物,这就意味着我们在对list
迭代器遍历时应同样考虑这些“特性”,避免写一堆奇怪的bug。