《Effective Python:编写高质量Python代码的90个有效方法》要点文摘
1. 查询自己使用的 Python 版本
- Python3 是最新版的 Python,而且收到了很好的支持,大家应该用 Python3 开发项目
- 在操作系统的命令行界面运行 Python 时,要确认该 Python 的版本是否跟你要使用的版本相同
- 不要再用 Python2 做开发了,因为该版本已于 2020 年 1 月 1 日停止更新维护
2. 遵循 PEP8 风格指南
- 编写 Python 代码时,总是应该遵循 PEP8 风格指南
- 与广大 Python 开发者采用同一套代码风格,可以使项目更利于多人协作
- 采用一致的风格编写代码,代码的后续修改更容易
3. 了解 bytes 与 str 的区别
- bytes 包含的是由 8 位值所组成的序列,str 包含的是由 Unicode 码点锁组成的序列
- 我们可以编写辅助函数来确保程序收到的字符序列确实是期望要操作的类型(要知道自己想操作的到底是 Unicode 码点,还是原始的 8 位值,用 UTF-8 标准给字符串编码,得到的就是这样的一系列 8 位值)
- bytes 与 str 这两种实例不能在某些操作符(例如>、==、+、% 操作符)上面混用
- 从文件中读取二进制数据(或者把二进制数据写入文件)时,应该用 rb(wb)这样的二进制模式打开文件
- 如果要从文件中读取(或者要写入文件之中)的是 Unicode 数据,那么必须注意系统默认的文本编码方案。若无法肯定,可通过 encoding 参数明确指定
4. 用支持插值的 f-string 取代 C 风格的字符串与 str.format 方法
- 采用 % 操作符把值填充到 C 风格的格式字符串时会遇到很多问题,而且这种写法比较繁琐
- str.format 方法专门用一套迷你语言来定义他的格式说明符,这套语言给我们提供了一些有用的概念,蛋仔其他方面,这个方法还是存在与 C 风格的格式字符串一样的多种缺点,所以我们也应该避免使用它
- f-string 采用新的写法,将值填充到字符串之中,解决了 C 风格的格式字符串所带来的最大问题
- f-string 是个简洁而强大的机制,可以直接在格式说明符里嵌入任意 Python 表达式
5. 用辅助函数取代复杂的表达式
- Python 的画法很容易把复杂的意思挤到同一行表达式里,这样写很难懂
- 复杂的表达式,尤其是那种需要重复使用的复杂表达式,应该写到辅助函数里面
- 用 if/else 结构写成的条件表达式,要比用 or 与 and 写成的 boolean 表达式更好懂
6. 把数据结构直接拆分到多个变量里,不要专门通过下标访问
- unpacking 是一种特殊的 Python 语法,只需要一行代码,就能把数据结构里面的多个值分别赋值给相应的变量
- unpacking 在 Python 中应用广泛,凡是可迭代的对象都能拆分,无论它里面还有多少层迭代结构
- 尽量通过 unpacking 来拆解序列之中的数据,而不要通过下标访问,这样可以让代码更简洁、更清晰
7. 尽量用 enumerate 取代 range
- enumerate 函数可以用简洁的代码迭代 iterator,而且可以指出当前这轮循环的序号
- 不要先通过 range 指定下标的取值范围,然后用下标去访问序列,而是应该直接用 enumerate 函数迭代
- 可以通过 enumerate 的第二个参数指定起始序号(默认为 0)
8. 用 zip 函数同时遍历两个迭代器
- 内置的 zip 函数可以同时遍历多个迭代器
- zip 会创建惰性生成器,让它每次只生成一个元组,所以无论输入的数据有多长,它都是一个一个处理的
- 如果提供的迭代器的长度不一致,那么只要其中任何一个迭代完毕,zip 就会停止
- 如果想按最长的那个迭代器来遍历,那就改用内置的 itertools 模块中的 zip_longest 函数
9. 不要在 for 与 while 循环后面写 else 块
- Python 有种特殊的语法,可以把 else 块紧跟在整个 for 循环或 while 循环的后面
- 只有在整个循环没有因为 break 提前跳出的情况下,else 块才会执行
- 把 else 块紧跟在整个循环后面,会让人不太容易看出这段代码的意思,所以要避免这样写
10. 用复制表达式减少重复代码
- 赋值表达式通过海象操作符(:=)给变量赋值,并且让这个值成为这条表达式的结果,于是,我们可以利用这项特性来缩减代码
- 如果赋值表达式是大表达式里的一部分,就得用一对括号把它括起来
- 虽说 Python 不支持 switch/case 与 do/while 结构,但可以利用赋值表达式清晰的模拟出这种逻辑
11. 学会对序列做切片
- 切片要尽可能写得简单一些:如果从头开始选取,就省略起始下标 0;如果选到序列末尾,就省略终止下标
- 切片允许起始下标或终止下标越界,所以很容易就能表达“取开头多少个元素”(例如 a[:20])或“取末尾多少个元素”(例如 a[-20:0)等含义,而不用担心切片是否真有这么多元素
- 把切片放在赋值符号的左侧可以将原列表中这段段位内的元素用赋值符号右侧的元素替换掉,但可能会改变原列表的长度
12. 不要在切片里同时指定起止下标与步进
- 同时指定切片的起止下标与步进值理解起来会很困难
- 如果要指定步进值,那就省略起止下标,而且最好采用正数作为步进值,尽量别用负数
- 不要把起始位置、终止位置与步进值全都写在同一个切片操作里。如果必须同时使用这三项指标,那就分两次来做(其中一次各位选取,另一次做切割),也可以改用 itertools 内置模块里的 islice 方法
13. 通过带星号的 unpacking 操作来捕获多个元素,不要用切片
- 拆分数据结构并把其中的数据赋值给变量时,可以用带星号的表达式,将结构中无法与普通变量相匹配的内容补货到一份列表里
- 这种带星号的表达式可以出现在赋值符号左侧的任意位置,它总是会形成一份含有零个或多个值的列表
- 在把列表拆解成互相不重叠的多个部分时,这种带星号的 unpacking 方式比较清晰,而通过下标与切片来实现的方式则很容易出错
14. 用 sort 方法的 key 参数来表示复杂的排序逻辑
- 列表的 sort 方法可以根据自然顺序给其中的字符串、整数、元组等内置类型的元素进行排序
- 普通对象如果通过特殊方法定义了自然顺序,那么也可以用 sort 方法来排列,但这样的对象并不多见
- 可以把辅助函数传给 sort 方法的 key 参数,让 sort 根据这个函数所返回的值来排列元素顺序,而不是根据元素本身来排序
- 如果排序时要依据的指标有很多项,可以把它们放在一个元组中,让 key 函数返回这样的元组。对于支持一元减操作符的类型来说,可以单独给这项指标取反,让排序算法在这项指标上按照相反的方向处理
- 如果这些指标不支持一元减操作符,可以多次调用 sort 方法,并在每次调用时分别指定 key 函数与 reverse 参数。最次要的指标要放在第一轮处理,然后逐步处理更为重要的指标,首要指标放在最后一轮处理
15. 不要过分依赖给字典添加条目时所用的顺序
- 从 Python3.7 版开始,我们就可以确信迭代标准的字典时所看到的顺序跟这些键值对插入字典时的顺序一致
- 在 Python 代码中,我们很容易就能定义跟标准的字典很像但本身并不是 dict 实例的对象。对于这种类型的对象,不能假设迭代时看到的顺序必定与插入时的顺序相同
- 如果不想把这种跟标准字典很相似的类型也当成标准字典来处理,那么可以考虑这样三种办法。第一,不要依赖插入时的顺序编写代码;第二,在程序运行时明确判断它是不是标准的字典;第三,给代码添加类型注解并做静态分析
16. 用 get 处理键不在字典中的情况,不要使用 in 与 KeyError
- 有四种办法可以处理键不在字典中的情况:in 表达式、KeyError 异常、get 方法与 setdefault 方法
- 如果跟键相关联的值是像计数器这样的基本类型,那么 get 方法就是最好的方案;如果是那种构造起来开销比较大,或是容易出异常的类型,那么可以把这个方法与赋值表达式结合起来使用
- 即使看上去最应该使用 setdefault 方案,也不一定要真的试用 setdefault 方案,而是可以考虑用 defaultdict 取代普通的 dict
17. 用 defaultdict 处理内部状态中缺失的元素,而不要用 setdefault
- 如果你管理的字典可能需要添加任意的键,那么应该考虑能否用内置的 collections 模块中的 defaultdict 实例来解决问题
- 如果这种键名比较随意的字典是别人传给你的,你无法把它创建成 defaultdict,那么应该考虑通过 get 方法访问其中的键值。然而,在个别情况下,也可以考虑改用 setdefault 方法,因为那样写更短
18. 学会利用 __missing__构造依赖键的默认值
- 如果创建默认值需要较大的开销,或者可能抛出异常,那就不适合用 dict 类型的 setdefault 方法实现
- 传给 defaultdict 的函数必须是不需要参数的函数,所以无法创建出需要依赖键名的默认值
- 如果要构造的默认值必须根据键名来确定,那么可以定义自己的 dict 子类并实现
__missing__方法
19. 不要把函数返回的多个数值拆分到三个以上的变量中
- 函数可以把多个值合起来通过一个元组返回给调用者,以便利用 Python 的 unpacking 机制去拆分
- 对于函数返回的多个值,可以把普通变量没有捕获到的哪些值全部都捕获到一个带星号的变量里
- 把返回的值拆分到四个或四个以上的变量是很容易出错的,所以最好不要那么写,而是应该通过小类或 namedtuple 实例完成
20. 遇到意外状况时应该抛出异常,不要返回 None
- 用返回值 None 表示特殊情况是很容易出错的,因为这样的值在条件表达式里面,没办法与 0 和空白字符串之类的值区分,这些值都相当于 False
- 用异常表示特殊的情况,而不要返回 None。让调用这个函数的程序根据文档里写的异常情况做出处理
- 通过类型注解可以明确禁止函数返回 None,即便在特殊情况下,它也不会返回这个值
21. 了解如何在闭包里面使用外围作用域中的变量
- 闭包函数可以引用定义它们的那个外围作用域之中的变量
- 按照默认的写法,在闭包里面给变量赋值并不会改变外围作用域中的同名变量
- 先用 nonlocal 语句说明,然后赋值,可以修改外围作用域中的变量
- 除特别简单的函数外,尽量少用 nonlocal 语句
22. 用数量可变的位置参数给函数设计清晰的参数列表
- 用 def 定义函数时,可以通过*args 的写法让函数接受数量可变的位置参数
- 调用函数时,可以在序列左边加上操作符,把其中的元素当成位置参数传给args 所表示的这一部分
- 如果*操作符加在生成器前,那么传递参数时,程序有可能因为耗尽内存而崩溃
- 给接受*args 的函数添加新位置参数,可能导致难以排查的 bug
23. 用关键字参数来表示可选的行为
- 函数的参数可以按位置指定,也可以用关键字的形式指定
- 关键字可以让每个参数的作用更加明了,因为在调用函数时只按位置指定参数,可能导致这些参数的含义不够明确
- 应该通过带默认值的关键字参数来扩展函数的行为,因为这不会影响原有的函数调用代码
- 可选的关键字参数总是应该通过参数名来传递,而不应该按位置传递
24. 用 None 和 docstring 来描述默认值会变的参数
- 参数的默认值只会计算一次,也就是在系统把定义函数的那个模块加载进来的时候,所以,如果默认值将来可能由调用方修改(例如
{}、[])或者要随着调用时的情况变化(例如 datetime.now()),那么出程序就会出现奇怪的效果 - 如果关键字参数的默认值属于这种会发生变化的值,那就应该写成 None,并且要在 docstring 里面描述函数此时的默认行为
- 默认值为 None 的关键字参数,也可以添加类型注解
25. 用只能以关键字指定和只能按位置传入的参数来设计清晰的参数列表
- Keyword-only argument 是一种只能通过关键字指定而不能通过位置指定的参数。这迫使使用者必须指明,这个值是传给哪一个参数的,在函数的参数列表中,这种参数位于 * 符号的右侧
- Positional-only argument 是这样一种参数,它不允许调用者通过关键字来指定,而是要求不许按位置传递。这可以降低调用代码与参数名称之间的耦合程度。在函数的参数列表中,这些参数位于 / 符号的左侧
- 在参数列表中,位于 / 与 * 之间的参数,可以按位置指定,也可以用关键字来指定。这也是 Python 普通参数的默认指定方式
26. 用 functools.wraps 定义函数修饰器
- 修饰器是 Python 中的一种写法,能够把一个函数封装在另一个函数里面,这样程序在执行原函数之前与执行完毕之后,就有机会执行其他一些逻辑了
- 修饰器可能会让那些利用 introspection 机制运作的工具(例如调试器)产生奇怪的行为
- Python 内置的 functools 模块里面有个叫做 wraps 的修饰器,可以帮助我们正确定义自己的修饰器,从而避开相关的问题
27. 用列表推导取代 map 与 filter
- 列表推导要比内置的 map 与 filter 函数清晰,因为它不用另外定义 lambda 表达式
- 列表推导可以很容易的跳过原列表中的某些数据,假如改用 map 实现,那么必须搭配 filter 才能实现
- 字典与集合也可以通过推导来创建
28. 控制推导逻辑的子表达式不要超过两个
- 推导的时候可以使用多层循环,每层循环可以带有多个条件
- 控制推导逻辑的子表达式不要超过两个,否则代码很难读懂
29. 用赋值表达式消除推导中的重复代码
- 编写推导式与生成器表达式时,可以在描述条件的那一部分通过赋值表达式定义的变量,并在其他部分复用该变量,可使程序简单易读
- 对于推导式与生成器表达式来说,虽然赋值表达式也可以出现在描述条件的那一部分之外,但最好别这么写
30. 不要让函数直接返回列表,应该让它逐个生成列表里的值
- 用生成器来实现比让函数把结果手机合到列表里再返回,要更加清晰一些
- 生成器函数所返回的迭代器可以产生一系列值,每次产生的那个值都是由函数体的下一条 yield 表达式所决定的
- 不管输入的数据量多大,生成器函数每次都只需要根据其中的一小部分来计算当前这次的输出值。它不用把整个输入值全都读取进来,也不用一次就把所有的输出值全都算好
31. 谨慎地迭代函数所收到的参数
- 函数和方法如果要把收到的参数便利很多遍,那就必须特别小心,因为如果这些参数为迭代器,那么程序可能得不到预期的值,从而出现奇怪的效果
- Python 的迭代器协议确定了容器与迭代器应该怎样跟内置的 iter 及 next 函数、for 循环及相关的表达式交互
- 要想让自定义的容器类型可以迭代,只需要把
__iter__方法实现为生成器即可 - 可以把值传给 iter 函数,检测它返回的是不是那个值本身。如果是,就说明这是个普通的迭代器,而不是一个可以迭代的容器。另外,也可以用内置的 isinstance 函数判断该值是不是 collections.abc.Iterator 类的实例
32. 考虑用生成器表达式改写数据量较大的列表推导
- 通过列表推导来处理大量的输入数据,可能会占用许多内存
- 改用生成器表达式来做,可以避免内存使用量过大的问题,因为这种表达式所形成的迭代器每次只会计算一项结果
- 生成器表达式所形成的迭代器可以当成 for 语句的子表达式出现在另一个生成器表达式里
- 把生成器表达式组合起来使用,能够写出执行速度快且占用内存少的代码
33. 通过yield from 把多个生成器连起来用
- 如果要连续使用多个生成器,那么可以通过 yield from 表达式来分别使用这些生成器,这样做能够免去重复的 for 结构
- yield from 的性能要胜过那种在 for 循环里手工编写 yield 表达式的方案
34. 不要用 send 给生成器注入数据
- send 方法可以把数据注入生成器,让它成为上一条 yield 表达式的求值结果,生成器可以把这个结果赋值给变量
- 把 send 方法与 yield from 表达式搭配起来使用,可能导致奇怪的结果,例如会让程序在本该输出有效值的地方输出 None
- 通过迭代器向组合起来的生成器输入数据,要比采用 send 方法的那种方案好,所以尽量避免使用 send 方法
35. 不要通过 throw 变换生成器的状态
- throw 方法可以把异常发送到生成器刚执行过的那条 yield 表达式里,让这个异常在生成器下次推进时重新抛出
- 通过 throw 方法注入异常,会让代码变得难懂,因为需要用多层嵌套的模板结构来抛出并捕获这种异常
- 如果确实遇到了这样的特殊情况,那么应该通过类的
__iter__方法实现生成器,并且专门提供一个方法,让调用者通过这个方法来触发这种特殊的状态变换逻辑
36. 考虑用 itertools 拼装迭代器与生成器
- itertools 包里面有三套函数可以拼装迭代器与生成器,它们分别能够连接多个迭代器(chain、repeat、cycle、tee、zip_longest),过滤源迭代器中的元素(islice、takewhile、dropwhile、filterfalse),以及用源迭代器中的元素合成新元素(accumulate、product、permutations、combinations、combinations_with_replacement)。
- 通过 help(itertools) 查看文档,了解这些函数所支持的其他参数,以及许多更为高级的函数和实用的代码范例
37. 用组合起来的类来实现多层结构,不要用嵌套的内置类型
- 不要在字典里嵌套字典、长元组,以及用其他内置类型构造的复杂结构
- namedtuple 能够实现出轻量级的容器,以存放不可变的数据,而且将来可以灵活地转换成普通的类
- 如果发现用字典来维护内部状态的那些代码已经越写越复杂了,那么就应该考虑改用多个类来实现
38. 让简单的接口接受函数,而不是类的实例
- 如果想设计简单的 Python 接口,让组件之间能够通过接口交互,那么可以考虑让接口受挂钩函数,而不一定非得定义新类,并要求使用者传入这种类的实例
- Python 的函数与方法都是头等对象,这意味着它们可以像其他类型那样,用在表达式里
- 某个类如果定义了
__call__特殊方法,那么它的实例就可以像普通的 Python 函数那样调用 - 如果想用函数来维护状态,那么可以考虑定义一个带有
__call__方法的新类,而不要用有状态的闭包去实现
39. 通过@classmethod多态来构造同一体系中的各类对象
- Python 只允许每个类有一个构造方法,也就是
__init__方法 - 如果想在超类中用通用的代码构造子类实例,那么可以考虑定义 @classmethod 方法,并在里面用 cls(...) 的形式构造具体的子类对象
- 通过类方法多态机制,我们能够以通用的形式构造并拼接具体的子类对象
40. 通过 super 初始化超类
- Python 有标准的方法解析顺序(MRO)规则,可以用来判定超类之间的初始化顺序,并解决菱形继承问题
- 可以通过 Python 内置的 super 函数正确触发超类的
__init__逻辑。一般情况下,不需要给这个函数指定参数
41. 考虑用 mix-in 类来表示可组合的功能
- 超类最好能写成不带实例属性与
__init__方法的 mix-in 类,以避免由多重继承所引发的一些问题 - 如果子类要定制(或者说修改)mix-in 所提供的功能,那么可以在自己的代码里面覆盖相关的实例方法
- 根据需求,mix-in 可以只提供实例方法,也可以只提供类方法,还可以同时提供这两种方法
- 把每个 mix-in 所提供的简单功能组合起来,可以实现比较复杂的功能
42. 优先考虑用 public 属性表示应受保护的数据,不要用 private 属性表示
- Python 编译器无法绝对禁止外界访问 private 属性
- 从一开始就应该考虑允许其他类能继承这个类,并利用其中的内部 API 与属性去实现更多功能,而不是把它们藏起来
- 把需要保护的数据设计成 protected 字段,并用文档加以解释,而不要通过 private 属性限制访问
- 只有在子类不收控制且名称有可能与超类冲突时,才可以考虑给超类设计 private 属性
43. 自定义的容器类型应该从 collections.abc 继承
- 如果要编写的新类比较简单,那么可以直接从 Python 的容器类型(例如 list 或 dict)里面继承
- 如果想让定制的容器类型能够像标准的 Python 容器那样使用,那么有可能要编写许多特殊方法
- 可以从 collections.abc 模块里的抽象基类之中派生自己的容器类型。这样可以让容器自动具备相关功能,同时又可以保证没有把实现这些功能所必备的方法给漏掉
44. 用纯属性与修饰器取代旧式的 setter 与 getter 方法
- 给新类定义接口时,应该先从简单的 public 属性写起, 避免定义 setter 与 getter 方法
- 如果在访问属性时确实有必要做特殊的处理,那就通过@property 来定义获取属性与设置属性的方法
- 实现@property 方法时,应该遵循最小惊讶原则,不要引发奇怪的副作用
- @property 方法必须执行的很快,复杂或缓慢的任务,尤其是涉及 I/O 或者会引发副作用的那些任务,还是用普通的方法来实现比较好
45. 考虑用@property 实现新的属性访问逻辑,不要急着重构原有的代码
- 可以利用@property 给已有的实例属性增加新的功能
- 可以利用@property 逐渐改善数据模型而不影响已经写好的代码
- 如果发现@property 使用太过频繁,那可能就该考虑重构这个类了,同时按照旧办法使用这个类的那些代码可能也要重构
46. 用描述符来改写需要复用的@property方法
- 如果想复用@property 方法所实现的行为与验证逻辑,则可以考虑自己定义描述符类
- 为了防止内存泄露,可以在描述符类中用 WeakKeyDictionary 取代普通的字典
- 不要太纠结于
__getattribute__是怎么通过描述符协议来获取并设置属性的
47. 针对惰性属性使用 __getattr__ 、__getattribute__ 及 __setattr__
- 如果想用自己的方式(例如惰性地或者按需地)加载并保存对象属性,那么可以在该对象所属的类里实现
__getattr__与__setattr__特殊方法 __getattr__只会在属性缺失时触发,而__getattribute__则在每次访问属性时都要触发- 在实现
__getattribute__与__setattr__的过程中,如果要使用本对象的普通属性,那么应该通过 super() (也就是 object 类)来使用,而不要直接使用,以避免无限递归
48. 用 __init_subclass__ 验证子类写得是否正确
- 如果某个类是根据元类所定义的,那么当系统把该类的 class 语句体全部处理完之后,就会将这个类的写法告诉元类的
__new__方法 - 可以利用元类在类创建完成前检视或修改开发者根据这个元类所定义的其他类,但这种机制通常显得有点笨重
__init_subclass__能够用来检查子类定义得是否合理,如果不合理,那么可以提前报错,让程序无法创建出这种子类的对象- 在分层的或者涉及多重继承的类体系里面,一定别忘了在你写的这些类的
__init_subclass__内通过 super() 来调用超类的__init_subclass__方法,以便按照正确的顺序触发各类的验证逻辑
49. 用 __init_subclass__ 记录现有的子类
- 类注册(class registration)是个相当有用的模式,可以用来构建模块的 Python 程序
- 我们可以通过基类的元类把用户从这个基类派生出来的子类自动注册给系统
- 利用元类实现类注册可以防止犹豫用户忘记注册而导致程序出现问题
- 优先考虑通过
__init_subclass__实现自动注册,而不要用标准的元类机制来实现,因为__init_subclass__更清晰,更便于初学者理解
50. 用 __set_name__ 给类属性加注解
- 我们可以通过元类把利用这个元类所定义的其他类拦截下来,从而在程序开始使用那些类之前,先对其中定义的属性做出修改
- 描述符与元类搭配起来,可以形成一套强大的机制,让我们既能采用声明式的写法来定义行为,又能在程序运行时检视这个行为的具体执行情况
- 你可以给描述符定义
__set_name__方法,让系统把使用这个描述符做属性的那个类以及它在类里面的属性名通过方法的参数告诉你 - 用描述符直接操纵每个实例的属性字典,要比把所有实例的属性都放到一份字典里更好,因为后者要求我们必须使用 weakref 内置模块之中的特殊字典来记录每个实例的属性值以防止内存泄露
51. 优先考虑通过类修饰器来提供可组合的扩充功能,不要使用元类
- 类修饰器其实就是个函数,只不过它可以通过参数获知自己所修饰的类,从而重建或调整这个类并返回修改结果
- 如果要给类中的每个方法或属性都施加一套逻辑,而且还想尽量少些一些例行代码,那么类修饰器是个很值得考虑的方案
- 元类之间很难组合,而类修饰器则比较灵活,他们可以施加在同一个类上,并且不会发生冲突
52. 用 subprocess 管理子进程
- subprocess 模块可以运行子进程并管理它们的输入流与输出流
- 子进程能够跟 Python 解释器所在的进程并行,从而充分利用各 CPU 核心
- 要开启子进程,最贱的办法就是调用 run 函数,另外也可以通过 Popen 类实现类似 UNIX 管道的高级用法
- 调用 communicate 方法时可以指定 timeout 参数,让我们有机会把陷入死锁或已经开煮的子进程关掉
53. 可以用线程执行阻塞式 I/O,但不要用它做并行计算
- 即便计算机具备多喝的 CPU,Python 线程也无法真正实现并行,因为它们会受全局解释器锁(GIL)牵制
- 虽然 Python 的多线程机制受 GIL 影响,但还是非常有用的,因为我们很容易就能通过多线程模拟同时执行多项任务的效果
- 多条 Python 线程可以并行的执行多个兄调用,这样就能让程序字啊执行阻塞式的 I/O 任务时,继续做其他运算
54. 利用 Lock 防止多个线程争用同一份数据
- 虽然 Python 有全局解释器锁,但开发者还是得设法避免线程之间发生的数据争用
- 把未经互斥锁保护的数据开放给多个线程去同时修改,可能导致这份数据的结构遭到破坏
- 可以利用 threading 内置模块之中的 Lock 类确保程序中的固定关系不会在多线程环境下收到干扰
55. 用 Queue 来协调各线程之间的工作进度
- 管道非常适合用来安排多阶段的任务,让我们能够把每一阶段都交给各自的线程去执行,这尤其适合用在 I/O 密集型的程序里面
- 构造这种并发的管道时,有很多问题需要注意,例如怎样防止线程频繁地查询队列状态,增氧通知线程尽快结束操作,以及怎样防止管道出现拥堵等
- 我们可以利用 Queue 类所具有的功能来构造健壮的管道系统,因为这个类提供了阻塞式的入队(put)与出队(get)操作,而且可以限定缓冲区的大小,还能够通过 task_done 与 join 来确保所有元素都已处理完毕
56. 学会判断什么场景必须做并发
- 程序范围变大、需求变复杂之后,经常要用多条路径平行的处理任务
- fan-out 与 fan-in 是最常见的两种并发协调(concurrency coordination)模式,前者用来生成一批新的并发单元,后者用来等待现有的并发单元全部完工
- Python 提供了很多种实现 fan-out 与 fan-in 的方案
57. 不要在每次 fan-out 时都新建一批 Thread 实例
- 每次都手工创建一批线程,是有很多缺点的,例如:创建并运行大量线程时的开销比较大,每条线程的内存占用量比较多,而且还必须采用 Lock 等机制来协调这些线程
- 线程本身并不会把执行过程中遇到的异常抛给启动线程或者等待该线程完工的那个人,所以这种异常很难调试
58. 学会正确的重构代码,以便用 Queue 做并发
- 把队列(Queue)与一定数量的工作线程搭配起来,可以高效地实现 fan-out(分派)与 fan-in(归集)
- 为了改用队列方案来处理 I/O,我们重构了许多代码,如果管道要分成好几个环节,那么要修改的地方会更多
- 利用队列并行地处理 I/O 任务,其处理 I/O 任务量有限,我们可以考虑用 Python 内置的某些功能与模块打造更好的方案
59. 如果必须用线程做并发,那就考虑通过 ThreadPoolExecutor 实现
- 利用 ThreadPoolExecutor,我们只需要稍微调整一下代码,就能够并行地执行简单的 I/O 操作,这种方案省去了每次 fan-out(分派)任务时启动线程的那些开销
- 虽然 ThreadPoolExecutor 不像直接启动线程的方案那样,需要消耗大量内存,但它的 I/O 并行能力也是有限的,因为它能够使用的最大线程数需要提前通过 max_workers 参数指定
60. 用协程实现高并发的 I/O
- 协程是采用 async 关键字所定义的函数,如果你想执行这个协程,但并不要求立刻就获得执行结果,而是稍后再来获取,那么可以通过 await 关键字表达这个意思
- 协程能够制造出这样一种效果,让人以为程序里有成千上万个函数都在同一时刻高效地运行着
- 协程可以用 fan-out(分派)与 fan-in(归集)模式实现并行的 I/O 操作,而且能够克服用线程做 I/O 时的缺陷
61. 学会用 asyncio 改写那些通过线程实现的 I/O
- Python 提供了异步版本的 for 循环、with 语句、生成器与推导机制,而且还有很多辅助的库函数,让我们能够顺利地迁移到协程方案
- 我们很容就能利用内置的 asyncio 模块来改写代码,让程序不要再通过线程执行阻塞式的 I/O,而是改用协程来执行异步 I/O
62. 结合线程与协程,将代码顺利迁移到 asyncio
- asyncio 模块的事件循环提供了一个返回 awaitable 对象的 run_in_executor 方法,它能够使协程把同步函数放在线程池执行器(ThreadPoolExecutor)里面执行,让我们可以顺利地采用线程方案所实现的项目,从上之下地迁移到 asyncio 方案
- asyncio 模块的事件循环还提供了一个可以在同步代码里面调用的 run_until_complete 方法,用来运行协程并等待其结束。它的功能跟 asyncio.run_coroutine_threadsafe 类似,只是后者面对的是跨线程的场合,而前者是为同一线程设计的。这些都有助于采用线程方案所实现的项目从下至上地迁移到 asyncio 方案
63. 让 asyncio 的事件循环保持畅通,以便进一步提升程序的响应能力
- 把系统调用(包括阻塞式的 I/O 以及启动线程等操作)放在协程里面执行,会降低程序的响应能力,增加延迟感
- 调用 asyncio.run 时,可以把 debug 参数设为 True,这样能够知道哪些协程降低了时间循环的反应速度
64. 考虑用 concurrent.futures 实现真正的并行计算
- 把需要耗费大量 CPU 资源的计算任务改用 C 扩展模块来写,或许能够有效提高程序的运行速度,同时又让程序里的其他代码依然能够利用 Python 语言自身的特性,但是,这样做的开销比较大,而且容易引入 bug
- Python 自带的 multiprocessing 模块提供了许多强大的工具,让我们只需要耗费很少的精力,就可以把某些类型的任务平行地放在多个 CPU 核心上面处理
- 要想发挥出 multiprocessing 模块的优势,最好是通过 concurrent.futures 模块及其 ProcessPoolExecutor 类来编写代码,因为这样做比较简单
- 只有在其他方案全都无效的情况下,才可以考虑直接使用 multiprocessing 里面的高级功能(那些功能用起来相当复杂)
65. 合理利用 try/except/else/finally 结构中的每个代码块
- try/finally 形成的符合语句可以确保,无论 try 块是否抛出异常,finally 块都会得到运行
- 如果某段代码应该在前一段代码顺利执行之后加以运行,那么可以把它放到 else 块里面,而不要把这两段代码全都写在 try 块之中,这样可以让 try 块更加专注,同时也能够跟 except 块形成明确对照:except 块写的是 try 块没有顺利执行时所要运行的代码
- 如果你要在某段代码顺利执行之后多做一些处理,然后再清理资源,那么通常可以考虑把这三段代码分别放在 try、else 与 finally 块里
66. 考虑用 contextlib 和 with 语句来改写可复用的 try/finally 代码
- 可以把 try/finally 逻辑封装到情景管理器里面,这样就能通过 with 结构反复运用这套逻辑,而不需要每次用到的时候,都手工打一遍代码
- Python 内置的 contextlib 模块提供了 contextmanager 修饰器,让我们可以很方便地修饰某个函数,从而制作出相应的情景管理器,使得这个函数能够运行在 with 语句里面
- 情景管理器通过 yield 语句所产生的值,可以由 with 语句之中位于 as 右侧的那个变量所接收,这样的话,我懵就可以通过该变量与当前的情景相交互了
67. 用 datetime 模块处理本地时间,不要用 time 模块
- 不要用 time 模块在不同时区之间转换
- 把 Python 内置的 datetime 模块与开发者社群提供的 pytz 模块相结合起来,可以在不同时区之间可靠的转换
- 在操纵时间数据的过程中,总是应该使用 UTC 时间,只有到了最后一步,才需要把它转回当地时间以便显示出来
68. 用 copyreg 实现可靠的 pickle 操作
- Python 内置的 pickle 模块,只适合用来在彼此信任的程序之间传递数据,以实现对象的序列化与反序列化功能
- 如果对象所在的这个类发生了变化(例如增加或删除了某些属性),那么程序在还原旧版数据的时候,可能会出现错误
- 把内置的 copyreg 模块与 pickle 模块搭配起来使用,可以让新版的程序兼容旧版的序列化数据
69. 在需要准确计算的场合,用 decimal 标识相应的数值
- 每一种数值几乎都可以用 Python 内置的某个类型,或者内置模块之中的某个类表示出来
- 在精度要求较高切需要控制舍入方式的场合(例如在计算费用的时候),可以考虑使用 Decimal
- 用小数构造 Decimal 时,如果想保证取值准确,那么一定要把这个数放在 str 字符串里面传递,而不要直接传过去,那样可能有误差
70. 先分析性能,然后再优化
- 优化 Python 程序之前,一定要先分析它的性能,因为导致程序速度缓慢的真正原因未必与我们想的一样
- 应该优先考虑用 cProfile 模块来分析性能,而不要用 profile 模块,因为前者得到的分析结果更加准确
- 把需要接受性能测试的主函数传给 Profile 对象的 runcall 方法,就可以专门分析出这个体系下面所有函数的调用情况了
- 可以通过 Stats 对象筛选出我们关系的那些分析结果,从而更为专注地思考如何优化程序性能
71. 优先考虑用 deque 实现生产者-消费者队列
- list 类型可以用来实现 FIFO 队列,生产者可以通过 append 方法向队列添加元素。但这种方案有个问题,就是消费者在用pop(0)从队列中获取元素时,所花的时间会随着队列长度,呈平方式增长
- 跟 list 不同,内置 collections 模块之中的 deque 类,无论是通过 append 添加元素,还是通过 popleft 获取元素,所花的时间都只跟队列长度呈线性关系,而非平方关系,这使得它非常适合于 FIFT 队列
72. 考虑用 bisect 搜索已排序的序列
- 用 index 方法在已经排好顺序的序列之中查找某个值,花费的时间与列表长度成正比,通过 for 循环单纯的做比较以寻找目标值,所花的时间也是如此
- Python 内置的 bisect 模块里面有个 bisect_left 函数,只需要花费对数级别的时间就可以在有序列表中搜寻某个值,这要比其他方法快好几个数量级
73. 学会使用 heapq 制作优先级队列
- 优先级队列让我们能够按照重要程度来处理元素,而不是必须按照先进先出的顺序处理
- 如果直接用相关的列表操作来模拟优先级队列,那么程序的性能会随着队列长度的增大而大幅下降,因为这样做的复杂程度是平方级别,而不是线性级别
- 通过 Python 内置的 heapq 模块所提供的函数,我们完全可以实现基于堆的优先级队列,从而高效地处理大量数据
- 要使用 heapq 模块,我们必须让元素所在的类型支持自然排序,这可以通过对类套用@functools.totalordering 装饰器并定义 `_lt` 方法来实现
74. 考虑用 memoryview 与 bytearray 来实现无需拷贝的 bytes 操作
- Python 内置的 memoryview 类型提供了一套无需执行拷贝的(也就是零拷贝 )操作接口,让我们可以对支持缓冲协议的 Python 对象制作切片,并通过这种切片高速地完成读取与写入
- Python 内置的 bytearray 类型也是一种与 bytes 相似但内容能够变改的类型,我么可以通过 socket.recv_from 这样的函数,以无需拷贝的方式(也就是零拷贝的方式)读取数据
- 我们可以用 memoryview 来封装 bytearray,从而用收到的数据覆盖底层缓冲里面的任务区段,同时又无需执行拷贝操作
75. 通过 repr 字符串输出调试信息
- 把内置类型的值传给 print,会打印出便于认读的那种字符串,但是其中不会包含类型信息
- 把内置类型的值传给 repr,会得到一个能够表示该值的可打印字符串,讲这个 repr 字符串传给内置的 eval 函数能够得到原值
- 在格式化字符串里用 %s 处理相关的值,就跟把这个值传给 str 函数一样,都能得到一个便于认读的那种字符串,如果用 %r 来处理,那么得到的就是 repr 字符串,在 f-string 中,也可以用值来取代其中有待替换的那一部分,并产生便于认读的那种字符串,但如果待替换的部分加了 !r 后缀,那么替换出来的就是 repr 字符串
- 给类定义
__repr__特殊方法,可以让 print 函数把该类实例的可打印表现形式展现出来,在实现这个方法时,还可以提供更为详尽的调试信息
76. 在 TestCase 子类里验证相关的行为
- Python 内置的 unittest 模块里有个 TestCase 类,我们可以定义它的字类,并在其中编写多个 test 方法,以便分别验证想要测试的每一种行为。TestCase 子类的这些 test 方法名称都必须以 test 这个词开头
- TestCase 类还提供了许多辅助方法,例如,可以在 test 方法中通过 assertEqual 辅助方法来确认两个值相等,而不采用内置的 assert 语句
- 可以用 subTest 辅助方法做数据驱动测试,这样就不用针对每项子测试重复编写相关的代码与验证逻辑了
77. 把测试前、后的准备与清理逻辑写在 setUp、tearDown、setUpModule 与 tearDownModule 中,以防用例之间互相干扰
- 单元测试验证的是每项功能本身是否正常,集成测试验证的是模块之间能否正确交互,这两种测试都很重要
- 把测试用例的准备与清理工作分别放在 setUp 与 tearDown 方法中,可以避免用例之间相互干扰,使他们都能从一套干净的环境开始执行
- 集成测试的准备与清理工作可以放在模块级别的 setUpModule 与 tearDownModule 函数里,系统在测试该模块与其中所有 TestCase 子类的过程中,只会把这两个函数各自运行一遍
78. 用 Mock 来模拟受测试代码所依赖的复杂函数
- unittest.mock 模块中的 Mock 类能够模拟某个接口的行为,我们可以用它替换受测函数所要调用的接口,因为那些接口可能不太容易在测试的过程中配置
- 如果用 mock 把受测代码所依赖的函数替换掉了,那么在测试的时候,不仅要验证受测代码的行为,而且还要验证它有没有正确地调用这些 mock,这可以通过 Mock.assert_called_once_with 等一系列方法实现
- 要想把受测函数所调用的其他函数用 mock 逻辑替换掉,一种办法是给受测函数设计只能以关键字来指定的参数;另一种办法是通过 unittest.mock.patch 系列的方法暂时隐藏那些函数
79. 把受测代码所依赖的系统封装起来,以便于模拟和测试
- 在写单元测试的时候,如果总是要反复使用许多代码来注入模拟的逻辑,那么可以考虑把受测函数所要用到的逻辑封装到类中,因为封装之后更容易注入
- Python 内置的 unittest.mock 模块里有个 Mock 类,它能模拟类的实例,这种 Mock 对象具备与原类中的方法相对于的属性。如果在它上面调用某个方法,就会触发相应的属性
- 如果想把程序完整地测一遍,那么可以重构代码,在原来直接使用复杂系统的地方引入辅助函数,让程序通过这些函数来获取它要用的系统,这样我们就可以通过辅助函数注入模拟逻辑
80. 考虑用 pdb 做交互调试
- 在程序里某个兴趣点直接调用 Python 内置的 breakpoint 函数就可以触发交互调试器
- Python 的交互调试界面(即 pdb 界面)也是一套完整的 Python 执行环境,在它里面我们可以检查正在运行的程序处于什么状态,并予以修改
- 我们可以在 pdb 界面里用相关的命令精确地控制程序的执行方式,这样就能做到一边检查状态,一边推进程序了
- pdb 模块还能够在程序出现错误的时候检查该程序的状态,这可以通过 Python -m pdb -c continue
命令实现,也可以现在普通的 Python 解释器界面运行受测程序,等到出现问题,再用 import pdb; pdb.pm() 切换至调试界面
81. 用 tracemalloc 来掌握内存的使用与泄露情况
- 不借助相关的工具,我们可能很难了解 Python 程序是怎样使用内存的,以及其中有些内存又是如何泄露的
- gc 模块可以帮助我们了解垃圾回收器追踪到了哪些对象,但它并不能告诉我们那些对象是如何分配的
- Python 内置的 tracemalloc 模块提供了一套强大的工具,可以帮助我们更好地了解内存的使用情况,并找到这些内存分别由哪一行代码所分配
82. 学会寻找由其他 Python 开发者所构建的模块
- Python Package Index(PyPI)含有许多常用的软件包,这些都是由广大 Python 开发者构建并维护的
- 可以用 pip 命令行工具从 PyPI 里面安装软件包
- 大多数 PyPI 模块都是自由及开源软件
83. 用虚拟环境隔离项目,并重建依赖关系
- 我们可以在每个虚拟环境里面,分别用 pip 命令安装它所需要的软件包,这样的话,同一台电脑中就可以存在许多互不冲突的环境了
- python3 -m venv 命令可以创建虚拟环境,source bin/activate 与 deactivate 命令分别可以启用与禁用该环境
- python3 -m pip freeze > requirements.txt 命令可以把当前环境所依赖的软件包保存到文件之中,之后可以通过 python3 -m pip install -r requirements.txt 在另一套环境里面重新安装这些包
84. 每月函数、类与模块都要写 docstring
- 每个模块、类、方法与函数都应该编写 docstring 文档,并且要与实现代码保持同步
- 模块的 docstring 要介绍本模块的内容,还要指出用户必须了解的关键类与重要函数
- 类的 docstring 要写在 class 语句的正下方,描述本类的行为与重要的属性,还要指出子类应该如何正确地继承这个类
- 函数与方法的 docstring 要写在 def 语句的正下方,描述本函数的每个参数、函数的返回值,可能抛出的异常以及其他相关的行为
- 如果某些信息已经通过类型注解表达过了,那就不要在 docstring 里面重复
85. 用包来安排模块,以提供稳固的 API
- Python 的包是一种包含其他模块的模块,这种结构让我们可以把代码划分成多个互不冲突的名称空间,即便两个实体同名,也能用它们所属的模块加以区分
- 如果要构建的包比较简单,那就把其中每个模块所对应的源文件都直接放在本包的目录下,并给目录里面创建一份
__init__.py文件。这样的话,这些源文件所表示的模块就会成为本包的子模块。这个目录里还可以创建子目录,以构建其他包 - 如果想限制外界通过引入该模块能够访问到哪些 API,那么可以把这些 API 的名称写在
__all__这个特殊的属性里面。 - 如果不想让外界看到某些内容,那么可以在包目录中的
__init__.py文件里面故意不引入这些内容,或者给这些只供本包内部使用的内容名称前面添加下划线 - 加入这个包只在某个团队或某个项目内部使用,那恐怕就没必要专门通过
__all__来指定外界能够访问到的 API 了
86. 考虑用模块级别的代码配置不同的部署环境
- 程序通常需要部署到许多种环境里面,无论在哪一种环境之中运行程序,都必须先准备好相关的资源,并做出适当的配置
- 可以像编写普通的 Python 语句那样,直接在模块作用域书写配置逻辑,以定制该模块的内容,从而针对不同的环境做出适当的部署
- 还可以根据其他的一些外部因素来调整模块的内容,例如通过 sys 或 os 模块查下与操作系统有关的信息,并据此定制该模块
87. 为自编的模块定义根异常,让调用者能够专门处理与此 API 有关的异常
- 给模块定义根异常,可以让使用这个模块的 API 用户将他们自己的代码与这个模块所提供的 API 隔开,以便分别处理其中的错误
- API 用户在处理完 API 所属模块有可能抛出的具体异常后,可以写一个针对模块根异常的 except 块,如果程序进入这个块,那就说明他使用 API 的方式可能有问题,例如可能忘记处理某种本来应该处理的具体异常
- API 用户还可以再写一个 except 块以捕获整个 Python 体系之中的根异常,如果程序进入了那个块,那说明所调用的 API 可能实现得有问题
- 在模块的根异常下,可以设立几个门类,让具体的异常不要直接继承总的根异常,而是继承各自门类中那个分根异常,这样的话,使用这个模块的开发者,就可以只关注这几个门类,即便你修改了某个门类之下的具体异常,也不会影响到他们已经写好的那些代码
88. 用适当的方式打破循环依赖关系
- 如果两个模块都要在开头引入对方,那就会形成循环依赖关系,这有可能导致程序在启动时崩溃
- 要想打破依赖循环,最好的办法是把这两个模块都要用到的那些代码重构到整个依赖体系的最底层
- 如果不想大幅度重构代码,也不想让代码变得太复杂,那么最简单的方案是通过动态引入来消除循环依赖关系
89. 重构时考虑通过 warnings 提醒开发者 API 已经发生变化
- 设计新版 API 的时候,可以通过 warnings 模块把已经过时的用法通知到调用者,让他们看到消息后尽快改用新的写法,以防程序在我们彻底放弃旧版 API 之后崩溃
- 在命令行界面执行 Python 解释器的时候,可以开启-W error 选项,从而将警告视为错误。这在执行自动测试的过程中特别有用,因为这样可以及时发现受测程序所依赖的 API 是否已经推出了新的版本
- 如果程序要部署到生产环境,那么可以通过 logging 模块将警告信息重定向到日志系统,把程序在运行过程中遇到的警告纳入现有的错误报告机制之中
- 如果你设计的 API 会发出警告,那么应该为此编写测试,确保下游开发者在使用 API 的过程中,能够在适当的时机收到正确的警告信息
90. 考虑通过 typing 做静态分析,以消除 bug
- Python 提供了内置的 typing 模块与一套特殊的写法,可以给变量、字段、函数与方法标注类型信息
- 静态类型检查工具可以利用标注的类型信息检查出许多常见的 bug,而不用让它们到程序运行的时候再暴露
- 有一些建议可以指导我们,程序里面哪些地方应该加类型注解,如何在设计 API 的时候使用类型注解,以及怎样才能让注解工作不影响编程效率