fluent python 读书笔记 2--Python的序列类型2

846 阅读13分钟

对 Python 中的序列类型进行操作是我们的必要需求。尤其是切片,以及从列表中建立一个新的列表等操作尤其需求的多。阅读这一部分,我收获很多。PS: 这篇博客有点长,一下看不完就请收藏吧。。。

切片

list, tuple, str 以及 Python 中的所有序列类型都支持切片操作,但是他们实际能做的事情比我们想象的多很多

为什么切片和 range 函数都不包括最后一个元素

Pythonic 的惯例是不包含切片中的最后一个元素的,这和 Python, C 语言中的用 0 作为位置索引的第一位是相吻合的。这些惯例包括:

  • 当只给了切片上限的时候,可以很容易的看出切片和 range 函数的长度。无论是函数 range(3) 还是 list[:3] 所得到的内容长度均为 3
  • 当切片上下限都给了的时候,内容长度也很容易得到 stop - start ,即上限减去下限
  • 把序列按照索引 x 分开而不发生重叠的方法很简单, list[:x]list[x:] :例如
l = [10, 20, 30, 40, 50, 60]
l[:2] # [10, 20]
l[2:] # [30, 40, 50, 60]

切片对象

s[a:b:c] 可以用来指明切片的步长 c ,使得目标切片跳过相应的元素。切片的步长也可以为负,这将导致切片的方向为负方向。

s = 'bicycle'
s[::3] # 'bye'
s[::-1] # 'elcycib'
s[::-2] # 'eccb'

a:b:c 只有位于 [] 内才生效,用来产生一个切片对象。当执行 seq[start:stop:step] 的时候, Python 会调用 seq.__getitem__(slice(start, stop, step)) 。即使你没有在你自定义的序列类型中实现这个方法,知道切片对象是怎么产生作用,也是很有用的。

如果需要处理偏平的数据 (flat-like data),如例子2-11所示的那样,为了处理的更加明确,可以直接将对应的切片命名。这对于可读性来说,是很有帮助的。

# 例子2-11,处理偏平数据
invoice = """
... 0.....6.................................40........52...55........
... 1909 Pimoroni PiBrella $17.50 3 $52.50
... 1489 6mm Tactile Switch x20 $4.95 2 $9.90
... 1510 Panavise Jr. - PV-201 $28.00 1 $28.00
... 1601 PiTFT Mini Kit 320x240 $34.95 1 $34.95
... """
SKU = slice(0, 6)
DESCRIPTION = slice(6, 60)
UNIT_PRICE = slice(40, 52)
QUANTITY = slice(52, 55)
ITEM_TOTAL = slice(55, None)
line_items = invoice.split('\n')[2:]
for item in line_items:
    print item[UNIT_PRICE], item[DESCRIPTION]

将对应的切片命名,可以便于我们阅读,找到我们需要提取的信息。

多维切片和省略号

在第三方包 NumPy 中,可以使用 [] 操作符操作用逗号分隔的多个索引值或者切片来获取元素。最简单的操作便是 a[i, j] ,其中 a 的类型为 numpy.ndarray 。二维的切片操作为 a[m:n, k:l]__getitem____setitem__ 特殊方法就是用来处理 [] 的。为了执行 a[i, j] ,实际的 Python 调用为 a.__getitem__((i, j)) 。里面的多维索引被封装成了元组传给了对应的特殊函数。但是 Python 内置的序列类型只是一维的,并不支持多维操作,sad。

Python 使用省略号来作为多维切片的简写,例如 x[i, ...] 等同于 x[i, :, :, :, ] 。此处 x 为四维数组。

对切片赋值

可变的序列类型可以用切片进行直接操作。

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
# Traceback (most recent call last):
# File "<stdin>", line 1, in <module>
# TypeError: can only assign an iterable
l[2:5] = [100]
l # [0, 1, 100, 22, 9]

对切片进行赋值时,右侧的对象必须得是可遍历对象,即使只有一个元素。

使用 +* 来操作序列

程序员都期待序列类型支持 +* 。当使用 + 操作的时候,两边的对象必须是相同的序列类型,而且两个对象都不能被改变,但是会产生一个相同类型的对象作为结果。

当把一个序列对象乘以一个整数的时候,一个新的序列会被创建,原序列不变。

l = [1, 2, 3]
l * 5 # [1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3]
5 * 'abc' # 'abcdabcdabcdabcdabcd'

+* 都不会改变其操作数,但是会产生一个新的对象。

小建议:

当进行 a * n 的操作时,而 a 又是一个包含可变元素的序列时,结果可能会很有趣。

a = [[1]]
b = a * 3 # [[1], [1], [1]]
a[0][0] = 2
a # [[2]]
b # [[2], [2], [2]]

这里面的传递是引用传递,所以进行修改时,会影响到很多。这方面切记!

建立一个列表的列表

有时我们需要建立一个包含嵌套列表的列表,实现这个的最好办法就是列表解析。

# 例子2-12,包含三个长度为3列表的列表,可以用来表示一个一字棋
border = [['_'] * 3 for i in range(3)] # 注释1
print border # [['_', '_', '_'], ['_', '_', '_'], ['_', '_', '_']]
border[1][2] = 'X' # 注释2
print border # [['_', '_', '_'], ['_', '_', 'X'], ['_', '_', '_']]
  1. 创建一个包含3个元素的列表,每个元素包含三个元素。注意查看其结构
  2. 在第一行第二列出放一个标记,查看结果

对比看来,例子2-13是一个错误的例子

# 例子2-13,同样的例子,但是是错的
weird_board = [['_'] * 3] * 3 # 注释1
print weird_board
weird_board[1][2] = '0' # 注释2
print weird_board
  1. 最外层列表有三个指向相同引用的列表组成。不改变的时候,看起来没毛病
  2. 在第一行第二列放置一个标记,可以发现每一行都指向了相同的元素

例子2-13其实相当于

row = ['_'] * 3
board = []
for i in range(3):
    board.append(row) # 注释1
  1. 被添加到 board 里面的列表都是同一个

而对应的,例子2-12和以下代码是等同的。

board = []
for i in range(3):
    row = ['_'] * 3 # 注释1
    board.append(row)
print board # [['_', '_', '_'], ['_', '_', '_'], ['_', '_', '_']]
board[2][0] = 'X'
print board # 注释2
# [['_', '_', '_'], ['_', '_', '_'], ['X', '_', '_']]
  1. 每次迭代都新建立了一个列表,并添加到了 board 后面
  2. 只有对应的第二列改变了,如我们所期待的那样

增广的序列赋值

+=*= 可以和正常的运算非常不同。此处只讨论 += ,其思想概念完全适用于 *= 。让其工作的特殊函数为 __iadd__ 。如果这个特殊函数没有被实现, Python 会调用 __add__ 作为备用。

a += b

如果 __iadd__ 被实现了就会被调用。对于可变序列(如 list, bytearray, array.array),a 会在原地被改变。而如果 __iadd__ 没有被实现,那么表达式 a += b 就和 a = a + b 完全一样了。同样的,*= 对应特殊函数 __imul__ ,一下是一些简单例子。

l = [1, 2, 3]
id(l) # 4311953800 注释1
l *= 2 
l # [1, 2, 3, 1, 2, 3]
id(l) # 4311953800 注释2
t = (1, 2, 3)
id(t) # 4312681568 注释3
t *= 2
id(t) # 4301348296 注释4
  1. 初始列表的id
  2. 做了运算之后,l 还是其本身,只是在最后面添加上了新的元素
  3. 初始元组的id
  4. 做了运算之后,元组被改变

重复进行不可变序列的连结是效率很低的,因为除了将需要的元素添加到目标元素的后面之外,解释器还得把得到的整个序列复制到新的内存中。

一个有趣的 += 赋值难题

# 例子2-14,一个谜题
t = (1, 2, [30, 40])
t[2] += [50, 60]

会发生什么呢?t 是一个不可变的元组,是不能做修改的,而 t[2] 又是一个可变的列表。结果可能会让人吃惊

# 例子2-15,例子2-14的输出
# Traceback (most recent call last):
# File "<stdin>", line 1, in <module>
# TypeError: 'tuple' object does not support item assignment
print t # (1, 2, [30, 40, 50, 60])

也就是说 Python 一边报错,一边输出了正确的结果。(傲娇的 Python )

这里安利一下一个网站 Online Python Tutor ,在这里可以看出程序运行期间具体都发生了什么,有兴趣就自己去看运行时吧。具体不介绍,但是谁用谁知道。

小建议:

值得指出的是,使用 t[2].extend([50, 60]) 就不会出错。这里只是指出 += 的怪异行为。

总结上面的例子:

  1. 把可变元素放在元组之中不是什么好主意
  2. 增广赋值操作并不是原子操作

list.sort 和内置的 sorted 函数

list.sort 函数会对 list 进行原地排序,函数的返回值为 None 来告知我们 list 本身已经被修改了,同时也不会产生新的 list 。这是 Python Api 的一个重要惯例:函数或者方法如果原地修改了一个对象,应该返回一个 None ,来让人清楚的知道对象本身被改变了,以及没有新的对象被创建。

与之形成对比的,Python 的内置函数 sorted 创建了一个新的 list 并将其返回。sorted 函数接受任何可遍历对象作为输入参数,包括不可变序列和生成器。无论输入参数是什么类型,sorted 函数总会创建一个新的 list ,并将其返回。

list.sortsorted 函数接收两个可选的,只接受关键词的参数

  • reverse 如果为 True ,元素会以逆序的形式返回,默认为 False
  • key 决定每个元素被排序时的排序依据。例如 key=str.lower 会进行大小写无关的排序,key=len 会把字符串按照长度排序。

具体的例子省略,但是没有任何难度。

通过 bisect 函数来操作排序的序列

bisect(haystack, needle)会对 haystack 做二分查找来搜寻 needle,这自然必须要求 haystack 是一个排序后的序列。

bisect 来搜索

# 例子2-17,bisect 找到元素的插入位置
import bisect
import sys

HAYSTACK = [1, 4, 5, 6, 8, 12, 15, 20, 21, 23, 23, 26, 29, 30]
NEEDLES = [0, 1, 2, 5, 8, 10, 22, 23, 29, 30, 31]

ROW_FMT = '{0:2} @ {1:2}         {2}{0:<2}'

def demo(bisect_fn):
    for needle in reversed(NEEDLES):
        position = bisect_fn(HAYSTACK, needle) # 注释1
        offset = position * '  |' # 注释2
        print ROW_FMT.format(needle, position, offset) # 注释3

if __name__ == '__main__':

    if sys.argv[-1] == 'left': # 注释4
        bisect_fn = bisect.bisect_left
    else:
        bisect_fn = bisect.bisect
    print('DEMO:', bisect_fn.__name__) # 注释5
    print('haystack ->', ' '.join('%2d' % n for n in HAYSTACK))
    demo(bisect_fn)
  1. 使用 bisect 函数来获得插入点
  2. 根据偏移量来画分割线
  3. 格式化输出
  4. 通过命令行参数来选择对应的 bisect 函数
  5. 在表头打印函数名称
('DEMO:', 'bisect')
('haystack ->', ' 1  4  5  6  8 12 15 20 21 23 23 26 29 30')
31 @ 14           |  |  |  |  |  |  |  |  |  |  |  |  |  |31
30 @ 14           |  |  |  |  |  |  |  |  |  |  |  |  |  |30
29 @ 13           |  |  |  |  |  |  |  |  |  |  |  |  |29
23 @ 11           |  |  |  |  |  |  |  |  |  |  |23
22 @  9           |  |  |  |  |  |  |  |  |22
10 @  5           |  |  |  |  |10
 8 @  5           |  |  |  |  |8 
 5 @  3           |  |  |5 
 2 @  1           |2 
 1 @  1           |1 
 0 @  0         0

这张图展示了 bisect 函数的具体工作过程。和函数 bisect_left 的比较就是,当遇到相同元素时,bisect (也就是 bisect_right),把元素插入了右边,而 bisect_left 插到了左边。例子2-18是一个简单应用。

# 例子2-18,给定分数,输入对应的成绩分区
def grade(score, breakpoints=[60, 70, 80, 90], grades='FDCBA'):
    i = bisect.bisect(breakpoints, score)
    return grades[i]

print [grade(score) for score in [33, 99, 77, 70, 89, 90, 100]]

bisect.insort 来插入

对序列排序是很耗时的操作,所以一旦一个序列已经排好序,我们希望后续的操作依旧可以保持排序状态。

# 例子2-19,Insort函数保持排序状态
import bisect
import random
SIZE = 7
random.seed(1729)
my_list = []
for i in range(SIZE):
    new_item = random.randrange(SIZE*2)
    bisect.insort(my_list, new_item)
    print('%2d ->' % new_item, my_list)

输出结果为:

('13 ->', [13])
('12 ->', [12, 13])
(' 5 ->', [5, 12, 13])
(' 6 ->', [5, 6, 12, 13])
(' 9 ->', [5, 6, 9, 12, 13])
(' 2 ->', [2, 5, 6, 9, 12, 13])
(' 4 ->', [2, 4, 5, 6, 9, 12, 13])

也许有时 List 不是最好的选择

list 是那么的好用,以至于我们选择使用 list 几乎不需要做多余的思考。但有时,例如我们需要存储一个一千万的浮点数列表,使用 array 是更高效的选择,因为 array 并不是把 float 对象完全的存贮下来,而只是存下来了打包的字节。如果我们需要不断的添加或者删除元素,这是 deque 是更好的选择。如果你需要做大量的 in 操作,那么 set 是更好的选择。

Arrays

如果序列中只有数, array.array 是一个更高效的选择。和 list 一样,它也支持所有的可变序列的操作,包括 pop, insert, extend。它还支持快速加载和保存的 frombytestofile 方法。

和 C 语言中的 array 一样,创建 array 需要指明存储类型。array('b') 表明每个元素都是一个字节,被解释为从 -128 到 127 的整数。对于体积很大的序列来说,这个很节省空间。同时 array 会检查你的输入,不会允许你把类型不对的元素放进去。

# 例子2-20,创建,保存,加载一个大的数组
from array import array # 注释1
from random import random
floats = array('d', (random() for i in range(10**7))) # 注释2
print floats[-1] # 注释3
fp = open('floats.bin', 'wb')
floats.tofile(fp) # 注释4
fp.close()
floats2 = array('d') # 注释5
fp = open('floats.bin', 'rb')
floats2.fromfile(fp, 10**7) # 注释6
fp.close()
print floats2[-1] # 注释7
print floats2 == floats # 注释8
  1. 创建一个数组类型
  2. 从一个可遍历对象中,创建一个双精度浮点数的数组(关键字为 d)。此处的可遍历对象为生成器
  3. 查看一下最后一个元素
  4. 把 array 存储为二进制文件
  5. 创建一个双精度浮点数的空数组
  6. 从二进制文件中读取一千万个浮点数
  7. 检查数组中的最后一个数
  8. 比较两个数组

重点是,这些操作相当迅速。我自己曾经试着用 list 来操作数据,写文件然后读文件,相当慢。对于数组来说 array.fromfile 只需要 0.1 秒的时间从二进制文件中来加载一千万个双精度浮点数的数组,这个几乎比从文本文件中读取要快 60 倍。同样的,array.tofile 也比一个个往每一行中写浮点数要快差不多 7 倍。更重要的是,存储一千万个浮点数的二进制文件只有差不多 80 M,而写文本文件则差不多要 180 M。

小建议:

在 Python 3.4 中,array 并没有像 list.sort() 那样的原地排序操作,如果需要的话,可以进行如下操作

a = array.array('d', sorted(a))

源码的github地址

源代码
以上源代码均来自于 fluent python 一书,仅为方便阅读之用。如侵删。