在学习 Python 的字符串和列表所支持的切片操作,以及 Numpy 所支持的各种花式索引操作时,总是会感到非常困惑, 究其原因,就是没有理解这背后的底层原理,因此有必要专门学习一下。

__getitem____setitem__ 方法

Python 为类型的 [] 操作提供支持的方法是 __getitem__ 方法和 __setitem__ 方法,分别提供读写的功能。

例如我们可以实现一个支持 [] 读操作的实验类型:

1
2
3
4
class 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
13
a = 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
7
a[...]
# 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
11
a[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
2
3
4
5
6
a[slice(1, 2)]
# type(index) = <class 'slice'>
# index = slice(1, 2, None)
a[1:2]
# type(index) = <class 'slice'>
# index = slice(1, 2, None)

至于二维和多维数组的索引,只是将传递的内容变成了元组,例如

1
2
3
4
5
6
7
8
9
10
11
a[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
7
a[(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
2
slice(stop)
slice(start, stop[, step])

在构造 slice 对象时,Python 解释器的处理逻辑还是比较简单的,稍微实验一下即可得知: 基于:分隔并进行解析,解析得到的不一定是数,可以是任何对象,然后将解析结果(可能是0到3个值)依次传递过去,用于slice对象的构造,个数不足3个时,后续的值就是 None,超过3个则会报解析错误。

下面是几种常见的情况:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
a[1:10:3]
# type(index) = <class 'slice'>
# index = slice(1, 10, 3)

a[1:3]
# type(index) = <class 'slice'>
# index = slice(1, 3, None)

a[1::2]
# type(index) = <class 'slice'>
# index = slice(1, None, 2)

a['s':'d':-3]
# type(index) = <class 'slice'>
# index = slice('s', 'd', -3)

a[::]
# type(index) = <class 'slice'>
# index = slice(None, None, None)

a[:]
# type(index) = <class 'slice'>
# index = slice(None, None, None)

slice只是一个处理索引语法的“中转站”,因为slice对象本身并不知道接收索引的对象所支持的合法范围,因此提供的三个属性很可能不是有效的,可能是 None 等其它类型, 这需要由接收索引的对象自行负责处理,包括检查参数,校验形状等细节,对应的各种语义也由接收索引对象自行决定。

slice类型提供了一个方法indices(),需要提供给它支持的索引长度,然后它就会返回更合理的 (start, stop, step) 三元组。

例如

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
slice(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
10
p = slice(10, 2, -2).indices(20)
# (10, 2, -2)

for i in range(*p):
print(i)

# 10
# 8
# 6
# 4