Python进阶系列二(流畅的Python第二版):数组序列 Part 2

1,151 阅读29分钟

切片

listtuplestr以及其它序列类型的具有一个共同的功能,那就是对切片运算的支持,这一功能比很多人所认知的要更为强大。

本节我们讲解切片高级形式的用法。其在自定义类中的实现将在系列十二中进行讲解。

为何切片和 range 或排除最后一项

切片和 range排除最后一项这一Pythonic惯例适用于Python、C和其它以0为初始索引的编程语言。这一惯例提供下述便利:

  • 在仅提供结束位置时易于查看切片或range的长度:range(3)my_list[:3]都生成三项。

  • 在提供了开始(start)和结束(stop)位置时易于计算切片或range的长度:只需使用stop - start

  • 易于在任意索引x处将序列分成两部分而不会存在重叠:即my_list[:x]my_list[x:]。例如:

    >>> l = [10, 20, 30, 40, 50, 60]
    >>> l[:2]  # split at 2
    [10, 20]
    >>> l[2:]
    [30, 40, 50, 60]
    >>> l[:3]  # split at 3
    [10, 20, 30]
    >>> l[3:]
    [40, 50, 60]
    

关于这一惯例的最佳论证由荷兰计算机科学家Edsger W. Dijkstra所写(参见扩展阅读部分)。

下面我们来仔细学习Python是如何解析切片标记的。

切片对象

这可能已经是常识,但有必要重复说明下:s[a:b:c]可用于指定步长c,使用得结果切片跳过指定数量的元素。步长也可为负数,此时逆向返回各元素。举三个例子来说明:

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

还有一个示例是在系列一中我们使用了deck[12::13]在尚未洗牌时获取所有的“老A”:

>>> deck[12::13]
[Card(rank='A', suit='spades'), Card(rank='A', suit='diamonds'),Card(rank='A', suit='clubs'), Card(rank='A', suit='hearts')]

a:b:c这种写法仅在索引或下标运算时放在[]中有效,产生一个切片对象slice(a, b, c)。在系列十二切片如何运行中会讲到,运算seq[start:stop:step]表达式,Python会调用seq.__getitem__(slice(start, stop, step))。即使不自己实现序列类型,了解切片对象也是有益的,因为这样我们可以对切片命名,就像是在 Excel 中对一组单元格命名。

假设需求解析例2-13中所示这种普通文本数据发票。我们可以不在代码中对切片进行硬编码,而是对其进行命名。参见在下面的for循环中如何增强了其可读性。

示例2-13: 取自普通发票的几行数据

>>> 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, 40)
>>> 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])
...
    $17.50   Pimoroni PiBrella
     $4.95   6mm Tactile Switch x20
    $28.00   Panavise Jr. - PV-201
    $34.95   PiTFT Mini Kit 320x240

系列十二向量#2:可进行切片的序列中讨论创建自己的集合时我们还会讲slice对象。同时从用户视角看,切片包含一些额外的功能,如多维切片以及展开符(...)写法。

多维切片与展开运算符

[]运算符还可接收以逗号分隔的多个索引或切片。处理[]运算的专有方法__getitem____setitem__a[i, j]中的索引按元组进行接收。也即运算a[i, j]时,Python调用的是a.__getitem__((i, j))

在外部包NumPy中就有使用,可使用a[i, j]来获取二维的numpy.ndarray、使用a[m:n, k:l]获取二维切片。本文稍后的例2-22有这种用法。

memoryview外,Python内置的序列类型都是一维的,因此均只支持一个索引或切片,无法使用它样的元组。

展开运算符使用三个点(...) ,而非普通的省略号(Unicode U+2026),Python解析器会将其识别为一个令牌。它是Ellipsis对象的别名,也是ellipsis类的唯一实例。因此,其可以参数传递给函数或切片定义时使用,如f(a, ..., z)a[i:...]。NumPy在获取多维数组切片时使用...作为一种简写方式,例如,假定x是四维数组,x[i, ...]就是x[i, :, :, :,]的简写。参见NumPy快速起步进行更详细的了解。

在写本文时,Python标准库中尚未有Ellipsis或多维索引及切片的用法。读者如果发现有,欢迎在评论区告知。这类语法特性用于支持自定义类型及NumPy这样的扩展。

切片不仅可用于提供序列中的信息,还可以原地修改可变序列,无需重新进行构建。

对切片赋值

可变序列可以使用切片符号进行切割、删除以及原地修改,这一符号可用在赋值语句左侧或作为del语句的目标。下面的例子演示了切片符号的强大之处:

>>> 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]

每个工程师都知道拼接是序列的常规操作。这时Python入门教程中都会讲解到+*的用法,但两者的运行存在一些细节,下一部分中会进行讲解。

对序列使用+和*

Python程序员默认序列支持+*。通常+的两边应当为相同序列类型,两者都不会被修改,而是新建一个同类型的序列作为拼接的结果。

要对相同序列的多个拷贝进行拼接,可使用一个整数乘上该序列。同样,这也会新建一个序列:

>>> 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来初始化列表时会产生一个对同一列表的三个引用,这可能不是你预期的结果。

下一节会讲解使用*初始化列表的列表时存在的坑。

构建列表的列表

有时我们需要使用一定数量的内嵌列表初始化列表,例如,将学生分到一组队伍里或表示游戏板中方块。实现的最佳方式是列表推导式,参见示例2-14.

示例2-14:一个由3组3元列表组成的列表可用于表示井字棋

>>> board = [['_'] * 3 for i in range(3)]  # 创建33元列表组成的列表。查看结构
>>> board
[['_', '_', '_'], ['_', '_', '_'], ['_', '_', '_']]
>>> board[1][2] = 'X'  # 在第1行第2列放入一个标记,查看结果
>>> board
[['_', '_', '_'], ['_', '_', 'X'], ['_', '_', '_']]

大家经常容易走示例2-15这种错误的捷径。

示例2-15: 指向同一列表的三个引用的列表毫无意义

>>> weird_board = [['_'] * 3] * 3  # 外部的列表由三个都指向同一内层列表的引用组成。在不进行修改时看不出异常。
>>> weird_board
[['_', '_', '_'], ['_', '_', '_'], ['_', '_', '_']]
>>> weird_board[1][2] = 'O' # 在第1行第2列放入标记后,会发现所有行都是引用同一对象的别名。
>>> weird_board
[['_', '_', 'O'], ['_', '_', 'O'], ['_', '_', 'O']]

示例2-15的问题是本质其类似如下代码:

row = ['_'] * 3
board = []
for i in range(3):
    board.append(row) # 同一个row连续3次添加到board中

示例2-14中的列表推导式等价于如下代码:

>>> board = []
>>> for i in range(3):
...     row = ['_'] * 3  # 每次迭代会新建一个row并添加至board
...     board.append(row)
...
>>> board
[['_', '_', '_'], ['_', '_', '_'], ['_', '_', '_']]
>>> board[2][0] = 'X'
>>> board  # 和预想的一样,只有行号为2的进行了修改
[['_', '_', '_'], ['_', '_', '_'], ['X', '_', '_']]

小贴士: 如果这部分中的问题或解决方案你看不懂,不必担心。系列六中会讲解引用和可变对象的机制及缺点。

至此我们讨论了对序列使用+*运算符,但还有+=*=运算符,根据目标序列的可变性会产生截然不同的结果。下一节会讲解个中原理。

序列的增量赋值

增量赋值运算符+=*=根据这个操作数的不同行为也不同。为便于讨论,我们先主要讲增量加(+=),但其原理同样适用*=及其它增量赋值运算符。

+=背后的特殊方法是__iadd__(用于原地加)。但如果未实现__iadd__,Python会自动降级至调用__add__。思考如下的简单表达式:

>>> a += b

如果a实现了__iadd__,就会调用该方法。对于可变序列(如listbytearrayarray.array),a会在原地改变(即效果类似于a.extend(b))。但在如果a没有实现__iadd__时,a += b表达式和a = a + b具有同等效果:先计算a + b表达式,生成新的对象,然后与a进行绑定。换句话说,绑定a的对象内存地址根据__iadd__的可用性会改变或不变。

对于可变序列,一般都实现了__iadd_,通常可假定+=为原地修改。对于不可变序列显示不可能做原地修改。
以上有关+=的讨论同样适用于*=,由__imul__进行实现。__iadd____imul__专用方法会在系列十六中进行讨论。
下面是*=对可变序列以及不可变序列的演示:

>>> l = [1, 2, 3]
>>> id(l)
4311953800  # 初始列表的
>>> l *= 2
>>> l
[1, 2, 3, 1, 2, 3]
>>> id(l)
4311953800  # 相乘后,列表为新增了元素后的同一对象
>>> t = (1, 2, 3)
>>> id(t)
4312681568  # 初始元组的ID
>>> t *= 2
>>> id(t)
4301348296  # 相乘后,新建了一个元组

对不可变序列反复拼接效率很低,因为它并不是直接添加元素,而是由解释器拷贝整个对象创建一个拼接了新元素的新对象。

我们已经学习了+=的常用用法。下一节通过有趣的极端案例讲解对于元组“不可变”是什么意思。

A +=赋值迷题

试着不用控制台操作先回答这个问题: 例2-16中的两个表达式的运算结果是什么?

示例2-16:猜一猜

>>> t = (1, 2, [30, 40])
>>> t[2] += [50, 60]

接下来会发生什么?选择最佳答案:

A.  t变为(1, 2, [30, 40, 50, 60])

B. 抛出错误消息为'tuple' object does not support item assignmentTypeError

C. A 和 B都不对。

D. A 和 B都对。

看到这一题时,我一开始坚定地选择了 B,但正确答案是 D:A 和 B都对。例2-17中演示了在Python 3.9控制台中实际输出结果。

示例2-17:意料之外的结果:t2被修改且抛出了异常

>>> t = (1, 2, [30, 40])
>>> t[2] += [50, 60]
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
TypeError: 'tuple' object does not support item assignment
>>> t
(1, 2, [30, 40, 50, 60])

Python在线课堂是一个用于详细可视化Python运行原理的很棒的工具。图2-5由两张截图组成,演示了示例2-17中元组t的初始状态和最终状态。

图2-5:元组赋值迷题的初始和最终状态(该图由 Python 在线课堂生成)

图2-5:元组赋值迷题的初始和最终状态(该图由 Python 在线课堂生成)

如果查看Python为s[a] += b表达式生成的字节码(例2-18),背后发生了什么会变得清晰。

示例2-18:s[a] += b表达式的字节码

>>> dis.dis('s[a] += b')
  1           0 LOAD_NAME                0 (s)
              2 LOAD_NAME                1 (a)
              4 DUP_TOP_TWO
              6 BINARY_SUBSCR            # 将s[a]的值放在栈顶(TOS)        
              8 LOAD_NAME                2 (b)
             10 INPLACE_ADD              # 执行TOS += b。如果TOS指向可变对象则成功(例2-17中为列表)        
             12 ROT_THREE
             14 STORE_SUBSCR             # 赋值s[a] = TOS。如s为不可变则失败(例2-17中 t中为元组)        
             16 LOAD_CONST               0 (None)
             18 RETURN_VALUE

本例是一种极端的情况,在原作者20年的Python编程中,也未一例因此影响到实际程序的。

从这里面我学习到了三点:

  • 不要将可变元素放到元组中
  • 增量赋值并非原子运算-我们只看到执行部分任务后其抛出异常
  • 查看Python字节码并不是太复杂,有助于了解底层的问题。

在学习了+*拼接的细节后,我们可以进入另一个序列的基本运算:排序。

list.sort vs. 内置的sorted

list.sort方法在原处对列表进行排序,即不使用拷贝。其返回值为None,这告诉我们它修改了接收器且未新建列表。这是一个重要的Python API公约:原地修改对象的函数或方法应返回None,以让调用者清楚接收器发生了变化,且未创建新对象。例如可以在random.shuffle(s)函数中看到类似的行为,它在原地对可变序列s进行了随机排序并返回None

注:返回None用于表示原地修改的公约有一个缺点:我们无法对这些方法进行级联调用。而返回新对象的方法(如所有str方法)可以流式接口进行调用,参见维基百科的流式接口词条中的详细描述。

与之对应的内置函数sorted新建列表并返回。它的参数可接收任意可迭代对象,包含不可变序列和生成器(见系列十七)。不论赋值给sorted的可迭代类型是什么,它总是返回一个新创建的列表。

list.sortsorted都可接收两个可选的关键词参数:

  • reverse:若为True,返回的元素按降序排列(即元素按反向比较)。其默认值为False
  • key:对每个元素应用一个单参数函数,生成排序的键。例如,在对一个字符串列表进行排序时,key=str.lower可用于执行一个不区分大小写的排序,key=len会按字符长度对字符串排序。默认为id 函数(即对元素自身进行比较)。

小贴士:也可以对可选关键字参数key使用内置函数min()max()以及其它标准库中的函数(如itertools.groupby()heapq.nlargest())。

以下示例用于说明这些函数和关键字参数的用法。这些示例也演示了Python的排序算法是稳定的(即它在元素相等时保留相对排序):

>>> fruits = ['grape', 'raspberry', 'apple', 'banana']
>>> sorted(fruits)
['apple', 'banana', 'grape', 'raspberry']  # 会生成按字母排序的新字符串列表
>>> fruits
['grape', 'raspberry', 'apple', 'banana']  # 查看原有列表并未改变
>>> sorted(fruits, reverse=True)
['raspberry', 'grape', 'banana', 'apple']  # 也字母反向排序
>>> sorted(fruits, key=len)
['grape', 'apple', 'banana', 'raspberry']  # 按升序排序的新字符串列表。因排序算法是稳定的,grape和apple的长度都是5,保留了原始排序。
>>> sorted(fruits, key=len, reverse=True)
['raspberry', 'banana', 'grape', 'apple']  # 字符串按长度降序排列。它并不是前面结果的反转,因为排序是稳定的,所以grape还是在apple前面。
>>> fruits
['grape', 'raspberry', 'apple', 'banana']  # 至此原来的列表fruits未发生改变
>>> fruits.sort()                          # 这会在原地对列表排序,返回None(在控制台中会省去)
>>> fruits
['apple', 'banana', 'grape', 'raspberry']  # 此时fruits进行了排序

默认Python按字符编码的排序对字符串排序。这表示ASCII大写字母会在小写字母前,而非ASCII字符不会通识进行排序。系列四对 Unicode 文本进行排序一节会讲解如何按人类通识进行排序。

序列进行排序后,可以进行高效搜索。在Python标准库的bisect模块中提供了二进制搜索算法。这个模块还包含bisect.insort函数,可用于确保排序的序列保持为排序状态。在配套网站 fluentpython.com使用二分查找管理已排序序列有对bisect模块的讲解。

本文至此所学的都适用于所有序列,而不仅仅是列表或元组。Python工程师有时会过度使用list类型因其非常顺手,我就这和干过。例如在处理大数据量的列表时,应考虑使用数组。本文接下来会讲解列表和元组的一些替代品。

在列表并非答案时

list类型灵活易用,但有某些特殊要求时,还有更好的选择。例如,在处理数百万浮点数时array会节省大量的内存。另外,如果一直在列表的另一端添加、删除元素,deque(双端队列)是一个性能更好的先进先出数据结构。

小贴士:如果代码中需要经常查看某个元素是否在容器中(如item in my_collection),考虑对my_collection使用set,尤其是在存储大量元素时。集合对快速成员查找做了优化。也可对其进行迭代,但它不是序列,因为集合中元素的排序是不固定的。在系列三中会进行讲解。

本文接下来会讨论可替代列表的可变序列类型,先从数组开始。

数组

如果列表中仅包含数字,array.array是一个更高效的替代。数组支持所有的可变序列运算(包含.pop.insert.extend),也支持其它用于快速加载和保存的方法,如.frombytes.tofile
Python的数组和C的数组一样轻量。如图2-1所示,浮点值数组并不存储完整的float实例,而仅仅是表示其机器值的已封装字节,类似C语言中的double数组。在创建array时,提供一个表示C语言底层用于存储数组中元素数据类型的类型代码。例如,b是C中称为signed char的类型代码,该类型的值在–128到127之间。如若创建一个array('b'),每个元素会在单个字节中进行存储并被解释为整型。对于数字的大型序列,这会节约大量内存。Python不允许将不匹配数组类型的数字放到其中。

示例2-19演示了创建、保存和加载一个包含一千万随机浮点值的数组。

示例2-19:创建、保存及加载大型浮点数组

>>> from array import array  # 导入array类型
>>> from random import random
>>> floats = array('d', (random() for i in range(10**7)))  # 通过任意迭代对象(此处为生成式表达式)创建一个双精度浮点值的数组(类型代码 'd')
>>> floats[-1]  # 查看数组的最后一个数
0.07802343889111107
>>> fp = open('floats.bin', 'wb') 
>>> floats.tofile(fp)  # 将数组保存为二进制文件
>>> fp.close()
>>> floats2 = array('d')  # 创建双精度的空数组
>>> fp = open('floats.bin', 'rb')
>>> floats2.fromfile(fp, 10**7)  # 从二进制文件中读取1000万个数字
>>> fp.close()
>>> floats2[-1]  # 查看数组的最后一个数
0.07802343889111107
>>> floats2 == floats  # 验证数组的内容是否一致
True

可以看出,array.tofilearray.fromfile使用起来看容易。如果测试本例,会发现运行很快。快速的实验表明array.fromfile从二进制文件加载1千万个双精度浮点值仅需0.1秒。它比从文本文件读取数字要快近60倍,那样会需要使用内置的float解析每一行。使用array.tofile进行保存比在文本文件中每行写一个浮点数要快7倍。此外,1千万个双精度值的二进制文件的大小为80,000,000字节(每个双精度值为8个字节,没有额外开销),而同样的数据文本文件要占到181,515,739字节。

具体表示二进制数据的数字数组,如栅格图像,Python有bytesbytearray类型,在系列四中会进行讲解。

我们使用表2-3来结束数组这部分内容,其中对比了listarray.array的特性。

表2-3:列表或数组的方法和属性(为保持简洁省去了已淘汰的数组方法及对象中也实现了的方法)

listarray
s.add(s2)s + s2—拼接
s.iadd(s2)s += s2—原地拼接
s.append(e)在最后添加一个元素
s.byteswap()根据大小端惯例交换数组中所有元素的字节
s.clear()删除所有元素
s.contains(e)e in s
s.copy()列表的浅拷贝
s.copy()对copy.copy的支持
s.count(e)计算某个二维出现的次数
s.deepcopy()对copy.deepcopy的优化后支持
s.delitem(p)在位置p处删除元素
s.extend(it)对可迭代的it添加元素
s.frombytes(b)通过解释为对齐机器值的字节序列添加元素
s.fromfile(f, n)通过解释为对齐机器值的二进制文件f添加 n 个元素
s.fromlist(l)通过列表添加元素;如出现类型错误则都不会添加
s.getitem(p)s[p]—在指定位置获取元素或切片
s.index(e)查找首次出现e的位置
s.insert(p, e)在位置 p 元素前插入元素 e
s.itemsize每个数组元素的字节长度
s.iter()获取迭代器
s.len()len(s)—元素数量
s.mul(n)s * n—反复拼接
s.imul(n)s *= n—原地反复拼接
s.rmul(n)n * s—反向反复拼接
s.pop([p])在位置 p 处删除、返回元素(默认为最后一项)
s.remove(e)删除第一次出现的元素e
s.reverse()原地对元素进行反向排序
s.reversed()获取从最后一个到第一个元素的扫描迭代器
s.setitem(p, e)s[p] = e—把 e 放到位p, 重写已有元素或切片
s.sort([key], [reverse])使用可选关键字参数key 和 reverse原地对元素排序
s.tobytes()按字节对象以对齐机器值返回元素
s.tofile(f)将元素以对齐机器值保存到二进制文件f中
s.tolist()按列表以数字对象返回元素
s.typecode标识元素的 C 语言类型的单字符字符串

小贴士:在Python 3.10中,数组类型并没有list.sort()这样的原地排序方法。如需对数组排序,使用内置的sorted函数重建数组:

a = array.array(a.typecode, sorted(a))在

在添加元素时如需保留数组排序,可使用bisect.insort函数。

如果大量使用数组却不知道memoryview,就少了些意思。参见下一节。

内存视图

内置的memoryview类是一个共享内存序列类型,可无需拷贝字节处理数组切片。这是受NumPy库的记性。NumPy的首席作者Travis Oliphant,在何时应使用memoryview中这样回答了这个问题:

memoryview基本上是一个Python内NumPy归纳的数组结构(不带有数学运算)。它允许我们无需先拷贝应在数据结构间共享内存(类似PIL图片、SQLite数据库、NumPy等)。对于大型数据集这非常重要。

使用array模块中类似符号,memoryview.cast方法可让我们无需移动比特流修改多字节读取或写入单元的方式。memoryview.cast返回另一个memoryview对象,共享相同的内存。

例2-20演示如何对同一个6字节数组创建替代视图,以2×3或3×2矩阵进行运算。

示例2-20:以1x6、2×3或3×2视图处理6字节内存:

>>> from array import array
>>> octets = array('B', range(6))  # 创建一个6字节数组(类型代码'B').
>>> m1 = memoryview(octets)  # 通过该数组创建memoryview,然后以列表导出
>>> m1.tolist()
[0, 1, 2, 3, 4, 5]
>>> m2 = m1.cast('B', [2, 3])  # 通过前一个新建一个两行三列的memoryview
>>> m2.tolist()
[[0, 1, 2], [3, 4, 5]]
>>> m3 = m1.cast('B', [3, 2])  # 另一个三行两列的memoryview
>>> m3.tolist()
[[0, 1], [2, 3], [4, 5]]
>>> m2[1,1] = 22  # 在m2的一行一列使用22进行重写
>>> m3[1,1] = 33  # 在m3的一行一列使用33重写
>>> octets  # 显示原始数组,证明octets, m1, m2和m3之间共享了内存
array('B', [0, 1, 2, 33, 22, 5])

memoryview的强大能力可用于修改数据。例2-21演示如何在16位整数的数组中修改元素的单个字节。

示例2-21:通过其中一个字节修改16位整数数组元素的值

>>> numbers = array.array('h', [-2, -1, 0, 1, 2])
>>> memv = memoryview(numbers)  # 通过5个16位有符号整数(类型代码'h')的数组创建memoryview
>>> len(memv)
5
>>> memv[0]  # memv中的5个元素与原数组相同
-2
>>> memv_oct = memv.cast('B')  # 通过将memv中的元素转换为字节(类型代码'B')创建memv_oct
>>> memv_oct.tolist()  # 以10字节列表导出memv_oct的元素以供查看
[254, 255, 255, 255, 0, 0, 1, 0, 2, 0]
>>> memv_oct[5] = 4  # 将4赋值给字节偏移位置5
>>> numbers
array('h', [-2, -1, 1024, 1, 2])  # 注意数字的变化:2字节无符号整数最高有效字节中的4为1024

使用结构体解析二进制记录中有关于通过struct包查看memoryview的示例。

如果在数组中进行高阶数字处理的话,应当使用NumPy库。下面就会进行简短的学习。

NumPy

本书中,我强调了Python标准库已存在的方法,这样大家可以使用到它们。但NumPy太过优秀,值得为它花费些篇幅。

对于高级数组和矩阵运算,NumPy是Python在科学计算应用中成为主流的一大原因。NumPy实现了多维同质数组和矩阵类型,不仅可存储数字,还可存储用记定义记录,并且提供了高效的元素级运算。

SciPy在NumPy基础上编写的库,提供了多种线性代数、微积分和数据统计的科学计算算法。SciPy快速可靠的原因是它使用 Netlib仓库中大量使用的C和Fortran代码。换句话说,SciPy给予科技两个领域的最佳:交互命令行和高级Python API,以及使用C和Fortran优化的业界强大的真大数据运算。

作为一个简短的NumPy演示,例2-22中展现了一些二维数组的基础运算。

示例2-22:numpy.ndarray中的行列基础运算

>>> import numpy as np # 安装后导入NumPy(它不属于Python标准库)。按照惯例,numpy导入为np。
>>> a = np.arange(12)  # 创建、查看一个整数0到11的numpy.ndarray
>>> a
array([ 0,  1,  2,  3,  4,  5,  6,  7,  8,  9, 10, 11])
>>> type(a)
<class 'numpy.ndarray'>
>>> a.shape  # 查看数组的维度,这是个一维12个元素的数组。
(12,)
>>> a.shape = 3, 4  # 改变数组的形状,添加一维,然后查看结果
>>> a
array([[ 0,  1,  2,  3],
       [ 4,  5,  6,  7],
       [ 8,  9, 10, 11]])
>>> a[2]  # 获取索引为2的行
array([ 8,  9, 10, 11])
>>> a[2, 1]  # 获取索引2, 1处的元素
9
>>> a[:, 1]  # 获取索引为1的列
array([1, 5, 9])
>>> a.transpose()  # 通过转置新建数组(交换行和列)
array([[ 0,  4,  8],
       [ 1,  5,  9],
       [ 2,  6, 10],
       [ 3,  7, 11]])

NumPy还支持加载、保存和对numpy.ndarray的所有元素进行运算的高级运算。

>>> import numpy
>>> floats = numpy.loadtxt('floats-10M-lines.txt')  # 通过文本文件加载1千万个浮点数
>>> floats[-3:]  # 使用序列切片符号查看最后三个数
array([ 3016362.69195522,   535281.10514262,  4566560.44373946])
>>> floats *= .5  # 对浮点数组中的每个元素乘上.5并再次查看最后三个元素
>>> floats[-3:]
array([ 1508181.34597761,   267640.55257131,  2283280.22186973])
>>> from time import perf_counter as pc # 导入高阶性能计量定时器(Python 3.3中加入)
>>> t0 = pc(); floats /= 3; pc() - t0 # 将每个元素除以3;1千万个浮点数花费时间小于40微秒
0.03690556302899495
>>> numpy.save('floats-10M', floats)  # 在.npy二进制文件中保存数组 
>>> floats2 = numpy.load('floats-10M.npy', 'r+')  # 将数组以内存映射文件加载到另一个数组中;即使它在内存中不能完整拟合,也会让数组切片的处理更高效
>>> floats2 *= 6
>>> floats2[-3:]  # 在每个元素乘上6后查看最后三个元素
memmap([ 3016362.69195522,   535281.10514262,  4566560.44373946])

以上只是开胃菜。

NumPy 和 SciPy 都是强大的库,是一些其它强大工具的基础,如Pandas,它实现了可存储非数字数据的高效数组类型,并为 .csv.xls、 SQL导出文件和HDF5等格式文件提供了导入导出函数,还有Scikit-learn,它是当前最广泛使用的机器学习工具集。NumPy和SciPy的大部分函数使用C或C++实现,这样可以利用CPU的所有内核,因为它们释放了的Python的GIL(全局解释器锁)。Dask项目支持跨服务器集群的并行NumPy、Pandas和Scikit-Learn运行。这些包可以写好几本书。本书不是介绍它们的。但不讲解NumPy数组对于Python序列又不完整。

在学习了平铺式序列-标准数组和NumPy数组之后,我们现在可以学习对于普通老列表完全不同的替代集:队列。

Deque和其它队列

.append.pop方法让list可以像栈或队列那样使用(若使用.append.pop(0)的话,会实现先进先出)。但在列表头部(索引为0的那端)插入、删除元素开销较大,因为整个列表都要在内存中偏移。

collections.deque类是一个线程安全的双端队列,设计用于快速在两端快速插入和删除。如果想像维护一个“最后查看”或此类性质的列表的话它也是好选择,因为deque可带边界,即使用最大长度创建。如果deque设置的边界已满,在添加新元素时它会在另一端删除一个元素。例2-23展示一些对deque执行的一些典型运算。

示例2-23:操作deque

>>> from collections import deque
>>> dq = deque(range(10), maxlen=10)  # 可选的maxlen参数设置在deque实例中允许的最大元素数;这会设置一个只读maxlen实例属性
>>> dq
deque([0, 1, 2, 3, 4, 5, 6, 7, 8, 9], maxlen=10)
>>> dq.rotate(3)  # n > 0时旋转在右端取出元素并在左端新增,n < 0时旋转在左端取出元素并在右端新增
>>> dq
deque([7, 8, 9, 0, 1, 2, 3, 4, 5, 6], maxlen=10)
>>> dq.rotate(-4)
>>> dq
deque([1, 2, 3, 4, 5, 6, 7, 8, 9, 0], maxlen=10)
>>> dq.appendleft(-1)  # 在deque已满时(len(d) == d.maxlen)添加元素会在另一端删除元素;注意在下一行中删去了0
>>> dq
deque([-1, 1, 2, 3, 4, 5, 6, 7, 8, 9], maxlen=10)
>>> dq.extend([11, 22, 33])  # 在右端添加3个元素,会在左侧删除 -1, 1 和 2
>>> dq
deque([3, 4, 5, 6, 7, 8, 9, 11, 22, 33], maxlen=10)
>>> dq.extendleft([10, 20, 30, 40])  # 注意extendleft(iter)通过在deque左侧添加iter参数的每个连续元素,因此元素最后的位置进行了翻转
>>> dq
deque([40, 30, 20, 10, 3, 4, 5, 6, 7, 8], maxlen=10)

表2-4比较了listdeque的具体方法(此处删去了object中已包含的那些)

可以看到deque实现了list大部分的方法,并添加了一些适合其设计的方法,如popleftrotate。但存在一个隐藏的开销:在deque的中间删除元素不怎么快。它主要的优化是在两端添加和删除元素。

appendpopleft是原子运算,因此在多线程应用中无需使用锁deque将作为先进先出队列使用是安全的。

表2-4:或deque中实现的方法 (为简化省去了object所实现的那些方法)

listdeque
s.add(s2)s + s2—拼接
s.iadd(s2)s += s2—原地拼接
s.append(e)在右侧(最后一个元素之后)添加一个元素
s.appendleft(e)在左侧(第一个元素之前)添加一个元素
s.clear()删除所有元素
s.contains(e)e in s
s.copy()列表的浅拷贝
s.copy()对copy.copy的支持 (浅拷贝)
s.count(e)计算一个元素出现的次数
s.delitem(p)在位置p处删除元素
s.extend(i)通过可迭代对象i在右侧添加元素
s.extendleft(i)通过可迭代对象i在左侧添加元素
s.getitem(p)s[p]—从指定位置获取元素或切片
s.index(e)查找e第一次出现的位置
s.insert(p, e)在位置p前插入元素 e
s.iter()获取迭代器
s.len()len(s)—元素数
s.mul(n)s * n—反复拼接
s.imul(n)s *= n—原地反复拼接
s.rmul(n)n * s—逆向反复拼接(系列16中讲解)
s.pop()删除并返回最后一个元素a_list.pop(p)允许在位置p处删除,但deque并不支持
s.popleft()删除并返回第一个元素
s.remove(e)按值删除第一个出现的e元素
s.reverse()原地对元素进行反向排序
s.reversed()获取迭代器从后向前扫描元素
s.rotate(n)从一端将 n 个元素移到另一端
s.setitem(p, e)s[p] = e—把e放到位置 p处,重写已有元素或切片
s.sort([key], [reverse])使用可选关键字参数key和 reverse对元素进行原地排序

deque外,Python标准包还实现了其它的队列:

  • queue:提供了同步(即线程安全)类SimpleQueueQueueLifoQueuePriorityQueue。它们可用于线程间的安全通信。除SimpleQueue外均可通过在构造函数中提供一个大于0的maxsize参数设置边界。但它们不会像deque那样会删除元素释放空间。而是在队列已满插入新元素时,等待其它线从队列中取出元素来释放空间,这对于限制活跃线程数很有用。
  • multiprocessing:实现其自己的无边界multiprocessing和有界Queue,类似于queue包中那些类,但设计用于跨进程通信。提供了一个专有的multiprocessing.JoinableQueu用于任务管理。
  • asyncio:提供带有受queuemultiprocessing模块中类启发的API的QueueLifoQueuePriorityQueueJoinableQueue,但它适配了异步编程中的任务管理。
  • heapq:与前面三上模块不同,heapq没有实现任何队列类,而是提供了heappushheappop函数,让我们可使用可变序列作为堆队列或优先级队列。

以上就讲完了list替代类型的综述,并且综合探讨了除str和二进制序列外的序列类型,这两个序列在系列四中会单独讲解。