每次学完 Python 后,由于实际 Python 程序写的不多,导致很快又忘了,所以总感觉对 Python 掌握的不深。这次又看了《流畅的 Python》,这本书的确非常不错,对于深入学习掌握 Python 有非常大的帮助。
Python 数据模型
Python 最好的品质之一是一致性。Python 的设计思想完全体现在数据模型上,而数据模型所描述的 API,为使用最地道的语言特性来构建你自己的对象提供了工具。数据模型其实就是对 Python 框架的描述,它规范了这门语言自身构建模块的接口,这些模块包括但不限于序列、迭代器、函数、类和上下文管理器。
不管在哪种框架下写程序,都会花大量时间去实现那些会被框架本身调用的方法,Python 也是如此。Python 解释器碰到特殊语法时,会使用特殊方法去激活一些基本对象操作,这些特殊方法的名字形如:__XXX__
。这些特殊方法名能让你自己的对象实现和支持以下的语言框架,并与之交互:
- 迭代
- 集合类
- 属性访问
- 运算符重载
- 函数和方法的调用
- 字符串表示形式和格式化
- 管理上下文(即 with 块)
一个实例
如下例子展示了纸牌类:
1 | #!/usr/bin/env python3 |
- namedtuple 是具名元祖,用以构建只有少数属性但是没有方法的对象
- 通过实现
__len__
以及__getitem__
,FrenchDeck 类可以像任何标准 Python 集合一样,支持 len() 函数,[] 运算
等 - 由于
__getitem__
方法把[]
操作交给了内部的列表,因此 FrenchDeck 类自动支持切片操作 - 由于事先了
__getitem__
方法,FrenchDeck 类支持迭代操作
通过实现特殊方法来利用 Python 数据模型,有以下好处:
- 作为类的用户,不需要记住标准操作的格式名称
- 自定义类型可以和 Python 内置数据类型一样,体现出 Python 的核心语言特性(如迭代、切片等)
- 可以更加方便地利用 Python 的标准库,而不用重新发明轮子
如何使用特殊方法
- 特殊方法的存在就是为了被 Python 解释器调用,你自己通常不需要调用它(除非有大量的元编程存在)
- 很多时候,特殊方法的调用也是隐式的
- 通常选择内置函数(例如 len、iter、str 等)来使用特殊方法是最好的选择
- 不要自己想当然地添加特殊方法,该方法名可能以后会被 Python 占用
如下展示了一个自定义向量类型,支持各种运算:
1 | #!/usr/bin/env python3 |
- Python 的内置函数 repr,能把一个对象用字符串的形式表示。repr 就是通过
__repr__
得到的对象的字符串表示。str.format 也是利用repr
函数才把!r
字段变成字符串(或者 % 格式字符串中的 %r 也会调用该函数)。__repr__
和__str__
的区别在于:后者是由 str() 函数调用,或者 print() 函数打印一个对象时才调用。如果只实现一个,最好实现__repr__
。因为一个对象没有__str__
,而 Python 又需要它时,会用__repr__
代替 __add__
和__mul__
分别为类实现了 + 和 * 运算符。中缀运算符的基本原则是不改变操作对象,而是产生一个新值- 为了判定一个值 x 是真还是假,Python 会调用
bool(x)
。默认情况下,自定义类型的实例总被认为是真,除非这个类自己实现了__bool__
或__len__
函数。bool(x) 会首先尝试调用__bool__
,如果不存在,则继续调用__len__
。如果返回 0,则 bool 返回 False,否则返回 True
总结
通过实现特殊方法,自定义数据类型可以表现地和内置类型一样,从而写出更 Pythonic 的代码。
序列构成的数组
深入理解 Python 中的不同序列类型,可以让我们避免重新发明轮子,它们的 API 还能帮助我们把自己定义的 API 设计的和原生的序列一样,或者和未来可能出现的序列类型保持兼容。
内置序列类型概览
Python 标准库用 C 实现了丰富的序列类型:
- 容器序列:list、tuple 和 collections.deque 这些序列能存放不同类型的数据
- 扁平序列:str、bytes、bytearray、memoryview 和 array.array,这些序列只能容纳一种类型
容器序列存放的是它们所包含的任意类型的对象的引用,而扁平序列存放的是值而不是引用。扁平序列其实是一段连续的内存空间。
序列类型还能按照能否被修改来分类:
- 可变序列:list、collections.dequeue、bytearray、array.array 和 memoryview
- 不可变序列:str、bytes、tuple
列表推导和生成器表达式
list 是一个最基础的序列类型。list 是一个可变序列,并且能存放不同类型的元素。列表推导是构建列表(list)的快捷方式,而生成器表达式可以创建出任何类型的序列。
1 | "abcdef" symbols = |
使用列表推导的代码更加简单,但是列表推导也不应该被滥用。通常的原则是,只用列表推导来创建新的列表,并且尽量简短。
- python 会忽略忽略代码里的 []、{} 和 () 中的换行,因此在这些代码里,可以忽略不太好看的续行符
\
。 - Python3 中列表推导、生成器表达式、以及类似的集合推导和字典推导都有自己的局部作用域,表达式内部的变量和赋值只在局部起作用,表达式上下文里的同名变量还是可以被正常引用,局部变量并不会影响它们。
列表推导可以把一个序列或者是其他可迭代类型中的元素过滤或者加工,然后新建一个列表,虽然 python 内置的 filter 和 map 函数也能达到类似效果,但是可读性不如列表推导:
1 | "abcdef" symbols = |
另外一种列表推导的常见用法是,计算两个序列的笛卡尔乘积,笛卡尔乘积是一个列表,列表里的元素是由输入的可迭代类型的元素对构成的元祖:
1 | "reb", "white", "blue"] colors = [ |
循环的嵌套关系和类似的 for 语句是相同的,也就是说,先以 colors 排列,再以 sizes 排列。
列表推导的作用只有一个:生成列表。如果想生成其他类型的序列,可以使用生成器。虽然也可以用列表推导来生成元素、数组或其他序列类型,但是生成器表达式是更好的选择。因为生成器表达式背后遵循了迭代器协议,可以逐个地产出元素,而不是先建立一个完整的列表,然后再把这个列表传递到某个构造函数里。生成器表达式显然更节省内存。
生成器表达式的语法和列表推导类似,只不过把方括号换成圆括号即可。下面用生成器表达式来建立元祖和数组:
1 | "abcdef" symbols = |
如果生成器表达式是函数调用过程中的唯一参数,那么不需要额外用括号把它围起来。如下展示了使用生成器表达式来计算笛卡尔乘积,它会在每次 for 循环运行时才生成一个组合,从而避免额外的内存占用:
1 | "reb", "white", "blue"] colors = [ |
这段代码中 % 格式运算符能把元祖元素匹配到格式字符串的占位符中,这其实是元祖拆包的一种应用。
元祖不仅仅是不可变的列表
Python 中的元祖除了用作不可变的列表,还可以用于没有字段名的记录。元素可以认为是对数据的记录,每个元素都存放了一个字段的数据,外加这个字段的位置,位置信息给数据赋予了意义。如果把元祖当做一些字段的集合,那么数量和位置信息就非常重要了。
元祖拆包可以应用到任何可迭代对象上,唯一的硬性要求是:可迭代对象中的元素数量必须和接受这些元素的元祖空档数一致,除非使用 * 来忽略多余的元素。最好辨认的元祖拆包形式就是平行赋值:即把一个可迭代对象里的元素,一并赋值到由对应的变量组成的元祖中。如下所示:
1 | 1, 2, 3) a, b, c = ( |
可以使用 * 运算符把一个可迭代对象拆开作为函数的参数。元祖拆包另外一种用法是,让一个函数用元祖的形式返回多个值。
在进行拆包时,如果对元祖里的某个数据不感兴趣,可以使用 _ 占位符。除此之外,在元祖拆包中使用 *
可以帮助我们把注意力集中在元祖的部分元素中。在 Python 中,使用 *args
来获取不确定数量的参数算是一种经典写法了。在 Python3 中,这个概念被扩展到了平行赋值中。在平行赋值中,* 前缀只能用在一个变量前面,但是该变量可以出现在任意位置。:
1 | 1, 2, 3) a, _, b = ( |
接受表达式的元祖可以是嵌套式的,只要这个接受元祖的嵌套结构符合表达式本身的嵌套结构,Python 就能做出正确的对应:
1 | 1, 2, (3, 4)) a, b, (c, d) = ( |
具名元祖
有时我们需要给记录中的字段命名,namedtuple 帮我们解决了这个问题。collections.namedtuple 是一个工厂函数,用来构建一个带有字段名的元祖和一个有名字的类。如下所示:
1 | from collections import namedtuple |
可以看到,在 namedtuple 中,可以通过字段名或者位置来获取一个字段的信息。
元祖是一种很强大的、可以当做记录来用的数据类型。它的第二个角色则是当做不可变列表。如果把元祖当做列表来用,需要清楚它们之间的相似度。除了跟增减元素相关的方法之外,元祖支持列表的其他所有方法。
切片
在 Python 里,像 list、tuple 和 str 这类序列类型都支持切片操作。在切片和区间操作里不包含区间范围的最后一个元素是 Python 的风格,这个习惯符合 Python、C 等以 0 作为起始下标的传统。好处如下:
- 当只有最后一个位置信息时,可以快速看出区间有几个元素
- 当起止位置信息可见时,可快速通过(end - start)计算出区间的长度
- 可以利用任意一个下标将序列分隔成两个不重叠的两部分:mylist[:x] 和 mylist[x:]
1 | 1, 2, 3, 4, 5] l = [ |
可以用 s[a:b:c] 的形式对 s 在 a 和 b 之间,以 c 为间隔进行取值。c 的值可以为负值,负值意味着反向取值。a:b:c
的用法只能作为索引或下标用在 [] 来返回一个切片对象,在对 seq[a:b:c]
进行求值时,Python 会调用 seq.__getitem__(slice(start, end, step))
。有时为了提高代码的可读性,我们可以使用有名字的切片,而不是硬编码的数字区间:
1 | "Jam US 18" s = |
多维切片和省略
[]
运算符里还可以使用逗号分开的多个索引或者是切片。要正确处理这种运算符,对象的特殊方法 __getitem__
和 __setitem__
需要以元祖形式来接收 a[i, j]
中的索引。
Python 内置的序列类型都是一维的,因此它们只支持单一索引,成对出现的索引是没有用的。
省略 ...
在 Python 解释器看来是一个符号,而它实际上是 Ellipsis 对象的别名,而 Ellipsis 对象又是 ellipsis 类的单一实例。它可以作为切片规范中的一部分,也可以用在函数清单中。
Python 标准库里没有多维索引和 Ellipsis 对象的用法,这些特性主要是为了支持用户自定义类或扩展。
给切片赋值
除了用来提取序列里的内容,切片还可以用来就地修改可变序列。如果把切片放在赋值语句的左边或者作为 del 操作的对象,就可以对序列进行嫁接、切除或者就地修改。
1 | list(range(10)) l = |
可以看到,如果赋值的对象是一个切片,那么赋值语句的右侧必须是一个可迭代对象。即便只有一个单独的值,也需要把它转换成可迭代的序列。
对序列使用 + 和 *
序列的拼接操作(+ 和 *)不会修改原有的操作对象,而是构建一个全新的序列:
1 | 1, 2, 3] l1 = [ |
在 a * n 的语句中,如果序列 a 里的元素是对其他可变对象的引用,需要格外注意。例如 mylist = [[]] * 3
来初始化一个由列表组成的列表,但是得到的列表包含的 3 个元素其实是 3 个引用,而这 3 个引用指向的是同一个列表,这可能并不符合你的预期。如下展示了这种情况:
1 | 1, 2, 3]] * 3 mylist = [[ |
有时需要初始化一个嵌套这几个列表的列表,例如:
1 | '_'] * 3 for i in range(3)] board = [[ |
而下面两种初始化方法则达不到目的,因为外面的列表其实包含了 3 个指向同一列表的引用,当我们不做修改时看上去不错,一旦修改,则立马暴露列表内的 3 个引用指向同一个对象的事实:
1 | '_'] * 3] * 3 board = [[ |
1 | '_'] * 3 row = [ |
这其实涉及引用和可变对象背后的原理和陷阱,后续还会详细介绍。
序列的增量赋值
增量赋值运算符 += 和 *= 的表现取决于它们的第一个操作对象,后续讨论都以 +=
为主,其他增量运算符也是一样的。
+=
背后的特殊方法是 __iadd__
(就地加法),如果一个类没有实现这个方法的话,Python 会退一步调用 __add__
。对于 a += b
而言:
- 如果 a 实现了
__iadd__
方法,就会调用该方法。如果 a 是可变序列,a 就会就地改动,如同调用 a.extend(b) - 如果 a 没有实现
__iadd__
方法,效果就等同于a = a + b
,即将a + b
计算得到的新对象赋值给 a
所以在 a += b
中,a 会不会关联到新的对象,完全取决于这个类型有没有实现 __iadd__
方法:
- 一般而言,可变序列都实现了
__iadd__
方法,因此+=
就是就地加法 - 而不可变序列根本不支持这个操作,所以根本无法实现这个
__iadd__
方法
如下展示了 +=
用在可变和不可变序列上的作用:
1 | 1, 2, 3] l = [ |
对于不可变序列进行重复拼接操作效率会很低,因此每次都有一个新对象,而解释器需要把原来对象中的元素先复制到新对象里,然后再追加新的元素。
最后看一个 +=
谜题:
1 | 1, 2, [3, 4, 5]) t = ( |
这里之所以可以对 t[2] 进行修改,是因为 t[2] 本身是一个可变对象,最后将修改后的可变对象重新赋值给元祖的元素时又会报错,因为元祖的元素不支持赋值。通过调用 extend 函数则可以避开元祖元素的赋值问题。
通过 python 的字节码可以验证这一点:
1 | import dis |
从这个问题可以得到几个经验:
- 不要把可变对象放在元祖中
- 增量操作不是一个原子操作,从上可以看出虽然它抛出了异常,但是元素还是被修改了
- 查看 Python 的字节码并不难,对我们了解代码背后的机制很有帮助
list.sort 方法和内置函数 sorted
list.sort 方法会对列表就地排序,所以这个方法返回 None,提醒你本方法不会新建一个列表。这其实也是 Python 的一个惯例:如果一个函数或者方法对对象进行就地改动,那么它就应该返回 None,好让调用者知道传入的参数发生了变动,而且未产生新的对象。但是就地改动也有一个弊端:调用者无法将其串联起来,而返回一个新对象的方法则正好正反,它们可以串联起来调用,从而形成连贯接口。
与 list.sort 相反的是内置函数 sorted,它会新建一个列表作为返回值。该方法可以接受任何形式的可迭代对象作为参数,包括不可变序列和生成器等。但是不管 sorted 接受的是怎样的参数,它都返回一个列表。
1 | 5, 4, 3, 2, 1] l = [ |
list.sort 和 sorted 函数都有两个可选的关键字参数:
- reverse:如果设置为 True,则降序输出。默认为 False
- key:一个只有一个参数的函数,该函数会用在序列的每一个元素中,所产生的结果将是排序算法所依赖的关键字。该参数的默认值是恒等函数(identify function),也就是默认用元素自己的值来排序
1 | "a", "bc", "def", "g"] l = [ |
用 bisect 来管理已排序的序列
bisect 模块包含两个主要函数,bisect 和 insort,两个函数都利用二分查找算法来在有序序列中查找或插入元素。
bisect(haystack, needle)
在 haystack
里搜索 needle
的位置,该位置满足的条件是,把 needle
插入这个位置后,haystack 还能保持升序。其中 haystack
必须是一个有序的序列。可以用 bisect(haystack, needle)
查找位置 index,然后再用 haystack.insert(index, needle)
来插入新值。也可以用 insort 来一步到位。
bisect 的行为受如下控制:
- 它有两个可选参数:lo 和 hi 用来缩小搜寻的范围。lo 默认为 0,hi 默认是序列的长度
- bisect 其实是 bisect_right 的别名,还有一个函数是 bisect_left,它们之间的区别是:在 bisect_left 中新元素会被放置于与它相等的元素的前面,而 bisect_right 返回的则是与跟它相等的元素之后的位置
如下展示了 bisect 的一个用法:
1 | import bisect |
排序很耗时,因此在得到一个有序的序列后,最好能保持它的有序性。bisect.insort(seq, item)
把变量 item 插入到序列 seq 中,并且能保持 seq 的升序排序。如下展示了 insort
的用法:
1 | #!/usr/bin/env python3 |
insort 也支持通过 lo 和 hi 两个可选参数来控制查找的范围。它也有个变体 insort_left,背后使用是 besect_left。
当列表不是首选时
虽然列表既灵活又简单,但是面对各类需求时,可能会有更好的选择。接下来介绍在某些情况下可以替代列表的数据类型。
数组
如果需要一个只包含数字的列表,那么 array.array
比 list 更高效,因为 Python 数组背后存的不是数字对象,而是数字的机器翻译,也就是字节表述。数组支持跟可变序列有关的操作,另外数组还提供从文件读取和存入文件的更快方法(.frombytes 和 .tofile)。
Python 数组和 C 语言数组一样精简,创建数组需要一个类型码,这个类型码用来表示在底层的 C 语言中应该存放什么样的数据类型。
1 | from array import array |
另一个快速序列化数字类型的方法是使用 pickle 模块。pickle 可以处理几乎所有内置数字类型,甚至包括用户自定义类。
从 python3.4 开始,数组类型不再支持诸如 list.sort()
这种就地排序,要给数组排序,需要使用 sorted()
函数新建一个数组。另外,如果想在保证有序的情况下为数组添加新元素,bisect.insort
还是可以使用。
1 | "I", range(10)) s = array( |
内存视图
memoryview 是一个内置类,能让用户在不复制内容的情况下操作同一个数组的不同切片。memoryview.cast 的概念跟数组模块类似,能用不同的方式读写同一块内存数据,而且内容字节不会随意移动,听上去和 C 语言的类型转换概念类似。memoryview.cast 会把同一块内存里的内容打包成一个全新的 memoryview 对象给你:
1 | >>> numbers = array('h', [-2, -1, 0, 1, 2]) |
这个示例演示了,通过另一个内存视图对象,修改了内存中的某个字节后,原始内存视图对象也看到了不同的值。这就说明了不同视图对象底层读写地是同一块内存。
NumPy 和 SciPy
凭借着 NumPy 和 SciPy 提供的高阶数组和矩阵操作,Python 成为科学计算应用的主流语言:
- NumPy 实现了多维同质数组和矩阵,这些数据结构不但能处理数字,还可以存放用户定义的记录。通过 NumPy,用户能够对这些数据结构里的元素进行高效的操作。
- SciPy 是基于 NumPy 的另一个库,提供了许多和科学计算有关的算法,专为线性代数、数值积分和统计学而设计的。SciPy 把基于 C 和 Fortran 的工业级数学计算功能用交互式且高度抽象的 Python 包装起来。
双向队列和其他形式的队列
利用 .append 和 .pop 方法,可以把 列表当做栈或者队列来使用。例如通过 .append
和 .pop(0)
就可以模拟先进先出特点。但是删除列表的第一个元素或者是在第一个元素前添加一个元素之类的操作是非常耗时的,因为这些操作会牵扯到移动列表里的所有元素。
collections.deque 类(双向队列)是一个线程安全的、可以快速从两端添加或者删除元素的数据类型。另外如果想要一种数据类型来存最近用到的几个元素,那么 dequeue 也是一个很好的选择:在新建双向队列时,可以指定队列的大小,如果这个队列满员了,可以从反向端删除过期的元素,然后在尾端添加新的元素。
如下展示了双向队列的典型操作:
1 | from collections import deque |
可以看到,当视图对一个已满的队列做尾部添加操作时,它头部的元素会被删除掉,当视图做头部添加操作时,它尾部的元素会被删除掉。另外当添加一个序列时,该方法会把序列中的每一个元素逐个添加到双向队列中。
双向队列实现了大部分列表所拥有的方法,也有一些符合自身设计的方法,例如 popleft 和 rotate 等。为了实现这些方法,双向队列也付出了一些代价,从队列中间删除元素的操作会慢一些,因为它只对头尾操作进行了优化。
除了 deque 之外,还有些其他的 Python 标准库也有队列的实现:
- queue:提供了线程安全类 Queue、LifoQueue 和 PriorityQueue。它们也接收一个可选参数用来限定队列的大小,但是当满员时,这些类不会丢掉旧的元素,相反,它会被锁住,直到另外的线程移除了某个元素而腾出了位置
- multiprocessing:这个包实现了自己的 Queue,和 queue.queue 类似,但是是给进程间通信用的,同时还有一个 JoinableQueue 类型,可以让任务管理变得更方便
- asyncio:提供了 Queue、LifoQueue、PriorityQueue 和 JoinableQueue,为异步编程里的任务管理提供了专门的便利
- heapq:heapq 提供了 headpush 和 heappop 方法,让用户可以把可变序列当做堆队列或者优先队列来使用
总结
要想写出准确、高效 的 Pythonic 代码,对标准库的序列类型的掌握是不可或缺的。Python 的序列类型最常见的分类是可变和不可变序列。另外一种分类方式是扁平序列和容器序列,前者体积小、速度快,但是只能保存一些原子性数据,例如数字、字符和字节。而容器序列则比较灵活,当容器序列遇到可变对象时,用户尤其要小心。
最后,一个小技巧,通过 import this
可以看到 python 之禅
。