【笔记】Effective Python《编写高质量 Python 代码的 90 个有效方法》——第 1 ~ 30 条读书笔记

(书基于 Python 3.7+ 语法规范)

知识点概括:

1
2
3
4
5
6
7
8
9
Pythonic、PEP 8、Pylint
插值格式字符串(f-string)
enumerate、zip函数
海象操作符(walrus operator)
切片somelist[start:end:stride]
带星号的表达式(starred expression)
Dict setdefault()、defaultdict
闭包、nonlocal、关键字来指定的参数、按位置传递的参数
functools.wraps、修饰器

1.培养 Pythonic 思维

Python 开发界用 Pythonic 这个词形容具有特定风格的代码,是大家在使用 Python 语言及合作的过程中逐渐形成的习惯。

1.1 第 1 条、查询自己使用的 Python 版本

命令行:

Python 2.x:

1
python --version

Python 3:

1
python3 --version

代码:

1
2
3
4
import sys

print(sys.version_info)
print(sys.version)

最后一个稳定的 Python 2 版本是 2.7.17

第 2 条、遵循 PEP 8 风格指南

Python Enhancement Proposal #8 叫作 PEP 8,它是一份针对 Python 代码格式而编订的风格指南。地址:https://www.python.org/dev/peps/pep-0008

采用一致的风格可以使代码更易读、更易懂。

比如以下几条规则:

与空白有关的建议

1
2
3
4
5
6
7
8
用空格(space)表示缩进,而不要用制表符(tab)。
和语法相关的每一层缩进都用4个空格表示。每行不超过79个字符。
对于占据多行的长表达式来说,除了首行之外的其余各行都应该在通常的缩进级别之上再加4个空格。
在同一份文件中,函数与类之间用两个空行隔开。
在同一个类中,方法与方法之间用一个空行隔开。
使用字典时,键与冒号之间不加空格,写在同一行的冒号和值之间应该加一个空格。
给变量赋值时,赋值符号的左边和右边各加一个空格,并且只加一个空格就好。
给变量的类型做注解(annotation)时,不要把变量名和冒号隔开,但在类型信息前应该有一个空格。

与命名有关的建议

1
2
3
4
5
6
7
函数、变量及属性用小写字母来拼写,各单词之间用下划线相连,例如:lowercase_underscore。
受保护的实例属性,用一个下划线开头,例如:_leading_underscore。
私有的实例属性,用两个下划线开头,例如:__double_leading_underscore。
类(包括异常)命名时,每个单词的首字母均大写,例如:CapitalizedWord。
模块级别的常量,所有字母都大写,各单词之间用下划线相连,例如:ALL_CAPS。
类中的实例方法,应该把第一个参数命名为self,用来表示该对象本身。
类方法的第一个参数,应该命名为cls,用来表示这个类本身。

与表达式和语句有关的建议

1
2
3
4
5
采用行内否定,即把否定词直接写在要否定的内容前面,而不要放在整个表达式的前面,例如应该写if a is not b,而不是if not a is b。
不要通过长度判断容器或序列是不是空的,例如不要通过if len(somelist) == 0判断somelist是否为[]或''等空值,而是应该采用if not somelist这样的写法来判断,因为Python会把空值自动评估为False。
如果要判断容器或序列里面有没有内容(比如要判断somelist是否为[1]或'hi'这样非空的值),也不应该通过长度来判断,而是应该采用if somelist语句,因为Python会把非空的值自动判定为True。
不要把if语句、for循环、while循环及except复合语句挤在一行。应该把这些语句分成多行来写,这样更加清晰。如果表达式一行写不下,可以用括号将其括起来,而且要适当地添加换行与缩进以便于阅读。
多行的表达式,应该用括号括起来,而不要用\符号续行。

与引入有关的建议

1
2
3
4
import语句(含from x import y)总是应该放在文件开头。
引入模块时,总是应该使用绝对名称,而不应该根据当前模块路径来使用相对名称。例如,要引入bar包中的foo模块,应该完整地写出from bar import foo,即便当前路径为bar包里,也不应该简写为import foo。
如果一定要用相对名称来编写import语句,那就应该明确地写成:from . import foo。
文件中的import语句应该按顺序划分成三个部分:首先引入标准库里的模块,然后引入第三方模块,最后引入自己的模块。属于同一个部分的import语句按字母顺序排列。

Pylint(https://www.pylint.org/)是一款流行的Python源码静态分析工具。它可以自动检查受测代码是否符合PEP 8 风格指南

第 3 条、了解 bytes 与 str 的区别

Python 有两种类型可以表示字符序列:一种是 bytes,另一种是 str。bytes 实例包含的是原始数据,即 8 位的无符号值(通常按照 ASCII 编码标准来显示)。

str 实例包含的是 Unicode 码点(code point,也叫作代码点),这些码点与人类语言之中的文本字符相对应。

str 实例不一定非要用某一种固定的方案编码成二进制数据,bytes 实例也不一定非要按照某一种固定的方案解码成字符串。要把 Unicode 数据转换成二进制数据,必须调用 str 的 encode 方法。要把二进制数据转换成 Unicode 数据,必须调用 bytes 的 decode 方法。

编写 Python 程序的时候,一定要把解码和编码操作放在界面最外层来做,让程序的核心部分可以使用 Unicode 数据来运作,这种办法通常叫作 Unicode 三明治(Unicode sandwich)。程序的核心部分,应该用 str 类型来表示 Unicode 数据,并且不要锁定到某种字符编码上面。这样可以让程序接受许多种文本编码(例如 Latin-1、Shift JIS 及 Big5),并把它们都转化成 Unicode,也能保证输出的文本信息都是用同一种标准(最好是 UTF-8)编码的。

两种不同的字符类型与 Python 中两种常见的使用情况相对应:

  • 开发者需要操作原始的 8 位值序列,序列里面的这些 8 位值合起来表示一个应该按 UTF-8 或其他标准编码的字符串。开发者需要操作通用的 Unicode 字符串,而不是操作某种特定编码的字符串。

我们通常需要编写两个辅助函数(helper function),以便在这两种情况之间转换,确保输入值类型符合开发者的预期形式。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
# 辅助函数接受bytes或str实例,并返回str:

def to_str(bytes_or_str):
if isinstance(bytes_or_str, bytes):
value = bytes_or_str.decode('utf-8')
else:
value = bytes_or_str
return value

# test
print(repr(to_str(b'foo'))); # 'foo'
print(repr(to_str('bar'))); # 'bar'

# 也接受bytes或str实例,但它返回的是bytes:
def to_bytes(bytes_or_str):
if isinstance(bytes_or_str, str):
value = bytes_or_str.encode('utf-8')
else:
value = bytes_or_str
return value

# test
print(repr(to_bytes(b'foo')));
print(repr(to_bytes('bar')))

可以用+操作符将 bytes 添加到 bytes,str 也可以这样。

1
2
print(b'one' + b'two');	# b'onetwo'
print('one' + 'two'); # 'onetwo'

但是不能将 str 实例添加到 bytes 实例,但是不能将 str 实例添加到 bytes 实例:

1
2
b'one' + 'two'  # TypeError
'one' + b'two' # TypeError

判断 bytes 与 str 实例是否相等,总是会评估为假(False),即便这两个实例表示的字符完全相同,它们也不相等。

1
b'foo' == 'foo'  # false

如果格式字符串是 bytes 类型,那么不能用 str 实例来替换其中的%s,因为 Python 不知道这个 str 应该按照什么方案来编码。但反过来却可以,也就是说如果格式字符串是 str 类型,则可以用 bytes 实例来替换其中的%s,问题是,这可能跟你想要的结果不一样。

1
print('red %s' % b'blue')  # red b'blue'

这样做,会让系统在 bytes 实例上面调用__repr__方法,然后用这次调用所得到的结果替换格式字符串里的%s,因此程序会直接输出b'blue',而不是像你想的那样,输出 blue 本身。

第二个问题发生在操作文件句柄的时候,这里的句柄指由内置的 open 函数返回的句柄。这样的句柄默认需要使用 Unicode 字符串操作,而不能采用原始的 bytes。

从文件中读取二进制数据(或者把二进制数据写入文件)时,应该用'rb''wb')这样的二进制模式打开文件。如果要从文件中读取(或者要写入文件之中)的是 Unicode 数据,那么必须注意系统默认的文本编码方案。若无法肯定,可通过 encoding 参数明确指定。

第 4 条、用支持插值的 f-string 取代 C 风格的格式字符串与 str.format 方法

格式化(formatting)是指把数据填写到预先定义的文本模板里面,形成一条用户可读的消息,并把这条消息保存成字符串的过程。

Python 里面最常用的字符串格式化方式是采用%格式化操作符。这个操作符左边的文本模板叫作格式字符串(format string),我们可以在操作符右边写上某个值或者由多个值所构成的元组(tuple),用来替换格式字符串里的相关符号。

1
2
3
4
# 通过%操作符把难以阅读的二进制和十六进制数值,显示成十进制的形式。
a = 0b10111011
b = 0xc5f
print('Binary is %d, hex is %d' % (a, b)) # Binary is 187, hex is 3167

格式字符串里面可以出现%d 这样的格式说明符,这些说明符的意思是,%右边的对应数值会以这样的格式来替换这一部分内容。

C 风格的格式字符串,在 Python 里有四个缺点。

  • 如果%右侧那个元组里面的值在类型或顺序上有变化,那么程序可能会因为转换类型时发生不兼容问题而出现错误。要想避免这种问题,必须经常检查%操作符左右两侧的写法是否相互兼容。这个过程很容易出错,因为每次修改完之后都要手工检查一遍。
  • 在填充模板之前,经常要先对准备填写进去的这个值稍微做一些处理,但这样一来,整个表达式可能就会写得很长,让人觉得比较混乱。
  • 如果想用同一个值来填充格式字符串里的多个位置,那么必须在%操作符右侧的元组中相应地多次重复该值。
  • 把 dict 写到格式化表达式里面会让代码变多。

为了解决上面提到的一些问题,Python 的%操作符允许我们用 dict 取代 tuple

例如%(key)s这个说明符,意思就是用字符串(s)来表示 dict 里面名为 key 的那个键所保存的值。但是,这种写法会让刚才讲的第二个缺点变得更加严重,因为字典格式字符串的引入,我们必须给每一个值都定义键名,而且要在键名的右侧加冒号,格式化表达式变得更加冗长,看起来也更加混乱。

内置的 format 函数与 str 类的 format 方法:

Python 3 添加了高级字符串格式化(advanced string formatting)机制,它的表达能力比老式 C 风格的格式字符串要强,且不再使用%操作符。

1
2
3
4
5
6
7
a = 1234.5678
formatted = format(a, ',.2f')
print(formatted) # 1,234.57

b = 'my string'
formatted = format(b, '^20s')
print('*', formatted, '*') # * my string *

但是str.format方法还是没有能够把 Python 表达式的优势充分发挥出来。

插值格式字符串:

插值格式字符串(interpolated format string,简称 f-string),可以解决上面提到的所有问题。新语法特性要求在格式字符串的前面加字母f作为前缀,这跟字母b与字母r的用法类似,也就是分别表示字节形式的字符串与原始的(或者说未经转义的)字符串的前缀。

1
2
3
4
5
key = 'my_var'
value = 1.234

formatted = f'{key} = {value}'
print(formatted) # my_var = 1.234

str.format方法所支持的那套迷你语言,也就是在{}内的冒号右侧所采用的那套规则,现在也可以用到 f-string 里面,而且还可以像早前使用str.format时那样,通过!符号把值转化成 Unicode 及 repr 形式的字符串。

1
2
formatted = f'{key!r:<10} = {value:.2f}'
print(formatted) # 'my_var' = 1.23

如果你想把值以适当的格式填充到字符串里面,那么首先应该考虑的就是采用 f-string 来实现。

第 5 条、用辅助函数取代复杂的表达式

如果你发现表达式越写越复杂,那就应该考虑把它拆分成多个部分,并且把这套逻辑写到辅助函数里面。这样虽然要多编几行代码,但可以让程序更加清晰,所以总体来说还是值得的。

语法简洁的 Python 虽然可以写出很多浓缩的句式,但应该避免让这样的写法把表达式弄得太复杂。我们要遵循 DRY 原则,也就是不要重复自己写过的代码(Don’t Repeat Yourself)。

第 6 条、把数据结构直接拆分到多个变量里,不要专门通过下标访问

Python 还有一种写法,叫作拆分(unpacking)。这种写法让我们只用一条语句,就可以把元组里面的元素分别赋给多个变量。如:

1
2
item = ('A', 'B')
first, second = item

通过 unpacking 来赋值要比通过下标去访问元组内的元素更清晰,而且这种写法所需的代码量通常比较少,这样可以让代码更简洁、更清晰。

第 7 条、尽量用 enumerate 取代 range

range 函数适合用来迭代一系列整数。

enumerate 能够把任何一种迭代器(iterator)封装成惰性生成器。这样的话,每次循环的时候,它只需要从 iterator 里面获取下一个值就行了,同时还会给出本轮循环的序号,即生成器每次产生的一对输出值。

1
2
3
4
flavor_list = ['vanilla', 'chocolate', 'pecan']
it = enumerate(flavor_list)
print(next(it)) # (0, 'vanilla')
print(next(it)) # (1, 'chocolate')

enumerate 输出的每一对数据,都可以拆分(unpacking)到 for 语句的那两个变量里面,这样会让代码更加清晰。

1
2
for i, flavor in enumerate(flavor_list):
print(f'{i + 1}: {flavor}')

另外,还可以通过 enumerate 的第二个参数指定起始序号,这样就不用在每次打印的时候去调整了。

1
2
for i, flavor in enumerate(flavor_list, 1):
print(f'{i}: {flavor}')

第 8 条、用 zip 函数同时遍历两个迭代器

写 Python 代码时,经常会根据某份列表中的对象创建许多与这份列表有关的新列表。下面这样的列表推导机制,可以把表达式运用到源列表的每个元素上面,从而生成一份派生列表

1
2
3
names = ['Cecilla', 'List', 'Marie']
counts = [len(n) for n in names]
print(counts) # [7, 4, 5]

派生列表中的元素与源列表中对应位置上面的元素有着一定的关系。如果想同时遍历这两份列表,那可以根据源列表的长度做迭代。

1
2
3
4
5
6
7
8
9
10
longest_name = None
max_count = 0

for i in range(len(names)):
count = counts[i]
if count > max_count:
longest_name = names[i]
max_count = count

print(longest_name) // Cecilia

这种写法的问题在于,整个循环代码看起来很乱。我们要通过下标访问 names 与 counts 这两个列表里的元素,所以表示下标的那个循环变量 i 在循环体里必须出现两次,这让代码变得不太好懂。改用 enumerate 实现会稍微好一些,但仍然不够理想。

1
2
3
4
5
for i, name in enumerate(names):
count = counts[i]
if count > max_count:
longest_name = name
max_count = count

为了把代码写得更清楚,可以用 Python 内置的 zip 函数来实现。这个函数能把两个或更多的 iterator 封装成惰性生成器(lazy generator)。每次循环时,它会分别从这些迭代器里获取各自的下一个元素,并把这些值放在一个元组里面。而这个元组可以拆分到 for 语句里的那些变量之中。这样写出来的代码,比通过下标访问多个列表的那种代码要清晰得多。

1
2
3
4
for name, count in zip(names, counts):
if count > max_count:
longest_name = name
max_count = count

zip 每次只从它封装的那些迭代器里面各自取出一个元素,所以即便源列表很长,程序也不会因为占用内存过多而崩溃。

但是,如果输入 zip 的那些列表的长度不一致,那就得小心了。因为 zip 函数本来就是这样设计的:只要其中任何一个迭代器处理完毕,它就不再往下走了。

在列表长度不同的情况下,zip 函数的提前终止行为可能跟你想实现的效果不一样。所以,如果无法确定这些列表的长度相同,那就不要把它们传给 zip,而是应该传给另一个叫作 zip_longest 的函数,这个函数位于内置的 itertools 模块里。

1
2
3
4
import itertools

for name, count in itertools.zip_longest(names, counts):
print(f'{name}: {count}')

如果其中有些列表已经遍历完了,那么 zip_longest 会用当初传给 fillvalue 参数的那个值来填补空缺(本例中空缺的为字符串’Rosalind’的长度值),默认的参数值是None

第 9 条、不要在 for 与 while 循环后面写 else 块

Python 的循环有一项大多数编程语言都不支持的特性,即可以把 else 块紧跟在整个循环结构的后面。奇怪的是,程序做完整个 for 循环之后,竟然会执行 else 块里的内容。既然是这样,那为什么要叫“else”呢?这应该叫“and”才对。

如果循环没有从头到尾执行完(也就是循环提前终止了),那么 else 块里的代码是不会执行的。在循环中使用 break 语句实际上会跳过 else 块。

1
2
3
4
5
6
for i in range(3):
print('Loop', i)
if i === 1:
break
else:
print('Else block') # 这条语句不会执行

还有一个奇怪的地方是,如果对空白序列做 for 循环,那么程序立刻就会执行 else 块。

1
2
3
4
for x in []:
print('Never runs')
else:
print('For else block'); # 会执行

while 循环也是这样,如果首次循环就遇到False,那么程序也会立刻运行 else 块。

1
2
3
4
while False:
print('Never runs')
else:
print('while Else block'); # 会执行

把 else 设计成这样,是想让你利用它实现搜索逻辑。例如,如果要判断两个数是否互质(也就是除了 1 之外,是不是没有别的数能够同时整除它们),就可以用这种结构实现。先把有可能同时整除它们的数逐个试一遍,如果全都试过之后还是没找到这样的数,那么循环就会从头到尾执行完(这意味着循环没有因为 break 而提前跳出),然后程序就会执行 else 块里的代码。

1
2
3
4
5
6
7
8
9
10
a = 4
b = 9

for i in range(2, min(a, b) + 1):
print('Testing', i)
if a % i == 0 and b % i == 0:
print('Not coprime')
break
else:
print('Coprime')

for/else 或 while/else 结构本身虽然可以实现某些逻辑表达,但它给读者(也包括你自己)带来的困惑,已经盖过了它的好处。因为 for 与 while 循环这种简单的结构,在 Python 里面读起来应该相当明了才对,如果把 else 块紧跟在它的后面,那就会让代码产生歧义。所以,请不要这么写。

第 10 条、用赋值表达式减少重复代码

赋值表达式(assignment expression)是 Python 3.8 新引入的语法,它会用到海象操作符(walrus operator)。这种写法可以解决某些持续已久的代码重复问题。a = b 是一条普通的赋值语句,读作 a equals b,而 a := b 则是赋值表达式,读作 a walrus b。这个符号为什么叫 walrus 呢?因为把:=顺时针旋转 90º 之后,冒号就是海象的一双眼睛,等号就是它的一对獠牙。

这种表达式很有用,可以在普通的赋值语句无法应用的场合实现赋值,例如可以用在条件表达式的 if 语句里面。赋值表达式的值,就是赋给海象操作符左侧那个标识符的值。

举个例子。如果有一筐新鲜水果要给果汁店做食材,那我们就可以这样定义其中的内容:

1
2
3
4
5
fresh_fruit = {
'apple': 10,
'banana': 8,
'lemon': 5
}

顾客点柠檬汁之前,我们先得确认现在还有没有柠檬可以榨汁。所以,要先查出柠檬的数量,然后用 if 语句判断它是不是非零的值。

1
2
3
4
5
6
7
8
9
def make_lemonade(count):
...
def out_of_stock():
...
count = fresh_fruit.get('lemon', 0)
if count:
make_lemonade(count)
else:
out_of_stock()

这段代码看上去虽然简单,但还是显得有点儿松散,因为 count 变量虽然定义在整个 if/else 结构之上,然而只有 if 语句才会用到它,else 块根本就不需要使用这个变量。所以,这种写法让人误以为 count 是个重要的变量,if 和 else 都要用到它,但实际上并非如此。

我们在 Python 里面经常要先获取某个值,然后判断它是否非零,如果是就执行某段代码。对于这种用法,我们以前总是要通过各种技巧,来避免 count 这样的变量重复出现在代码之中,这些技巧有时会让代码变得比较难懂(参考第 5 条里面提到的那些技巧)。Python 引入赋值表达式正是为了解决这样的问题。下面改用海象操作符来写:

1
2
3
4
if count := fresh_fruit.get('lemon', 0):
make_lemonade(count)
else:
out_of_stock()

这种写法明确体现出 count 变量只与 if 块有关。这个赋值表达式先把:=右边的值赋给左边的 count 变量,然后对自身求值,也就是把变量的值当成整个表达式的值。由于表达式紧跟着 if,程序会根据它的值是否非零来决定该不该执行 if 块。这种先赋值再判断的做法,正是海象操作符想要表达的意思。

柠檬汁效力强,所以只需要一颗柠檬就能做完这份订单,这意味着程序只需判断非零即可。如果客人点的是苹果汁,那就至少得用四个苹果才行。

1
2
3
4
if (count := fresh_fruit.get('apple', 0)) >= 4:
make_cider(count)
else:
out_of_stock()

但是这次,我们还要注意另外一个现象:赋值表达式本身是放在一对括号里面的。为什么要这样做呢?因为我们要在 if 语句里面把这个表达式的结果跟 4 这个值相比较。刚才柠檬汁的例子没有加括号,因为那时只凭赋值表达式本身的值就能决定 if/else 的走向:只要表达式的值不是 0,程序就进入 if 分支。但是这次不行,这次要把这个赋值表达式放在更大的表达式里面,所以必须用括号把它括起来。当然,在没有必要加括号的情况下,还是尽量别加括号比较好。

Python 新手经常会遇到这样一种困难,就是找不到好办法来实现 switch/case 结构。最接近这种结构的做法是在 if/else 结构里面继续嵌套 if/else 结构,或者使用 if/elif/else 结构。

例如,我们想按照一定的顺序自动给客人制作饮品,这样就不用点餐了。下面这段逻辑先判断能不能做香蕉冰沙,如果不能,就做苹果汁,还不行,就做柠檬汁:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
count = fresh_fruit.get('banana', 0)
if count > 2:
pieces = slice_bananas(count)
to_enjoy = make_smoothies(pieces)
else:
count = fresh_fruit.get('apple', 0)
if count > 4:
to_enjoy = make_cider(count)
else:
count = fresh_fruit.get('lemon', 0)
if count:
to_enjoy = make_lemonade(count)
else:
to_enjoy = 'Nothing'

幸好现在有了海象操作符,让我们能够轻松地模拟出很接近 switch/case 的方案。

1
2
3
4
5
6
7
8
9
if (count := fresh_fruit.get('banana', 0)) >= 2:
pieces = slice_bananas(count)
to_enjoy = make_smoothies(pieces)
elif (count := fresh_fruit.get('apple', 0)) >= 4:
to_enjoy = make_cider(count)
elif count := fresh_fruit.get('lemon', 0):
to_enjoy = make_lemonade(count)
else:
to_enjoy = 'Nothing'

只要碰到刚才那种难看的结构,我们就应该考虑能不能改用海象操作符来写。

Python 新手还会遇到一个困难,就是缺少 do/while 循环结构。例如,我们要把新来的水果做成果汁并且装到瓶子里面,直到水果用完为止。下面先用普通的 while 循环来实现:

1
2
3
4
5
6
7
8
9
10
11
12
def pick_fruit():
...
def make_juice(fruit, count):
...

bottles = []
fresh_fruit = pick_fruit()
while fresh_fruit:
for fruit, count in fresh_fruit.items()
batch = make_juice(fruit, count)
botties.extend(batch)
fresh_fruit = pick_fruit()

有了海象操作符,我们可以在每轮循环的开头给 fresh_fruit 变量赋值,并根据变量的值来决定要不要继续循环。这个写法简单易读,所以应该成为首选方案。

1
2
3
4
5
bottles = []
while fresh_fruit := pick_fruit():
for fruit, count in fresh_fruit.items():
batch = make_juice(fruit, count)
bottles.extend(batch)

总之,如果某个表达式或赋值操作多次出现在一组代码里面,那就可以考虑用赋值表达式把这段代码改得简单一些。


2. 列表与字典

第 11 条、学会对序列做切片

Python 有这样一种写法,可以从序列里面切割(slice)出一部分内容,让我们能够轻松地获取原序列的某个子集合。最简单的用法就是切割内置的 list、str 与 bytes。其实,凡是实现了__getitem____setitem__这两个特殊方法的类都可以切割。

最基本的写法是用somelist[start:end]这一形式来切割,也就是从 start 开始一直取到 end 这个位置,但不包含 end 本身的元素。

1
2
3
a = ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h']
print('Middle two: ', a[3:5]) # Middle two: ['d', 'e']
print('All but ends:', a[1:7]) # All but ends: ['b', 'c', 'd', 'e', 'f', 'g']

如果是从头开始切割列表,那就应该省略冒号左侧的下标 0,这样看起来更清晰。

1
assert a[:5] == a[0:5]

如果一直取到列表末尾,那就应该省略冒号右侧的下标,因为用不着专门把它写出来。

1
assert a[5:] == a[5:len(a)]

用负数作下标表示从列表末尾往前算。

如果起点与终点所确定的范围超出了列表的边界,那么系统会自动忽略不存在的元素。利用这项特性,很容易就能构造出一个最多只有若干元素的输入序列。

用带负号的下标来切割列表,只有在个别情况下才会出现奇怪的效果。只要 n 大于或等于 1,somelist[-n:]总是可以切割出你想要的切片。只有当 n 为 0 的时候,才需要特别注意。此时somelist[-0:]其实相当于somelist[0:],所以跟somelist[:]一样,会制作出原列表的一份副本。

第 12 条、不要在切片里同时指定起止下标与步进

Python 还有一种特殊的步进切片形式,也就是somelist[start:end:stride]。这种形式会在每 n 个元素里面选取一个,这样很容易就能把奇数位置上的元素与偶数位置上的元素分别通过x[::2]x[1::2]选取出来。

1
2
3
x = ['red', 'orange', 'yellow', 'green', 'blue', 'purple']
odds = x[::2] # ['red', 'yellow', 'blue']
evens = x[1::2] # ['orange', 'green', 'purple']

带有步进的切片经常会引发意外的效果,并且使程序出现 bug。例如,Python 里面有个常见的技巧,就是把-1 当成步进值对 bytes 类型的字符串做切片,这样就能将字符串反转过来。

1
2
x = b'mongooose'
y = x[::-1] # b'esoognom'

Unicode 形式字符串也可以这样反转,但如果把这种字符串编码成 UTF-8 标准的字节数据,就不能用这个技巧来反转了。

1
2
3
4
w = '寿司'
x = w.encode('utf-8')
y = x[::-1] # UnicodeDecodeError
z = y.decode('utf-8')

同时使用起止下标与步进会让切片很难懂。方括号里面写三个值显得太过拥挤,读起来不大容易,而且在指定了步进值(尤其是负数步进值)的时候,我们必须很仔细地考虑:这究竟是从前往后取,还是从后往前取。

建议大家不要把起止下标和步进值同时写在切片里。如果必须指定步进,那么尽量采用正数,而且要把起止下标都留空。即便必须同时使用步进值与起止下标,也应该考虑分成两次来写。

1
2
y = x[::2]		# ['a', 'c', 'e', 'g']
z = y[1:-1] # ['c', 'e']

像刚才那样先隔位选取然后再切割,会让程序多做一次浅拷贝(shallow copy)。所以,应该把最能缩减列表长度的那个切片操作放在前面。如果程序实在没有那么多时间或内存去分两步操作,那么可以改用内置的itertools模块中的islice方法,这个方法用起来更清晰,因为它的起止位置与步进值都不能是负数。

第 13 条、通过带星号的 unpacking 操作来捕获多个元素,不要用切片

基本的 unpacking 操作有一项限制,就是必须提前确定需要拆解的序列的长度。

Python 新手经常通过下标与切片来处理这个问题。如:

1
2
3
4
5
6
car_ages = [0, 9, 4, 8, 7, 20, 19, 1, 6, 15]
car_ages_descending = sorted(car_ages, reverse=True)

oldest = car_ages_descending[0]
second_oldest = car_ages_descending[1]
other = car_age_descending[2:]

这样做没问题,但是下标与切片会让代码看起来很乱。而且,用这种办法把序列中的元素分成多个子集合,其实很容易出错,因为我们通常容易把下标多写或少写一个位置。

这个问题通过带星号的表达式(starred expression)来解决会更好一些,这也是一种 unpacking 操作,它可以把无法由普通变量接收的那些元素全都囊括进去。

1
oldest, second_oldest, *others = car_ages_descending

这样写简短易读,而且不容易出错,因为它不要求我们在修改完其中一个下标之后,还必须记得同步更新其他的下标。这种带星号的表达式可以出现在任意位置,所以它能够捕获序列中的任何一段元素。

1
oldest, *others, youngest = car_ages_descending

只不过,在使用这种写法时,至少要有一个普通的接收变量与它搭配,否则就会出现SyntaxError

另外,对于单层结构来说,同一级里面最多只能出现一次带星号的 unpacking。

这种带星号的表达式可以出现在赋值符号左侧的任意位置,它总是会形成一份含有零个或多个值的列表。在把列表拆解成互相不重叠的多个部分时,这种带星号的 unpacking 方式比较清晰,而通过下标与切片来实现的方式则很容易出错。

第 14 条、用 sort 方法的 key 参数来表示复杂的排序逻辑

内置的列表类型提供了名叫 sort 的方法,可以根据多项指标给 list 实例中的元素排序。

但是,一般的对象又该如何排序呢?

1
2
3
4
5
6
7
8
9
10
11
12
13
class Tool:
def __init__(self, name, weight):
self.name = name;
self.weight = weight

def __repr__(self):
return f'Tool({self.name!r}, {self.weight})'
tools = [
Tool('level', 3.5),
Tool('hammer', 1.25),
Tool('screwdriver', 0.5),
Tool('chisel', 0.25)
]

如果仅仅这样写,那么这个由该类的对象所构成的列表是没办法用 sort 方法排序的,因为 sort 方法发现,排序所需要的特殊方法并没有定义在 Tool 类中。

这些排序标准通常是针对对象中的某个属性(attribute)。我们可以把这样的排序逻辑定义成函数,然后将这个函数传给 sort 方法的 key 参数。key 所表示的函数本身应该带有一个参数,这个参数指代列表中有待排序的对象,函数返回的应该是个可比较的值(也就是具备自然顺序的值),以便 sort 方法以该值为标准给这些对象排序。下面用 lambda 关键字定义这样的一个函数,把它传给 sort 方法的 key 参数,让我们能够按照 name 的字母顺序排列这些 Tool 对象。

1
tools.sort(key=lambda x: x.name)

如果想改用另一项标准(比如 weight)来排序,那只需要再定义一个 lambda 函数并将其传给 sort 方法的 key 参数就可以了。

1
tools.sort(key=lambda x: x.weight)

对于字符串这样的基本类型,我们可能需要通过 key 函数先对它的内容做一些变换,并根据变换之后的结果来排序。

1
2
places = ['home', 'work', 'New York', 'Paris']
places.sort(key=lambda x: x.lower())

有时我们可能需要用多个标准来排序,最简单的方案是利用元组(tuple)类型实现。两个元组之间是可以比较的,因为这种类型本身已经定义了自然顺序。

元组在实现这些特殊方法时会依次比较每个位置的那两个对应元素,直到能够确定大小为止。

1
2
3
4
drill = (4, 'drill')
sander = (4, 'sander')

assert drill < sander

利用元组的这项特性,我们可以用工具的 weight 与 name 构造一个元组。下面就定义这样一个 lambda 函数,让它返回这种元组,把首要指标(也就是 weight)写在前面,把次要指标(也就是 name)写在后面。

1
power_tools.sort(key=lambda x: (x.weight, x.name))

这种做法有个缺点,就是 key 函数所构造的这个元组只能按同一个排序方向来对比它所表示的各项指标(要是升序,就都得是升序;要是降序,就都得是降序),所以不太好实现 weight 按降序排而 name 按升序排的效果。

sort 方法可以指定 reverse 参数,这个参数会同时影响元组中的各项指标(例如在下面的例子中,weight 与 name 都会按照降序处理,所以’sander’会出现在’drill’的前面,而不是像刚才的例子那样出现在后面)。

1
2
power_tools.sort(key=lambda x:(x.weight, x.name),
reverse=True)

如果其中一项指标是数字,那么可以在实现 key 函数时,利用一元减操作符让两个指标按照不同的方向排序。也就是说,key 函数在返回这个元组时,可以单独对这项指标取相反数,并保持其他指标不变,这就相当于让排序算法单独在这项指标上采用逆序。下面就演示怎样按照重量从大到小、名称从小到大的顺序排列

1
power_tools.sort(key=lambda x: (-x.weight, x.name))

我们应该考虑 sort 方法的一项特征,那就是这个方法是个稳定的排序算法。这意味着,如果 key 函数认定两个值相等,那么这两个值在排序结果中的先后顺序会与它们在排序前的顺序一致。于是,我们可以在同一个列表上多次调用 sort 方法,每次指定不同的排序指标。下面我们就利用这项特征实现刚才想要达成的那种效果。把首要指标(也就是重量)降序放在第二轮,把次要指标(也就是名称)升序放在第一轮。

1
2
3
power_tools.sort(key=lambda x: x.name)
power_tools.sort(key=lambda x: x.weight,
reverse = True)

在实现多个指标按不同方向排序时,应该优先考虑让 key 函数返回元组,并对元组中的相应指标取相反数。只有在万不得已的时候,才可以考虑多次调用 sort 方法。

第 15 条、不要过分依赖给字典添加条目时所用的顺序

在 Python 3.5 与之前的版本中,迭代字典(dict)时所看到的顺序好像是任意的,不一定与当初把这些键值对添加到字典时的顺序相同。

1
2
3
4
5
baby_names = {
'cat': 'kitten',
'dog': 'puppy',
}
print(baby_names) # {'dog': 'puppy', 'cat': 'kitten'}

每次看到的顺序不固定,因此很难在测试用例中使用。这会让调试工作变得困难,尤其是容易使 Python 新手感到困惑。

之所以出现这种效果,是因为字典类型以前是用哈希表算法来实现的(这个算法通过内置的 hash 函数与一个随机的种子数来运行,而该种子数会在每次启动 Python 解释器时确定)。所以,这样的机制导致这些键值对在字典中的存放顺序不一定会与添加时的顺序相同,而且每次运行程序的时候,存放顺序可能都不一样。

从 Python 3.6 开始,字典会保留这些键值对在添加时所用的顺序,而且 Python 3.7 版的语言规范正式确立了这条规则。

其实,内置的 collections 模块早就提供了这种能够保留插入顺序的字典,叫作 OrderedDict。它的行为跟(Python 3.7 以来的)标准 dict 类型很像,但性能上有很大区别。如果要频繁插入或弹出键值对(例如要实现 least-recently-used 缓存),那么 OrderedDict 可能比标准的 Python dict 类型更合适

但处理字典的时候,不能总是假设所有的字典都能保留键值对插入时的顺序。

如果不想把这种跟标准字典很相似的类型也当成标准字典来处理,那么可以考虑这样三种办法。第一,不要依赖插入时的顺序编写代码;第二,在程序运行时明确判断它是不是标准的字典;第三,给代码添加类型注解并做静态分析。

第 16 条、用 get 处理键不在字典中的情况,不要使用 in 与 KeyError

字典有三种基本的交互操作:访问、赋值以及删除键值对。

Python 内置的字典(dict)类型提供了 get 方法,可以通过第一个参数指定自己想查的键,并通过第二个参数指定这个键不存在时应返回的默认值。

1
count = counters.get(key, 0)

dict 类型提供了setdefault方法,能够继续简化代码。这个方法会查询字典里有没有这个键,如果有,就返回对应的值;如果没有,就先把用户提供的默认值跟这个键关联起来并插入字典,然后返回这个值。

1
names = votes.setdefault(key, [])

这样写是正确的,而且要比采用赋值表达式的 get 方案少一行。但这种写法不太好懂,因为该方法的名字 setdefault(设置默认值)没办法让人立即明白它的作用。如果字典里本身就有这个键,那么这个方法要做的,其实仅仅是返回相关的值而已,这时它并不会 set(设置)什么数据。

还有个关键的地方要注意:在字典里面没有这个键时,setdefault 方法会把默认值直接放到字典里,而不是先给它做副本,然后把副本放到字典中。这有可能产生比较大的性能开销。

只有在少数几种情况下用 setdefault 处理缺失的键才是最简短的方式,例如这种情况:与键相关联的默认值构造起来开销很低且可以变化,而且不用担心异常问题。

即使看上去最应该使用 setdefault 方案,也不一定要真的使用 setdefault 方案,而是可以考虑用 defaultdict 取代普通的 dict。

第 17 条、用 defaultdict 处理内部状态中缺失的元素,而不要用 setdefault

1
2
3
4
visits = {
'Mexico': {'Tulum', 'Puerto Vallarta'},
'Japan': {'Hakone'},
}

Python 内置的collections模块提供了defaultdict类,它会在键缺失的情况下,自动添加这个键以及键所对应的默认值。我们只需要在构造这种字典时提供一个函数就行了,每次发现键不存在时,该字典都会调用这个函数返回一份新的默认值,如:

1
2
3
4
5
6
7
8
9
10
11
12
from collections import defaultdict

class Visits:
def __init__(self):
self.data = defaultdict(set)

def add(self, country, city):
self.data[country].add(city)

visits = Visits();
visits.add('England', 'Bath')
visits.add('England', 'London')

这次的 add 方法相当简短。因为我们可以确定,访问这种字典的任意键时,总能得到一个已经存在的 set 实例。

第 18 条、学会利用missing构造依赖键的默认值

有一些任务是 setdefault 和 defaultdict 都处理不好的。

例如,我们要写一个程序,在文件系统里管理社交网络账号中的图片。这个程序应该用字典把这些图片的路径名跟相关的文件句柄(file handle)关联起来,这样我们就能方便地读取并写入图像了。

Python 内置了一种解决方案,可以通过继承 dict 类型并实现__missing__特殊方法来解决这个问题。我们可以把字典里不存在这个键时所要执行的逻辑写在这个方法中。

1
2
3
4
5
6
7
8
9
10
class Pictures(dict):
def __missing__(self, key):
value = open_picture(key)
self[key] = value
return value

pictures = Pictures()
handle = pictures[path]
handle.seek(0)
image_data = handle.read()

访问pictures[path]时,如果 pictures 字典里没有 path 这个键,那就会调用__missing__方法。这个方法必须根据 key 参数创建一份新的默认值,系统会把这个默认值插入字典并返回给调用方。以后再访问pictures[path],就不会调用__missing__了,因为字典里已经有了对应的键与值。


3.函数

第 19 条、不要把函数返回的多个数值拆分到三个以上的变量中

在返回多个值的时候,可以用带星号的表达式接收那些没有被普通变量捕获到的值。

我们不应该把函数返回的多个值拆分到三个以上的变量里。一个三元组最多只拆成三个普通变量,或两个普通变量与一个万能变量(带星号的变量)。当然用于接收的变量个数也可以比这更少。假如要拆分的值确实很多,那最好还是定义一个轻便的类或 namedtuple,并让函数返回这样的实例。

第 20 条、遇到意外状况时应该抛出异常,不要返回 None

函数的返回值有时可能会用在 if 条件语句里面,那时可能会根据值本身是否相当于 False 来做判断。

1
2
3
4
5
def careful_divide(a, b):
try:
return a / b
except ZeroDivisionError:
return None

用 None 来表示特殊状况的函数是很容易出错的。有两种办法可以减少这样的错误。第一种办法是,利用二元组把计算结果分成两部分返回。元组的首个元素表示操作是否成功,第二个元素表示计算的实际值。

1
2
3
4
5
def carefult_divide(a, b):
try:
return True, a / b
except ZeroDivisionError:
return False, None

这样写,会促使调用函数者去拆分返回值,他可以先看看这次运算是否成功,然后再决定怎么处理运算结果。但问题是,有些调用方总喜欢忽略返回元组的第一个部分。

第二种办法比刚才那种更好,那就是不采用None表示特例,而是向调用方抛出异常(Exception),让他自己去处理。

1
2
3
4
5
def careful_divide(a, b):
try:
return a / b
except ZeroDivisionError:
raise ValueError('Invalid inputs')

这个办法也可以扩展到那些使用类型注解的代码中,我们可以把函数的返回值指定为float类型,这样它就不可能返回None了。

然而,Python 采用的是动态类型与静态类型相搭配的 gradual 类型系统,我们不能在函数的接口上指定函数可能抛出哪些异常(有的编程语言支持这样的受检异常(checked exception),调用方必须应对这些异常)。所以,我们只好把有可能抛出的异常写在文档里面,并希望调用方能够根据这份文档适当地捕获相关的异常,

1
2
3
4
5
6
7
8
9
10
def careful_divide(a: float, b: float) -> float:
"""Divides a by b.

Raises:
ValueError: When the inputs cannot be divided.
"""
try:
return a / b
except ZeroDivisionError:
raise ValueError('Invalid inputs')

这样写,输入、输出与异常都显得很清晰,所以调用方出错的概率就变得很小了。

第 21 条、了解如何在闭包里面使用外围作用域中的变量

有时,我们要给列表中的元素排序,而且要优先把某个群组之中的元素放在其他元素的前面。例如,渲染用户界面时,可能就需要这样做,因为关键的消息和特殊的事件应该优先显示在其他信息之前。

实现这种做法的一种常见方案,是把辅助函数通过 key 参数传给列表的 sort 方法,让这个方法根据辅助函数所返回的值来决定元素在列表中的先后顺序。辅助函数先判断当前元素是否处在重要群组里,如果在,就把返回值的第一项写成 0,让它能够排在不属于这个组的那些元素之前。

1
2
3
4
5
6
7
8
9
10
def sort_priority(values, group):
def helper(x):
if x in group:
return (0, x)
return (1, x)
values.sort(key=helper)

numbers = [8, 4, 2, 5, 3]
group = {2, 3, 5, 7}
sort_priority(numbers, group)

它为什么能够实现这个功能呢?

Python 支持闭包(closure),这让定义在大函数里面的小函数也能引用大函数之中的变量。下面有个错误的方法:

1
2
3
4
5
6
7
8
9
def sort_priority2(numbers, group):
found = False
def helper(x):
if x in group:
found = True
return (0, x)
return (1, x)
numbers.sort(key=helper)
return found

在表达式中引用某个变量时,Python 解释器会按照下面的顺序,在各个作用域(scope)里面查找这个变量,以解析(resolve)这次引用

  • 当前函数的作用域。
  • 外围作用域(例如包含当前函数的其他函数所对应的作用域)。
  • 包含当前代码的那个模块所对应的作用域(也叫全局作用域,global scope)。
  • 内置作用域(built-in scope,也就是包含 len 与 str 等函数的那个作用域)。

如果这些作用域中都没有定义名称相符的变量,那么程序就抛出 NameError 异常。

Python 会把包含赋值操作的这个函数当成新定义的这个变量的作用域。这可以解释刚才那种写法错在何处。sort_priority2 函数里面的 helper 闭包函数是把 True 赋给了 found 变量。当前作用域里没有这样一个叫作 found 的变量,所以就算外围的 sort_priority2 函数里面有 found 变量,系统也还是会把这次赋值当成定义,也就是会在 helper 里面定义一个新的 found 变量,而不是把它当成给 sort_priority2 已有的那个 found 变量赋值。

这种问题有时也称作作用域 bug(scoping bug),Python 新手可能认为这样的赋值规则很奇怪,但实际上 Python 是故意这么设计的。因为这样可以防止函数中的局部变量污染外围模块。假如不这样做,那么函数里的每条赋值语句都有可能影响全局作用域的变量,这样不仅混乱,而且会让全局变量之间彼此交互影响,从而导致很多难以探查的 bug。

Python 有一种特殊的写法,可以把闭包里面的数据赋给闭包外面的变量。用 nonlocal 语句描述变量,就可以让系统在处理针对这个变量的赋值操作时,去外围作用域查找。然而,nonlocal 有个限制,就是不能侵入模块级别的作用域(以防污染全局作用域)。

1
2
3
4
5
6
7
8
9
10
def sort_priority3(numbers, group):
found = False
def helper(x):
nonlocal found
if x in group:
found = True
return (0, x)
return (1, x)
numbers.sort(key=helper)
return found

nonlocal 语句清楚地表明,我们要把数据赋给闭包之外的变量。有一种跟它互补的语句,叫作global,用这种语句描述某个变量后,在给这个变量赋值时,系统会直接把它放到模块作用域(或者说全局作用域)中。

我们都知道全局变量不应该滥用,其实 nonlocal 也这样。除比较简单的函数外,大家尽量不要用这个语句,因为它造成的副作用有时很难发现。尤其是在那种比较长的函数里,nonlocal 语句与其关联变量的赋值操作之间可能隔得很远。

如果 nonlocal 的用法比较复杂,那最好是改用辅助类来封装状态。下面就定义了这样一个类,用来实现与刚才那种写法相同的效果。这样虽然稍微长一点,但看起来更清晰易读

1
2
3
4
5
6
7
8
9
10
11
12
13
class Sorter:
def __init__(self, group):
self.group = group
self.found = False

def __call__(self, x):
if x in self.group:
self.found = True
return (0, x)
return (1, x)

sorter = Sorter(group)
numbers.sort(key=sorter)

第 22 条、用数量可变的位置参数给函数设计清晰的参数列表

让函数接受数量可变的位置参数(positional argument),可以把函数设计得更加清晰(这些位置参数通常简称 varargs,或者叫作 star args,因为我们习惯用*args 指代)。

例如,假设我们要记录调试信息。如果采用参数数量固定的方案来设计,那么函数应该接受一个表示信息的 message 参数和一个 values 列表(这个列表用于存放需要填充到信息里的那些值)。

1
2
3
4
5
6
7
8
9
def log(message, values):
if not values:
print(message)
else:
values_str = ', '.join(str(x) for x in values)
print(f'{message}: {values_str}')

log('test', [1, 2])
log('test 2', [])

即便没有值需要填充到信息里面,也必须专门传一个空白的列表进去,这样显得多余,而且让代码看起来比较乱。最好是能允许调用者把第二个参数留空。

1
2
3
4
5
6
7
8
9
def log(message, *values):
if not values:
print(message)
else:
values_str = ', '.join(str(x) for x in values)
print(f'{message}: {values_str}')

log('test', [1, 2])
log('test 2')

这种写法与拆解数据时用在赋值语句左边带星号的 unpacking 操作非常类似。

如果想把已有序列(例如某列表)里面的元素当成参数传给像 log 这样的参数个数可变的函数(variadic function),那么可以在传递序列的时采用*操作符。这会让 Python 把序列中的元素都当成位置参数传给这个函数。

1
2
nums = [1, 2, 3]
log('test3', *nums); // test3 1,2,3

令函数接受数量可变的位置参数,可能导致两个问题。第一个问题是,程序总是必须先把这些参数转化成一个元组,然后才能把它们当成可选的位置参数传给函数。这意味着,如果调用函数时,把带*操作符的生成器传了过去,那么程序必须先把这个生成器里的所有元素迭代完(以便形成元组),然后才能继续往下执行。这个元组包含生成器所给出的每个值,这可能耗费大量内存,甚至会让程序崩溃。

接受*args参数的函数,适合处理输入值不太多,而且数量可以提前预估的情况。在调用这种函数时,传给*args这一部分的应该是许多个字面值或变量名才对。这种机制,主要是为了让代码写起来更方便、读起来更清晰。

第二个问题是,如果用了*args之后,又要给函数添加新的位置参数,那么原有的调用操作就需要全都更新。例如给参数列表开头添加新的位置参数 sequence,那么没有据此更新的那些调用代码就会出错。

1
log('test4', 7, 233)

第三次调用 log 函数的那个地方并没有根据新的参数列表传入 sequence 参数,这样的 bug 很难排查,因为程序不会抛出异常,只会采用错误的数据继续运行下去。为了彻底避免这种漏洞,在给这种*arg 函数添加参数时,应该使用只能通过关键字来指定的参数(keyword-only argument)。要是想做得更稳妥一些,可以考虑添加类型注解。

第 23 条、用关键字参数来表示可选的行为

与大多数其他编程语言一样,Python 允许在调用函数时,按照位置传递参数。

1
2
3
4
def remainder(number, divisor):
return number % divisor

assert remainder(20, 7) == 6

Python 函数里面的所有普通参数,除了按位置传递外,还可以按关键字传递。调用函数时,在调用括号内可以把关键字的名称写在=左边,把参数值写在右边。这种写法不在乎参数的顺序,只要把必须指定的所有位置参数全都传过去即可。另外,关键字形式与位置形式也可以混用。下面这四种写法的效果相同:

1
2
3
4
remainder(20, 7)
remainder(20, divisor=7)
remainder(number=20, divisor=7)
remainder(divisor=7, number=20)

如果混用,那么位置参数必须出现在关键字参数之前,否则就会出错。

1
remainder(number=20, 7)

每个参数只能指定一次,不能既通过位置形式指定,又通过关键字形式指定。

如果有一份字典,而且字典里面的内容能够用来调用 remainder 这样的函数,那么可以把**运算符加在字典前面,这会让 Python 把字典里面的键值以关键字参数的形式传给函数。

1
2
3
4
5
my_kwargs = {
'number': 20,
'divisor': 7,
}
assert remainder(**my_kwargs) == 6

调用函数时,带**操作符的参数可以和位置参数或关键字参数混用,只要不重复指定就行。

1
2
3
4
my_kwargs = {
'divisor': 7,
}
assert remainder(number=20, **my_kwargs) == 6

也可以对多个字典分别施加**操作,只要这些字典所提供的参数不重叠就好。

1
2
3
4
5
6
7
my_kwargs = {
'number': 20,
}
other_kwargs = {
'divisor': 7
}
assert remainder(**my_kwargs, **other_kwargs) == 6

定义函数时,如果想让这个函数接受任意数量的关键字参数,那么可以在参数列表里写上万能形参**kwargs,它会把调用者传进来的参数收集合到一个字典里面稍后处理。

1
2
3
4
5
def print_parameters(**kwargs):
for key, value in kwargs.items():
print(f'{key} = {value}')

print_parameters(alpha=1.5, beta=9, gamma=4)

关键字参数的灵活用法可以带来三个好处。

  • 第一个好处是,用关键字参数调用函数可以让初次阅读代码的人更容易看懂。
  • 关键字参数的第二个好处是,它可以带有默认值,该值是在定义函数时指定的。在大多数情况下,调用者只需要沿用这个值就好,但有时也可以明确指定自己想要传的值。这样能够减少重复代码,让程序看上去干净一些。
  • 关键字参数的第三个好处是,我们可以很灵活地扩充函数的参数,而不用担心会影响原有的函数调用代码。

第 24 条、用 None 和 docstring 来描述默认值会变的参数

有时,我们想把那种不能够提前固定的值,当作关键字参数的默认值。例如,记录日志消息时,默认的时间应该是触发事件的那一刻。所以,如果调用者没有明确指定时间,那么就默认把调用函数的那一刻当成这条日志的记录时间。

要想在 Python 里实现这种效果,惯用的办法是把参数的默认值设为 None,同时在 docstring 文档里面写清楚,这个参数为 None 时,函数会怎么运作。

给函数写实现代码时,要判断该参数是不是 None,如果是,就把它改成相应的默认值。

1
2
3
4
5
6
7
8
9
10
11
def log(message, when=None):
"""Log a message with a timestamp.

Args:
message: Message to print.
WHen: datatime of when the message occurred.
Defaults to the present time.
"""
if when is None:
when = datetime.now()
print(f'{when}: {message}')

把参数的默认值写成 None 还有个重要的意义,就是用来表示那种以后可能由调用者修改内容的默认值。

这个思路可以跟类型注解搭配起来。下面这种写法把 when 参数标注成可选(Optional)值,并限定其类型为 datetime。于是,它的取值就只有两种可能,要么是 None,要么是 datetime 对象。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
from typing import Optional

def log_typed(message: str,
when: Optional[datetime]=None) -> None:
"""Log a message with a timestamp.

Args:
message: Message to print.
WHen: datatime of when the message occurred.
Defaults to the present time.
"""
if when is None:
when = datetime.now()
print(f'{when}: {message}')

第 25 条、用只能以关键字指定和只能按位置传入的参数来设计清晰的参数列表

例如,计算两数相除的结果时,可能需要仔细考虑各种特殊情况。例如,在除数为 0 的情况下,是抛出ZeroDivisionError异常,还是返回无穷(infinity);在结果溢出的情况下,是抛出OverflowError异常,还是返回 0。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
def safe_division(number, divisor,
ignore_overflow,
ignore_zero_division):
try:
return number / divisor
except OverflowError:
if ignore_overflow:
return 0
else:
raise
except ZeroDivisionError:
if ignore_zero_division:
return float('inf')
else:
raise

表示要不要忽略异常的那两个参数都是Boolean值,所以容易弄错位置,这会让程序出现难以查找的 bug。要想让代码看起来更清晰,一种办法是给这两个参数都指定默认值。按照默认值,该函数只要遇到特殊情况,就会抛出异常。然而,由于这些关键参数是可选的,我们没办法要求调用者必须按照关键字形式来指定这两个参数。

对于这种参数比较复杂的函数,我们可以声明只能通过关键字指定的参数(keyword-only argument),这样的话,写出来的代码就能清楚地反映调用者的想法了。这种参数只能用关键字来指定,不能按位置传递。

参数列表里的*符号把参数分成两组,左边是位置参数,右边是只能用关键字指定的参数。

1
2
3
4
5
6
def safe_division_c(number, divisor, *,
ignore_overflow=False,
ignore_zero_division=False)
# ...

result = safe_division_c(1.0, 0, ignore_zero_division=True)

其实最重要的问题在于,我们根本就没打算把 number 和 divisor 这两个名称纳入函数的接口。

Python 3.8 引入了一项新特性,可以解决这个问题,这就是只能按位置传递的参数(positional-only argument)。这种参数与刚才的只能通过关键字指定的参数(keyword-only argument)相反,它们必须按位置指定,绝不能通过关键字形式指定。

参数列表中的/符号,表示它左边的那些参数只能按位置指定。

1
2
3
4
5
def safe_division_c(number, divisor, /, *,
ignore_overflow=False,
ignore_zero_division=False)
# ...
safe_division_c(2, 5)

在函数的参数列表之中,/符号左侧的参数是只能按位置指定的参数,*符号右侧的参数则是只能按关键字形式指定的参数。

在设计 API 时,为了体现某编程风格或者实现某些需求,可能会允许某些参数既可以按位置传递,也可以用关键字形式指定,这样可以让代码简单易读。例如,给下面这个 safe_division 函数的参数列表添加一个可选的 ndigits 参数,允许调用者指定这次除法应该精确到小数点后第几位。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
def safe_division_e(numberator, denominator, /,
ndigits=10, *,
ignore_overflow,
ignore_zero_division):
try:
fraction = numerator / denominator
return round(fraction, ndigits)
except OverflowError:
if ignore_overflow:
return 0
else:
raise
except ZeroDivisionError:
if ignore_zero_division:
return float('inf')
else:
raise

safe_division_e(22, 7)
safe_division_e(22, 7, 5)
safe_division_e(22, 7, ndigits=2)

第 26 条、用 functools.wraps 定义函数修饰器

用修饰器(decorator)来封装某个函数,从而让程序在执行这个函数之前与执行完这个函数之后,分别运行某些代码。这意味着,调用者传给函数的参数值、函数返回给调用者的值,以及函数抛出的异常,都可以由修饰器访问并修改。这是个很有用的机制,能够确保用户以正确的方式使用函数,也能够用来调试程序或实现函数注册功能,此外还有许多用途。

例如,假设我们要把函数执行时收到的参数与返回的值记录下来。这在调试递归函数时是很有用的,因为我们需要知道,这个函数执行每一层递归时,输入的是什么参数,返回的是什么值。下面我们就定义这样一个修饰器,在实现这个修饰器时,用*args**kwargs表示受修饰的原函数 func 所收到的参数。

1
2
3
4
5
6
7
def trace(func):
def wrapper(*args, **kwargs):
result = func(*args, **kwargs)
print(f'{func.__name__}({args!r}, {kwargs!r})'
f'-> {result!r}')
return result
return wrapper

写好之后,我们用@符号把修饰器运用在想要调试的函数上面。

1
2
3
4
5
6
@trace
def fibonacci(n):
"""Return the n-th Fibonacci number"""
if n in (0, 1):
return n
return (fibonacci(n - 2) + fibonacci(n - 1))

这样写,相当于先把受修饰的函数传给修饰器,然后将修饰器所返回的值赋给原来那个函数。这样的话,如果我们继续通过原来那个名字调用函数,那么执行的就是修饰之后的函数。

修饰过的 fibonacci 函数,会在执行自身的代码前,先执行 wrapper 里位于func(*args, **kwargs)那一行之前的逻辑;并且在执行完自身的代码后,执行 wrapper 里位于func(*args,**kwargs)那一行之后的逻辑。

这样写确实能满足需求,但是会带来一个我们不愿意看到的副作用。修饰器返回的那个值,也就是刚才调用的 fibonacci,它的名字并不叫“fibonacci”。

1
print(fibonacci)  # <function trace.<locals>.wrapper at )xsfjoisdjf2>

trace 函数返回的,是它里面定义的 wrapper 函数,所以,当我们把这个返回值赋给 fibonacci 之后,fibonacci 这个名称所表示的自然就是 wrapper 了。问题在于,这样可能会干扰那些需要利用 introspection 机制来运作的工具,例如调试器(debugger)

例如,如果用内置的 help 函数来查看修饰后的 fibonacci,那么打印出来的并不是我们想看的帮助文档,它本来应该打印前面定义时写的那行’Return the n-th Fibonacci number’文本才对。

对象序列化器(object serializer)也无法正常运作,因为它不能确定受修饰的那个原始函数的位置。

要想解决这些问题,可以改用 functools 内置模块之中的 wraps 辅助函数来实现。wraps 本身也是个修饰器,它可以帮助你编写自己的修饰器。把它运用到 wrapper 函数上面,它就会将重要的元数据(metadata)全都从内部函数复制到外部函数。

1
2
3
4
5
6
7
8
9
10
from functools import wraps
def trace(func):
@wraps(func)
def wrapper(*args, **kwargs):
...
return wrapper

@trace
def fibonacci(n):
...

虽然原来的 fibonacci 函数现在封装在修饰器里面,但我们还是可以看到它的文档。对象序列化器,现在也正常了。

除了这里讲到的几个方面之外,Python 函数还有很多标准属性(例如__name____module____annotations__)也应该在受到封装时得以保留,这样才能让相关的接口正常运作。wraps 可以帮助保留这些属性,使程序表现出正确的行为。


4.推导与生成

我们经常需要处理列表(list)、含有键值对的字典(dict),以及集合(set)等数据结构,并且要以这种处理逻辑为基础来构建程序。Python 提供了一种特殊的写法,叫作推导(comprehension),可以简洁地迭代这些结构,并根据迭代结果派生出另一套数据。这种写法能够让我们用相当清晰的代码实现许多常见任务,而且还有一些其他好处。

第 27 条、用列表推导取代 map 与 filter

Python 里面有一种很精简的写法,可以根据某个序列或可迭代对象派生出一份新的列表。用这种写法写成的表达式,叫作列表推导(list comprehension)。假设我们要用列表中每个元素的平方值构建一份新的列表。传统的写法是,采用下面这个简单的 for 循环来实现。

1
2
3
4
5
a = [1, 2, 3, 4, 5, 6, 7, 8]
squares = []
for x in a:
squares.append(x**2)
print(squares)

上面那段代码可以改用列表推导来写,这样可以在迭代列表的过程中,根据表达式算出新列表中的对应元素。

1
squares = [x**2 for x in a]

这种功能当然也可以用内置函数 map 实现,它能够从多个列表中分别取出当前位置上的元素,并把它们当作参数传给映射函数,以求出新列表在这个位置上的元素值。如果映射关系比较简单(例如只是把一个列表映射到另一个列表),那么用列表推导来写还是比用 map 简单一些,因为用 map 的时候,必须先把映射逻辑定义成 lambda 函数,这看上去稍微有点烦琐。

1
alt = map(lambda x: x ** 2, a)

列表推导还有一个地方比 map 好,就是它能够方便地过滤原列表,把某些输入值对应的计算结果从输出结果中排除。

字典与集合也有相应的推导机制,分别叫作字典推导(dictionary comprehension)与集合推导(set comprehension)。编写算法时,可以利用这些机制根据原字典与原集合创建新字典与新集合。

1
2
even_squares_dict = {x: x ** 2 for x in a if x % 2 == 0}
threes_cubed_set = {x**3 or x in a if x & 3 == 0}

如果改用 map 与 filter 实现,那么还必须调用相应的构造器(constructor),这会让代码变得很长,需要分成多行才能写得下。这样看起来比较乱,不如使用推导机制的代码清晰。

1
2
3
4
alt_dict = dict(map(lambda x: (x, x ** 2),
filter(lambda x: x % 2 == 0, a)))
alt_set = set(map(lambda x: x **3,
filter(lambda x: x % 3 == 0, a)))

第 28 条、控制推导逻辑的子表达式不要超过两个

列表推导还支持多层循环。例如,要把矩阵(一种二维列表,它的每个元素本身也是列表)转化成普通的一维列表,那么可以在推导时,使用两条 for 子表达式。这些子表达式会按照从左到右的顺序解读。

1
2
3
4
matrix = [[1, 2, 3], [4, 5, 6], [7, 8, 9]]
flat = [x for row in matrix for x in row]

# [1,2,3,4,5,6,7,8,9]

这样写简单易懂,这也正是多层循环在列表推导之中的合理用法。多层循环还可以用来重制那种两层深的结构。例如,如果要根据二维矩阵里每个元素的平方值构建一个新的二维矩阵,那么可以采用下面这种写法。这看上去有点儿复杂,因为它把小的推导逻辑[x**2 for x in row]嵌到了大的推导逻辑[1]里面,不过,这行语句总体上不难理解。

1
2
3
squared = [[x**2 for x in row] for row in matrix]

# [[1,4,9],[16,25,36],[49,64.81]]

如果推导过程中还要再加一层循环,那么语句就会变得很长,必须把它分成多行来写,例如下面是把一个三维矩阵转化成普通一维列表的代码。

1
2
3
4
5
6
7
my_lists = [
[[1, 2, 3], [4, 5, 6]],
...
]
flat = [x for sublist1 in my_lists
for sublist2 in sublist1
for x in sublist2]

在这种情况下,采用列表推导来实现,其实并不会比传统的 for 循环节省多少代码。下面用常见的 for 循环改写刚才的例子。这次我们通过级别不同的缩进表示每一层循环的深度,这要比刚才那种三层矩阵的列表推导更加清晰。

1
2
3
4
flat = []
for sublist1 in my_lists:
for sublist2 in sublist1:
flat.extend(sublist2)

推导的时候,可以使用多个 if 条件。如果这些 if 条件出现在同一层循环内,那么它们之间默认是 and 关系,也就是必须同时成立。例如,如果要用原列表中大于 4 且是偶数的值来构建新列表,那么既可以连用两个 if,也可以只用一个 if,下面两种写法效果相同。

1
2
3
a = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
b = [x for x in a if x > 4 if x % 2 == 0]
c = [x for x in a if x. > 4 and x % 2 == 0]

这个逻辑用列表推导来写,并不需要太多的代码,但是这些代码理解起来会很困难。

1
2
3
4
5
matrix = [[1, 2, 3], [4, 5, 6], [7, 8, 9]]
filtered = [[x for x in row if x % 3 == 0]
for row in matrix if sum(row) >= 10]

# [[6], [9]]

总之,在表示推导逻辑时,最多只应该写两个子表达式(例如两个 if 条件、两个 for 循环,或者一个 if 条件与一个 for 循环)。只要实现的逻辑比这还复杂,那就应该采用普通的 if 与 for 语句来实现,并且可以考虑编写辅助函数。

第 29 条、用赋值表达式消除推导中的重复代码

推导 list、dict 与 set 等变体结构时,经常要在多个地方用到同一个计算结果。例如,我们要给制作紧固件的公司编写程序以管理订单。顾客下单后,我们要判断当前的库存能否满足这份订单,也就是说,要核查每种产品的数量有没有达到可以发货的最低限制(8 个为一批,至少要有一批,才能发货)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
stock = {
'nails': 125,
'screws': 35,
'wingnuts': 8,
'washers': 24,
}

order = ['screws', 'wingnuts', 'clips']

def get_batches(count, size):
return count

result = {}
for name in order:
count = stock.get(name, 0)
batches = get_batches(count, 8)
if batches:
result[name] = batches

# {'screws': 4, 'wingnuts': 1}

这段循环逻辑,如果改用字典推导来写,会简单一些

1
2
3
4
5
found = {name: get_batches(stock.get(name, 0), 8)
for name in order
if get_batches(stock.get(name, 0), 8)}

# {'screws': 4, 'wingnuts': 1}

这样写虽然比刚才简短,但问题是,它把get_batches(stock.get(name, 0), 8)写了两遍。这样会让代码看起来比较乱,而且实际上,程序也没有必要把这个结果计算两遍。另外,如果这两个地方忘了同步更新,那么程序就会出现 bug。

有个简单的办法可以解决这个问题,那就是在推导的过程中使用 Python 3.8 新引入的:=操作符进行赋值表达

1
2
found = {name: batches for name in order
if (batches := get_batches(stock.get(name, 0), 8))}

这条batches := get_batches(...)赋值表达式,能够从 stock 字典里查到对应产品一共有几批,并把这个批数放在 batches 变量里。

在推导过程中,描述新值的那一部分也可以出现赋值表达式。但如果在其他部分引用了定义在那一部分的变量,那么程序可能就会在运行时出错

1
2
3
4
result = {name: (tenth := count // 10)
for name, count in stock.items() if tenth > 0}

# NameError

为了解决这个问题,可以把赋值表达式移动到 if 条件里面,然后在描述新值的这一部分引用已经定义过的 tenth 变量。

1
2
result = {name: tenth for name, count in stock.items()
if (tenth := count // 10) > 0}

如果推导逻辑不带条件,而表示新值的那一部分又使用了:=操作符,那么操作符左边的变量就会泄漏到包含这条推导语句的那个作用域里。

建议赋值表达式只出现在推导逻辑的条件之中。

赋值表达式不仅可以用在推导过程中,而且可以用来编写生成器表达式(generator expression)。下面这种写法创建的是迭代器,而不是字典实例,该迭代器每次会给出一对数值,其中第一个元素为产品的名字,第二个元素为这种产品的库存。

1
2
3
4
5
found = ((name, batches) for name in order
if (batches := get_batches(stock.get(name, 0), 8)))

# ('screws', 4)
# ('wingnuts', 1)

第 30 条、不要让函数直接返回列表,应该让它逐个生成列表里的值

如果函数要返回的是个包含许多结果的序列,那么最简单的办法就是把这些结果放到列表中。例如,我们要返回字符串里每个单词的首字母所对应的下标。下面这种写法,会把每次遇到的新单词所在的位置追加(append)到存放结果的 result 列表中,在函数末尾返回这份列表。

1
2
3
4
5
6
7
8
def index_words(text):
result = []
if text:
result.append(0)
for index, letter in enumerate(text):
if letter == ' ':
result.append(index + 1)
return result

index_words 函数有两个缺点。第一个缺点是,它的代码看起来有点杂乱。每找到一个新单词,它都要调用 append 方法,而调用这个方法时,必须写上 result.append 这样一串字符,这就把我们想要强调的重点,也就是这个新单词在字符串中的位置(index + 1)淡化了。另外,函数还必须专门用一行代码创建这个保存结果的 result 列表,并且要用一条 return 语句把它返回给调用者。这样算下来,虽然函数的主体部分大约有 130 个字符(非空白的),但真正重要的只有 75 个左右。

这种函数改用生成器(generator)来实现会比较好。生成器由包含 yield 表达式的函数创建。

1
2
3
4
5
6
def index_words_iter(text):
if text:
yield 0
for index, letter in enumerate(text):
if letter == ' ':
yield index + 1

调用生成器函数并不会让其中的代码立刻得到执行,它会返回一个迭代器(iterator)。把迭代器传给 Python 内置的 next 函数,就可以将生成器函数推进到它的下一条 yield 表达式。生成器会把 yield 表达式的值通过迭代器返回给调用者。

1
2
3
it = index_words_iter(address)
next(it) # 0
next(it) # 5

这次的 index_words_iter 函数,比刚才那个函数好懂得多,因为它把涉及列表的那些操作全都简化掉了。它通过 yield 表达式来传递结果,而不像刚才那样,要把结果追加到列表之中。如果确实要制作一份列表,那可以把生成器函数返回的迭代器传给内置的 list 函数。

index_words 函数的第二个缺点是,它必须把所有的结果都保存到列表中,然后才能返回列表。如果输入的数据特别多,那么程序可能会因为耗尽内存而崩溃。

相反,用生成器函数来实现,就不会有这个问题了。它可以接受长度任意的输入信息,并把内存消耗量压得比较低。例如下面这个生成器,只需要把当前这行文字从文件中读进来就行,每次推进的时候,它都只处理一个单词,直到把当前这行文字处理完毕,才读入下一行文字。

1
2
3
4
5
6
7
8
9
def index_file(handle):
offset = 0
for line in handle:
if line:
yield offset
for letter in line:
offset += 1
if letter == ' ':
yield offset

该函数运行时所耗的内存,取决于文件中最长的那一行所包含的字符数。