楔子

本篇文章来探讨一下切片是如何实现的,因为在操作字符串、元组、列表等数据结构时,我们经常会使用切片截取数据,所以对切片做一个全方位的了解是很有必要的。

data = list(range(10))
print(data[1: 8: 3])  # [1, 4, 7]

以上就是基于切片截取数据,在工作中我们会大量使用切片。但你知道吗,其实切片也是一个对象,类型为 slice,下面我们来看一下切片的底层结构。

切片的底层结构

切片的类型是 slice,那么根据解释器的 API 命名规则,我们猜测:

  • 切片(slice 对象)在底层对应 PySliceObject 结构体实例;
  • slice 类型本身在底层对应 PySlice_Type;

下面看一下具体实现。

// Include/sliceobject.h
typedef struct {
    PyObject_HEAD
    PyObject *start, *stop, *step;
} PySliceObject;

切片是不可变对象,除了对象的公共头部之外,还有三个字段,分别表示切片的起始位置、终止位置、步长。这也意味着创建切片时,可以给 slice 传递三个参数。

# 创建一个切片
s = slice(1, 8, 3)
print(s)  # slice(1, 8, 3)

问题来了,切片创建的时候,传递的参数绑定在了哪些属性上呢?前面我们说过,实例对象可以绑定哪些属性,会定义在类型对象的 tp_members 字段中。

我们看到切片拥有三个属性,名称也是 start、stop、step。

s = slice(1, 8, 3)
print(s)  # slice(1, 8, 3)
print(s.start)  # 1
print(s.stop)  # 8
print(s.step)  # 3

非常简单,你在 Python 里面看到的一切,都能从源码中找到答案。

切片是怎么创建的

切片是内置类型的实例对象,对于这样的对象,有两种创建方式,相信你已经知道我要说什么了。我们在最开始专门用了十篇文章,从宏观的角度介绍了 Python 的对象模型,目的就在于此。

创建内置对象的两种方式:

  • 通过对象的特定类型 API 创建,只适用于内置对象;
  • 通过调用类型对象创建,所有对象都适用;

解释器对内置对象了如指掌,它们对应的结构体在源码中是写死的,直接 sizeof 一下即可知晓要申请多大内存,完全不需要借助类型对象。

data = list(range(10))
# 通过特定类型 API 创建
print(data[1: 8: 3])  # [1,4,7]
# 通过调用类型对象创建
print(data[slice(1, 8, 3)])  # [1,4,7]

解释器看到 data[1: 8: 3] 就知道要创建一个切片,并且是在数据截取的过程中创建的,我们不能单独写一个 1: 8: 3,这是不符合语法规则的。如果真的想单独创建一个切片,那么需要通过 slice(1, 8, 3) 的方式。

下面通过源码,看一下底层的创建过程。

// Objects/sliceobject.c
static PyObject *
slice_new(PyTypeObject *type, PyObject *args, PyObject *kw)
{
    PyObject *start, *stop, *step;
    start = stop = step = NULL;
    // slice 不接收关键字参数,因此 kw 要指向空字典
    if (!_PyArg_NoKeywords("slice", kw))
        return NULL;
    // slice 接收 1 ~ 3 个位置参数,因此 args 指向的元组必须包含 1 ~ 3 个元素
    // 然后解析 args,将内部的元素分别赋值给 start、stop、step
    if (!PyArg_UnpackTuple(args, "slice", 1, 3, &start, &stop, &step))
        return NULL;

    // 如果 stop == NULL,说明只传递了一个参数,按照顺序这个参数会赋值给 start
    // 但很明显,如果只有一个参数,那么这个参数应该交给 stop 保存
    // 于是让 stop = start,并让 start = NULL,至于这么做的原因可以想象一下 range
    // 如果是 range(0, 9),那么起始位置和终止位置就是 0 和 9
    // 但如果是 range(9),那么这个 9 就是终止位置
    if (stop == NULL) {
        stop = start;
        start = NULL;
    }
    // 假设传了一个参数 8,那么这里就是 PySlice_New(NULL, 8, NULL)
    // 假设传了两个参数 1、8,那么这里就是 PySlice_New(1, 8, NULL)
    // 假设传了三个参数 1、8、3,那么这里就是 PySlice_New(1, 8, 3)
    return PySlice_New(start, stop, step);
}

// 切片缓存,注:slice_cache 只能缓存一个切片
static PySliceObject *slice_cache = NULL;

PyObject *
PySlice_New(PyObject *start, PyObject *stop, PyObject *step)
{
    PySliceObject *obj;
    // 如果 slice_cache 不为 NULL,证明缓存了切片,那么赋值给 obj
    // 由于 slice_cache 只能缓存一个切片,那么赋值给 obj 之后,自身要重置为 NULL
    if (slice_cache != NULL) {
        obj = slice_cache;
        slice_cache = NULL;
        _Py_NewReference((PyObject *)obj);
    } else {
        // 否则调用 PyObject_GC_New 为 PySliceObject 实例申请内存
        obj = PyObject_GC_New(PySliceObject, &PySlice_Type);
        if (obj == NULL)
            return NULL;
    }
    // 如果 start、stop、step 是 NULL,那么转成 Python 的 None
    if (step == NULL) step = Py_None;
    Py_INCREF(step);
    if (start == NULL) start = Py_None;
    Py_INCREF(start);
    if (stop == NULL) stop = Py_None;
    Py_INCREF(stop);
    // 设置切片的 start、stop、step 属性
    obj->step = step;
    obj->start = start;
    obj->stop = stop;
    // 接收 GC 跟踪(在之后的篇章中会解释)
    _PyObject_GC_TRACK(obj);
    // 转成泛型指针之后返回
    return (PyObject *) obj;
}

以上就是切片的创建过程,非常简单,我们用 Python 代码演示一遍。

print(slice(8))  # slice(None, 8, None)
print(slice(1, 8))  # slice(1, 8, None)
print(slice(1, 8, 3))  # slice(1, 8, 3)

结果和源码是一致的。

切片的缓存

从源码中可以看到,切片是有缓存的。

static PySliceObject *slice_cache = NULL;

这个字段用于缓存被回收的切片,并且从切片的创建过程可以看出只会缓存一个,而不是像浮点数那样以链表的形式缓存多个。之所以这么做,是因为在大部分情况下,切片用完之后会立即销毁。

data = list(range(10))
# 创建一个切片,截取完数据之后就销毁
print(data[0: 3])  # [0, 1, 2]
# 创建一个切片,截取完数据之后就销毁
print(data[2: 7])  # [2, 3, 4, 5, 6]

data[start: stop: step] 这种形式,当数据截取完毕之后,创建的切片会立即回收,所以对于解释器来说,它只需要缓存一个切片即可。因此你可以认为同一时刻只会存在一个有效切片,那什么时候会存在多个呢?

data = list(range(10))
s1 = slice(0, 3)
s2 = slice(2, 7)
print(data[s1])  # [0, 1, 2]
print(data[s2])  # [2, 3, 4, 5, 6]

在这种情况下,会同时存在多个有效切片,比如 s1 和 s2 都指向了有效的切片。但很明显,我们在工作中不会这么做,而是在截取数据时,让解释器通过切片的特定类型 API 自动创建。

下面我们来打印切片的地址,看看切片是否被缓存起来了。

# 创建一个切片,缓存如果存在,从缓存获取,否则创建新的切片
>>> s1 = slice(0, 3)
>>> id(s1)
140190801666944

# 创建切片,因为 s1 和 s2 是两个独立的切片,所以它们的地址是不一样的
>>> s2 = slice(2, 7)
>>> id(s2)
140190800965120

# 删除 s1,那么它指向的切片会被放到缓存中
>>> del s1

# 创建新的切片,使用缓存,显然它的地址和之前 s1 指向的切片的地址是一样的
>>> s3 = slice(1, 5)
>>> id(s3)
140190801666944

# 由于缓存为空,那么删除 s2,它指向的切片会被放入缓存
>>> del s2

# 创建新的切片,显然它的地址和之前 s2 指向的切片的地址是一样的
>>> s4 = slice(1, 6)
>>> id(s4)
140190800965120

打印结果表明,切片是会被缓存的,但我们怎么证明切片只会缓存一个呢?这个直接看源码即可,根据之前的经验,对象被放入缓存这一步一定发生在对象被销毁的时候,所以我们只需要看切片的销毁过程即可。

对象被销毁时,会调用类型对象的 tp_dealloc,也就是析构函数。

类型对象的 tp_basicsize 保存了实例对象的基础大小,对于切片而言就是 sizeof(PySliceObject),然后切片又是定长对象,因此 tp_itemsize 是 0。所以切片的大小是固定的,PyObject 占 16 字节,start、end、step 各占 8 字节,总共 40 字节,因此任何一个切片的大小都是固定的 40 字节。

而这个大小即使不借助类型对象也可以计算出来,因为内置对象的定义都是写死的,解释器对它们了如指掌。

为了唤醒大家的记忆,加深理解,以前的内容会时不时回顾一下。我们继续看切片的销毁,对应的析构函数是 slice_dealloc。

// Objects/sliceobject.c
static void
slice_dealloc(PySliceObject *r)
{
    // 取消 GC 跟踪,相关内容后续介绍
    _PyObject_GC_UNTRACK(r);
    // 切片销毁时,减少 start、stop、step 指向对象的引用计数
    Py_DECREF(r->step);
    Py_DECREF(r->start);
    Py_DECREF(r->stop);
    // 关键来了,如果 slice_cache 为 NULL,证明没有缓存
    // 那么让 slice_cache 保存销毁的切片的指针,而切片的内存不释放
    // 这样下一次创建切片时就不需要申请内存了,直接使用缓存即可
    // 因为没有申请内存,只是初始化了 start、stop、step 三个字段,所以效率会更高
    if (slice_cache == NULL)
        slice_cache = r;
    // 否则释放切片所占的内存
    else
        PyObject_GC_Del(r);
}

从源码中可以看到,如果 slice_cache 不为空,说明已经缓存了一个切片,if 条件不成立,于是会选择释放内存,所以切片只会缓存一个。

切片属性的初始化

切片接收 1 到 3 个元素,但我们可能只传一个,那么剩余的属性是怎么初始化的呢?举个例子:

>>> data = list(range(10))
>>> data[: 5]
[0, 1, 2, 3, 4]
>>> data[1:]
[1, 2, 3, 4, 5, 6, 7, 8, 9]
>>> data[1:: 2]
[1, 3, 5, 7, 9]
>>> data[:: 2]
[0, 2, 4, 6, 8]
>>> data[:]
[0, 1, 2, 3, 4, 5, 6, 7, 8, 9]

这些切片都是合法的,当参数不足时,它们的 start、end、step 是怎么设置的呢?

// Objects/sliceobject.c

// 该函数接收指向切片的指针,以及三个整型指针
// 会将切片的起始位置、终止位置、步长解析出来,赋值给 *start、*stop、*step
// 所以该函数会在其它函数的内部被调用,先声明 Py_ssize_t start, stop, step
// 然后将切片指针、&start、&stop、&end 传递给 PySlice_Unpack 进行调用
// 当该函数执行完毕时,外部就拿到了切片的起始位置、终止位置以及步长
int
PySlice_Unpack(PyObject *_r,
               Py_ssize_t *start, Py_ssize_t *stop, Py_ssize_t *step)
{
    // 将 PyObject * 转成 PySliceObject *
    PySliceObject *r = (PySliceObject*)_r;
    Py_BUILD_ASSERT(PY_SSIZE_T_MIN + 1 <= -PY_SSIZE_T_MAX);
  
    // 判断步长,如果解析出的步长为空,那么将 *step 赋值为 1
    // 所以当不指定步长时,步长会被设置为 1,因此 data[::] 等价于 data[:: 1]
    if (r->step == Py_None) {
        *step = 1;
    }
    else {
        // 如果步长不为空,那么它应该是整数,或者是实现了 __index__ 的类的实例对象
        // 但如果步长的类型不合法,那么 _PyEval_SliceIndex 里面会设置异常
        // 合法的话,会将 r->step 赋值给 *step
        if (!_PyEval_SliceIndex(r->step, step)) return -1;
        // 步长不能为 0,否则设置 ValueError("slice step cannot be zero")
        if (*step == 0) {
            PyErr_SetString(PyExc_ValueError,
                            "slice step cannot be zero");
            return -1;
        }
        // 如果步长小于 -PY_SSIZE_T_MAX,那么设置为 -PY_SSIZE_T_MAX
        // 显然这一步基本不会发生
        if (*step < -PY_SSIZE_T_MAX)
            *step = -PY_SSIZE_T_MAX;
    }
  
    // 检测起始位置,如果为 None
    // 当步长大于 0 时,将 *start 设置为 0
    // 当步长小于 0 时,将 *start 设置为 int64 最大值,这背后的原理一会儿解释
    if (r->start == Py_None) {
        *start = *step < 0 ? PY_SSIZE_T_MAX : 0;
    }
    // 说明起始位置不为 None
    else {
        // 如果起始位置的类型不合法,那么设置异常,直接返回,否则赋值给 *start
        if (!_PyEval_SliceIndex(r->start, start)) return -1;
    }
  
    // 如果终止位置为 None
    // 当步长大于 0 时,将 *stop 设置为 PY_SSIZE_T_MAX,即 int64 最大值
    // 当步长小于 0 时,将 *stop 设置为 PY_SSIZE_T_MIN,即 int64 最小值
    if (r->stop == Py_None) {
        *stop = *step < 0 ? PY_SSIZE_T_MIN : PY_SSIZE_T_MAX;
    }
    // 说明终止位置不为 None
    else {
        // 如果终止位置不合法,那么设置异常,直接返回,否则赋值给 *stop
        if (!_PyEval_SliceIndex(r->stop, stop)) return -1;
    }

    return 0;
}

代码逻辑有一些绕,虽然我们知道它在做什么,但问题是这么做的意义是什么呢?在解释之前,我们先用 Python 代码将该函数所做的事情再描述一遍,这样更容易理解。

PY_SSIZE_T_MAX = 2 ** 63 - 1
PY_SSIZE_T_MIN = -2 ** 63

# 如果切片同时包含起始位置、终止位置、步长,会直接赋值给 start、end、step
# 这种情况最简单,就不赘述了,我们来讨论未被同时指定的情况

# 步长为空,比如 data[1: 8]
start = 1
end = 8
step = 1

# 起始位置为空,步长大于 0,比如 data[: 8]
start = 0
end = 8
step = 1
# 起始位置为空,步长小于 0,比如 data[: 8: -1]
start = PY_SSIZE_T_MAX
end = 8
step = -1

# 终止位置为空,步长大于 0,比如 data[2:]
start = 2
end = PY_SSIZE_T_MAX
step = 1
# 终止位置为空,步长小于 0,比如 data[2:: -1]
start = 2
end = PY_SSIZE_T_MIN
step = -1

# 起始位置、终止位置均为空,步长大于 0,比如 data[::]
start = 0
end = PY_SSIZE_T_MAX
step = 1
# 起始位置、终止位置均为空,步长小于 0,比如 data[:: -1]
start = PY_SSIZE_T_MAX
end = PY_SSIZE_T_MIN
step = -1

下面来分析一下它为什么要这么做,首先我们要知道,所谓的切片截取数据,本质上就是一层 for 循环。

def slice_data(data: list, start: int, end: int, step: int) -> list:
    ret_data = []
    assert step != 0
    if step > 0:
        while start < end and start < len(data):
            ret_data.append(data[start])
            start += step
    else:
        while start > end and start >= 0:
            ret_data.append(data[start])
            start -= step * -1
    return ret_data

data = list(range(0, 10))
print(data[: 5])
print(slice_data(data, 0, 5, 1))
"""
[0, 1, 2, 3, 4]
[0, 1, 2, 3, 4]
"""
print(data[8: 3: -1])
print(slice_data(data, 8, 3, -1))
"""
[8, 7, 6, 5, 4]
[8, 7, 6, 5, 4]
"""

所以当步长大于 0 时,从左往右遍历,当步长小于 0 时,从右往左遍历。最后我们再画两张图,看完之后你就彻底理解了。

当步长大于 0 时:

步长大于 0 时,从左往右遍历。

如果 start 未指定,那么设置为 0,表示从头截取,这很好理解,但问题是 end 应该设置为多少。由于 PySlice_Unpack 相当于只是做了一步预处理,它并不包含截取的原始数据的信息,所以 end 如果不指定,直接设置为 int64 最大值。

当步长小于 0 时:

当步长小于 0 时,从右往左遍历。

因为不知道截取的原始数据有多长,所以如果 start 未指定,那么设置为 int64 最大值。但不管是从左往右还是从右往左,end 都是不包含的,所以当 end 为空时,不能指定为 0,否则索引为 0 的元素就取不到了。当然也不能设置为 -1,因为 -1 会被当成是合法的负数索引,后续截取数据时会被解释为最后一个元素的索引,所以它被设置成了 int64 最小值。

我们以使用切片截取列表为例,后续介绍列表的时候还会详细说:

代码中的 item 指向切片,截取数据之前要先获取它内部的 start、stop、step 属性,于是创建三个 Py_ssize_t 变量,并将指针作为参数,调用 PySlice_Unpack。当调用结束后,就拿到了切片的起始位置、终止位置、步长。

但还没有结束,我们说 PySlice_Unpack 只是对切片里面的值做了一些预处理,比如当 step 为 1 并且 end 没有指定时,那么 end 会被设置为 PY_SSIZE_T_MAX。

所以它下面又调用了函数 PySlice_AdjustIndices,会将截取的原始数据的长度也传进去,然后对 start、end、step 做进一步处理,所有的序列对象在基于切片截取数据时都会有这两步。我们看一下该函数的逻辑。

// Objects/sliceobject.c
Py_ssize_t
PySlice_AdjustIndices(Py_ssize_t length,
                      Py_ssize_t *start, Py_ssize_t *stop, Py_ssize_t step)
{
    // 参数 length:截取的原始数据的长度
    // 参数 start、stop:指向起始位置和终止位置的指针
    // 参数 step:步长
  
    assert(step != 0);
    assert(step >= -PY_SSIZE_T_MAX);
    
    // 如果起始位置小于 0
    if (*start < 0) {
        // 那么加上长度,得到正数索引,因为负数索引就是个语法糖
        *start += length;
        // 如果加上长度之后还小于 0,那么判断步长
        /* 如果 step > 0,表示从前往后遍历,因此当 *start < 0 时,
           直接将 *start 设置为 0,最终会从第一个元素开始往后遍历
        
           如果 step < 0,表示从后往前遍历,因此当 *start < 0 时,
           显然遍历不到任何元素,因为索引是大于 0 的,所以直接将 *start 设置为 -1 */        
        if (*start < 0) {
            *start = (step < 0) ? -1 : 0;
        }
    }
    // 如果起始位置大于等于长度,继续判断步长
    else if (*start >= length) {
        /* 如果 step > 0,表示从前往后遍历,因此当 *start >= length 时,
           显然遍历不到任何元素,因为最大索引为 length - 1
           所以直接将 *start 设置为 length
           
           如果 step < 0,表示从后往前遍历,因此当 *start >= length 时,
           直接将 *start 设置为 length - 1,最终会从最后一个元素往前遍历 */       
        *start = (step < 0) ? length - 1 : length;
    }
    
    // 如果终止位置小于 0
    if (*stop < 0) {
        // 那么加上长度,得到正数索引
        *stop += length;
        // 如果加上长度之后还小于 0,那么判断步长
        /* 如果 step > 0,表示从前往后遍历,因此当 *stop < 0 时,
           显然遍历不到任何元素,因此直接将 *stop 设置为 0
           
           如果 step < 0,表示从后往前遍历,因此当 *stop < 0 时,
           直接将 *stop 设置为 -1,最终会从后往前遍历到头 */           
        if (*stop < 0) {
            *stop = (step < 0) ? -1 : 0;
        }
    }
    // 如果终止位置大于等于长度,继续判断步长
    else if (*stop >= length) {
        /* 如果 step > 0,表示从前往后遍历,因此当 *stop >= length 时,
           直接设置为 length,会从 start 往后遍历到头
           
           如果 step < 0,表示从后往前遍历,因此当 *stop >= length 时,
           此时遍历不到任何元素,直接设置为 length - 1 */           
        *stop = (step < 0) ? length - 1 : length;
    }
    
    // 到这里 *start、*stop 就已经转换好了
    // 或者说 PY_SSIZE_T_MIN、PY_SSIZE_T_MAX 已经基于 length 被替换掉了
    // 然后计算 *start 和 *stop 之间的距离,也就是应该要遍历多少个元素
    if (step < 0) {
        if (*stop < *start) {
            return (*start - *stop - 1) / (-step) + 1;
        }
    }
    else {
        if (*start < *stop) {
            return (*stop - *start - 1) / step + 1;
        }
    }
    // 如果不符合条件的话,比如像 data[3: 8: -1],显然遍历不到任何元素
    // 那么直接返回 0
    return 0;
}

可以看到,哪有什么岁月静好,我们之所以能够通过各种姿势使用切片,全靠解释器在替我们负重前行,它在背后做了非常多的工作。正如前面提到的,C 是一门很单纯的语言,Python 的花里胡哨的操作回归到 C 里面,就是普通的 if else 以及 while、for。

小结

以上我们就介绍了切片的底层结构,切片也是一个对象,拥有自己的缓存。并且 Python 针对切片提供的语法也非常丰富:

  • data[:: 1],从左往右遍历,或者说从前往后遍历;
  • data[:: -1],从右往左遍历;
  • data[:: 2],只筛选索引为偶数的元素;
  • 起始位置和终止位置可以为负数,会自动转成正数;

所以切片用起来很方便,但要明白这背后都是因为解释器做了大量的工作。当然大部分情况下我们使用切片都是无感知的,一般不会刻意地想着要去创建一个切片,只是字符串、列表、元组等数据都支持通过切片截取数据,所以切片还是值得我们深入了解一下的。


 

欢迎大家关注我的公众号:古明地觉的编程教室。

如果觉得文章对你有所帮助,也可以请作者吃个馒头,Thanks♪(・ω・)ノ。