Python 理解切片的底层原理
在学习 Python 的字符串和列表所支持的切片操作,以及 Numpy 所支持的各种花式索引操作时,总是会感到非常困惑, 究其原因,就是没有理解这背后的底层原理,因此有必要专门学习一下。
__getitem__
和
__setitem__
方法
Python 为类型的 []
操作提供支持的方法是
__getitem__
方法和 __setitem__
方法,分别提供读写的功能。
例如我们可以实现一个支持 []
读操作的实验类型:
1
2
3
4class Demo:
def __getitem__(self, index):
print(f"type(index) = {type(index)}")
print(f"index = {index}")
尝试一下基本的索引操作 1
2
3
4
5
6
7
8
9
10
11
12
13a = Demo()
a[0]
# type(index) = <class 'int'>
# index = 0
a[-1]
# type(index) = <class 'int'>
# index = -1
a['xyz']
# type(index) = <class 'str'>
# index = xyz
还可以玩点更酷炫的 1
2
3
4
5
6
7a[...]
# type(index) = <class 'ellipsis'>
# index = Ellipsis
a[[1,2]]
# type(index) = <class 'list'>
# index = [1, 2]
通过实验可知,__getitem__
方法原样获取了我们提供的“索引”,无论我们提供的是整数还是字符串,Python解释器都会原样传递。
但是,如果我们在 []
中的内容含有冒号,就会触发特殊行为:Python解释器自动将其转换为
slice
对象,将其传递给 __getitem__
方法或
__setitem__
方法。
这就是困惑产生的根源,所有的切片操作都归结于此。
例如 1
2
3
4
5
6
7
8
9
10
11a[1:20:3]
# type(index) = <class 'slice'>
# index = slice(1, 20, 3)
a[1:]
# type(index) = <class 'slice'>
# index = slice(1, None, None)
a[:]
# type(index) = <class 'slice'>
# index = slice(None, None, None)
我们完全可以手动创建 slice
对象进行传递,这在语法上完全等价的,例如
1 | a[slice(1, 2)] |
至于二维和多维数组的索引,只是将传递的内容变成了元组,例如
1
2
3
4
5
6
7
8
9
10
11a[1,2]
# type(index) = <class 'tuple'>
# index = (1, 2)
a[1:,2]
# type(index) = <class 'tuple'>
# index = (slice(1, None, None), 2)
a[1,:,:]
# type(index) = <class 'tuple'>
# index = (1, slice(None, None, None), slice(None, None, None))
我们总是可以手动创建元组进行传递 1
2
3
4
5
6
7a[(1,2)]
# type(index) = <class 'tuple'>
# index = (1, 2)
a[1,2]
# type(index) = <class 'tuple'>
# index = (1, 2)
C++ 由于冒号表达式的存在,使得
a[i,j] == a[j]
,让多维数组的操作始终无法像 Python 一样简便自然。
slice
Python在索引操作中会自动将含有冒号的部分转换为 slice
对象,下面进一步探究 slice
对象的细节。
slice
对象有三个属性:
start
起始索引stop
终止索引step
步长
slice
类型有两种构造方法,分别对应一到三个参数的构造
1
2slice(stop)
slice(start, stop[, step])
在构造 slice
对象时,Python
解释器的处理逻辑还是比较简单的,稍微实验一下即可得知:
基于:
分隔并进行解析,解析得到的不一定是数,可以是任何对象,然后将解析结果(可能是0到3个值)依次传递过去,用于slice
对象的构造,个数不足3个时,后续的值就是
None
,超过3个则会报解析错误。
下面是几种常见的情况:
1 | a[1:10:3] |
slice
只是一个处理索引语法的“中转站”,因为slice
对象本身并不知道接收索引的对象所支持的合法范围,因此提供的三个属性很可能不是有效的,可能是
None
等其它类型,
这需要由接收索引的对象自行负责处理,包括检查参数,校验形状等细节,对应的各种语义也由接收索引对象自行决定。
slice
类型提供了一个方法indices()
,需要提供给它支持的索引长度,然后它就会返回更合理的
(start, stop, step)
三元组。
例如 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17slice(0, 10, 2).indices(20)
# (0, 10, 2)
slice(10, 2, -1).indices(20)
# (10, 2, -1)
slice(None, 10, 1).indices(20)
# (0, 10, 1)
slice(1, None, 2).indices(20)
# (1, 20, 2)
slice(0, 5, None).indices(20)
# (0, 5, 1)
slice(None, None, None).indices(20)
# (0, 20, 1)
通过实验发现,indices
方法所做的修正很简单:
start
如果是None
,修正为0
;stop
如果是None
,修正为输入的参数,即最大长度;step
如果是None
,修正为1
。
可以直接利用range()
来展示真正遍历的索引,例如
1
2
3
4
5
6
7
8
9
10p = slice(10, 2, -2).indices(20)
# (10, 2, -2)
for i in range(*p):
print(i)
# 10
# 8
# 6
# 4