Effective Python
笔记未完成,有待success@更新 。
第1章:培养Pythonic思维
第1条 查询使用的Python版本
学会查询Python的版本
import sys
print(sys.version_info)
print(sys.version)
>>>
sys.version_info(major=3, minor=8, micro=3, releaselevel='final', serial=0)
3.8.3 (default, Jul 2 2020, 17:30:36) [MSC v.1916 64 bit (AMD64)]
第2条 遵循PEP 8 风格指南
要让Python代码更具有Pythonic特点,那么编写代码的风格最好遵循PEP 8指南(针对Python代码格式而编订的指南)。
完整的内容可以从网上找到,这里推荐两个地址:
①英文原版
②中文翻译
Effective Python书中主要提到的建议是关于以下几个方面:
- 与空白相关:主要注意缩进时空格与制表符不能混用、长代码的缩进处理
- 与命名相关:函数、变量、类等命名时的规范
- 与表达式和语句相关:规范表达式和语句的写法,尽量从简
- 与引入相关:import语句总是在文件开头,引入模块用绝对名称
第3条 了解bytes与str的区别
Python3中的字符串有两种:str和bytes,我们需要区分这两者的区别,文本(str)和二进制数据(bytes),文本总是Unicode,用str类型,二进制数据则用bytes类型表示。
bytes类型之间可以使用+操作符以及比较大小,str类型也可以。但是两者不能混搭进行操作。如:
b'one' + b'two' ✔
b'one' + 'two' ✗
str与bytes的相互转换:
str.encode(‘encoding’) —-> bytes
bytes.decode(‘encoding’) —-> str
其中encoding指的是编码方式,对于中文,它可以是’utf-8’,’gb2312’,’gbk’,’big5’等,一般默认是’utf-8’。不同编码方式,结果也不同。
下面的图示与例子便于理解。并且要注意的是转换之后的长度可能会因编码方案的不同而发生改变。

str_a = 'T恤'
bytes_a = str_a.encode('utf-8')
str_a1 = bytes_a.decode('utf-8')
>>>
bytes_a = b'T\xe6\x81\xa4'
str_a1 = 'T恤'
第4条 用支持插值的f-string取代C风格的格式字符串与str.format方法
在Python中采用%格式化操作符有四个缺点:
- %右侧元组里面的值在类型或顺序上发生变化时,程序可能因转换类型时不兼容而出现错误。
- 在填充具体变量时经常需要做一些处理(居中,保留小数位等),使得表达式冗长混乱。
- 同一个值填充格式字符串的多个位置时,在%右侧的元组需要多次重复这个值。
- 把dict写到格式化表达式里会让表达式特别长。
而str.format方法虽然比C风格的格式化字符串好一些,但是仍然不能解决上述第二个缺点。而插值格式字符串f-string用新的写法,尽可能的简化了表达式的写法。以下是几种表达式写法的对比:
key = 'my_var'
value = 1.234
f_string = f'{key:<10} = {value:.2f}'
c_tuple = '%-10s = %.2f' % (key, value)
str_args = '{:<10} = {:.2f}'.format(key, value)
str_kw = '{key:<10} = {value:.2f}'.format(key = key, value = value)
c_dict = '%(key)-10s = %(value).2f' % {'key': key, 'value': value}
f-string是个简洁而强大的机制,可以直接在格式说明符里嵌入任意Python表达式,所以应当利用好这个方法,以简化代码。
第5条 用辅助函数取代复杂的表达式
- 对于一个变量或是其他什么数据结构,如果需要对其进行多种操作,如转换为整数、布尔表达式等,应当尽量简化写法,而不要一起堆积到一行中。
- 对于复杂的表达式,并且是需要重复使用的情况,应该将表达式写到辅助函数中。
- 用
if/else结构写成的条件表达式,要比or与and写成的Boolean表达式更易懂。
第6条 把数据结构直接拆分到多个变量里,不要专门通过下标访问
元组类型变量不能修改其值,如果要将元组中的值分别赋给不同的其他变量,可以采用拆分(unpacking)的写法,只需一条语句就可以完成。
item = ('blue', 'red')
first, second = item
print(first,'-',second)
>>>blue - red
通过unpacking赋值要比通过下标访问变量值更清晰,unpacking用法灵活,可迭代对象都能拆分,对于列表等都适用。
交换两个对象的值时也可以一行解决。
a, b = b, a
上述拆分机制在for循环或类似的结构(推导与生成表达式,见27条)很重要,可以把复杂的变量拆分到相关的变量中。
第7条 尽量用enumerate取代range
对于需要迭代的情况,尽量使用enumerate,而不是range,enumerate可以把任何一种迭代器(iterator)封装成惰性生成器(lazy generator,见30条),同时给出本轮循环的序号。用法如下:
color = ['red', 'green', 'blue']
for i, col in enumerate(color, 1):
print(f'{i}: {col}')
>>>
1: red
2: green
3: blue
enumerate的第二个参数为起始的序号,省去了打印时额外调整的步骤。
第8条 用zip函数同时遍历两个迭代器
有时需要从源列表中产生另一个列表(派生列表),如果想同时遍历这两份列表,那么可以使用内置的zip函数实现,如下:
num = [1, 2, 3, 4]
num_2 = [1, 4, 9, 16]
for a, a_2 in zip(num, num_2):
print(f'{a} squared is {a_2}')
>>>
1 squared is 1
2 squared is 4
3 squared is 9
4 squared is 16
而如果zip中的列表长度不一致,则其循环次数取决于最短的列表长度。如果想按最长的迭代器来遍历,需要用itertools模块的zip_longest函数。
第9条 不要在for与while循环后面写else块
在使用for循环以及while循环时,其后可以跟else语句,而只有在整个循环没有因为break提前跳出的情况下,else块才会执行。并且因为这里的else与if/else中的else含义不一样,所以会让人看不太懂,应当避免在循环后使用else语句。
第10条 用赋值表达式减少重复代码
a = b是普通的赋值语句,Python 3.8引入了新的语法:赋值表达式,它是使用海象操作符:=给变量赋值,并且让这个值成为表达式的结果。相比普通的赋值,使用海象操作符可以减少重复代码,让代码更精简。
一般在写if/else结构、模拟switch/case结构、do/while结构时,常需要在代码前写好变量初始赋值语句,然后再进入if语句进行判断,如果使用海象操作符则可以将之前的赋值语句合并到后面进行if语句判断的地方,从而减少重复代码。
需要注意的是,如果赋值表达式是大表达式中的一部分,就要用一对括号把它括起来。参考下面的例子:
my_list = [1,2,3,4]
if (count := len(my_list)) >= 3:
print(f"The length of my_list is {count}")
if count := len(my_list) >= 3:
print(f"The length of my_list is {count}")
>>>
The length of my_list is 4
The length of my_list is True
第2章:列表与字典
第11条 学会对序列做切片
Python可以对序列做切割(slice),只要实现了__getitem__与__setitem__两个特殊方法的类都可以切割(见43条)。
基本用法就是somelist[start:end]。这样实际取值是从somelist[start]到somelist[end-1],如果从起始位置0切割,即start为0,则0可以省略,若end为somelist的长度时(序列末尾),end也可省略。
切片比较需要注意的有以下三点:
start或end可以取负数,取负数时表示的位置是从相反方向开始数,最后结果就是序列中start所指位置的元素到end所指位置的元素。最简单的理解方式是,可以将含有负数的切片转变为正常的切片方式,只需要将负数值加上序列的长度就变成正常的切片了。
切片时start和end允许越界,超出范围时系统会自动忽略不存在的元素。但是直接对序列取值则不能越界。
在赋值时可以在赋值符号左边使用切片,这样表示的意义是把序列切片所取的值用右边的元素替换,所以当赋值符号左右两边表示的元素个数不相等时,序列的长度会发生变化。
范例代码:
>>>a = [1, 2, 3, 4, 5, 6]
>>>a[:3]
[1, 2, 3]
>>>a[4:]
[5, 6]
>>>a[1:-1] # 第一点。等同a[1:5]
[2, 3, 4, 5]
>>>a[:50] # 第二点。
[1, 2, 3, 4, 5, 6]
>>>a[1:3] = ['a'] # 第三点
[1, 'a', 4, 5, 6]
第12条 不要在切片里同时指定起止下标与步进
序列的切片方法还有一种步进的形式,就是somelist[start:end:stride],那么和普通的切片形式相比,就是end之后多指定了stride步进这个值,表示从start开始每过stride便取一次值,而没有指定stride时可以认为是默认为1。
但是带有步进的切片常常会引发意外的效果,使程序出现bug。步进stride值可以取负数,因此可以做到bytes字符串、Unicode字符串反转等操作,但是stride不能取0,会报ValueError错误。如果正常使用时,同时含有起止下标和步进值,会让切片比较难看懂,尤其是步进值为负数的时候。所以,应当尽量避免把起止下标和步进值同时放入切片,而可以考虑分两次(一次隔位选取,一次切割)来写,也可以用itertools内置模块的islice方法,因为它的起止位置和步进值都不能为负值。
范例代码:
>>>from itertools import islice
>>>a = 'world'
>>>a[::2]
'wrd'
>>>a[::-1]
'dlrow'
>>>a[2::-2]
'rw'
>>>list(islice(a, 0, 5, 2))
['w', 'r', 'd']
第13条 通过带星号的unpacking操作来捕获多个元素,不要用切片
基本的unpacking操作(见第6条)有一项限制,就是需要确定拆解的序列长度才能把其中的值拆分给变量,而有时候我们只是需要其中的某一个值,不需要把所有值都拆分出来。而unpacking操作还可以使用带*的表达式来捕获多个值,而不需要使用多个切片的语句。
需要注意的有以下几点:
- 使用带
*的unpacking操作时,至少要要有一个普通的接收变量搭配,否则报SyntaxError。 - 对单层结构来说,同一级最多只能有一个带
*的unpacking。对于多层结构,不同层级部分可以出现带*的unpacking。 - 带
*的unpacking表达式会形成一份列表实例,其有可能为空。当要拆分的序列数据量非常庞大时,要确定系统有足够的内存存储拆分出来的数据,再进行带*的unpacking操作,否则可能耗尽计算机的内存导致程序崩溃。
范例代码:
>>>num = [1, 2, 3, 4, 5]
>>>book_dict = {'Effective Python': ('Brett Slatkin', 'Python', 129), 'Python classic example': ('Steven F. Lott', 'Python', 139), 'Python Crawler': ('Cui Qingcai', 99)}
>>>min_num, *others, max_num = num
>>>print(min_num, others, max_num)
1 [2, 3, 4] 5
>>>*others = num # 第一点
File "<input>", line 1
SyntaxError: starred assignment target must be in a list or tuple
>>>first, *middle1, *middle2, last = num # 第二点
File "<input>", line 1
SyntaxError: multiple starred expressions in assignment
第14条 用sort方法的key参数来表示复杂的排序逻辑
Python中的sort函数可以给列表排序(默认为升序),前提是列表中的元素是具备自然顺序的内置类型,如:字符串、整数、浮点数。而对于一般的类的对象构成的列表,如果这个类像整数一样具有自然顺序,那么可以定义一些特殊的方法(见第73条),使其可以像整数、字符串那样直接调用sort函数进行排序;否则一般情况下就是针对对象中某一属性进行排序,把这样的排序逻辑定义成函数传给sort方法的key参数,再以该标准排序。
下面的代码分别实现了不同的排序情况:
- 按类某一指标进行排序
- 按类的多个指标进行同向排序
class Student:
def __init__(self, name, stuid, weight):
self.name = name
self.stuid = stuid
self.weight = weight
def __repr__(self): # __repr__只是用于显示对象信息
return f'Student {self.name}\'s ID is {self.stuid}'
>>>p = [
Student('Tom', '001', 120),
Student('Lisa', '002', 115),
Student('Jack', '003', 117),
Student('Peter', '004', 120),
Student('Lisa', '005', 104)
]
>>>p.sort(key = lambda x: x.name) #以姓名为标准给对象排序
>>>
[Student Jack's ID is 003,
Student Lisa's ID is 002,
Student Peter's ID is 004,
Student Tom's ID is 001]
>>>p.sort(key = lambda x: (x.name, x.stuid))
第15条 不要过分依赖给字典添加条目时所用的顺序
从Python 3.7版开始,迭代标准的字典的顺序与键值插入字典时的顺序一致,而早期的版本则没有这个特性。
在Python代码中,很容易定义跟标准字典很像但本身不是dict实例的对象,对于这种对象,不能保证迭代的顺序与插入时的顺序一致。因此编写代码时需要注意:
- 不要依赖插入时的顺序编写代码
- 在程序运行时明确判断它是否是标准字典
- 给代码添加类型注解并静态分析
第16条 用get处理键不在字典中的情况,不要使用in与KeyError
字典的三种基本操作:访问、赋值及删除键值对。在处理键不在字典中的情况时,有四种方法:
- 用in表达式判断
- 抛出KeyError异常
- 利用get方法
- 利用setdefault方法
前两种方法没有后两种更简单,而如果与键关联的值是像计数器这样的基本类型,那么用get是最好的方案;如果是构造开销较大或容易出异常的类型,那么可以把这个方法与赋值表达式结合使用。
此外即使看上去最应该使用setdefault方案,也不一定要真的使用setdefault方案,而是可以考虑用defaultdict取代普通的dict。
第17条 用defaultdict处理内部状态中缺失的元素,而不要用setdefault
如果管理的字典需要添加任意的键,则应该考虑是否用内置的collections模块的defaultdict实例来解决问题。这个defaultdict类会在键缺失的情况下,自动添加这个键以及键所对应的默认值。只需要在构造这个字典时提供一个函数,每次发现键不存在时,该字典都会用这个函数返回一份新的默认值。
第18条 学会利用__missing__构造依赖键的默认值
之前说到,defaultdict可以给不存在的键调用函数创建默认值,setdefault也可以返回默认值,但是有时这两种方法都不能很好的处理需求,书中给了一个关联图片路径和文件句柄的例子,提供文件句柄需要提供文件路径,所以用defaultdict是不适用的,因为它的创建函数不能传入路径这个参数。所以解决方案是通过继承dict类型并实现__missing__魔术方法,把字典里不存在这个键时要执行的逻辑写在这个方法中。
第3章:函数
第19条 不要把函数返回的多个数值拆分到三个以上的变量中
函数可以把多个值合起来通过一个元组返回,而接收返回值时可以用unpacking操作。所以当函数返回多个值时,可以用带*的变量接收,而不是把值拆分到三个以上的变量中,这样会让代码臃肿和不美观。实在不行就要通过创建类来返回。
第20条 遇到意外状况时应该抛出异常,不要返回None
用返回值None表示特殊情况是很容易出错的,因为这样的值在条件表达式中没办法和0、空白符之类的值进行区分,这些值都相当于False。所以捕获异常来表示特殊情况,而不是都返回None值会更好。比如常见的ZeroDivisionError。
第21条 了解如何在闭包里面使用外围作用域中的变量
首先,闭包是指在一个内部函数中,对外部作用域的变量进行引用,(并且一般外部函数的返回值为内部函数),那么内部函数就被认为是闭包。简单来说是大函数中套了一个小函数,小函数可以引用大函数中的变量。这里要了解一下作用域的问题,引用变量时,会按照一些顺序查找变量:
- 当前函数作用域
- 外围作用域
- 包含当前代码的模块所对应的作用域(全局作用域)
- 内置作用域
闭包函数可以引用定义他们的那个外围作用域的变量,在闭包里给变量赋值并不会改变外围作用域的同名变量,如果要修改这个赋值操作,可以用nonlocal关键字,这样就可以修改外围作用域的变量。
第22条 用数量可变的位置参数给函数设计清晰的参数列表
用def定义函数时,可以通过args的写法(加\)让函数接收数量可变的位置参数。区别于关键字参数,必须注意调用函数时参数的正确位置,所以使用这个位置参数时参数不应该太多,至少是可控的,否则传递参数时可能因为耗尽内存而崩溃。
第23条 用关键字参数来表示可选的行为
常见的函数都是使用位置参数,即调用时按照位置顺序传入对应参数。而关键字参数则是指定了参数的名称,可以不局限于顺序。用法是给参数带上参数名,而创建函数时可以写上万能形参**kwargs。用关键字参数的好处有:
- 可以让代码可阅读性更好
- 可以在定义函数时指定参数默认值
- 灵活的扩充函数的参数,而不用担心影响原有的函数调用代码
第24条 用None和docstring来描述默认值会变的参数
函数的默认值一般设定为是不变的,但有些时候我们希望关键字参数的默认值是可变的。如记录日志消息时,默认的时间应该是触发事件的那一刻,所以没法给时间这个参数设定固定值。如果说把参数设置为when=datetime.now(),也是不行的,因为参数的默认值只会在系统加载这个模块时计算一遍,而不是每次执行都计算一次,所以这个参数还是不变的。那么要实现这种效果,惯用做法是把参数默认值设为None,同时在函数的注释docstring文档里说明,当参数为None时执行什么操作。同时推荐使用类型注解,这能让代码清晰明了,很容易明白函数的用法。
第25条 用只能以关键字指定和只能按位置传入的参数来设计
给函数传参时有两种方法,一种是按位置传参,另一种是按关键字传参,这两种各有优缺点。有时我们会想让一些参数只支持位置传参或关键字传参,比如有时候改变了函数参数的名称,这就要更改所有之前按关键字参数调用的代码。Python中有一种写法:func(arg1, arg2, /, , arg3=’value1’, arg4=’value2’),这表示在/左边的参数只能按位置传参,在\右边的参数只能按照关键字传参,否则会报错。而在/和*之间的参数对两种传参方式都可以使用。要注意的是只按位置传参的特性是在3.8版本之后才有的。可以说这个特性可以让函数的使用更规范和好用吧。
第26条 用functools.wraps定义函数修饰器
这一条是讲定义函数修饰器需要注意的地方。用修饰器封装某个函数,让程序在执行这个函数之前与执行完之后分别运行一些代码。当一般的定义一个修饰器时,外部函数的返回值是内部函数,所以当用修饰器修饰函数或把这个返回值赋给使用的函数时,这个函数就变了,变成那个内部函数了,要让封装的函数属性等都保留下来,可以使用functools的wraps修饰器,可以帮助我们正确定义修饰器,保留函数本身的属性。
第4章:推导与生成
第27条 用列表推导取代map与filter
Python可以根据某个序列或可迭代对象派生出一份新的列表,一般写法是套一个列表的符号[],里面写一个一行的循环,这种写法叫列表推导。这种功能也可以用内置函数map实现。如果映射关系比较简单(比如取平方等简单的表达式),用列表推导式来写会比用map简单一些,因为用map需要先把映射逻辑定义成lambda匿名函数,还有就是列表推导能够方便的过滤原列表。关于推导机制,还有字典与集合也可以,分别叫做字典推导和集合推导。所以说,在用map与filter时,可以考虑用推导式来写。
第28条 控制推导逻辑的子表达式不要超过两个
我们已经知道如何写列表推导了,那么如果理解了这种逻辑,还可以写多层循环,例如把一个二维列表转化为普通的一维列表,类似于嵌套。同时,推导的时候可以使用多个if条件,同一层的if默认是and关系。总之,在使用列表推导时,尽量不要用于超过两个子表达式的情况,因为这样会失去逻辑清晰的优点,让代码不容易读懂,这种情况下还不如直接采用多行的循环写法。
第29条 用赋值表达式消除推导中的重复代码
赋值表达式是在普通的一个表达式上加上了赋值操作,用了一个海象操作符:=进行赋值。在第10条中有讲过,在for、while循环中使用赋值表达式的方式可以减少重复代码,在推导表达式(列表推导、字典推导)中也可以使用,用来减少重复代码。一般要先判断某个值是否存在或类似的情况下都可以用:=直接给表达式赋值,同时也不失易读性。
第30条 不要让函数直接返回列表,应该让它逐个生成列表里的值
有时需要在函数中返回一系列的值,那么可以返回一个列表。书中说到这样做的一个问题是代码显得杂乱,因为常常需要调用append方法来给列表添加值,个人认为这个问题不是很重要,因为看起来也并没有那么乱。另一个要注意的问题是返回列表会增加内存消耗,如果数据特别多,程序可能会因为耗尽内存而崩溃,所以可以选择用生成器函数实现。和返回列表不同的是,返回的迭代器是不能重复使用的,迭代器是有状态的。同时如果确实需要列表格式的数据,也可以直接用list函数转换为列表。
第31条 谨慎地迭代函数所收到的参数
在函数和方法中如果接收了一个需要迭代的参数,需要进行遍历,就需要小心,因为如果这些参数为迭代器,则可能出现奇怪的效果,不能按预期进行迭代。解决方法有:1.在对参数遍历前进行一个检查,如果是迭代器则要先转换为列表。2.把参数封装成一个容器,就是实现一个__iter__魔术方法,让其可以反复调用这个迭代方法。
第32条 考虑用生成器表达式改写数据量较大的列表推导
前面了解到推导式(列表推导、字典推导。。。)的好处,以列表推导为例,它同样有的一个问题是,如果数据量非常多,可能会消耗太多的内存而崩溃。所以可以用生成器表达式来实现,生成器表达式的写法和列表推导式的写法类似,列表推导式是写在一对方括号[]中,而生成器表达式是写在一对圆括号()中,它会返回一个迭代器。生成器表达式也可以组合,生成另一个生成器,但同样要注意,生成器表达式返回的迭代器是有状态的,迭代完一轮后就不能继续使用了。
第33条 通过yield from 把多个生成器连起来用
生成器有很多好处,而且能够解决许多常见的问题。yield from常常可以将多个生成器连起来用,从嵌套进去的小生成器里面取值,如果生成器已经用完,那么程序的控制流程就会回到yield from所在的函数中。并且yield from的性能要胜过那种在for循环里手工yield表达式的方案。
第34条 不要用send给生成器注入数据
yield表达式可以让我们轻松地写出生成器函数,每次获取输出序列的一项结果,但是这种是单向的,意思是无法让生成器接收参数来返回结果,而send可以把数据注入生成器,让它成为上一条yield表达式的求值结果,生成器可以把这个结果赋给变量。但是把send方法和yield from表达式搭配起来使用效果没有那么好。总之,应尽量避免使用send方法。
第35条 不要通过throw变换生成器的状态
生成器有一个方法throw,调用这个方法传入一个Exception实例,那么生成器下次推进时就会直接抛出异常,但这个方法通常会让代码变得难懂,因为需要用多层嵌套的模板结构来抛出并捕获这种异常。而常常可以通过类的__iter__方法实现生成器,触发这个容器的迭代。
第36条 考虑用itertools拼装迭代器与生成器
Python内置的itertools模块里有很多函数,可以用来安排迭代器之间的交互关系。下面分三大类来了解其中最重要的函数。
- 连接多个迭代器:把多个迭代器连成一个使用
chain:把多个迭代器从头到尾连成一个迭代器。例子:itertools.chain([1,2,3], (x for x in range(4,7)))
repeat:返回只含某一个值的迭代器,可从第二个参数指定值重复的次数。例子:itertools.repeat(‘world’, 3)
cycle:循环地输出某段内容中的各项元素。例子:itertools.cycle([‘a’, ‘b’])
tee:让一个迭代器分裂成多个平行的迭代器,具体个数由第二个参数指定。例子:itertools.tee(it, 3)
zip_longest:与内置的zip函数类似,但区别在于,如果源迭代器的长度不同,那么它会用fillvalue参数的值来填补提前耗尽的那些迭代器所留下的空缺。例子:itertools.zip_longest(key, value, fillvalue=’nope’)
- 过滤源迭代器中的元素
islice:在不拷贝数据的前提下,按照下标切割源迭代器。可以只给出切割的终点,也可以同时给出起点与终点,还可以指定步进值。例子:itertools.islice(it, 2, 7, 2)
takewhile:一直从源迭代器里获取元素,直到某元素让测试函数返回False为止。例子:itertools.takewhile(func, it)
dropwhile:与takewhile相反,dropwhile会一直跳过源序列里的元素,直到某元素让测试函数返回True为止,然后它会从这个地方开始逐个取值。例子:itertools.dropwhile(func, it)
filterfalse:和内置的filter函数相反,逐个输出源迭代器里使得测试函数返回False的那些元素。例子:itertools.filterfalse(func, it)
用源迭代器中的元素合成新元素
accumulate:从源迭代器中取出一个元素,并把已累计的结果与这个元素一起传给表示累加逻辑的函数,然后输出那个函数的计算结果,并把结果作为新的累计值。例子:itertools.accumulate(it, func)
product:从一个或多个源迭代器里获取元素,并计算笛卡尔积,它可以取代那种多层嵌套的列表推导代码。例子:itertools.product(it1, it2)
permutations:考虑源迭代器所能给出的全部元素,并逐个输出由其中N个元素形成的每种有序排列方式,元素相同但顺序不同,算两种排列。itertools.permutations(it, N)
combinations:考虑源迭代器所能给出的全部元素,并逐个输出由其中N个元素形成的每种无序组合方式,元素相同但顺序不同,算一种组合。
combinations_with_replacement:与combinations类似,但它允许同一个元素在组合里多次出现。
第5章:类与接口
第37条 用组合起来的类来实现多层结构,不要用嵌套的内置类型
通常要写一个类来满足一些需求时,会写一个类并添加属性和实现一些方法,当需求增加时往往会形成越来越复杂的数据结构,比如字典里嵌套字典、长元组等,但是这样会让代码变得臃肿且不容易看懂。所以最好还是实现多个相关的类,组合使用来实现需求。还提到namedtuple具名元组,它可以存放不可变的数据,灵活的转换为普通的类。如果发现用字典来维护类的内部状态的那些代码已经越写越复杂了,那么就应该考虑改用多个类实现。
第38条 让简单的接口接受函数,而不是类的实例
Python有许多内置的API,允许传入某个函数来定制它的行为。这种函数可以叫做挂钩(hook),API在执行过程中,会回调这些挂钩函数。比如list类型的sort方法可以指定key参数,按照提供的挂钩函数决定列表每个元素的先后顺序。在其他编程语言中,挂钩可能会用抽象类来定义。但在Python中,许多挂钩都是无状态的函数,带有明确的参数与返回值。对于一个类,可以实现__call__魔术方法,让这个对象可调用(callable),那么这个类的实例方法也可以作为挂钩函数使用。对于简单的接口函数则最好直接接受函数,而不是类的实例,对于复杂的功能才考虑类的实例,因为这样才能够保证代码更清晰。
第39条 通过@classmethod多态来构造同一体系中的各类对象
在Python中对象和类都支持多态。多态机制可以使同一体系中的多个类按照各自独有的方式实现同一个方法,这些类都可以满足同一套接口。理解类中的实例方法和类方法以及静态方法,使用@classmethod定义一个类方法,可以让子类也具有该方法。通过类方法多态机制,能够以通用的形式构造并拼接具体的子类对象。
第40条 通过super初始化超类
当某个类继承了多个超类,如果还用普通的_init\_方法来初始化超类,会容易产生问题,因为调用多个超类的初始化构造方法,如果是同一个基类,那么初始化方法就会重复多次,并且初始化顺序和定义的语句有关。直接调用__init__所产生的第二个问题在于,无法正确处理菱形继承,菱形继承是指子类通过类体系里两条不同路径的类继承了同一个超类。Python内置的super函数规定了标准的方法解析顺序,可以保证菱形继承体系中的共同超类只初始化一次。所以应当学会用super().__init__方法来初始化超类。
第41条 考虑用mix-in类来表示可组合的功能
mix-in可以理解为混入,是一种编程模式,在Python面向对象编程中,表示实现了某种功能单元的类,用于被其他子类继承。利用Python的多重继承,子类可以继承不同功能的的mixin类,按需动态组合使用。如果子类要修改mix-in提供的功能,那么可以在代码里覆盖相关的实例方法。当多个类都实现了同一种功能时,应该考虑把这个功能抽离成mix-in类。
第42条 优化考虑用public属性表示应受保护的数据,不要用private属性表示
要了解类的属性,有两种访问级别:public和private。public属性可以公开访问,private属性(两个下划线开头)在类的外面不能直接访问。而类方法可以访问本类的private属性。private字段只给这个类自己使用,子类不能访问超类的private字段。但是private字段只是变换了名称,所以还是可以通过变换后的名称访问private属性的,为了减少在不知情的情况下访问内部数据造成损伤,建议以单下划线开头的字段命名,叫作受保护的字段,同时在文档中加以解释,而不要通过private属性限制访问。
第43条 自定义的容器类型应该从collections.abc继承
如果要编写简单的新类,那么可以直接从内置的容器类型继承;但如果功能复杂,或者说想要让定制的容器类型能像标准的Python容器使用,就可以考虑从collections.abc模块里的抽象基类之中派生出自己的容器类型,这样可以让容器自动具备相关的功能。
第6章:元类与属性
第44条 用纯属性与修饰器取代旧式的setter与getter方法
给新类定义接口时,应该先从简单的public属性写起,避免定义setter与getter方法。如果在访问属性时确实要做特殊处理,那就通过@property修饰器来定义获取属性与设置属性的方法。实现@property方法时,应当遵循最小惊讶原则,不要引发奇怪的副作用,保证执行的很快,不要做复杂或缓慢的任务。
第45条 考虑用@property实现新的属性访问逻辑,不要急着重构原有的代码
Python内置的@property修饰器可以让程序在获取或设置相关属性时,触发这些逻辑。可以利用@property给已有的实例属性增加新的功能,也可以改善数据模型而不影响已经写好的代码,所以在重构前可以考虑用这个修饰器实现。如果发现@property使用太过频繁,那可能就该考虑重构这个类了。
第46条 用描述符来改写需要复用@property方法
如果想复用@property方法所实现的行为与验证逻辑,可以考虑自己定义描述符类。描述符协议规定了程序应该如何处理属性访问操作,这个类应该实现__get__与__set__方法。
第47条 针对惰性属性使用__getattr__、__getattribute__及__setattr__
如果想用自己的方式(惰性地或按需地)加载并保存对象属性,那么可以实现一些有关属性访问和设置的魔术方法。__getattr__会在访问的属性缺失时触发,也就是实例本身的__dict__不包含这个属性,就会触发__getattr__。__getattribute__只要访问对象中的属性就会执行这个方法,不管属性存在与否。写代码实现这个方法时要注意递归的问题,因为访问对象中的属性都要经过这个方法本身,如果要使用本对象的普通属性,应该通过super()来使用。__setattr__则是对属性赋值的操作,不管是直接赋值还是通过内置的setattr函数赋值都会触发__setattr__方法。
第48条 用__init_subclass__验证子类写得是否正确
如果某个类是根据元类所定义的,那么当系统把该类的class语句体全部处理完之后,就会将这个类的写法告诉元类的__new__方法。__init_subclass__能够用来检查子类定义得是否合理,如果不合理可以提前报错,让程序无法创建出这种子类的对象。在分层的或涉及多重继承的类体系里面,别忘了在写的这些类的__init_subclass__内通过super()来调用超类的__init_subclass__方法,以便按照正确的顺序触发各类的验证逻辑。
第49条 用__init_subclass__记录现有的子类
类注册是个想当有用的模式,可以用来构建模块式的Python程序。我们可以通过基类的元类把用户从这个基类派生出来的子类自动注册给系统。利用元类实现类注册可以防止由于用户忘记注册而导致程序出现问题。优先考虑__init_subclass__实现自动注册,而不要用标准的元类机制来实现,因为__init_subclass__更清晰,更便于初学者理解。
第50条 用__set_name__给类属性加注解
元类可以当作class语句的挂钩,只要class语句体定义完毕,元类就会看到它的写法并尽快做出应对。描述符与元类搭配起来,可以形成一套强大的机制,让我们既能采用声明式的写法来定义行为,又能在程序运行时检视这个行为的具体执行情况。可以给描述符定义__set_name__方法,让系统把使用这个描述符做属性的那个类以及它在类里的属性名通过方法的参数告诉你。
第51条 优先考虑通过类修饰器来提供可组合的扩充功能,不要使用元类
尽管元类允许我们用各种方式来定制其他类的创建逻辑,但有些情况它未必能够处理得很好。类修饰器其实就是个函数,只不过它可以通过参数获知自己所修饰的类,从而重建或调整这个类并返回修改结果。如果要给类中的每个方法或属性都施加一套逻辑,而且还想尽量少写一些例行代码,那么类修饰器是个很值得考虑的方案。元类之间很难组合,而类修饰器则比较灵活,他们可以施加在同一个类上,并且不会发生冲突。
第7章:并发与并行
第52条 用subprocess管理子进程
Python里有很多方式可以运行子进程,例如os.open函数以及os.exec*系列的函数,其中最好的办法是通过内置的subprocess模块来管理。subprocess模块可以运行子进程并管理它们的输入流与输出流。子进程能够跟Python解释器所在的进程并行,从而充分利用各CPU核心。要开启子进程最简单的办法就是调用run函数,另外也可以通过Popen类实现类似UNIX管道的高级用法。
第53条 可以用线程执行阻塞式I/O,但不要用它做并行计算
即便计算机具备多核的CPU,Python线程也无法真正实现并行,因为它们会受到全局解释器GIL牵制。虽然Python的多线程机制受GIL影响,但还是非常有用的,因为我们很容易就能通过多线程模拟同时执行多项任务的效果。多条Python线程可以并行地执行多个系统调用,这样就能让程序在执行阻塞式的I/O任务时,继续做其他运算。
第54条 利用Lock防止多个线程争用同一份数据
虽然Pyhton有全局解释器锁,但开发者还是得设法避免线程之间发生数据争用。把未经互斥锁保护的数据开放给多个线程同时修改,可能导致这份数据的结构遭到破坏。可以利用threading内置模块之中的Lock类确保程序中的固定关系不会在多线程环境下受到干扰。
第55条 用Queue来协调各线程之间的工作进度
管道非常适合用来安排多阶段的任务,让我们能够把每一阶段都交给各自的线程去执行,这尤其适合用在I/O密集型的程序里面。构造这种并发的管道时,有很多问题需要注意,例如怎样防止线程频繁地查询队列状态,怎样通知线程尽快结束操作,以及怎样防止管道出现拥堵等。我们可以利用Queue类所具有的功能来构造健壮的管道系统,因为这个类提供了阻塞式的入队与出队操作,而且可以限定缓冲区的大小,还能通过task_done与join来确保所有元素都已处理完毕。
第56条 学会判断什么场合必须做并发
程序范围变大、需求变复杂后,经常要用多条路径平行地处理任务。fan-out与fan-in是最常见的两种并发协调模式,前者用来生成一批新的并发单元,后者用来等待现有的并发单元全部完工。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的asyncio语句很容易让我们把采用线程实现的阻塞式I/O操作转化为采用协程实现的异步I/O操作。Python提供了异步版本的for循环、with语句、生成器与推导机制,而且还有很多辅助的库函数,让我们能够顺利地迁移到协程方案。我们很容易就能利用内置的asyncio模块来改写代码,让程序不要再通过线程执行阻塞式的I/O,而是改用协程来执行异步I/O。
第62条 结合线程与协程,将代码顺利迁移到asyncio
asyncio模块的事件循环提供了一个返回awaitable对象的run_in_executor方法,它能够使协程把同步函数放在线程执行器里执行,让我们可以顺利地将采用线程方案所实现的项目,从上至下地迁移到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核心上面处理。通过concurrent.futures模块以及ProcessPoolExecutor类来编写代码可以发挥出multiprocessing模块的优势,且这样做比较简单。只有在其他方案全都无效的情况下,才可以考虑直接使用multiprocessing里面的高级功能。
第8章:稳定与性能
第65条 合理利用try/except/else/finally结构中的每个代码块
try/finally形式:如果想确保无论某段代码是否出现异常,都执行另一段代码,就可以用try/finally结构,常见的例子就是确保文件句柄能够关闭。
try/except/else形式:在某段代码发生特定类型的异常时,把这种异常向上传播,同时又要在代码没有发生异常的情况下,执行另一段代码。如果try块代码没有发生异常,那么else块也会运行。
完整的try/except/else/finally。要某段代码顺利执行后多做一些处理,然后再清理资源,则可以用这四个组合的语句块。
第66条 考虑用contexlib和with语句来改写可复用的try/finally代码
Python里的with语句可以用来强调某段代码需要在特殊情境下执行。与try/finally结构相比,with语句的好处在于写起来方便,也好理解。如果想让其他的对象与函数,也能用在with语句中,那么就可以通过内置的contextlib模块来实现。这个模块提供了contextmanager修饰器,它可以使没有经过特别处理的普通函数也能受到with语句支持。这比标准做法简单,因为一般要受到with语句支持需要定义新类并实现名为__enter__与__exit__的魔术方法。
with还有一种写法是with…as…,它可以把情境管理器所返回的对象赋给as右侧的局部变量,这样的话,with结构的主体部分代码就可以通过这个局部变量与情境管理器所针对的那套情境交互了。很常见的就是用with语句打开文件进行操作了。这个语句位于as右侧的变量是由contextmanager修饰器里面的yield语句返回的值。
第67条 用datetime模块处理本地时间,不要用time模块
协调世界时(UTC)是标准的时间表示方法,但它不太直观,Python有两种方法可以转换时区,一种是老办法,通过内置的time模块来做,但容易出错;另一种是通过内置的datetime模块来做,这种办法可以跟第三方库pytz搭配起来,形成很好的转换效果。time模块的localtime函数可以把UNIX时间戳转换为与计算机的时区相符的本地时间,strftime方法可以格式化时间。time模块没办法稳定的处理多个时区,所以不要用这个模块来编写这方面的代码。而datetime模块则可以在不同时区之间可靠地转换。
第68条 用copyreg实现可靠的pickle操作
Python内置的pickle模块,只适合用来在彼此信任的程序之间传递数据,以实现对象的序列化与反序列化功能。如果对象所在的这个类发生了变化,例如增加或删除了某些属性,则程序在还原旧版数据的时候就可能会出现错误。把内置的copyreg模块与pickle模块搭配使用,可以让新版的程序兼容旧版的序列化数据。
第69条 在需要准确计算的场合,用decimal表示相应的数值
Python中浮点数必须表示成IEEE754格式,所以采用浮点数计算出的结果可能跟实际的结果稍有偏差。这样的计算应该用内置的decimal模块提供的Decimal类来做,它默认支持28位小数,并且这种数值所支持的舍入方式也比浮点数丰富可控。Decimal的初始值可以传入含有数值的字符串来构造,Decimal类提供了quantize函数,可以根据指定的舍入方式把数值调整到某一位。另外如果想表示精度不受限的有理数,可以使用内置的fractions模块里的Fraction类。
第70条 先分析性能,然后再优化
Python的动态机制,让我们很难预判程序在运行时的性能。Python内置的profiler模块,可以找到程序里占总执行时间比例最高的一部分,从而针对的去优化这部分代码。Python内置了两种profiler,一种是由profile模块提供的纯Python版本,还有一种是由cProfile模块提供的C扩展版本,它比纯Python版本好,因为它在执行评测过程中,对受测程序的影响较小,评测结果更准确。把需要接受性能测试的主函数传给Profile对象的runcall方法,就可以专门分析出这个体系下面的所有函数调用情况了。
第71条 优先考虑用deque实现生产者-消费者队列
写程序时常用到先进先出(first-in,first-out,FIFO)队列,也叫生产者-消费者队列。一般会用内置的list类型实现FIFO队列,但是当基数变多后,性能就会下降。Python内置的collections模块的deque类可以解决这个问题,它实现的是双向队列,从头部执行插入或尾部执行删除操作,都只需要固定的时间,所花的时间都只跟队列长度呈线性关系。
第72条 考虑用bisect搜索已排序的序列
有时需要从一份有序的列表中搜索值,这样花的时间是和长度呈正比的,而内置的bisect模块可以更好地搜索有序列表。其中的bisect_left函数,能够快速地对任何一个有序序列执行二分搜索,如果有这个值则返回与这个值相等的头一个元素所在位置;如果没有则返回插入位置,也就是说把这个值插入这个位置可以保持序列有序。并且bisect不局限于list类型,可以用在任何一种行为类似序列的对象上面。
第73条 学会使用heapq制作优先级队列
常用的先进先出的队列是按照接收元素的顺序来保存元素的,有时候,我们想根据元素的重要程度来排序,就可以使用优先级队列。如果直接用相关的列表操作来模拟优先级队列,则程序的性能会随着队列长度增加而大幅下降,复杂程度是平方级别。Python中内置的heapq模块可以高效地实现基于堆的优先级队列,从而高效地处理大量数据。要注意heapq模块规定,添加到优先级队列里的元素必须是可比较的,并且要具备自然顺序,可以使用Python内置的functools模块的totalordering的类修饰器,并定义\_lt__魔术方法。
第74条 考虑用memoryview与bytearray来实现无须拷贝的bytes操作
Python内置的memoryview类型提供了一套无须执行拷贝的操作接口,让我们可以对支持缓冲协议的Python对象制作切片,并通过这种切片高速地完成读取与写入。内置的bytearray类型是一种与bytes相似但内容能够改变的类型,我们可以通过socket.recv_from这样的函数,以无须拷贝的方式读取数据。可以用memoryview来封装bytearray,从而用收到的数据覆盖底层缓冲里面的任意区段,同时又无需执行拷贝操作。
第9章:测试与调试
第75条 通过repr字符串输出调试信息
调试Python程序时,可以通过print函数与格式字符串,或者利用内置的logging模块,相当深入地观察程序的运行情况。对于内置类型以外的类来说,print函数所打印的默认就是实例的repr值,所以无须专门调用repr。给类定义__repr__魔术方法,可以让print函数把该类实例的可打印表现形式展现出来,在实现这个方法时,还可以提供更为详细的调试信息。
第76条 在TestCase子类里验证相关的行为
在Python中编写测试的最经典的办法是使用内置的unittest模块。这个模块中有个TestCase类,我们可以定义它的子类,并在其中编写多个test方法,以便分别验证想要测试的每一种行为。TestCase子类的这些test方法名称都必须以test这个词开头。TestCase类还提供了许多辅助方法,例如可以在test方法中通过assertEqual辅助方法来确认两个值相等,而不采用内置的assert语句。可以用subTest辅助方法做数据驱动测试,这样就不用针对每项子测试重复编写相关的代码与验证逻辑了。
第77条 把测试前、后的准备与清理逻辑写在setUp、tearDown、setUpModule与tearDownModule中,以防用例之间互相干扰。
TestCase子类在执行其中的每个test方法之前,经常需要先把测试环境准备好,这套准备逻辑有时也叫测试装置或测试用具。可以在TestCase子类中覆写setUp与tearDown方法,并把相应的准备逻辑与清理逻辑写在里面。系统在执行每个test方法之前都会先调用一遍setUp方法,并在执行完test方法之后调用一遍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的交互调试界面也是一套完整的Python执行环境,在它里面可以检查正在运行的程序处于什么状态,并予以修改。pdb模块还能够在程序出现错误的时候检查该程序的状态。
第81条 用tracemalloc来掌握内存的使用与泄露情况
不借助相关的工具,可能很难了解Python程序是怎样使用内存的,以及其中有些内存又是如何泄露的。gc模块可以帮助我们了解垃圾回收器追踪到了哪些对象,但它并不能告诉我们那些对象是如何分配的。Python内置的tracemalloc模块提供了一套强大的工具,可以帮助我们更好地了解内存的使用情况,并找到这些内存分别由哪一行代码所分配。
第10章:协作开发
第82条 学会寻找由其他Python开发者所构建的模块
有时面对一项不太熟悉的需求,可以先去PyPI上寻找一下,上面有很多的开源模块,可以使用pip方便快捷的安装软件包。
第83条 用虚拟环境隔离项目,并重建依赖关系
同一个模块在Python的全局环境中只能存在一个版本。如果某个软件包需要使用新版模块,而另一个软件包需要使用旧版模块,那么总有一个会不能正常使用。可以使用venv工具,在每个虚拟环境里分别用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语句正下方,描述本类的行为与重要的属性,还要指出子类应该如何正确地继承这个类。还可以在函数参数中使用类型注解。



