1 前言
我记得之前在python
使用for
遍历list
时不建议删除和插入内部元素,原因之一是可能导致原来迭代器失效,python
报错抛出异常。但最近写的时候发现又没有这个问题了,一时之间不清楚是我记错了现象还是记错了用法。
于是我做了一些实验,翻了一些源码,分析一下原因。
2 使用迭代器遍历时插入
迭代器遍历在python
里就很简单,举一个简单的例子:
a = [5, 6, 7, 8, 9]
for i in a:
print(i)
输出:
5
6
7
8
9
2.1 尾部插入
那在上面的基础上,我们改一下代码,希望在访问到元素7的时候在尾部追加元素10,我们先这么写:
a = [5, 6, 7, 8, 9]
for i in a:
print(i)
if i == 7:
a.append(10)
输出:
5
6
7
8
9
10
一个非常有意思的事情发生了,我们看到元素10
被迭代器访问并输出到控制台中。由此我们得到一个现象:
list
使用迭代器访问时,在尾部插入元素,迭代器也可以访问到该元素。
也就是说,list
的迭代器在插入后它的下限(end)在某种机制下更新了,以至于能访问到最后一个新插入的元素。
2.2 头部插入
那我们换一个位置插入试试,这次我们把目标放到了头部:
a = [5, 6, 7, 8, 9]
for i in a:
print(i)
if i == 7:
a.insert(0, 4)
输出:
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
的迭代器实现。
首先,我们需要知道python
的所有类型、对象都是PyTypeObject
类型衍生出来的,就比如list
的迭代器类型定义:
typedef struct {
PyObject_HEAD
Py_ssize_t it_index;
PyListObject *it_seq; /* Set to NULL when iterator is exhausted */
} listiterobject;
static void listiter_dealloc(listiterobject *);
static int listiter_traverse(listiterobject *, visitproc, void *);
static PyObject *listiter_next(listiterobject *);
static PyObject *listiter_len(listiterobject *);
static PyObject *listiter_reduce_general(void *_it, int forward);
static PyObject *listiter_reduce(listiterobject *);
static PyObject *listiter_setstate(listiterobject *, PyObject *state);
PyDoc_STRVAR(length_hint_doc, "Private method returning an estimate of len(list(it)).");
PyDoc_STRVAR(reduce_doc, "Return state information for pickling.");
PyDoc_STRVAR(setstate_doc, "Set state information for unpickling.");
static PyMethodDef listiter_methods[] = {
{"__length_hint__", (PyCFunction)listiter_len, METH_NOARGS, length_hint_doc},
{"__reduce__", (PyCFunction)listiter_reduce, METH_NOARGS, reduce_doc},
{"__setstate__", (PyCFunction)listiter_setstate, METH_O, setstate_doc},
{NULL, NULL} /* sentinel */
};
PyTypeObject PyListIter_Type = {
PyVarObject_HEAD_INIT(&PyType_Type, 0)
"list_iterator", /* tp_name */
sizeof(listiterobject), /* tp_basicsize */
0, /* tp_itemsize */
/* methods */
(destructor)listiter_dealloc, /* tp_dealloc */
0, /* tp_print */
0, /* tp_getattr */
0, /* tp_setattr */
0, /* tp_reserved */
0, /* tp_repr */
0, /* tp_as_number */
0, /* tp_as_sequence */
0, /* tp_as_mapping */
0, /* tp_hash */
0, /* tp_call */
0, /* tp_str */
PyObject_GenericGetAttr, /* tp_getattro */
0, /* tp_setattro */
0, /* tp_as_buffer */
Py_TPFLAGS_DEFAULT | Py_TPFLAGS_HAVE_GC,/* tp_flags */
0, /* tp_doc */
(traverseproc)listiter_traverse, /* tp_traverse */
0, /* tp_clear */
0, /* tp_richcompare */
0, /* tp_weaklistoffset */
PyObject_SelfIter, /* tp_iter */
(iternextfunc)listiter_next, /* tp_iternext */
listiter_methods, /* tp_methods */
0, /* tp_members */
};
我们注意到该迭代器的tp_iternext
部分赋值了一个iternextfunc
类型的listiter_next
函数,我们看一下它的实现:
static PyObject *
listiter_next(listiterobject *it)
{
PyListObject *seq;
PyObject *item;
assert(it != NULL);
seq = it->it_seq;
if (seq == NULL)
return NULL;
assert(PyList_Check(seq));
if (it->it_index < PyList_GET_SIZE(seq)) {
item = PyList_GET_ITEM(seq, it->it_index);
++it->it_index;
Py_INCREF(item);
return item;
}
it->it_seq = NULL;
Py_DECREF(seq);
return NULL;
}
这部分逻辑很简单,就是从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
中删除元素理论上也会出现类似的问题,我们来一个试一试:
a = [5, 6, 7, 8, 9]
for i in a:
print(i)
if i == 7:
a.remove(7)
输出
5
6
7
9
我们注意到元素8
没了,说明到7的时候,我们把元素7删除,此时下标2的元素变成了8,迭代器在下次遍历时又+1,最终指向了9。
4 总结
总而言之,python
的list
迭代器是一个基于下标随机访问的封装产物,这就意味着我们在对list
迭代器遍历时应同样考虑这些“特性”,避免写一堆奇怪的bug。