列表作为序列型对象都支持哪些操作,它们在底层是怎么实现的?

文摘   2024-07-29 12:17   北京  

楔子



列表拥有非常多的方法,比如添加元素、查询元素等,这些都属于列表的自定义方法。当然不光是列表,任何对象都可以有自己的自定义方法,而这些方法会保存在类型对象的 tp_methods 里面。

当然列表除了拥有自定义的方法之外,还拥有作为序列型对象所共有的方法,比如合并、基于索引和切片获取元素、基于索引和切片设置元素等等。

这些方法会基于种类被抽象成三个方法簇,分别是:

  • tp_as_number:数值型对象拥有的方法;

  • tp_as_sequence:序列型对象拥有的方法;

  • tp_as_mapping:映射型对象拥有的方法;


每个方法簇都包含了大量的 C 函数,每个 C 函数一般会对应 Python 里的一个魔法方法和操作符。比如 tp_as_sequence 的 sq_concat 对应序列型对象的 __add__ 方法,tp_as_number 的 nb_subtract 对应数值型对象的 __sub__ 方法。

那么接下来我们就详细剖析一下这些方法的具体实现过程。



列表的相加



序列型对象都实现了加法运算,比如列表,两个列表相加可以合并为一个新的列表。

print([123] + [45])  
"""
[1, 2, 3, 4, 5]
"""

虽然使用了 + 操作符,但它在底层是由 tp_as_sequence.sq_concat 负责实现的,该字段被赋值为 list_concat 函数,看一下它的内部逻辑。

// Objects/listobject.c
static PyObject *
list_concat(PyListObject *a, PyObject *bb)
{   
    // 两个列表相加之后的新列表的长度
    Py_ssize_t size;
    Py_ssize_t i;
    PyObject **src, **dest;
    PyListObject *np;
    // 如果 bb 不是列表,抛出 TypeError
    if (!PyList_Check(bb)) {
        PyErr_Format(PyExc_TypeError,
                  "can only concatenate list (not \"%.200s\") to list",
                  Py_TYPE(bb)->tp_name);
        return NULL;
    }
#define b ((PyListObject *)bb)
    // 两个列表的长度相加一定小于 PY_SSIZE_T_MAX
    assert((size_t)Py_SIZE(a) + (size_t)Py_SIZE(b) < PY_SSIZE_T_MAX);
    // 新列表的长度,等于相加的两个列表的长度之和
    size = Py_SIZE(a) + Py_SIZE(b);
    // 如果长度为 0,那么创建一个空列表,然后返回即可
    if (size == 0) {
        return PyList_New(0);
    }
    // 为 PyListObject 和底层数组申请空间(空间大小为 8 * size)
    np = (PyListObject *) list_new_prealloc(size);
    if (np == NULL) {
        return NULL;
    }
    // 将第一个列表的元素增加引用计数之后,拷贝到新列表中
    src = a->ob_item;
    dest = np->ob_item;
    for (i = 0; i < Py_SIZE(a); i++) {
        PyObject *v = src[i];
        dest[i] = Py_NewRef(v);
    }
    // 将第二个列表的元素增加引用计数之后,拷贝到新列表中
    src = b->ob_item;
    dest = np->ob_item + Py_SIZE(a);
    for (i = 0; i < Py_SIZE(b); i++) {
        PyObject *v = src[i];
        dest[i] = Py_NewRef(v);
    }
    // 将新列表的 ob_size 设置为 size
    Py_SET_SIZE(np, size);
    // 转成泛型指针之后返回
    return (PyObject *)np;
#undef b
}

逻辑非常简单,假设两个列表 a 和 b 相加,过程如下。

  • 先申请一个新列表,长度为 len(a) + len(b);

  • 将列表 a 的元素拷贝到新列表中;

  • 将列表 b 的元素拷贝到新列表中;


说白了就是两个 for 循环。



列表的重复



列表可以乘上一个整数,将自身重复指定次数,该过程会返回一个新列表。

print([123] * 3)
"""
[1, 2, 3, 1, 2, 3, 1, 2, 3]
"""

虽然使用了 * 操作符,但它在底层是由 tp_as_sequence.sq_repeat 负责实现的,该字段被赋值为 list_repeat 函数,看一下它的内部逻辑。

// Objects/listobject.c
static PyObject *
list_repeat(PyListObject *a, Py_ssize_t n)
{
    // 获取 a 指向的列表的长度
    const Py_ssize_t input_size = Py_SIZE(a);
    // 如果列表长度为 0,或者乘上一个小于等于 0 的数
    // 那么创建的新列表的长度依旧是 0,因此直接返回空列表即可
    if (input_size == 0 || n <= 0)
        return PyList_New(0);
    assert(n > 0);
    // 长度有限制,不能超过 PY_SSIZE_T_MAX
    if (input_size > PY_SSIZE_T_MAX / n)
        return PyErr_NoMemory();
    // 新列表的长度
    Py_ssize_t output_size = input_size * n;
    // 为新列表和底层数组申请空间,底层数组的长度为 output_size
    PyListObject *np = (PyListObject *) list_new_prealloc(output_size);
    if (np == NULL)
        return NULL;
    // 指向新列表的底层数组的首元素
    PyObject **dest = np->ob_item;
    // 如果原始列表的长度为 1,比如 a = [1],n = 3
    // 那么新列表就是 [1, 1, 1]
    if (input_size == 1) {
        // 拿到第一个元素
        PyObject *elem = a->ob_item[0];
        // 因为要重复 n 次,所以引用计数增加 n
        _Py_RefcntAdd(elem, n);
        PyObject **dest_end = dest + output_size;
        // 将新列表的底层数组的元素全部设置为 elem
        while (dest < dest_end) {
            *dest++ = elem;
        }
    }
    // 如果原始列表的长度不为 1
    else {
        PyObject **src = a->ob_item;
        PyObject **src_end = src + input_size;
        // 将原始列表的元素依次拷贝到新列表中,但此时只拷贝了 1 次
        while (src < src_end) {
            _Py_RefcntAdd(*src, n);
            *dest++ = *src++;
        }
        // 将新列表里面的元素再重复 output_size / input_size - 1  次
        // 说白了就是再重复 n - 1 次
        _Py_memory_repeat((char *)np->ob_item, 
                          sizeof(PyObject *)*output_size,
                          sizeof(PyObject *)*input_size);
    }
    // 将新列表的 ob_size 设置为 output_size
    Py_SET_SIZE(np, output_size);
    return (PyObject *) np;
}

假设列表 a 和整数 n 相乘,过程如下。

  • 创建一个新列表,长度为 len(a) * n;

  • 将列表 a 的元素拷贝到新列表中;

  • 将新列表的元素再重复 n - 1 次;



基于索引和切片获取元素



列表可以基于索引和切片截取元素。

data = [12345]
print(data[1])  # 2
print(data[14])  # [2, 3, 4]

在底层它由 tp_as_mapping.mp_subscript 实现,该字段被赋值为 list_subscript 函数,看一下它的内部逻辑。

// Objects/listobject.c
static PyObject *
list_subscript(PyListObject* self, PyObject* item)
{   
    // 在基于索引和切片截取时,所有序列型对象的逻辑都差不多
    if (_PyIndex_Check(item)) {
        // 如果 item 是索引,那么转成 Py_ssize_t 整数
        Py_ssize_t i;
        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 获取 ob_item 中索引为 i 的元素
        return list_item(self, i);
    }
    // 如果 item 是切片
    else if (PySlice_Check(item)) {
        // start, stop, step 分别表示起始位置、终止位置、步长
        // slicelength 表示切片截取的长度,也就是要截取多少个元素
        Py_ssize_t start, stop, step, slicelength, i;
        size_t cur;
        PyObject* result;
        PyObject* it;
        PyObject **src, **dest;
        // 获取切片的 start、stop、step
        if (PySlice_Unpack(item, &start, &stop, &step) < 0) {
            return NULL;
        }
        // 传入原始列表的长度,对 start 和 stop 进行调整,并返回 slicelength
        slicelength = PySlice_AdjustIndices(Py_SIZE(self), &start, &stop,
                                            step);
        // 如果 slicelength <= 0,说明截取不到任何元素
        // 比如 data[5: 1] 或者 data[1: 5: -1],那么直接返回空列表
        if (slicelength <= 0) {
            return PyList_New(0);
        }
        // 如果步长为 1,那么直接将列表中 start 到 stop 之间的元素拷过去即可
        else if (step == 1) {
            return list_slice(self, start, stop);
        }
        // 否则说明步长不为 1
        else {
            // 为创建的新列表和底层数组申请空间
            result = list_new_prealloc(slicelength);
            if (!result) return NULL;
            src = self->ob_item;
            dest = ((PyListObject *)result)->ob_item;
            // 从 start 处开始遍历,将元素拷贝过去
            // 然后 cur 每次增加 step,遍历次数为 slicelength
            for (cur = start, i = 0; i < slicelength;
                cur += (size_t)step, i++) {
                it = Py_NewRef(src[cur]);
                dest[i] = it;
            }
            // 将新列表的 ob_size 设置为 slicelength
            Py_SET_SIZE(result, slicelength);
            return result;
        }
    }
    // 否则说明 item 既不是索引也不是切片,那么报错
    else {
        PyErr_Format(PyExc_TypeError,
                     "list indices must be integers or slices, not %.200s",
                     Py_TYPE(item)->tp_name);
        return NULL;
    }
}

这个和之前介绍的 bytes 对象有点像,因为它们都是序列型对象,在基于索引和切片截取元素时的逻辑也是类似的。

但 bytes 对象只能截取元素,却不能设置元素,而列表是可以的,因为列表是可变对象。



基于索引和切片设置元素



列表是可变对象,因为它支持设置元素,即对内部元素进行修改。基于索引设置元素就不说了,我们主要看切片,它背后还是有一些复杂的。

data = [12345678]

# 通过切片设置元素,右值一定是一个可迭代对象
data[03] = [112233]
# 会将 data[0] 设置为 11,将 data[1] 设置为 22,将 data[2] 设置为 33
print(data)
"""
[11, 22, 33, 4, 5, 6, 7, 8]
"""


# 而且它们的长度是可以不相等的,这里表示将 [0: 3] 的元素设置为 [1, 2]
# 即 data[0] 设置成 1,data[1] 设置成 2,那么问题来了,data[2] 咋办?
# 由于右值中已经没有元素与之匹配了,那么 data[2] 就会被删掉
data[03] = [12]
print(data)
"""
[1, 2, 4, 5, 6, 7, 8]
"""


# 所以如果想删除 [0: 3] 的元素,那么只需要执行 data[0: 3] = [] 即可
# 因为 [] 里面没有元素能与之匹配,所以 data 中 [0: 3] 的位置由于匹配不到
# 那么相当于执行了删除操作,当然由于 Python 的动态特性,还可以像下面这么做
# data[0: 3] = []、data[0: 3] = ()、data[0: 3] = "" 等等也是没问题的
data[03] = ""
print(data)
"""
[5, 6, 7, 8]
"""

# 实际上执行 del data[0] 的时候,就是执行了 data[0: 1] = []
# 当然,如果右值元素多的话也是可以的,相当于插入
# 比如这里的 data[0] 匹配 1,然后左边就结束了
# 于是右侧剩余的元素会依次插在后面
data[01] = [1234]
print(data)
"""
[1, 2, 3, 4, 6, 7, 8]
"""

# 重点来了,如果切片的步长不等于 1 的话,那么两边一定要匹配
# 由于 data[:: 2] 会得到 4 个元素,那么右边的可迭代对象的长度就必须也是 4
data[:: 2] = ['a''b''c''d']
print(data)
"""
['a', 2, 'b', 4, 'c', 7, 'd']
"""


# 但如果长度不一致,那么会报错
try:
    data[:: 2] = ['a''b''c']
except Exception as e:
    # 显然会报错
    print(e)  
"""
attempt to assign sequence of size 3 to extended slice of size 4
"""

至于它的源码有兴趣可以自己看一下,在底层它由 tp_as_mapping.mp_ass_subscript 负责实现,该字段被赋值为 list_ass_subscript 函数。逻辑比较长,但不难理解,我们总结一下。

list_subscript 用于获取元素,list_ass_subscript 用于设置元素。调用这两个函数,我们即可以传入索引,也可以传入切片。

  • 获取元素时传入的是索引,那么 list_subscript 内部会调用 list_item,传入的是切片,那么会调用 list_slice。

  • 设置元素时传入的是索引,那么 list_ass_subscript 内部会调用 list_ass_item,传入的是切片,那么会调用 list_ass_slice。并且 list_ass_slice 虽然是设置元素,但删除元素也是调用的它,比如通过 data[n:n+1]=[] 便可删除索引为 n 的元素。事实上 remove 和 pop 方法都只是计算出待删除元素的索引,真正的删除操作还是通过 list_ass_slice 来执行的。

  • 另外,当传入切片时,只有步长为 1,才会调用 list_slice 和 list_ass_slice。如果步长不为 1,那么就采用循环的方式逐个遍历。



小结


以上我们就介绍了列表作为序列型对象拥有的方法,但除了这些它还有很多自定义的方法。由于列表用得非常广泛,关于它的方法我们都来详细地说上一说,下一篇文章介绍列表的自定义方法。

古明地觉的编程教室
Python、Rust 程序猿,你感兴趣的内容我都会写,点个关注吧(#^.^#)
 最新文章