Fluent Python 笔记——序列类型及其丰富的操作

序列的分类

Python 标准库用 C 语言实现了丰富的序列类型的数据结构,如:

  • 容器序列(能存放不同类型的数据):listtuplecollections.deque
  • 扁平序列(只容纳同一类型的数据):strbytesbytearraymemoryviewarray.array
1
2
>>> a_list = [1, '2', True, [1, 2, 3], 4.5]
>>> a_str = 'helloworld'

容器序列存放的是对象的引用,扁平序列存放的是。即扁平序列是一段连续的内存空间

1
2
3
4
5
6
7
8
9
>>> a_list = [1, '2', True, [1, 2, 3], 4.5]
>>> embedded_list = a_list[3]
>>> embedded_list
[1, 2, 3]
>>> embedded_list.append(4)
>>> embedded_list
[1, 2, 3, 4]
>>> a_list
[1, '2', True, [1, 2, 3, 4], 4.5]

序列还可以按照是否可变(能够被修改)进行分类:

  • 可变序列:listbytearrayarray.arraycollections.dequememoryview
  • 不可变序列:tuplestrbytes
1
2
3
4
5
6
7
8
9
>>> a_list = [1, 2, 3]
>>> a_list[0] = 2
>>> a_list
[2, 2, 3]
>>> a_tuple = (1, 2, 3)
>>> a_tuple[0] = 2
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
TypeError: 'tuple' object does not support item assignment

列表推导

for 循环:

1
2
3
4
5
6
7
>>> symbols = '!@#$%'
>>> codes = []
>>> for symbol in symbols:
... codes.append(ord(symbol))
...
>>> codes
[33, 64, 35, 36, 37]

列表推导:

1
2
3
4
>>> symbols = '!@#$%'
>>> codes = [ord(symbol) for symbol in symbols]
>>> codes
[33, 64, 35, 36, 37]

通常的原则是,只用列表推导创建新的列表,并尽量保持简短

列表推导(包括集合推导、字典推导)、生成器表达式在 Python3 中有自己的局部作用域。

1
2
3
4
5
6
>>> x = 'ABC'
>>> dummy = [ord(x) for x in x]
>>> x
'ABC'
>>> dummy
[65, 66, 67]

列表推导与 filter/map 的比较:

1
2
3
4
5
6
7
>>> symbols = '$¢£¥€¤'
>>> beyond_ascii = [ord(s) for s in symbols if ord(s) > 127]
>>> beyond_ascii
[162, 163, 165, 8364, 164]
>>> beyond_ascii = list(filter(lambda c: c > 127, map(ord, symbols)))
>>> beyond_ascii
[162, 163, 165, 8364, 164]

作为记录的元组

元组其实是一种数据记录(Record),其中的每个元素都对应记录中一个字段的数据,字段在元组中的位置则可以用来区分其含义。

1
2
3
4
5
6
7
8
9
>>> lax_coordinates = (33.9425, -118.408056)
>>> city, year, pop, area = ('Tokyo', 2003, 32450, 8014)
>>> traveler_ids = [('USA', '31195855'), ('BRA', 'CE342567'), ('ESP', 'XDA205856')]
>>> for country, _ in traveler_ids:
... print(country)
...
USA
BRA
ESP

元组拆包

元组拆包可以应用到任何可迭代对象上,唯一的要求即可迭代对象中的元素数量与接收这些元素的空档数一致(除非用 * 忽略多余的元素)。

元组拆包(平行赋值):

1
2
3
4
5
6
>>> lax_coordinates = (33.9425, -118.408056)
>>> latitude, longitude = lax_coordinates
>>> latitude
33.9425
>>> longitude
-118.408056

不使用中间变量交换两个变量的值:

1
2
3
4
5
6
7
>>> a = 1
>>> b = 2
>>> a, b = b, a
>>> a
2
>>> b
1

使用 * 运算符把一个可迭代对象拆开作为函数的参数:

1
2
3
4
5
>>> divmod(20, 8)
(2, 4)
>>> t = (20, 8)
>>> divmod(*t)
(2, 4)

元组拆包可以方便一个函数以元组的方式返回多个值,调用函数的代码就可以轻松地(有选择地)接受这些值。

1
2
3
4
>>> import os
>>> _, filename = os.path.split('/home/luciano/.ssh/idrsa.pub')
>>> filename
'idrsa.pub'

* 处理多余的元素:

1
2
3
4
5
6
7
8
9
10
11
12
>>> a, b, *rest = range(5)
>>> a, b, rest
(0, 1, [2, 3, 4])
>>> a, b, *rest = range(3)
>>> a, b, rest
(0, 1, [2])
>>> a, b, *rest = range(2)
>>> a, b, rest
(0, 1, [])
>>> a, *body, c, d = range(5)
>>> a, body, c, d
(0, [1, 2], 3, 4)

具名元组

collections.namedtuple 可以用来创建一个带字段名的元组和一个有名字的类,便于对程序进行调试。其类实例消耗的内存与元组是一样的,跟普通的对象实例相比更小一些(不用 __dict__ 存放实例的属性)。

1
2
3
4
5
6
7
8
9
10
11
>>> from collections import namedtuple
>>> City = namedtuple('City', 'name country population coordinates')
>>> tokyo = City('Tokyo', 'JP', 36.933, (35.689722, 139.691667))
>>> tokyo
City(name='Tokyo', country='JP', population=36.933, coordinates=(35.689722, 139.691667))
>>> tokyo.population
36.933
>>> tokyo.coordinates
(35.689722, 139.691667)
>>> tokyo[1]
'JP'

创建具名元组需要传入两个参数,第一个是类名,第二个是类的各个字段的名称。后者可以是多个字符串组成的可迭代对象或由空格分隔开的字段名组成的字符串。
可以通过字段名或位置获取某个字段的信息。

具名元组的 _fields 属性包含由这个类中所有字段名称组成的元组;_asdict() 方法可以把具名元组以 collections.OrderedDict 的形式返回。

切片

关于切片和区间忽略最后一个元素
在切片和区间操作里不包含最后一个元素是 Python 的风格,同时也符合 C 和其他以 0 为起始下标的语言的习惯。
部分原因如下:

  • 当只有最后一个位置信息时,可以快速看出区间里包含多少个元素:range(3)my_list[:3] 都返回 3 个元素
  • 起止位置都可见时,可以快速算出区间的长度(stop - start),如切片 my_list[3:6] 即包含 6 - 3 = 3 个元素
  • 可以利用任意一个下标把序列分割成不重叠的两部分(my_list[:x]my_list[x:]
step

可以用 s[a:b:c] 的形式对 s 在 a 和 b 之间以 c 为间隔取值。c 值还可以为负,表示反向取值。

1
2
3
4
5
6
7
>>> s = 'bicycle'
>>> s[::3]
'bye'
>>> s[::-1]
'elcycib'
>>> s[::-2]
'eccb'

seq[start:stop:step] 求值时,Python 会调用 seq.__getitem__(slice(start, stop, step))

对切片赋值

如果把切片放在赋值语句左边,或把它作为 del 操作的对象,则可以对切片所属的序列进行拼接、切除或就地修改等操作。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
>>> l = list(range(10))
>>> l
[0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
>>> l[2:5] = [20, 30]
>>> l
[0, 1, 20, 30, 5, 6, 7, 8, 9]
>>> del l[5:7]
>>> l
[0, 1, 20, 30, 5, 8, 9]
>>> l[3::2] = [11, 22]
>>> l
[0, 1, 20, 11, 5, 22, 9]
>>> l[2:5] = [100]
>>> l
[0, 1, 100, 22, 9]

需要注意的是,在对切片进行赋值操作时,赋值语句的右侧必须是个可迭代对象。

对序列使用 + 和 *

Python 程序员一般默认序列都会支持 +* 的拼接操作。在拼接过程中,两个被操作的序列不会发生任何改动,Python 会创建一个新的包含拼接结果的序列。

1
2
3
4
5
>>> l = [1, 2, 3]
>>> l * 5
[1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3]
>>> 5 * 'abcd'
'abcdabcdabcdabcdabcd'

如果 a * n 语句中序列 a 里的元素是对其他可变对象的引用的话,这个式子的结果可能会出乎意料。比如用 my_list = [[]] * 3 来初始化一个有列表组成的列表,实际上得到的列表里包含的三个元素是三个引用,且这三个引用都指向同一列表

1
2
3
4
5
6
>>> weird_board = [['-'] * 3] * 3
>>> weird_board
[['-', '-', '-'], ['-', '-', '-'], ['-', '-', '-']]
>>> weird_board[1][2] = 'O'
>>> weird_board
[['-', '-', 'O'], ['-', '-', 'O'], ['-', '-', 'O']]

其错误的本质等同于如下代码:

1
2
3
4
5
6
7
8
>>> row = ['-'] * 3
>>> board = []
>>> for i in range(3):
... board.append(row)
...
>>> board[1][2] = 'O'
>>> board
[['-', '-', 'O'], ['-', '-', 'O'], ['-', '-', 'O']]

即追加同一个行对象(row)到游戏币(board)

正确的做法代码如下:

1
2
3
4
5
6
>>> board = [['-'] * 3 for i in range(3)]
>>> board
[['-', '-', '-'], ['-', '-', '-'], ['-', '-', '-']]
>>> board[1][2] = 'O'
>>> board
[['-', '-', '-'], ['-', '-', 'O'], ['-', '-', '-']]

等同于如下代码:

1
2
3
4
5
6
7
8
9
10
>>> board = []
>>> for i in range(3):
... row = ['-'] * 3
... board.append(row)
...
>>> board
[['-', '-', '-'], ['-', '-', '-'], ['-', '-', '-']]
>>> board[1][2] = 'O'
>>> board
[['-', '-', '-'], ['-', '-', 'O'], ['-', '-', '-']]

即每次迭代中都新建了一个列表,作为新的一行(row)追加到游戏板子(board)

序列的增量赋值

增量赋值运算符 +=*= 的行为取决于第一个操作对象。
+= 调用的特殊方法是 __iadd__(自增)。如果某个类没有实现该方法,Python 会退一步调用 __add__

a += b 就会调用 a 中实现的 __iadd__ 方法,同时对于可变序列(如 listbytearrayarray.array),该方法的行为类似于 a.extend(b),在 a 上就地改动。
如 a 没有实现 __iadd__a += b 的效果就类似于 a = a + b,计算 a + b 得到一个新的对象,再把这个对象赋值给 a。

*= 对应的是 __imul__

1
2
3
4
5
6
7
8
9
10
11
12
13
14
>>> l = [1, 2, 3]
>>> id(l)
2888988078920
>>> l *= 2
>>> l
[1, 2, 3, 1, 2, 3]
>>> id(l)
2888988078920
>>> t = (1, 2, 3)
>>> id(t)
2888988799688
>>> t *= 2
>>> id(t)
2888988107592

作为可变对象的列表运用增量乘法后,ID 没变;而作为不可变对象的元组运用增量乘法后,新的元组被创建。

因此对于不可变序列做重复拼接操作效率会很低,每次都会有一个新对象。但字符串除外,由于对字符串做 += 等操作太普遍,CPython 专门做了优化。在为字符串初始化内存时,程序会预留额外的可扩展空间。

list.sort 与 sorted

list.sort 方法会就地排序列表,即在原列表的基础上完成排序,不会再另外复制一份。也因此其返回值为 None
内置的 sorted 函数则会新建一个列表作为返回值。它可以接收任何形式的可迭代对象(包含不可变序列和生成器),最后返回的始终是排序好的列表。

1
2
3
4
5
6
7
8
9
10
11
12
>>> fruits = ['grape', 'raspberry', 'apple', 'banana']
>>> sorted(fruits)
['apple', 'banana', 'grape', 'raspberry']
>>> fruits
['grape', 'raspberry', 'apple', 'banana']
>>> sorted(fruits, key=len)
['grape', 'apple', 'banana', 'raspberry']
>>> fruits
['grape', 'raspberry', 'apple', 'banana']
>>> fruits.sort()
>>> fruits
['apple', 'banana', 'grape', 'raspberry']

参考资料

Fluent Python