• 8. 解密Python中列表的底层实现,通过源码分析列表支持的相关操作


    楔子

    Python中的列表可以说使用的非常广泛了,在初学列表的时候,老师会告诉你列表就是一个大仓库,什么都可以存放。不过在最开始的几个章节中,我们花了很大的笔墨介绍了Python中的对象,并明白了Python中变量的本质,我们知道列表中存放的元素其实都是泛型指针PyObject *,所以到现在列表已经没有什么好神秘的了。

    并且根据我们使用列表的经验,我们可以得出以下两个结论:

    • 每个列表中的元素个数可以不一样:所以这是一个变长对象
    • 可以对列表中的元素进行添加、删除、修改等操作,所以这是一个可变对象

    在分析列表对应的底层结构之前,我们先来回顾一下列表的使用。

    # 创建一个列表,这里是通过Python/C API创建的
    >>> lst = [1, 2, 3, 4]
    >>> lst
    [1, 2, 3, 4]
    
    # 往列表尾部追加一个元素,此时是在本地操作的,返回值为None
    # 但是列表被改变了
    >>> lst.append(5)
    >>> lst
    [1, 2, 3, 4, 5]
    
    # 从尾部弹出一个元素,会返回弹出的元素
    >>> lst.pop()
    5
    # 此时列表也会被修改
    >>> lst
    [1, 2, 3, 4]
    # 另外在pop的时候还可以指定索引,弹出指定的元素
    >>> lst.pop(0)
    1
    >>> lst
    [2, 3, 4]
    
    # 也可以在指定位置插入一个元素
    >>> lst.insert(0, 'x')
    >>> lst
    ['x', 2, 3, 4]
    
    # 通过extend在尾部追加多个元素
    >>> lst.extend([7, 8, 9])
    >>> lst
    ['x', 2, 3, 4, 7, 8, 9]
    
    # 查找指定元素第一次出现的位置
    >>> lst.index(3)
    2
    
    # 计算某个元素在列表中出现的次数
    >>> lst.count(3)
    2
    
    # 翻转列表
    >>> lst.reverse()
    >>> lst
    [9, 8, 7, 4, 3, 2, 'x']
    
    # 根据元素的值删除第一个出现的元素
    >>> lst.remove(4)
    [9, 8, 7, 3, 2, 'x']
    
    # 清空列表
    >>> lst.clear()
    >>> lst
    []
    >>>
    

    上面的一些操作是列表经常使用的,但是在分析它的实现之前,我们肯定要了解它们的时间复杂度如何。这些东西即使不看源码,也是必须要知道的,尤其想要成为一名优秀的Python工程师。

    • append:会向尾部追加元素,所以时间复杂度为O(1)
    • pop:默认从尾部弹出元素,所以时间复杂度为O(1);如果不是尾部,而是从其它的位置弹出元素的话,那么该位置后面所有的元素都要向前移动,此时时间复杂度为O(n)
    • insert:向指定位置插入元素,该位置后面的所有元素都要向后移动,所以时间复杂度为O(1)

    注意:由于列表里面的元素个数是可以自由变化的,所以列表有一个容量的概念,我们后面会说。当添加元素时,列表可能会扩容;同理当删除元素时,列表可能会缩容。

    下面我们就来看一下列表对应的底层结构。

    列表的内部结构--PyListObject

    list 对象(列表)Python 内部,由 PyListObject 结构体表示,定义于头文件 Include/listobject.h 中:

    typedef struct {
        PyObject_VAR_HEAD
        PyObject **ob_item;
        Py_ssize_t allocated;
    } PyListObject;
    

    我们看到里面有如下成员:

    • PyObject_VAR_HEAD: 变长对象的公共头部信息
    • ob_item:一个二级指针,指向一个PyObject *类型的指针数组,这个指针数组保存的便是对象的指针,而操作底层数组都是通过ob_item来进行操作的。
    • allocated:容量, 我们知道列表底层是使用了C的数组, 而底层数组的长度就是列表的容量

    列表之所以要有容量的概念,是因为列表可以动态添加元素,但是底层的数组在创建完毕之后,其长度却是固定的。所以一旦添加新元素的时候,发现底层数组已经满了,这个时候只能申请一个更长的数组,同时把原来数组中的元素依次拷贝到新的数组里面(这一过程就是列表的扩容),然后再将新元素添加进去。但是问题来了,总不可能每添加一个元素,就申请一次数组、将所有元素都拷贝一次吧。所以Python在列表扩容的时候,会将底层数组申请的长一些,可以在添加元素的时候不用每次都申请新的数组。

    这便是列表的底层结构示意图,图中的object只是单纯的代指对象,不是Python中的基类object。我们看到底层数组的长度为5,说明此时列表的容量为5,但是里面只有3个PyObject *指针,说明列表的ob_size是3,或者说列表里面此时有3个元素。注意:尽管底层数组的容量目前是5个,但是我们访问的时候,最多只能访问到第三个元素,也就是说索引最大只能是2,这是显而易见的,因为列表里面只有3个元素。

    如果这个时候我们往列表中append一个元素,那么会将这个新元素设置在底层数组中索引为ob_size的位置、或者说第四个位置。一旦设置完,ob_size会自动加1,因为ob_size要和列表的长度保持一致。

    如果此时再往列表中append一个元素的话,那么还是将新元素设置在索引为ob_size的位置,此时也就是第5个位置。

    列表的容量是5,但此时长度也达到了5,这说明当下一次append的时候已经没有办法再容纳新的元素了。因为此时列表的长度、或者说元素个数已经达到了容量,当然最直观的还是这里的底层数组,很明显全都占满了。那这个时候如果想再接收新的元素的话,要怎么办呢?显然只能扩容了。

    原来的容量是5个,长度也是5个,当再来一个新元素的时候由于没有位置了,所以要扩容。但是扩容的时候肯定会将容量申请的大一些、即底层数组申请的长一些(具体申请多长,Python内部有一个公式,我们后面会说),假设申请的新的底层数组长度是8,那么说明列表的容量就变成了8。然后将原来数组中的PyObject *按照顺序依次拷贝到新的数组里面,再让ob_item指向新的数组。最后将要添加的新元素设置在新的数组中索引为ob_size的位置、即第6个位置,然后将ob_size加1,此时ob_size就变成了6。

    以上便是列表底层在扩容的时候所经历的过程。

    由于扩容会申请新的数组,然后将旧数组的元素拷贝到新数组中,所以这是一个时间复杂度为O(n)的操作。而append可能会导致列表扩容,因此append最坏情况下也是一个O(n)的操作,只不过扩容不会频繁发生,所以append的平均时间复杂度还是O(1)。

    另外我们还可以看到一个现象,那就是Python中的列表在底层是分开存储的,因为PyListObject结构体实例并没有存储相应的指针数组,而是存储了指向这个指针数组的二级指针。显然我们添加、删除、修改元素等操作,都是通过ob_item这个二级指针来间接操作这个指针数组。

    所以底层对应的PyListObject实例的大小其实是不变的,因为指针数组没有存在PyListObject里面。但是Python在计算内存大小的时候是会将这个指针数组也算进去的,所以Python中列表的大小是可变的。

    而且我们知道,列表在append之后地址是不变的,至于原因上面的几张图已经解释的很清楚了。如果长度没有达到容量,那么append其实就是往底层数组中设置了一个新元素;如果达到容量了,那么会扩容,但是扩容只是申请一个新的指针数组,然后让ob_item重新指向罢了。所以底层数组会变,但是PyListObject结构体实例本身是没有变化的。因此列表无论是append、extend、pop、insert等等,只要是在本地操作,那么它的地址是不会变化的。

    下面我们再来看看Python中的列表所占的内存大小是怎么算的:

    • PyObject_VAR_HEAD: 24字节
    • ob_item: 二级指针, 8字节
    • allocated: 8字节

    但是不要忘记,在计算列表大小的时候,ob_item指向的指针数组也要算在内。所以:一个列表的大小 = 40 + 8 * 指针数组长度(或者列表容量)。注意是底层数组长度、或者列表容量,可不是列表长度,因为底层数组一旦申请了,不管你用没用,大小就摆在那里了。就好比你租了间房子,就算不住,房租该交还是得交。

    # 显然一个空数组占40个字节
    print([].__sizeof__())  # 40
    
    # 40 + 3 * 8 = 64
    print([1, 2, "x" * 1000].__sizeof__())  # 64
    # 虽然里面有一个长度为1000的字符串,但我们说列表存放的都是指针, 所以大小都是8字节
    
    
    # 注意: 我们通过l = [1, 2, 3]这种方式创建列表的话
    # 不管内部元素有多少个, 其ob_size和allocated都是一样的
    # 那么列表什么时候会扩容呢? 答案是在添加元素的时候发现容量不够了才会扩容
    lst = list(range(10))
    # 40 + 10 * 8 = 120
    print(lst.__sizeof__())  # 120
    # 这个时候append一个元素
    lst.append(123)
    print(lst.__sizeof__())  # 184
    # 我们发现大小达到了184, (184 - 40) // 8 = 18, 说明扩容之后申请的底层数据的长度为18 
    

    所以列表的大小我们就知道是怎么来的了,而且为什么列表在通过索引定位元素的时候,时间复杂度是O(1)。因为列表中存储的都是对象的指针,不管对象有多大,其指针大小是固定的,都是8字节。通过索引可以瞬间计算出偏移量,从而找到对应元素的指针,而操作指针会自动操作指针所指向的内存。

    print([1, 2, 3].__sizeof__())  # 64
    print([[1, 2, 3]].__sizeof__())  # 48
    

    相信上面这个结果,你肯定能分析出原因。因为第一个列表中有4个指针,所以是40 + 24 = 64;而第二个列表中有一个指针,所以是40 + 8 = 48。用一张图来展示一下[1, 2, 3][[1, 2, 3]]的底层结构,看看它们之间的区别:

    分析完PyListObject之后,我们来看看它支持的操作,显然我们要通过类型对象PyList_Type来查看。

    PyTypeObject PyList_Type = {
        PyVarObject_HEAD_INIT(&PyType_Type, 0)
        "list",
        sizeof(PyListObject),
        0,
        (destructor)list_dealloc,                   /* tp_dealloc */
        0,                                          /* tp_vectorcall_offset */
        0,                                          /* tp_getattr */
        0,                                          /* tp_setattr */
        0,                                          /* tp_as_async */
        (reprfunc)list_repr,                        /* tp_repr */
        0,                                          /* tp_as_number */
        &list_as_sequence,                          /* tp_as_sequence */
        &list_as_mapping,                           /* tp_as_mapping */
        //......
    };
    

    我们看到,列表支持序列型操作和映射型操作,下面我们就来分析一下。

    列表支持的操作

    我们看看平常使用的列表所支持的操作在底层是如何实现的。

    自动扩容

    我们先来说说列表的扩容,因为我们知道列表是会自动扩容的,那么什么时候会扩容呢?我们说列表扩容的时候,是在添加元素时发现底层数组已经满了的情况下才会扩容。换句话说,一个列表在添加元素的时候会扩容,那么说明在添加元素之前,其内部的元素个数和容量是相等的。然后我们看看底层是怎么实现的,这些操作都位于Objects/listobject.c中。

    static int
    list_resize(PyListObject *self, Py_ssize_t newsize)
    {   //参数self就是列表啦,newsize指的元素在添加之后的ob_size
        //比如列表的ob_size是5,那么在append的时候发现容量不够,所以会扩容,那么这里的newsize就是6
        //如果是extend添加3个元素,那么这里的newsize就是8
        //当然list_resize这个函数不仅可以扩容,也可以缩容,假设列表原来有1000个元素,这个时候将列表清空了
        //那么容量肯定缩小,不然会浪费内存,如果清空了列表,那么这里的newsize显然就是0
        
        //items是一个二级指针,显然是用来指向指针数组的
        PyObject **items;
        //新的容量,以及对应的内存大小
        size_t new_allocated, num_allocated_bytes;
        //获取原来的容量
        Py_ssize_t allocated = self->allocated;
    	
        //如果newsize达到了容量的一半,但还没有超过容量, 那么意味着newsize、或者新的ob_size和容量是匹配的,所以不会变化
        if (allocated >= newsize && newsize >= (allocated >> 1)) {
            assert(self->ob_item != NULL || newsize == 0);
            //只需要将列表的ob_size设置为newsize即可
            Py_SIZE(self) = newsize;
            return 0;
        }
    
        //走到这里说明容量和ob_size不匹配了,所以要进行扩容或者缩容。
        //因此要申请新的底层数组,申请多少个?这里给出了公式,一会儿我们可以通过Python进行测试
        new_allocated = (size_t)newsize + (newsize >> 3) + (newsize < 9 ? 3 : 6);
        //显然容量不可能无限大,是有范围的,当然这个范围基本上是达不到的
        if (new_allocated > (size_t)PY_SSIZE_T_MAX / sizeof(PyObject *)) {
            PyErr_NoMemory();
            return -1;
        }
    	
        //如果newsize为0,那么容量也会变成0,假设将列表全部清空了,容量就会变成0
        if (newsize == 0)
            new_allocated = 0;
        
        //我们说数组中存放的都是PyObject *, 所以要计算内存
        num_allocated_bytes = new_allocated * sizeof(PyObject *);
        //申请相应大小的内存,将其指针交给items
        items = (PyObject **)PyMem_Realloc(self->ob_item, num_allocated_bytes);
        if (items == NULL) {
            //如果items是NULL, 代表申请失败
            PyErr_NoMemory();
            return -1;
        }
        //然后让ob_item = items, 也就是指向新的数组, 此时列表就发生了扩容或缩容
        self->ob_item = items;
        //将ob_size设置为newsize, 因为它维护列表内部元素的个数
        Py_SIZE(self) = newsize;
        //将原来的容量大小设置为新的容量大小
        self->allocated = new_allocated;
        return 0;
    }
    

    我们看到还是很简单的,没有什么黑科技,下面我们就来分析一下列表扩容的时候,容量和元素个数之间的规律。其实在list_resize函数中是有注释的,其种一行写着:The growth pattern is: 0, 4, 8, 16, 25, 35, 46, 58, 72, 88, ...

    说明我们往一个空列表中不断append元素的时候,容量会按照上面的规律进行变化,我们来试一下。

    # 还记得底层是怎么改变容量的吗?
    # 我们说有一个公式: new_allocated = (size_t)newsize + (newsize >> 3) + (newsize < 9 ? 3 : 6);
    # 我们来看一下
    
    lst = []
    allocated = 0
    print("此时容量是: 0")
    
    for item in range(100):
        lst.append(item)  # 添加元素
        
        # 计算ob_size
        ob_size = len(lst)
    
        # 判断ob_size和当前的容量
        if ob_size > allocated:
            # lst的大小减去空列表的大小, 再除以8显然就是容量的大小, 因为不管你有没有用, 容量已经分配了
            allocated = (lst.__sizeof__() - [].__sizeof__()) // 8
            print(f"列表扩容啦, 新的容量是: {allocated}")
    """
    此时容量是: 0
    列表扩容啦, 新的容量是: 4
    列表扩容啦, 新的容量是: 8
    列表扩容啦, 新的容量是: 16
    列表扩容啦, 新的容量是: 25
    列表扩容啦, 新的容量是: 35
    列表扩容啦, 新的容量是: 46
    列表扩容啦, 新的容量是: 58
    列表扩容啦, 新的容量是: 72
    列表扩容啦, 新的容量是: 88
    列表扩容啦, 新的容量是: 106
    
    Process finished with exit code 0
    """
    

    我们看到和官方给的结果是一样的,显然这是毫无疑问的,我们根据底层的公式也能算出来。

    ob_size = 0
    allocated = 0
    
    print(allocated, end=" ")
    for item in range(100):
        ob_size += 1
        if ob_size > allocated:
            allocated = ob_size + (ob_size >> 3) + (3 if ob_size < 9 else 6)
            print(allocated, end=" ")
    # 0 4 8 16 25 35 46 58 72 88 106 
    

    但还是那句话,扩容是指解释器发现容量不够的情况下才会扩容,如果我们直接通过lst = []这种形式创建列表的话,那么其长度和容量是一样的。

    lst = [0] * 1000
    # 长度和容量一致
    print(len(lst), (lst.__sizeof__() - [].__sizeof__()) // 8)  # 1000 1000
    
    # 但此时添加一个元素的话, 那么ob_size会变成1001, 大于容量1000
    # 所以此时列表就要扩容了, 执行list_resize, 里面的new_size就是1001, 然后是怎么分配容量来着
    # new_allocated = (size_t)newsize + (newsize >> 3) + (newsize < 9 ? 3 : 6);
    print("新容量:", 1001 + (1001 >> 3) + (3 if 1001 < 9 else 6))  # 新容量: 1132
    
    # append一个元素,列表扩容
    lst.append(123)
    # 计算容量
    print((lst.__sizeof__() - [].__sizeof__()) // 8)  # 1132
    
    # 结果是一样的, 因为底层就是这么实现的, 所以结果必须一样
    # 只不过我们通过这种测试的方式证明了这一点, 也更加了解了底层的结构是什么样子的。
    

    介绍完扩容,那么介绍缩容,因为列表元素个数要是减少到和容量不匹配的话,也要进行缩容。

    举个生活中的例子,假设你租了10间屋子用于办公,显然你要付10间屋子的房租,不管你有没有住,一旦租了肯定是要付钱的。同理底层数组也是一样,只要你申请了,不管有没有元素,内存已经占用了。但有一天你用不到10间屋子了,假设会用8间或者9间,那么会让剩余的屋子闲下来。但由于退租比较麻烦,并且只闲下来一两间屋子,所以多余的屋子就不退了,还是会付10间屋子的钱,这样当没准哪天又要用的时候就不用重新租了。对于列表也是如此,如果在删除元素(相当于屋子不用了)的时候发现长度没有超过容量但是又达到了容量的一半,所以也不会缩容。但是,如果屋子闲了8间,也就是只需要两间屋子就足够了,那么此时肯定要退租了,闲了8间,可能会退掉6间。

    lst = [0] * 1000
    print(len(lst), (lst.__sizeof__() - [].__sizeof__()) // 8)  # 1000 1000
    
    # 删除500个元素, 此时长度或者说ob_size就为500
    lst[500:] = []
    # 但是ob_size还是达到了容量的一半, 所以不会缩容
    print(len(lst), (lst.__sizeof__() - [].__sizeof__()) // 8)  # 1000 1000
    
    # 如果再删除一个元素的话, 那么不好意思, 显然就要进行缩容了, 因为ob_size变成了499, 小于1000 // 2
    # 缩容之后容量怎么算呢? 还是之前那个公式
    print(499 + (499 >> 3) + (3 if 499 < 9 else 6))  # 567
    
    # 测试一下, 删除一个元素, 看看会不会按照我们期待的规则进行缩容
    lst.pop()
    print(len(lst), (lst.__sizeof__() - [].__sizeof__()) // 8)  # 499 567
    

    一切都和我们想的是一样的,另外在代码中我们还看到一个if语句,就是如果newsize是0,那么容量也是0,我们来测试一下。

    lst = [0] * 1000
    print(len(lst), (lst.__sizeof__() - [].__sizeof__()) // 8)  # 1000 1000
    
    lst[:] = []
    print(len(lst), (lst.__sizeof__() - [].__sizeof__()) // 8)  # 0 0
    
    # 如果按照之前的容量变化公式的话, 会发现结果应该是3, 但是结果是0, 就是因为多了if判断:如果newsize是0, 就把容量也设置为0
    print(0 + (0 >> 3) + (3 if 0 < 9 else 6))  # 3
    # 但为什么要这么做呢?因为Python认为, 列表长度为0的话,说明你不想用这个列表了, 所以多余的3个也没有必要申请了
    
    # 我们还以租房为栗, 如果你一间屋子都不用了, 说明可能你不用这里的屋子办公了
    # 因此多余3间屋子也没有必要再租了, 所以直接全部退掉
    

    以上就是列表在改变容量时所采用的策略,我们从头到尾全部分析了一遍。

    append追加元素

    append方法用于像尾部追加一个元素,我们看看底层实现。

    static PyObject *
    list_append(PyListObject *self, PyObject *object)
    {	
        //显然调用的app1是核心, 它里面实现了添加元素的逻辑
        //Py_RETURN_NONE是一个宏,表示返回Python中的None, 因为list.append返回的就是None
        if (app1(self, object) == 0)
            Py_RETURN_NONE;
        return NULL;
    }
    
    static int
    app1(PyListObject *self, PyObject *v)
    {	//self是列表,v是要添加的对象
        
        //获取列表的长度
        Py_ssize_t n = PyList_GET_SIZE(self);
        assert (v != NULL);
        //如果长度已经达到了限制,那么无法再添加了, 会抛出OverflowError
        if (n == PY_SSIZE_T_MAX) {
            PyErr_SetString(PyExc_OverflowError,
                "cannot add more objects to list");
            return -1;
        }
    	
        //还记得这个list_resize吗? self就是列表, n + 1就是newsize,或者说新的ob_size
        //会自动判断是否要进行扩容, 当然里面还有重要的一步,就是将列表的ob_size设置成newsize、也就是这里的n + 1
        //因为append之后列表长度大小会变化,而ob_size则要时刻维护这个大小
        if (list_resize(self, n+1) < 0)
            return -1;
    	
        //因为v作为了列表的一个元素,所以其指向的对象的引用计数要加1
        Py_INCREF(v);
        //然后调用PyList_SET_ITEM,这是一个宏,它的作用就是设置元素的,我们下面会看这个宏长什么样
        //原来的列表长度为n, 里面的元素的最大索引是n - 1,那么追加的话就等于将元素设置在索引为n的地方
        PyList_SET_ITEM(self, n, v);
        return 0;
    }
    
    //我们说PyList_SET_ITEM是用来设置元素的,设置在什么地方呢?显然是设置在底层数组中
    //PyList_SET_ITEM一个宏,除了这个宏之外,还有很多其它的宏,它们位于Inlcude/listobject.h中
    #define PyList_GET_ITEM(op, i) (((PyListObject *)(op))->ob_item[i])
    #define PyList_SET_ITEM(op, i, v) (((PyListObject *)(op))->ob_item[i] = (v))
    #define PyList_GET_SIZE(op)    (assert(PyList_Check(op)),Py_SIZE(op))
    //这些宏的作用是啥,一目了然
    

    获取元素

    我们在使用列表的时候,可以通过val = lst[1]这种方式获取元素,那么底层是如何实现的呢?

    static PyObject *
    list_subscript(PyListObject* self, PyObject* item)
    {	
        //先看item是不是一个整型,显然这个item除了整型之外,也可以是切片
        if (PyIndex_Check(item)) {
            Py_ssize_t i;
            //这里检测i是否合法,因为Python的整型是没有限制的
            //但是列表的长度和容量都是由一个有具体类型的变量维护的,所以其个数肯定是有范围的
            //所以你输入一个lst[2 ** 100000]显然是不行的, 在Python中会报错IndexError: cannot fit 'int' into an index-sized integer
            i = PyNumber_AsSsize_t(item, PyExc_IndexError);
            
            //设置异常
            if (i == -1 && PyErr_Occurred())
                return NULL;
            
            //如果i小于0, 那么加上列表的长度, 变成正数索引
            if (i < 0)
                i += PyList_GET_SIZE(self);
            //然后调用list_item
            return list_item(self, i);
        }
        else if (PySlice_Check(item)) {
        //......
        }    
        else {
            //......
        }
    }
    
    
    static PyObject *
    list_item(PyListObject *a, Py_ssize_t i)
    {	
        //检测索引i的合法性,如果i > 列表的长度, 那么会报出索引越界的错误。
        if (!valid_index(i, Py_SIZE(a))) {
            //如果索引为负数也会报出索引越界错误,因为上面已经对负数索引做了处理了,但如果负数索引加上长度之后还是个负数, 那么同样报错。
            //假设列表长度是5, 你的索引是-100, 加上长度之后是-95,结果还是个负数, 所以也会报错
            if (indexerr == NULL) {
                indexerr = PyUnicode_FromString(
                    "list index out of range");
                if (indexerr == NULL)
                    return NULL;
            }
            PyErr_SetObject(PyExc_IndexError, indexerr);
            return NULL;
        }
        //通过ob_item获取第i个元素
        Py_INCREF(a->ob_item[i]);
        //返回
        return a->ob_item[i];
    }
    

    显然获取元素的时候不光可以通过索引,还可以通过切片的方式。

    static PyObject *
    list_subscript(PyListObject* self, PyObject* item)
    {
        if (PyIndex_Check(item)) {
            //.......
        }
        else if (PySlice_Check(item)) {
            /*
            start: 切片的起始位置
            end: 切片的结束位置
            step: 切片的步长
            slicelength: 获取元素个数,比如[1:5:2],显然slicelength就是2, 因为只能获取索引为1和3的元素
            cur: 底层数组中元素的索引
            i: 循环变量, 因为切片的话只能循环获取每一个元素, 比如[1:5:2], 需要循环两次。第一次循环, 上面的cur就是1, 第二次循环cur就是3
            */
            Py_ssize_t start, stop, step, slicelength, cur, i;
            //返回的结果
            PyObject* result;
            
            //下面代码中会有所体现
            PyObject* it;
            PyObject **src, **dest;
    		
            //对切片item进行解包进行解包, 得到起始位置、结束位置、步长
            if (PySlice_Unpack(item, &start, &stop, &step) < 0) {
                return NULL;
            }
            //计算出slicelength, 因为即便我们指定的切片是[1:3:5], 但如果列表只有3个元素, 所以slicelength也只能是1
            slicelength = PySlice_AdjustIndices(Py_SIZE(self), &start, &stop,
                                                step);
    		
            //如果slicelength为0, 那么不好意思, 表示没有元素可以获取, 因此直接返回一个空列表即可
            if (slicelength <= 0) {
                //PyList_New表示创建一个PyListObject, 里面的参数表示底层数组的长度
                //另外对于创建列表,Python底层只提供了PyList_New这一种Python/C API
                //当我们执行lst = [1, 2, 3]的时候就会执行PyList_New(3)
                return PyList_New(0);
            }
            //如果步长为1, 那么会调用list_slice,这个函数内部的逻辑很简单,首先接收一个PyListObject *和两个整型(ilow, ihigh)
            //然后在内部会创建一个PyListObject *np, 申请相应的底层数组,设置allocated
            //然后将参数列表中索引为ilow的元素到索引为ihigh的元素依次拷贝到np -> ob_item里面, 然后这是ob_size并返回
            else if (step == 1) {
                return list_slice(self, start, stop);
            }
            else {
                //走到这里说明步长不为1, 我们说result是一个PyListObject *, 底层数组没有存储在PyListObject中,而是通过ob_item发生关联
                //所以这一步是申请底层数组、设置容量的,容量就是这里的slicelength, 上面的list_slice中也调用了这一步
                result = list_new_prealloc(slicelength);
                if (!result) return NULL;
    			
                //src是一个二级指针, 也就是self -> ob_item
                src = self->ob_item;
                //同理dest是result -> ob_item
                dest = ((PyListObject *)result)->ob_item;
                //进行循环, cur从start开始遍历, 每次加上step步长
                for (cur = start, i = 0; i < slicelength;
                     cur += (size_t)step, i++) {
                    //it就是self -> ob_item中的元素
                    it = src[cur];
                    //增加指向的对象的引用计数
                    Py_INCREF(it);
                    //将其设置到dest中
                    dest[i] = it;
                }
                //将大小设置为slicelength,说明通过切片创建新列表, 其长度和容量也是一致的
                Py_SIZE(result) = slicelength;
                //返回结果
                return result;
            }
        }
        else {
            //此时说明item不合法
            PyErr_Format(PyExc_TypeError,
                         "list indices must be integers or slices, not %.200s",
                         item->ob_type->tp_name);
            return NULL;
        }
    }
    

    我们发现这个和字符串类似啊,因为通过字符串也支持切片的方式获取。

    随着源码的分析,我们也渐渐明朗Python的操作在底层是如何实现的了,真的一点不神秘,实现的逻辑非常简单。

    设置元素

    获取元素知道了,设置元素也不难了。

    static int
    list_ass_subscript(PyListObject* self, PyObject* item, PyObject* value)
    {	//在list_subscript的基础上多了一个value参数
        
        if (PyIndex_Check(item)) {
            //依旧是进行检测i是否合法
            Py_ssize_t i = PyNumber_AsSsize_t(item, PyExc_IndexError);
            if (i == -1 && PyErr_Occurred())
                return -1;
            //索引小于0,则加上列表的长度
            if (i < 0)
                i += PyList_GET_SIZE(self);
            //调用list_ass_item进行设置,我们之前见到了list_item,是用来基于索引获取的
            //这里的list_ass_item是基于索引进行元素设置的
            return list_ass_item(self, i, value);
        }
        else if (PySlice_Check(item)) {
        	//......
        }
    }
    
    
    static int
    list_ass_item(PyListObject *a, Py_ssize_t i, PyObject *v)
    {	
        //判断索引是否越界
        if (!valid_index(i, Py_SIZE(a))) {
            PyErr_SetString(PyExc_IndexError,
                            "list assignment index out of range");
            return -1;
        }
        //这里的list_ass_slice后面会说
        if (v == NULL)
            return list_ass_slice(a, i, i+1, v);
        //增加v指向对象的引用计数,因为指向它的指针被传到了列表中
        Py_INCREF(v);
        //将第i个元素设置成v
        Py_SETREF(a->ob_item[i], v);
        return 0;
    }
    

    通过索引设置元素,逻辑很容易,关键是通过切片设置元素会比较复杂。而复杂的原因就在于步长,我们通过Python来演示一下。

    lst = [1, 2, 3, 4, 5, 6, 7, 8]
    
    # 首先通过切片进行设置的话, 右值一定要是一个可迭代对象
    lst[0: 3] = [11, 22, 33]
    # 会将lst[0]设置为11, lst[1]设置为22, lst[2]设置为33
    print(lst)  # [11, 22, 33, 4, 5, 6, 7, 8]
    
    
    # 而且它们的长度是可以不相等的
    # 这里表示将[0: 3]的元素设置为[1, 2], lst[0]设置成1, lst[1]设置成2
    # 问题来了, lst[2]咋办? 由于右值中已经没有元素与之匹配了, 那么lst[2]就会被删掉
    lst[0: 3] = [1, 2]
    print(lst)  # [1, 2, 4, 5, 6, 7, 8]
    
    # 所以我们如果想删除[0: 3]的元素, 那么只需要执行lst[0: 3] = []即可
    # 因为[]里面没有元素能与之匹配, 所以lst中[0: 3]的元素由于匹配不到, 所以直接就没了
    # 当然由于Python的动态特性, lst[0: 3] = []、lst[0: 3] = ()、lst[0: 3] = ""等等都是可以的
    lst[0: 3] = ""
    print(lst)  # [5, 6, 7, 8]
    # 实际上我们del lst[0]的时候, 实际上就是执行了lst[0: 1] = []
    
    
    # 当然如果右值元素多的话也是可以的
    lst[0: 1] = [1, 2, 3, 4]
    print(lst)  # [1, 2, 3, 4, 6, 7, 8]
    # lst[0]匹配1很好理解, 但是此时左边已经结束了, 所以剩余的元素会依次插在后面
    
    
    # 然后重点来了, 如果切片有步长的话, 那么两边一定要匹配
    # 由于此时lst中有8个元素, lst[:: 2]会得到4个元素, 那么右边的可迭代对象的长度也是4
    lst[:: 2] = ['a', 'b', 'c', 'd']
    print(lst)  # ['a', 2, 'b', 4, 'c', 7, 'd']
    
    # 但是,如果长度不一致
    try:
        lst[:: 2] = ['a', 'b', 'c']
    except Exception as e:
        # 显然会报错
        print(e)  # attempt to assign sequence of size 3 to extended slice of size 4
    

    至于通过切片来设置元素,源码很长,这里就不分析了,总之核心如下:

    • 如果步长为1: 那么会调用list_ass_slice。我们说: list_item是基于索引获取元素、list_slice是基于切片获取元素、list_ass_item是基于索引设置元素、list_ass_slice是基于切片设置元素。而list_ass_slice内部的代码逻辑也很长,但是核心并不难, 我们通过lst[a: b] = [v1, v2, v3, ...]这种方式就会走这里的list_ass_slice。
    • 如果步长不为1,那么就是采用循环的方式逐个设置。

    主要是考虑的情况比较多,但是核心逻辑并不复杂,有兴趣可以自己去深入了解一下。

    insert插入元素

    insert用来在指定的位置插入元素,我们知道它是一个时间复杂度为O(n)的一个操作,因为插入位置后面的所有元素都要向后移动。

    int
    PyList_Insert(PyObject *op, Py_ssize_t where, PyObject *newitem)
    {	
        //类型检查
        if (!PyList_Check(op)) {
            PyErr_BadInternalCall();
            return -1;
        }
        //底层又调用ins1
        return ins1((PyListObject *)op, where, newitem);
    }
    
    
    static int
    ins1(PyListObject *self, Py_ssize_t where, PyObject *v)
    {	
        /*参数self:PyListObject *
        参数where:索引
        参数v:插入的值,这是一个PyObject *指针,因为list里面存的都是指针
        */
        
        //i:后面for循环遍历用的,n则是当前列表的元素个数
        Py_ssize_t i, n = Py_SIZE(self);
        //指向指针数组的二级指针
        PyObject **items;
        //如果v是NULL,错误的内部调用
        if (v == NULL) {
            PyErr_BadInternalCall();
            return -1;
        }
        //列表的元素个数不可能无限增大,一般当你还没创建到PY_SSIZE_T_MAX个对象时
        //你内存就已经玩完了,但是python仍然做了检测,当达到这个PY_SSIZE_T_MAX时,会报出内存溢出错误
        if (n == PY_SSIZE_T_MAX) {
            PyErr_SetString(PyExc_OverflowError,
                "cannot add more objects to list");
            return -1;
        }
    	
        //调整列表容量,既然要inert,那么就势必要多出一个元素
        //这个元素还没有设置进去,但是先把这个坑给留出来
        //当然如果容量够的话,是不会扩容的,只有当容量不够的时候才会扩容
        if (list_resize(self, n+1) < 0)
            return -1;
    	
        //确定插入点
        if (where < 0) {
            //这里可以看到如果where小于0,那么我们就加上n,也就是当前列表的元素个数
            //比如有6个元素,那么我们where=-1,加上6,就是5,显然就是insert在最后一个索引的位置上 
            where += n;
            //如果吃撑了,写个-100,加上元素的个数还是小于0
            if (where < 0)
                //那么where=0,就在开头插入
                where = 0;
        }
        //如果where > n,那么就索引为n的位置插入,
        //可元素个数为n,最大索引是n-1啊,对,所以此时就相当于append
        if (where > n)
            where = n;
        //拿到原来的二级指针,指向一个指针数组
        items = self->ob_item;
        //然后不断遍历,把索引为i的值赋值给索引为i+1
        //既然是在where处插入那么where之前的就不需要动了,到where处就停止了
        for (i = n; --i >= where; )
            items[i+1] = items[i];
        //增加v指向的对象的引用计数,因为列表中的元素也引用了该对象
        Py_INCREF(v);
        //将索引为where的值设置成v
        items[where] = v;
        return 0;
    }
    

    所以可以看到,Python插入数据是非常灵活的。不管你在什么位置插入,都是合法的。因为它会自己调整位置,在确定位置之后,会将当前位置以及之后的所有元素向后挪动一个位置,空出来的地方设置为插入的值。

    pop弹出元素

    pop默认是从尾部弹出元素的,因为如果不指定索引的话,默认是-1。当然我们也可以指定索引,弹出指定索引对应的元素。

    static PyObject *
    list_pop_impl(PyListObject *self, Py_ssize_t index)
    {	
        //弹出的对象的指针,因为弹出一个元素实际上是先用某个变量保存起来,然后再从列表中删掉
        PyObject *v;
        
        //下面代码中体现
        int status;
    	
        //如果列表长度为0,显然没有元素可以弹, 因此会报错
        if (Py_SIZE(self) == 0) {
            PyErr_SetString(PyExc_IndexError, "pop from empty list");
            return NULL;
        }
        //索引小于0,那么加上列表的长度得到正数索引
        if (index < 0)
            index += Py_SIZE(self);
        //依旧是调用valid_index,判断是否越界。显然pop没有insert那么智能
        //insert的话,索引在加上列表长度之和还小于0,那么默认是在索引为0的地方插入
        //但是pop就不行了,pop的话会报出索引越界错误,同理索引大于等于列表长度,insert会等价于append,而pop同样报出索引越界错误
        if (!valid_index(index, Py_SIZE(self))) {
            PyErr_SetString(PyExc_IndexError, "pop index out of range");
            return NULL;
        }
        //根据索引获取指定位置的元素
        v = self->ob_item[index];
        
        //这里同样是一个快分支,如果index是最后一个元素
        if (index == Py_SIZE(self) - 1) {
            //那么直接调用list_resize即可,我们说只要涉及元素的添加、删除都要执行list_resize
            //至于容量是否变化,就看是否满足newsize和allocated之间的关系,如果allocated//2 <= newsize <= allocated,那么容量就不变
            //list_resize中会将ob_size设置成newsize,也就是原来的ob_size减去1, 因为是在尾部删除的,所以只需要将ob_size设置为ob_size-1即可
            status = list_resize(self, Py_SIZE(self) - 1);
            //list_resize执行成功会返回0
            if (status >= 0)
                //直接将对象的指针返回
                return v; 
            else
                return NULL;
        }
        //否则说明不是快分支
        Py_INCREF(v);
        //这里调用了list_ass_slice, 这一步等价于self[index: index + 1] = []
        status = list_ass_slice(self, index, index+1, (PyObject *)NULL);
        if (status < 0) {
            //设置失败,减少引用计数
            Py_DECREF(v);
            return NULL;
        }
        //返回指针
        return v;
    }
    

    所以pop本质上也是调用了list_ass_slice。

    index查询元素的索引

    index可以接收一个元素,返回该元素首次出现的索引。当然还可以额外指定一个start和end,表示查询的范围

    static PyObject *
    list_index_impl(PyListObject *self, PyObject *value, Py_ssize_t start,
                    Py_ssize_t stop)
    {
        Py_ssize_t i;
    	
        //如果start小于0,加上长度。
        //还小于0,那么等于0
        if (start < 0) {
            start += Py_SIZE(self);
            if (start < 0)
                start = 0;
        }
        if (stop < 0) {
            //如果stop小于0,加上长度
            //还小于0,那么等于0
            stop += Py_SIZE(self);
            if (stop < 0)
                stop = 0;
        }
        //从start开始循环
        for (i = start; i < stop && i < Py_SIZE(self); i++) {
            //获取相应元素
            PyObject *obj = self->ob_item[i];
            //增加引用计数,因为有指针指向它
            Py_INCREF(obj);
            //进行比较PyObject_RichCompareBool是一个富比较,接收三个参数:元素1、元素2、操作(这里显然是Py_EQ)
            //相等返回1,不相等返回0
            int cmp = PyObject_RichCompareBool(obj, value, Py_EQ);
            //比较完之后,减少引用计数
            Py_DECREF(obj);
            if (cmp > 0)
                //如果相等,返回其索引
                return PyLong_FromSsize_t(i);
            else if (cmp < 0)
                return NULL;
        }
        //循环走完一圈,发现都没有相等的,那么报错,提示元素不再列表中
        PyErr_Format(PyExc_ValueError, "%R is not in list", value);
        return NULL;
    }
    

    所以lst.index是一个时间复杂度为O(n)的操作,因为它在底层要循环整个列表,如果运气好,可以第一个元素就是,运气不好可能就好循环整个列表了。同理后面要说的if value in lst这种方式也是一样的,因为都要循环整个列表,只不过后者返回的是一个布尔值。

    count查询元素出现的次数

    static PyObject *
    list_count(PyListObject *self, PyObject *value)
    {	
        //初始为0
        Py_ssize_t count = 0;
        Py_ssize_t i;
    	
        //遍历每一个元素
        for (i = 0; i < Py_SIZE(self); i++) {
            //获取元素
            PyObject *obj = self->ob_item[i];
            //如果相等,那么count自增1,继续下一次循环
            //注意这里的相等,判断的是什么呢?显然是对象的地址,如果地址一样,那么肯定指向同一个对象,所以一定相等。
            if (obj == value) {
               count++;
               continue;
            }
            Py_INCREF(obj);
            //走到这里说明地址不一样,但是地址不一样只能说明a is b不成立,但并不代表a == b不成立,所以调用PyObject_RichCompareBool进行判断
            int cmp = PyObject_RichCompareBool(obj, value, Py_EQ);
            Py_DECREF(obj);
            //大于0,说明相等,count++
            if (cmp > 0)
                count++;
            else if (cmp < 0)
                return NULL;
        }
        //返回count
        return PyLong_FromSsize_t(count);
    }
    

    count毫无疑问,无论在什么情况下,它都是一个时间复杂度为O(n)的操作,因为列表必须要从头遍历到尾。

    remove根据元素的值删除元素

    除了根据索引删除元素之外,也可以元素指向的对象维护的值删除元素,删除第一个出现元素。

    static PyObject *
    list_remove(PyListObject *self, PyObject *value)
    {
        Py_ssize_t i;
    
        for (i = 0; i < Py_SIZE(self); i++) {
            //从头开始遍历,获取元素
            PyObject *obj = self->ob_item[i];
            Py_INCREF(obj);
            //比较是否相等
            int cmp = PyObject_RichCompareBool(obj, value, Py_EQ);
            Py_DECREF(obj);
            //如果相等
            if (cmp > 0) {
                //调用list_ass_slice删除元素
                if (list_ass_slice(self, i, i+1,
                                   (PyObject *)NULL) == 0)
                    //返回None
                    Py_RETURN_NONE;
                return NULL;
            }
            else if (cmp < 0)
                return NULL;
        }
        //否则说明元素不在列表中
        PyErr_SetString(PyExc_ValueError, "list.remove(x): x not in list");
        return NULL;
    }
    

    reverse翻转列表

    static PyObject *
    list_reverse_impl(PyListObject *self)
    {	
        //如果列表长度不大于1的话, 那么直接返回其本身即可
        if (Py_SIZE(self) > 1)
            //大于1的话,执行reverse_slice, 传递了两个参数
            //第一个参数self -> ob_item显然是底层数组首元素的地址
            //而第二个参数self->ob_item + Py_SIZE(self)则是底层数组中索引为ob_size的元素的地址
            //但是很明显能访问的最大索引应该是ob_size - 1才对, 别急我们继续往下看, 看一下reverse_slice函数的实现
            reverse_slice(self->ob_item, self->ob_item + Py_SIZE(self));
        Py_RETURN_NONE;
    }
    
    
    static void
    reverse_slice(PyObject **lo, PyObject **hi)
    {
        assert(lo && hi);
    	
        //我们看到又执行了一次--hi,将hi移动到了ob_size - 1位置,也就是说此时二级指针hi保存的还是索引为ob_size - 1的元素的值
        //所以个人觉得有点纳闷, 直接reverse_slice(self->ob_item, self->ob_item + Py_SIZE(self) - 1);不行吗
        --hi;
        //当lo小于hi的时候
        while (lo < hi) {
            PyObject *t = *lo;
            *lo = *hi;
            *hi = t;
            //上面三步就等价于 *lo, *hi = *hi, *lo, 但是C不支持这么写
            //所以我们看到就是将索引为0的元素和索引为ob_size-1的元素进行了交换,前后两个指针继续靠近,指向的元素继续交换,知道两个指针相遇
            ++lo;
            --hi;
        }
    }
    

    所以到现在,你还认为Python中的列表神秘吗?虽然我们不可能写出一个Python解释器,但是底层的一些思想其实并没有那么难,作为一名程序猿很容易想的到。

    两个列表相加

    static PyObject *
    list_concat(PyListObject *a, PyObject *bb)
    {
        Py_ssize_t size;  //相加之后的列表长度
        Py_ssize_t i; //循环变量
        //两个二级指针,指向ob_item
        PyObject **src, **dest;
        //新的列表
        PyListObject *np;
        //类型检测
        if (!PyList_Check(bb)) {
            PyErr_Format(PyExc_TypeError,
                      "can only concatenate list (not "%.200s") to list",
                      bb->ob_type->tp_name);
            return NULL;
        }
    #define b ((PyListObject *)bb)
        //判断长度是否溢出
        if (Py_SIZE(a) > PY_SSIZE_T_MAX - Py_SIZE(b))
            return PyErr_NoMemory();
        //计算新列表的长度
        size = Py_SIZE(a) + Py_SIZE(b);
        //设置np -> ob_item指向的底层数组
        np = (PyListObject *) list_new_prealloc(size);
        if (np == NULL) {
            return NULL;
        }
        //获取a -> ob_item和np -> ob_item
        src = a->ob_item;
        dest = np->ob_item;
        //将元素依次拷贝过去, 增加引用计数
        for (i = 0; i < Py_SIZE(a); i++) {
            PyObject *v = src[i];
            Py_INCREF(v);
            dest[i] = v;
        }
        //获取b->ob_item
        //获取np->ob_item + Py_SIZE(a), 要从Py_SIZE(a)的位置开始设置, 否则就把之前的元素覆盖掉了
        src = b->ob_item;
        dest = np->ob_item + Py_SIZE(a);
        //将元素依次拷贝过去, 增加引用计数
        for (i = 0; i < Py_SIZE(b); i++) {
            PyObject *v = src[i];
            Py_INCREF(v);
            dest[i] = v;
        }
        //设置ob_size
        Py_SIZE(np) = size;
        //返回np
        return (PyObject *)np;
    #undef b
    }
    
    

    判断元素是否在列表中

    对于一个序列来说,可以使用in操作符,等价于调用其__contains__魔法方法。

    static int
    list_contains(PyListObject *a, PyObject *el)
    {
        PyObject *item;
        Py_ssize_t i;
        int cmp;
    	//挨个循环,比较是否相等。存在cmp会等于1,cmp == 0 && i < Py_SIZE(a)不满足,直接返回
        //不相等则为0, 会一直比完列表中所有的元素
        for (i = 0, cmp = 0 ; cmp == 0 && i < Py_SIZE(a); ++i) {
            item = PyList_GET_ITEM(a, i);
            Py_INCREF(item);
            cmp = PyObject_RichCompareBool(el, item, Py_EQ);
            Py_DECREF(item);
        }
        return cmp;
    }
    

    真的非常简单,没有什么好说的。

    列表的深浅拷贝

    列表的深浅拷贝也是初学者容易犯的错误之一,我们看一个Python的例子。

    lst = [[]]
    
    # 默认是浅拷贝, 这个过程会创建一个新列表, 会将里面的指针拷贝一份
    # 但是指针指向的内存并没有拷贝
    lst_cp = lst.copy()
    
    # 两个对象的地址是一样的
    print(id(lst[0]), id(lst_cp[0]))  # 2207105155392 2207105155392
    
    # 操作lst[0], 会改变lst_cp[0]
    lst[0].append(123)
    print(lst, lst_cp)  # [[123]] [[123]]
    
    # 操作lst_cp[0], 会改变lst[0]
    lst_cp[0].append(456)
    print(lst, lst_cp)  # [[123, 456]] [[123, 456]]
    

    我们通过索引或者切片也是一样的道理

    lst = [[], 1, 2, 3]
    val = lst[0]
    lst_cp = lst[0: 1]
    
    print(lst[0] is val is lst_cp[0])  # True
    # 此外,lst[:]完全等价于lst.copy()
    

    之所以会有这样现象,是因为我们说过Python中变量、容器里面的元素都是一个泛型指针PyObject *,在传递的时候会传递指针, 但是在操作的时候会操作指针指向的内存。

    所以lst.copy()就是创建了一个新列表,然后把元素拷贝了过去,并且这里的元素是指针。因为只是拷贝指针,没有拷贝指针指向的对象(内存),所以它们的地址都是一样的,因为指向的是同一个对象。

    但如果我们就想在拷贝指针的同时也拷贝指针指向的对象呢?答案是使用一个叫copy的模块。

    import copy
    
    lst = [[]]
    # 此时拷贝的时候,就会把指针指向的对象也给拷贝一份
    lst_cp1 = copy.deepcopy(lst)
    lst_cp2 = lst[:]
    
    lst_cp2[0].append(123)
    print(lst)  # [[123]]
    print(lst_cp1)  # [[]]
    
    # lst[:]这种方式也是浅拷贝, 所以修改lst_cp2[0], 也会影响lst[0]
    # 但是没有影响lst_cp1[0], 证明它们是相互独立的, 因为指向的是不同的对象
    

    浅拷贝示意图如下:

    里面的两个底层数组的元素是一样的

    深拷贝示意图如下:

    里面的两个底层数组的元素是不一样的

    注意:copy.deepcopy虽然在拷贝指针的同时会将指针指向的对象也拷贝一份,但这仅仅是针对于可变对象,对于不可变对象是不会拷贝的。

    import copy
    
    lst = [[], "古明地觉"]
    lst_cp = copy.deepcopy(lst)
    
    print(lst[0] is lst_cp[0])  # False
    print(lst[1] is lst_cp[1])  # True
    

    为什么会这样,其实原因很简单。因为不可变对象是不支持本地修改的,你若想修改只能指向新的对象,但是对其它的变量则没有影响,其它变量该指向谁就还指向谁。因为a = b只是将b的指针拷贝一份给a,然后a和b都指向了同一个对象,至于a和b本身则是没有任何关系的。如果此时b指向了新的对象,是完全不会影响a的,a还是指向原来的对象。所以如果一个指针指向的对象不支持本地修改,那么深拷贝不会拷贝对象本身,因为指向的是不可变对象,所以不会有修改一个影响另一个的情况出现。

    关于列表还有一些陷阱:

    lst = [[]] * 5
    lst[0].append(1)
    print(lst)  # [[1], [1], [1], [1], [1]]
    # 列表乘上一个n,等于把列表里面的元素重复n次
    # 注意: 类似于lst = [1, 2, 3], 虽然我们写的是整数,但是它存储的并不是整数,而是其指针
    # 所以会把指针重复5次, 因此列表里面5个指针都指向了同一个列表
    
    
    # 这种方式创建的话,里面的元素都指向了不同的列表
    lst = [[], [], [], [], []]
    lst[0].append(1)
    print(lst)  # [[1], [], [], [], []]
    
    
    # 再比如字典,在后续系列中会说
    d = dict.fromkeys([1, 2, 3, 4], [])
    print(d)  # {1: [], 2: [], 3: [], 4: []}
    d[1].append(123)
    print(d)  # {1: [123], 2: [123], 3: [123], 4: [123]}
    # 它们都指向了同一个列表,因此这种陷阱在工作中要注意, 因为一不小心就会出现大问题
    

    创建PyListObject

    我们说创建一个列表,Python底层只提供了唯一一个Python/C API,也就是PyList_New。这个函数接收一个size参数,从而允许我们在创建一个PyListObject对象时指定底层数组的长度。

    PyObject *
    PyList_New(Py_ssize_t size)
    {	
        //声明一个PyListObject *对象
        PyListObject *op;
    #ifdef SHOW_ALLOC_COUNT
        static int initialized = 0;
        if (!initialized) {
            Py_AtExit(show_alloc);
            initialized = 1;
        }
    #endif
    	
        //如果size小于0,直接抛异常
        if (size < 0) {
            PyErr_BadInternalCall();
            return NULL;
        }
        //缓存池是否可用,如果可用
        if (numfree) {
            //将缓存池内对象个数减1
            numfree--;
            //从缓存池中获取
            op = free_list[numfree];
            //设置引用计数
            _Py_NewReference((PyObject *)op);
    #ifdef SHOW_ALLOC_COUNT
            count_reuse++;
    #endif
        } else {
            //不可用的时候,申请内存
            op = PyObject_GC_New(PyListObject, &PyList_Type);
            if (op == NULL)
                return NULL;
    #ifdef SHOW_ALLOC_COUNT
            count_alloc++;
    #endif
        }
        //如果size小于等于0,ob_item设置为NULL
        if (size <= 0)
            op->ob_item = NULL;
        else {
            //否则的话,创建一个指定容量的指针数组,然后让ob_item指向它
            //所以是先创建PyListObject对象, 然后创建底层数组, 最后通过ob_item建立联系
            op->ob_item = (PyObject **) PyMem_Calloc(size, sizeof(PyObject *));
            if (op->ob_item == NULL) {
                Py_DECREF(op);
                return PyErr_NoMemory();
            }
        }
        //设置ob_size和allocated,然后返回op
        Py_SIZE(op) = size;
        op->allocated = size;
        _PyObject_GC_TRACK(op);
        return (PyObject *) op;
    }
    

    我们注意到源码里面有一个缓冲池,是的,创建PyListObject对象时,会先检测缓冲池free_lists里面是否有可用的对象,有的话直接拿来用,否则通过malloc在系统堆上申请。缓冲池中最多维护80个PyListObject对象。

    /* Empty list reuse scheme to save calls to malloc and free */
    #ifndef PyList_MAXFREELIST
    #define PyList_MAXFREELIST 80
    #endif
    static PyListObject *free_list[PyList_MAXFREELIST];
    

    根据之前的经验我们知道,既然能从缓存池中获取,那么在执行析构函数的时候也要把列表放到缓存池里面。

    static void
    list_dealloc(PyListObject *op)
    {
        Py_ssize_t i;
        PyObject_GC_UnTrack(op);
        Py_TRASHCAN_SAFE_BEGIN(op)
        if (op->ob_item != NULL) {
            i = Py_SIZE(op);
            //将底层数组中每个指针指向的对象的引用计数都减去1
            while (--i >= 0) {
                Py_XDECREF(op->ob_item[i]);
            }
          	//然后释放底层数组所占的内存
            PyMem_FREE(op->ob_item);
        }
        //判断缓冲池里面PyListObject对象的个数,如果没满,就添加到缓存池
        //注意:我们看到执行到这一步的时候, 底层数组已经被释放掉了
        if (numfree < PyList_MAXFREELIST && PyList_CheckExact(op))
            free_list[numfree++] = op;
        else
            //否则的话再释放掉PyListObject对象所占的内存
            Py_TYPE(op)->tp_free((PyObject *)op);
        Py_TRASHCAN_SAFE_END(op)
    }
    

    我们知道在创建一个新的PyListObject对象时,实际上是分为两步的,先创建PyListObject对象,然后创建底层数组,最后让PyListObject对象中的ob_item成员指向这个底层数组。同理,在销毁一个PyListObject对象时,先销毁ob_item维护的底层数组,然后再释放PyListObject对象自身(如果缓存池已满的情况下)

    现在可以很清晰地明白了,原本空荡荡的缓存池其实是被已经死去的PyListObject对象填充了,在以后创建新的PyListObject对象时,Python会首先唤醒这些死去的PyListObject对象,给它们一个洗心革面、重新做人的机会。但需要注意,这里缓存的仅仅是PyListObject对象,对于底层数组,其ob_item已经不再指向了。从PyList_New中我们看到,PyListObject对象在放进缓存池之前,ob_item指向的数组就已经被释放掉了,同时数组中指针指向的对象的引用计数会减1。所以最终数组中这些指针指向的对象也大难临头各自飞了,或生存、或毁灭,总之此时和PyListObject之间已经没有任何联系了。但是为什么要这么做呢?为什么不连底层数组也一起维护呢?可以想一下,如果继续维护的话,数组中指针指向的对象永远不会被释放,那么很可能会产生悬空指针的问题,所以这些指针指向的对象所占的空间必须交还给系统(前提是没有其它指针指向了)

    但是实际上,是可以将PyListObject对象维护的底层数组进行保留的,即只将数组中指针指向的对象的引用计数减1,然后将数组中的指针都设置为NULL,不再指向之前的对象了,但是并不释放底层数组本身所占用的内存空间。因此这样一来,释放的内存不会交给系统堆,那么再次分配的时候,速度会快很多。但是这样带来一个问题,就是这些内存没人用也会一直占着,并且只能供PyListObject对象的ob_item指向的底层数组使用,因此Python还是为避免消耗过多内存,采取将底层数组的内存交换给了系统堆这样的做法,在时间和空间上选择了空间。

    元组的底层结构--PyTupleObject

    因为元组比较简单,和列表比较相似,所以就放在一起介绍了。我们知道元组,就相当于不支持元素添加、修改、删除等操作的列表。

    元组的实现机制非常简单,可以看做是在列表的基础上删除了增删改等操作。既然如此,那要元组有什么用呢?毕竟元组的功能只是列表的子集。元组存在的最大一个特点就是,它可以作为字典的key、以及可以作为集合的元素。因为字典和集合存储数据的原理是哈希表,字典和集合我们后续章节会说。对于列表这样的可变对象来说是可以动态改变的,而哈希值是一开始就计算好的,显然如果支持动态修改的话,那么哈希值肯定会变,这是不允许的。所以如果我们希望字典的key是一个序列,显然元组再适合不过了。

    从tuple的特点也能看出:tuple的底层是一个变长对象,但同时也是一个不可变对象。

    typedef struct {
        PyObject_VAR_HEAD
        PyObject *ob_item[1];
    } PyTupleObject;
    

    可以看到,对于不可变对象来说,它底层结构体定义也非常简单。一个引用计数、一个类型、一个指针数组。这里的1可以想象成n,我们在PyLongObject中说过。

    并且我们发现不像列表,元组没有allocated,这是因为它是不可变的,不支持resize操作。至于维护的值,同样是指针组成的数组,数组里面的每一个指针都指向了具体的值。

    PyTupleObject的创建

    正如列表一样,Python创建PyTupleObject也提供了类似的初始化方法。

    PyObject *
    PyTuple_New(Py_ssize_t size)
    {	
        //PyTupleObject指针
        PyTupleObject *op;
        Py_ssize_t i;
        if (size < 0) {
            PyErr_BadInternalCall();
            return NULL;
        }
    #if PyTuple_MAXSAVESIZE > 0
        //元组同样有缓存池
        if (size == 0 && free_list[0]) {
            op = free_list[0];
            Py_INCREF(op);
    #ifdef COUNT_ALLOCS
            tuple_zero_allocs++;
    #endif  
            //如果长度为0,那么直接返回
            return (PyObject *) op;
        }
        if (size < PyTuple_MAXSAVESIZE && (op = free_list[size]) != NULL) {
            //从缓存池中获取
            free_list[size] = (PyTupleObject *) op->ob_item[0];
            numfree[size]--;
    #ifdef COUNT_ALLOCS
            fast_tuple_allocs++;
    #endif
            /* Inline PyObject_InitVar */
    #ifdef Py_TRACE_REFS
            //设置ob_size,和ob_type
            Py_SIZE(op) = size;
            Py_TYPE(op) = &PyTuple_Type;
    #endif
            //引用计数初始化为1
            _Py_NewReference((PyObject *)op);
        }
        else
    #endif
        {
            /* 元组的元素个数同样有限制,但我们说这个限制一般达不到 */
            if ((size_t)size > ((size_t)PY_SSIZE_T_MAX - sizeof(PyTupleObject) -
                        sizeof(PyObject *)) / sizeof(PyObject *)) {
                return PyErr_NoMemory();
            }
            //申请空间
            op = PyObject_GC_NewVar(PyTupleObject, &PyTuple_Type, size);
            if (op == NULL)
                return NULL;
        }
        for (i=0; i < size; i++)
            op->ob_item[i] = NULL;
    #if PyTuple_MAXSAVESIZE > 0
        if (size == 0) {
            free_list[0] = op;
            ++numfree[0];
            Py_INCREF(op);          /* extra INCREF so that this is never freed */
        }
    #endif
    #ifdef SHOW_TRACK_COUNT
        count_tracked++;
    #endif
        _PyObject_GC_TRACK(op);
        return (PyObject *) op;
    }
    

    和PyListObject初始化类似,同样需要做一些类型检测,内存是否溢出等等。

    当然有了列表的经验,元组的一些底层操作我们就不分析了,它是列表的子集。

    静态资源缓存

    列表和元组两者在通过索引查找元素的时候是一致的,但是元组除了能作为字典的key之外,还有一个特点,就是分配的速度比较快。一方面是因为由于其不可变性,使得在编译的时候就确定了,另一方面就是它还具有静态资源缓存的作用。

    对于一些静态变量,比如元组,如果它不被使用并且占用空间不大时,Python 会暂时缓存这部分内存。这样,下次我们再创建同样大小的元组时,Python 就可以不用再向操作系统发出请求,去寻找内存,而是可以直接分配之前缓存的内存空间,这样就能大大加快程序的运行速度。你可以理解为PyTupleObject对象在被析构时,不仅对象本身没有被回收,连底层的指针数组也被缓存起来了。

    from timeit import timeit
    
    
    t1 = timeit(stmt="x1 = [1, 2, 3, 4, 5]", number=1000000)
    t2 = timeit(stmt="x2 = (1, 2, 3, 4, 5)", number=1000000)
    
    print(round(t1, 2))  # 0.05
    print(round(t2, 2))  # 0.01
    

    可以看到用时,元组只是列表的五分之一。这便是元组的另一个优势,可以将资源缓存起来。

    小结

    这次我们介绍了Python中列表的底层实现,以及它的相关操作。当然内容都在正文里面,也没啥好总结的了。话说写到这里,我的typora明显变卡了。。。。。。

  • 相关阅读:
    PHP数据类型
    Windows定时备份Mysql数据库
    Linux定时删除n天前日志
    使用file_get_contents() 发送GET、POST请求
    使用Git工具批量拉取代码
    Git常用命令
    点击开关按钮,通过改变类名切换按钮
    两个行内元素的间隙问题
    vue和angular双向数据绑定原理
    原生js实现 双向数据绑定
  • 原文地址:https://www.cnblogs.com/traditional/p/13461463.html
Copyright © 2020-2023  润新知