Python进阶系列二:数组序列

Python Alan 2年前 (2022-02-05) 3166次浏览 2个评论 扫描二维码

在写出Python之前,Python是ABC编程语言的贡献者,这是一个为初学者设计编程环境持续了10年的研究项目。ABC引入大量现在称为Pythonic的概念:对不同类型序列的通用操作、内置元组和映射类型,缩进的代码结构、无变量声明的强类型等等。Python如此友好不是一蹴而就的。

Python继承了ABC中的序列统一处理。字符串、列表、字节序列、数组、XML及数据库结果共享大量的通用操作,包括迭代、切片、排序和拼接。

理解Python中大量的序列有避免我们重复造轮子,并且它们的通用接口对我们创建支持和使用现有及未来的序列类型的 API提供了宝贵的参考。

本文中的大部分适用于所有序列,从大家熟悉的list到Python 3中所新增的strbytes类型。有关列表、元组、数组和队列的具体内容这里也会涉及,但Unicode字符串和字节序列的详细内容在本系列系列四中讲解。此处旨在讲解开箱即用的序列类型。如何创建自己的序列类型在第12篇文章中讲解。

更多 Python 教程请见 GitHub 仓库

本文的主要内容有:

  • 列表推导式和生成器表达式的基础
  • 以记录使用元组和以不可变列表使用元组
  • 序列解包和序列模式
  • 读取切片及写入切片
  • 专有序列类型,如数组和队列

内置序列概述

标准库中提供了大量的以C语言实现的序列类型:

  • 容器序列:可以存储不同类型的数据,包含内嵌容器,例如:listtuplecollections.deque
  • 扁平序列:存储某个简单类型。例如:strbytesarray.array

容器序列中存储其内对象的引用,可以是任意类型,而扁平序列在自有内容空间中存储其内容的值,并不存储为独特Python对象。参见图2-1。

Python进阶系列二:数组序列

图2-1:元组和数据的内存简化图表,每个3项。灰格表示每个 Python对象的内存头(未按比例绘制)。元组拥有一组对数据的引用。每一条数据为单个 Python 对象,可能存储的是对另一个 Python 对象的引用,如其中的列表。相对应的,Python 中的数组是单个对象,存储着3个 double 类型数据的 C 语言数组。

因上扁平序列更为紧凑,但仅能存储像字节、整数和浮点数这样的原始机器值。

内存中每个Python对象都有一个包含元数据的头。最简单的Python对象,float有一个数值字段和两个元数据字段:

  • ob_refcnt:对象的引用数
  • ob_type:对象类型的指针
  • ob_fval:存储浮点值的 C 语言double

Python 64位版本中,每个字段包含8个字节。这也是为什么浮点数组要比浮点元组更紧凑:数组是存储原始浮点值的单个对象,而元组只多个对象组成-元组自身及其所包含的各个float对象。

另一种是通过可变性对序列类型进行分组:

  • 可变序列:如listbytearrayarray.arraycollections.deque
  • 不可变序列:如tuplestrbytes

图2-2有助于从视觉上看可变序列如何继承不可变序列的所有方法,以及实现额外的一些方法。内置的具体序列类型实际上并不是Sequence 和 MutableSequence抽象基类(abstract base class – ABC)的子类,但它们是通过这些抽象基类注册的虚拟子类,在系列十三中会讲解。作为虚拟子类,tuple 和 list可通过如下测试:

Python进阶系列二:数组序列

图2-2:collections.abc中一些类的简化UML图(父类在左侧,继承由箭头通过子类指向父类,斜体名称为抽象类和抽象方法)

记住这些共同特征:可变和不可变,容器和扁平。对于从一个序列类型推导至其它类型会很有帮助。

最基础的序列类型是list:一个可变容器。读者对列表应该很熟悉了,所以我们会直接讲解列表推导式,这是一种构建列表很强大的工具,但由于写法看起来有些怪导致有时用得过少。掌握列表推导式为我们打开生成器表达式的大门,后者可以生成填充其它类型序列的元素,当然不止这个。我们在下面一节中进行讨论。

列表推导式和生成器表达式

创建序列最快速的方式是列表推导式(针对list)或生成器表达式(针对其它类型的序列)。如果读者日常不使用这些语法形式,通常无法快速写出可读性高的代码。

如果对这种结构的“可读性”表示怀疑的话,请继续往下看。

列表推导式和可读性

下面是一个测试:示例2-1示例2-2哪个更易读?

示例2-1:通过字符串构建一个Unicode代码点列表

示例2-2:使用列表推导式通过字符串构建一个Unicode代码点列表

稍有些 Python基础的读者都可以读懂示例2-1。但在学习过列表推导式之后,我发现示例2-2的可读性更强,因为其内容更清晰。

for循环可用于完成很多任务:扫描序列进行计数或提取子项、聚合运算(求和、平均值)或其它一些什么位置。示例2-1中的代码在构建一个 列表。而列表推导式则更为显式。其作用就是构建新的列表。

当然也可能滥用列表推导式写出难懂的代码。我见过误用列表推导式来重复代码块的代码。如果对所生成的列表不做任务操作的话,那么不应该使用这种语法。同时应当保持简短。如果列表推导式超过了两行,最好把它拆开或使用普通的for循环进行我一定。程序员需要自行判断,Python和英语一样,没有书写的定式。

语法小贴士:Python代码中,会忽略[]{}()内的换行。因此可以通过不使用\转义符换行创建多行列表、列表推导式、元组、字典等,并且这种转义在不小心后面有空格时还会失效。同时,这些定界符用于定义逗号分隔子项序列字面量,结尾的逗号会被忽略。例如,在编写多行列表字面量时,在最后一项的后面加上逗号是一件贴心的事,因为其它程序员在添加新增项时会更为轻松,对于读取变化也会减少噪音。
列表推导式和生成器表达式中的本地作用域

在Python 3中,列表推导式、生成器表达式及相应的集合和字典推导式,在for语句中带有一个持有变量的局部作用域。

但是,通过海象运算符:=赋值的变量在这些推导式或表达式返回后仍可以访问,这点和函数的局部变量不同。PEP 572—赋值表达式定义:=对象的作用域为闭包函数,除非对该对象使用了globalnonlocal声明。

列表推导式通过过滤、转化子项来从序列或其它可迭代类型创建列表。内置的filtermap也可以完成相同的任务,但它们的可读性很差,接下来我们会学习。

列表推导式 vs map 和 filter

列表推导式可以完成filtermap函数的所有任务,无需对功能复杂的 Python lambda做任何修改。参见示例2-3

示例2-3:由列表推导式和 map/filter 组合所创建的相同列表

我曾经以为filtermap要比对应的列表推导式更快,但Alex Martelli 指出并非如此-至少上例中并非这样。《流畅的Python》代码仓库中的02-array-seq/listcomp_speed.py脚本是一个对比列表推导式和filter/map运行速度的简单示例。

在本系列第7篇中会进一步讲解filtermap。下面我们要使用列表推导式来计算笛卡尔积:包含由两个或多个列表所有子项所创建元组的列表。

笛卡尔积

列表推导式可以通过两个或多个可迭代对象的笛卡尔积创建列表。组成笛卡尔积的子项为由各个输入迭代对象所构造的元素。所产生的列表的长度与输入迭代对象长度的积相等。参见图2-3

例如,设想需要生成一个有两种颜色、三个尺码的 T 恤 列表。示例2-4展示了如何使用列表推导式生成列表。结果为6项。

示例2-4:使用列表推导式得到的笛卡尔积

Python进阶系列二:数组序列

图2-3:三个大小、四种花色扑克牌的笛卡尔积是一个12组的序列

第一篇示例1-1中我们使用了如下表达式来初始化由4种花色每种13张组成的52张一副扑克牌,先按花色然后按大小排序:

列表推导式的一招鲜就是创建列表。要生成其它序列类型的数据,可以使用生成器表达式。下一节我们简单地学习创建列表外序列的生成器表达式。

生成器表达式

初始化元组、数组及其它类型的序列,我们可以先使用列表推导式,但生成器表达式更节省内存,因为它使用迭代器协议逐一生成子项,而非构建整个列表来存放其它的构造序列。

生成器表达式和列表推导式的语法相同,但外面使用小括号而非中括号。

示例2-5展示了创建元组和数组的基本用法。

示例2-5:通过生成器表达式初始化元组和数组

示例2-6使用生成器表达式计算笛卡尔积打印两种颜色三种尺码的一组 T 恤。对比示例2-4,这里T 恤列表的6项未在内存中创建:生成器表达式在for循环中逐一填充。如果笛卡尔积中的两个列表分别有1000弋矶山街道,使用生成器表达式会节约仅对for循环喂数据构建百万子项列表产生的开销。

示例2-6:生成器表达式笛卡尔积

注:系列十七详细讲解生成器的运行原理。此外仅用于展示如何使用生成表达式初始化列表外的序列,或生成无需保存在内存中的内容。

接下来我们学习Python中的另一个基本序列类型:元组。

元组不只是个不可变列表

Python一些介绍文本中把元组描述为“不可变列表”,但这只是为推广的简述。元组有两重职责:可用作不可变列表,也可用作不带字段名的记录。这一用法有时会被人忽略,所以我们先介绍这点。

元组用作记录

元组中存储记录:元组中的每一项存储一个字段的数据,子项的位置表达其含义。

如果把元组仅看成是不可变列表,其子项的数量及排序根据使用场景可能是重要的或无关紧要。但把元组用作字段集合时,子项的数量通常是固定的,其排序也非常重要。

示例2-7展示了用作记录的元组。注意在每个表达式中,对元组排序会毁坏其信息,因为每个字段的含义由元组中的位置给定。

示例2-7:用作记录的元组

小贴士:把_用作虚拟变量是一种惯例。虽然奇怪但它是一个有效的变量名。而在match/case语句中,_是不与值绑定匹配任意值的通配符。参见序列的模式匹配一节。在Python控制台中,前一条命令的结果在不为None时被赋值给_

我们经常认为记录是带有具名字段的数据结构。系列五中会讲解两种创建带有具名字段的元组。

但通常无需为对字段命名创建一个类,尤其是使用解包并避免使用索引访问字段时。在示例2-7中,我们在单条语句中对city, year, pop, chg, area赋值('Tokyo', 2003, 32_450, 0.66, 8014)。然后%运算符将passport元组中的每项赋值给print参数中格式化字符串的对应位置。这两处都是元组解包的示例。

注:元组解包(tuple unpacking)被 Python 死忠粉们广泛使用,而迭代解包的说法却存在阻力,参见PEP 3132 — 扩展的迭代解包

序列和可迭代解包中会讲解元组、序列以及常用可迭代对象的解包。

元组用作不可变列表

Python解释器和标准库大量地将元组用作不可变列表,读者也应该这么用。这会带来两大好处:

  1. 清晰性:在代码中看到元组时,就知道其长度不会改变。
  2. 性能:元组比同等长度的列表占用更少的内存,允许Python做一些优化。

但是,请注意元组的不可变性仅作用于其所包含的引用。元组中的引用无法删除或替换。但如果其中一个引用指向可变对象,该对象改变时,元组的值也发生了改变。以下的代码段通过创建两个一开始相等的元组ab来讲解这点。图2-4表示元组b在内存中的初始布局。

b中的最后一项发生改变时,ba就不相等了:

Python进阶系列二:数组序列

图2-4:元组本身的内容不可变,但这仅表示元组存储的引用会一直指向同一对象。但如果其中的引用对象可变,比如说是列表,那么内容也随之改变。

带有可变子项的元组可能会产生 bug。在什么是可哈希值一节中,我们会学到仅在值完全不会改变时对象才是可哈希的。不可哈希的元组是不能作为字典的键或集合(set)的元素的。

如果希望显示决定元组(或任意对象)的值是否固定,可认使用内置hash函数创建一个像下面这样的fixed函数:

我们会在未来的元组的相对可变性一节中进行更深入的探讨。

尽管存在这一问题,元组仍被广泛地用作不可变列表。Python的核心开发者Raymond Hettinger在对StackOverflow上的问题Python中的元组是否比列表更高效?的解答中说明了其性能上的一些优势。总结Hettinger所写的如下:

  • 为运算元组字面量,Python编译器在运算中对元组常量生成了字节码,而对于列表字面量,所生成的字节码将每个常以单独的元素压入数据栈,然后再构建列表。
  • 对于元组ttuple(t)仅会返回对同一的t引用。无需进行拷贝。而对于列表llist(l)构造器必须新建一个l的拷贝。
  • 因其定长,会对tuple实例分配所需的精确内存空间。而list的实例则会分配多余的空间,用于缓解未来新增所带来的开销。
  • 元组中子项的引用在元组结构体中心数组进行存储,而列表对引用数组的指针却存储在其它位置。这种间接性是因为列表在超过当前所分配的空间时,Python会需要重新分配引用的数组来添加空间。额外的间接引用会让CPU缓存更为低效。

比对元组和列表方法

在将元组用作列表的不可变替代品时,最好知道其API的相似性。参见表2-1,元组支持所有不涉及添加或删除子项的列表方法,有一个例外,那就是元组没有__reversed__方法。但这是出于优化目的,reversed(my_tuple)不需要使用它。

表2-1:列表或元组中的方法及属性(为进行简化省略了对象实现的方法)

列表元组
s.__add__(s2)s + s2—拼接
s.__iadd__(s2)s += s2—原地拼接
s.append(e)在最后添加一个元素
s.clear()删除所有项
s.__contains__(e)e in s
s.copy()列表的浅拷贝
s.count(e)元素出现次数
s.__delitem__(p)在位置p删除子项
s.extend(it)通过可迭代对象it添加子项
s.__getitem__(p)s[p]—获取指定位置子项
s.__getnewargs__()支持通过pickle的序列化优化
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—反向反复拼接
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对子项进行原地排序

注:反向运算符在系列十六中讲解

下面我们切换到有关 Python编程常用的一个重要话题:元组、列表和迭代解包。

序列和迭代解包

解包非常重要,因为在从序列中提取元素时它可以避免不必要及易于出错的索引。解包可将任意可迭代对象用作数据源,包含那些不支持索引符号[]的迭代器。唯一的要求是可迭代对象对每个接收端变量仅产生一个子项,除非按照使用*获取多余子项一节那样使用星号(*)获取多余子项。

最易查看的解包是并行赋值,即将可迭代对象的子项赋值给一组变量,可参见下例:

解包的一种优雅应用是不使用临时变量实现值变量值的互换:

另一个解包的示例是调用函数时在参数前加*

以上代码展示了解包的另一种应用:允许函数返回多个值方便调用者使用。另一个例子,os.path.split()函数通过文件路径构建一个元组(path, last_part)

还有一种方式使用*语法来在解包时仅使用其中的一些子项,一会儿我们就会学到。

使用*获取多余子项

通过*args定义参数来获取自定义的额外参数是Python一种经典特性。

在Python 3中,这一做法被扩展到了并行赋值中:

在并行赋值的场景中,*前缀可用于具体的某个变量,但可以放在任意位置:

在函数调用和序列字面量中使用*解包

PEP 448—其它解包总结中为迭代解包引入更灵活的语法,在Python 3.5新增中进行了很好的总结 。

在函数调用中,我们可以多次使用*

*也可在定义listtupleset字面量时使用,参见Python 3.5新增中的这些示例:

PEP 448为**引入了类似的新语法,我们在映射解包中会进行学习。

最后,元组解包的一个强大特性是其可用于嵌套结构。

嵌套解包

解包可用于嵌套,即(a, b, (c, d))。如果值有相同的嵌套结构Python会执行相应操作。示例2-8演示了嵌套解包。

示例2-8:解包嵌套元组获取经度

示例2-8的输出结果为:

解包赋值也给用于列表,但几乎没有好的应用场景。我只知道一个,如果有数据库查询返回一条记录(如带有LIMIT 1语句的SQL查询),那么可以使用下面的代码在解包的同时确保仅有一条结果:

如果记录仅有一个字段,可以像这样直接获取:

这两种都可以使用元组编写,但别忘记单元素元组那个奇怪的写法:懒得做在最后加一个逗号。所以第一个应使用(record,),第二个使用((field,),)。两个例子中如果没加逗号的话会默默地产生 bug。

接下来要学习模式匹配了,它支持更为强大的序列解包方法。

序列的模式匹配

Python 3.10中最明显的新特性就是PEP 634—结构化模式匹配:规范中提议的match/case语句的模式匹配。

注:Python核心开发者Carol Willing在Python 3.10新增结构化模式匹配一节有关于模式匹配非常好的介绍。可以阅读一下快速了解。本系列中选择将模式匹配按模式类型分散至不同文章中:映射的模式匹配类实例的模式匹配。扩展的示例参见系列十八中的lis.py中的模式匹配:案例研究

这里是match/case处理序列的第一个示例。假设 你在设计一个机器人,可以接收以单词和数字序列发送的命令,如BEEPER 440 3。在分割解析数字后,得到['BEEPER', 440, 3]这样的消息。可以使用这样的方法来处理这些消息:

示例2-9

表面上match/case和C 语言中的switch/case相似,但这仅是片面的理解。match相对switch的一个关键改进是解构-比解包更高级的形式。解构在Python是属于新增的概念,但对于支持模式匹配的语言,如Scala和Elixir,文档中很常见。

示例2-10为解构的第一个示例,它对示例2-8的部分代码使用match/case进行了重写。

示例2-10:解构嵌套元组,要求使用Python ≥ 3.10

通常来说,在主体满足以下条件时匹配序列模式:

  1.  主体为序列且:
  2. 主体和序列元素数量致且:
  3. 每个子项含嵌套子项相匹配

例如,示例2-10中的[name, _, _, (lat, lon)]模式匹配具有4个子项的序列,最后一项必须为二元序列。

序列模式可为元组或列表或嵌套元组及列表的任意组合,但使用的语法并有任何分别:模式中元组和列表匹配任意序列。在示例2-10中使用带有二元元组的列表模式只是为了避免重复的中括号或小括号。

序列模式可以匹配collections.abc.Sequence的大多数子类或虚拟子类,除strbytesbytearray

在标准库中,以下类型与序列模式相匹配:

不同于解包,模式不解构非序列的迭代对象(如迭代器)。

模式中的_符号很特殊:它匹配该处的任意单项,但不绑定匹配项的值。_是唯一能在模式中多次出现的变量。

可以使用as关键字通过变量绑定模式的任意部分:

假定主体为['Shanghai', 'CN', 24.9, (31.1, 121.3)],以上的模式会匹配,并设置如下变量:

name
'Shanghai'
lat
31.1
lon
121.3
coord
(31.1, 121.3)

我们可以通过添加类型信息让模式更为具体。例如,以下模式匹配上例中相同的嵌套序列结构,但第一项必须为str的实例,并且二元元组的两项必须为float实例。

小贴士:str(name)float(lat)表达式像是构造调用,但在模式匹配中,这种语句用作运行时类型检查:以上的模式匹配带有4项的序列,第0项必须为str,第3项必须为一对浮点数。此外,第0项中的str会绑定到name对象上,第3项的浮点值会分别绑定到latlon上。因此虽然str(name)借用了构造调用的语法,在模式匹配中其语义完全不同。在模式中使用自定义的内容在系列五模式匹配类实例一节中讲解。

另外,如果我们希望匹配以str开头,并以两个浮点值的嵌套序列结尾的主体,可以这么写:

*_匹配任意数量的子项,无需绑定变量。使用*extra替换*_会将这些子项绑定到一个有0项或多项的列表extra中。

可选的以if开头的守卫语句仅在模式匹配时运行,并可使用模式中绑定的变量,如在示例2-10中:

带有print语句的嵌套代码块仅在模式匹配且守卫表达式为真时运行。

小贴士:运用模式的解构极具表达力,有时带有单个casematch会让代码简化。龟叔有一组case/match示例,其中有一个题为深度迭代和类型匹配提取

示例2-10不是对示例2-8的改进。仅是对执行相关操作时两种方法的对比。下面的例子展示模式匹配如何让代码更清晰、简洁、高效。

解释器中的模式匹配序列

斯坦福大学的Peter Norvig编写了lis.py:一个以132行优雅、易读Python代码编写的针对Lisp编程语言Scheme方言子集的解释器。这里取了Norvig的 MIT 协议授权的代码并将其更新为Python 3.10,用于展示模式匹配。本节中,我们将Norvig一段使用了if/elif和解包的关键代码与使用match/case重写的版本进行对比。

lis.py 的两个主要函数为parseevaluate。解析器接收Scheme括号表达式,返回Python列表,以下是两个示例:

求值程序接收这样的列表并执行。第一个示例以18和45为参数调用gcd函数。运行后计算出它们的最大公约数:9。第二个示例定义了一个带有参数n的函数double。函数体是一个表达式(* n 2)。在Scheme中调用函数的结果是函数体最后一个表达式的值。

这里的关注点是解构序列,因此不会解释求值程序的操作。参见系列十八中的lis.py中的模式匹配:案例研究一节学习有关lis.py如何运行的更多详情。

以下是对Norvig求值程序进行了微调,简化仅显示序列模式部分:

示例2-11:不使用match/case的模式匹配

注意各个elif语句是如何检测列表第一项然后对列表解包并忽略第一项的。大量地使用了解包表明Norvig忠爱模式匹配,但他最初是用Python 2编写(虽然现在与Python 3兼容)。

在Python ≥ 3.10中使用match/case,我们可以这样重构evaluate

示例2-12:使用match/case的模式匹配,要求Python ≥ 3.10

不进行兜底的话,在主体不匹配所有case时,整个match语句什么也不做,这是一种静默失败。

Norvig在lis.py中刻意避免错误检查,以保持代码更易于理解 。通过模式匹配,我们可以添加更多的检测,同时仍易于阅读。例如,在'define'模式中,原来的代码无法保证nameSymbol的实例:那会要求有if代码块、一个isinstance调用以及更多的代码一。示例2-12示例2-11更简短、更安全。

lambda的替代模式

这是Scheme中的lambda语句,使用了后缀用于表示元素可能出现一次或多次:

'lambda'的一个简单模式可以是:

但这会匹配parms处的任意值,包含无效主体中的第一个'x'

Scheme中lambda关键词后的嵌套列表存储了函数的正式参数名称,即使仅有一个元素也必须是列表。如果函数无参数,类似Python中的random.random(),可以为空列表。

示例2-12中使用了嵌套序列模式来让'lambda'模式更为安全:

在序列模式中,*在每个序列中仅能出现一次。这里有两个序列:外层序列和内层序列。

parms周围添加[*]可以让模式看起来更像Scheme所处理的语句,并增加了结构检查。

函数定义的短语法

Scheme有一种替代define语法用于不使用嵌套lambda创建具名函数。语句如下:

define关键词后接新函数名称及0个或多个参数名的列表。其后为有一条或多条表达式的函数体。

match中添加如下两行可以处理这种实现:

把这段case语句放到示例2-12中的define case之后。本例中define case的顺序不重要,因为没有主体可以同时匹配这两种模式:原来的define case中第二个元素必须为Symbol,便在函数定义短语法中必须为一个以Symbol开头的序列。

现在试想一下在示例2-11中不借助模拟匹配需要多少工作来添加对第二个define语法的支持。match比类 C语言中的switch语句完成的任务更多。

模式匹配是声明式编程的一个示例:代码描述想要匹配“什么”,而不是“如何”匹配。代码的形状遵循数据的形状,如表2-2所示。

表2-2:一些Scheme语句形式和处理它们的case模式

Scheme语句序列模式
(quote exp)['quote', exp]
(if test conseq alt)['if', test, conseq, alt]
(lambda (parms…) body1 body2…)['lambda', [*parms], *body] if body
(define name exp)['define', Symbol() as name, exp]
(define (name parms…) body1 body2…)['define', [Symbol() as name, *parms], *body] if body

希望使用模式匹配对Norvig的evaluate进行重构能够让读者理解match/case可使用代码更易读、更安全。

注:我们会在系列十八中的lis.py中的模式匹配:案例研究一节中复习evaluate中完整的match/case示例时更深入地讲解lis.py 。如果想要学习Norvig的lis.py,请阅读他所写的文章如何用 Python 编写 Lisp 解释器

至此完成了解包、解构、通过序列进行模式匹配的首站。我们在后续文章中会讲解其它类型的模式。

每个Python程序都知道序列可以使用s[a:b]语句来进行切片。接下来我们就学习切片一些鲜为人知的知识。

切片

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:]。例如:

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

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

切片对象

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

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

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

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

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

系列十二向量#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语句的目标。下面的例子演示了切片符号的强大之处:

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

对序列使用+和*

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

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

+*总是新建对象,而不对运算项进行修改。

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

构建列表的列表

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

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

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

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

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

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

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

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

序列的增量赋值

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

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

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

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

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

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

A +=赋值迷题

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

示例2-16:猜一猜

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

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被修改且抛出了异常

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

Python进阶系列二:数组序列

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

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

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

本例是一种极端的情况,在原作者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的排序算法是稳定的(即它在元素相等时保留相对排序):

序列进行排序后,可以进行高效搜索。在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:创建、保存及加载大型浮点数组

可以看出,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函数重建数组:

在添加元素时如需保留数组排序,可使用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:以1×6、2×3或3×2视图处理6字节内存:

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

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

使用结构体解析二进制记录中有关于通过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中的行列基础运算

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

以上只是开胃菜。

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

表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和二进制序列外的序列类型,这两个序列在系列四中会单独讲解。

 

 

 

 

喜欢 (0)
[]
分享 (0)
发表我的评论
取消评论

表情 贴图 加粗 删除线 居中 斜体 签到

Hi,您需要填写昵称和邮箱!

  • 昵称 (必填)
  • 邮箱 (必填)
  • 网址
(2)个小伙伴在吐槽
  1. 请问还继续更新么?
    kingtheon2022-05-10 14:54 回复
    • Alan
      会的,被上海的疫情打乱了计划,电量正在逐步恢复 :razz:
      Alan2022-05-11 16:47 回复