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