当前位置 : 主页 > 编程语言 > python >

《Effective Python 2nd》 读书笔记——列表与字典

来源:互联网 收集:自由互联 发布时间:2022-07-14
引言 Python提供了一些特殊的语法和内置的模块,能够扩充列表与字典的能力,让我们可以用清晰的代码实现很多强大的功能。 #11 学会对序列做切片 Python可以从序列里切割(slice)出一部


引言

Python提供了一些特殊的语法和内置的模块,能够扩充列表与字典的能力,让我们可以用清晰的代码实现很多强大的功能。

#11 学会对序列做切片

Python可以从序列里切割(slice)出一部分内容。凡是实现了​​__getitem__​​​与​​__setitem__​​这两个特殊方法的类都可以切割。

切割最基本的写法是​​somelist[start:end]​​​,从​​start​​​开始取,不包括​​end​​。

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

如果从头开始切割列表,可以省略冒号左侧的下标0。

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

如果一直取到列表末尾,那就应该省略冒号右侧的下标。

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

用负数做下标表示从列表末尾往前算。下面看一些切割示例:

print(a[:]) # ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h']
print(a[:5]) # ['a', 'b', 'c', 'd', 'e']
print(a[:-1]) # ['a', 'b', 'c', 'd', 'e', 'f', 'g'] # -1表示最后一个
print(a[4:]) # ['e', 'f', 'g', 'h']
print(a[-3:]) # ['f', 'g', 'h']
print(a[2:5]) # ['c', 'd', 'e']
print(a[2:-1]) # ['c', 'd', 'e', 'f', 'g']
print(a[-3:-1]) # ['f', 'g']['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h']
['a', 'b', 'c', 'd', 'e']
['a', 'b', 'c', 'd', 'e', 'f', 'g']
['e', 'f', 'g', 'h']
['f', 'g', 'h']
['c', 'd', 'e']
['c', 'd', 'e', 'f', 'g']
['f', 'g']

如果起点与终点所确定的范围超出了列表的边界,那么系统会自动忽略不存在的元素。

first_twenty_items = a[:20] # 取a的前20个元素,但是a没有那么多元素,就取出a的所有元素
last_twenty_items = a[-20:] # 取a的最后20个元素
first_twenty_items # ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h']['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h']

切割出来的列表是一份全新的列表。即使把某个元素换掉,也不会影响原列表。

b = a[3:]
print('Before ', b)
b[1] = 99
print('After ', b)
print('No change:',a)Before ['d', 'e', 'f', 'g', 'h']
After ['d', 99, 'f', 'g', 'h']
No change: ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h']

切片还可以出现在赋值符号的左侧,表示用右侧那些元素把原列表中位于这个范围之内的元素换掉。
这种赋值不要求左右两侧所指定的元素个数相等。在原列表中,位于切片范围之前和之后的那些元素会予以保留,但是列表的长度可能有所变化。

例如,下面这个例子中,列表会变短,因为赋值符号的右侧只提供了3个值,但是左侧那个切片却涵盖了5个值,列表会比原来少两个元素。

print('Before ', a)
a[2:7] = [99, 22, 14]
print('After ', a)Before ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h']
After ['a', 'b', 99, 22, 14, 'h']

而下面这段代码会使列表变长:

print('Before ', a)
a[2:3] = [47, 11]
print('After ', a)Before ['a', 'b', 99, 22, 14, 'h']
After ['a', 'b', 47, 11, 22, 14, 'h']

起止位置都留空的切片,出现在赋值右侧,表示给这个列表做副本。

b = a[:]
assert b == a and b is not

把不带起止下标的切片放在赋值符号左边,表示是用右边那个列表的副本把左侧列表的全部内容替换掉。

b = a
print('Before a', a)
print('Before b', b)
a[:] = [101, 102, 103] #左侧列表的引用不变,值发生了改变。
assert a is b
print('After a ', a)
print('After b ', b)Before a ['a', 'b', 47, 11, 22, 14, 'h']
Before b ['a', 'b', 47, 11, 22, 14, 'h']
After a [101, 102, 103]
After b [101, 102, 103]

#12 不要在切片里同时指定起止下标与步长

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

x = ['red', 'orange', 'yellow', 'green', 'blue', 'purple']
odds = x[::2] # 从下标0开始,每2个元素取一个(隔1个取1个)
evens = x[1::2] # 从下标1开始,每2个元素取一个
print(odds)
print(evens)['red', 'yellow', 'blue']
['orange', 'green', 'purple']

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

x = b'mongoose'
y = x[::-1]
y # b'esoognomb'esoognom'

Unicode形式的字符串也可以这样反转

x = '月饼'
y = x[::-1]'饼月'

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

w = '月饼'
x = w.encode('utf-8')
y = x[::-1]
z = y.decode('utf-8')---------------------------------------------------------------------------

UnicodeDecodeError Traceback (most recent call last)

<ipython-input-24-778a665e5529> in <module>
2 x = w.encode('utf-8')
3 y = x[::-1]
----> 4 z = y.decode('utf-8')


UnicodeDecodeError: 'utf-8' codec can't decode byte 0xbc in position 0: invalid start byte

除了​​-1​​外,用其他负数做步长值,有没有意义呢?

x = ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h']
print(x[::2]) # ['a', 'c', 'e', 'g'] 从头开始,每隔1个取一个
print(x[::-2]) # ['h', 'f', 'd', 'b'] 从末尾开始往前['a', 'c', 'e', 'g']
['h', 'f', 'd', 'b']

那么​​2::2​​​是什么意思?​​-2::2​​​、​​-2:2:-2​​等又是什么意思?

print(x[2::2]) # ['c', 'e', 'g']
print(x[-2::-2]) # ['g', 'e', 'c', 'a']
print(x[-2:2:-2]) # ['g', 'e']
print(x[2:2:-2]) # []['c', 'e', 'g']
['g', 'e', 'c', 'a']
['g', 'e']
[]

同时使用起止下标与步长会让切片很难懂。
为了避免这个问题,建议大家不要把起止下标和步长同时写在切片里。
如果必须指定步长,那么尽量采用正数,而且要把起止下标都留空。即便必须同时使用步长值与起止下标,也应该考虑分成两次来写。

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

像上面这样先隔位选取然后再切割,会让程序做一次浅拷贝。如果程序没有那么多时间或内存取分两步操作,那么可以改用内置的​​itertools​​​模块中的​​islice​​方法。

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

基本的unpacking操作有一项限制,就是必须提前确定需要拆解的序列的长度。
例如,销售汽车的时候,我们可能会把每辆车的年龄写到一份列表中,然后按照从大到小的顺序排好。如果试着通过基本的unpacking操作获取其中最旧的两辆车,那么程序运行时就会出现异常。

car_ages = [0, 9, 4, 8, 7, 20, 19, 1, 6, 15]car_ages_descending = sorted(car_ages, reverse=True)oldest, second_oldest =---------------------------------------------------------------------------ValueError Traceback (most recent call last)<ipython-input-34-99634ca0e408> in <module> 1 car_ages = [0, 9, 4, 8, 7, 20, 19, 1, 6, 15] 2 car_ages_descending = sorted(car_ages, reverse=True)----> 3 oldest, second_oldest = car_ages_descending


ValueError: too many values to unpack (expected 2)

新手经常通过下标与切片来处理这个问题。例如,可以明确通过下标把最旧和第二旧的那两辆车取出来,然后把其余的车放到另一份列表中。

oldest = car_ages_descending[0]
second_oldest = car_ages_descending[1]
others = car_ages_descending[2:]
print(oldest, second_oldest, others) # 20 19 [15, 9, 8, 7, 6, 4, 1, 0]20 19 [15, 9, 8, 7, 6, 4, 1, 0]

下标与切片会让代码看起来很乱。而且,这样也很容易出错。
这个问题通过星号表达式(starred expresion)来解决更会更好一些,这也是一种unpacking操作,它可以把无法由普通变量接收的那些元素全部囊括进去。
下面用带星号的unpacking操作改写刚才的代码。

oldest, second_oldest, *others = car_ages_descending
print(oldest, second_oldest, others) # 20 19 [15, 9, 8, 7, 6, 4, 1, 0]20 19 [15, 9, 8, 7, 6, 4, 1, 0]

这样写简短易读,而且不容易出错。
这种星号表达式可以出现在任意位置,所以它能捕获序列中的任何一段元素。

oldest, *others, youngest = car_ages_descending
print(oldest, youngest, others) # 20 0 [19, 15, 9, 8, 7, 6, 4, 1]

*others, second_youngest, youngest = car_ages_descending
print(youngest, second_youngest, others) # 0 1 [20, 19, 15, 9, 8, 7, 6, 4]20 0 [19, 15, 9, 8, 7, 6, 4, 1]
0 1 [20, 19, 15, 9, 8, 7, 6, 4]

不过,在使用这种写法时,至少要确保有一个普通的接收变量与它搭配,否则就会出错。例如不能像下面这样,只使用带星的表达式而不搭配普通变量。

*others = File "<ipython-input-38-77c6f344fe32>", line 1
*others = car_ages_descending
^
SyntaxError: starred assignment target must be in a list or tuple

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

first, *middle, *second_middle, last = [1, 2, 3, 4] File "<ipython-input-39-77dccc131ad1>", line 1
first, *middle, *second_middle, last = [1, 2, 3, 4]
^
SyntaxError: two starred expressions in assignment

如果要拆解的结构有多层,那么同一级的不同部分里可以各自出现带星号的unpacking操作。
但是不推荐这种写法,这里举一个例子,让大家了解一下。

car_inventory = { 'Downtown': ('Silver Shadow', 'Pinto', 'DMC'), 'Airport': ('Skyline', 'Viper', 'Gremlin', 'Nova'),}((loc1, (best1, *rest1)), (loc2, (best2, *rest2))) = car_inventory.items()print(f'Best at {loc1} is {best1}, {len(rest1)} others')print(f'Best at {loc2} is {best2}, {len(rest2)} others')Best at Downtown is Silver Shadow, 2 othersBest at Airport is Skyline, 3 others

星号表达式总会形成一份列表实例。如果要拆分的序列里已经没有元素留给它了,那么列表就是空白的。
如果能提前确定有待处理的序列里面至少会有N个元素,那么这项特性就相当有用。

short_list = [1, 2]first, second, *rest = short_listprint(first, second, rest)1 2 []

unpacking操作也可以用在迭代器上,但是这样写与把数据拆分到多个变量里面的那种基本写法相比,并没有太大优势。

对迭代器做unpacking操作的好处,主要体现在带星号的用法上面,它使迭代器的拆分值更清晰。
例如,这里有个生成器,每次可以从含有整个一周的汽车订单的CSV文件中取出一行数据。

def generate_csv(): yield ('Date', 'Make' , 'Model', 'Year', 'Price') for i in range(100): yield ('2019-03-25', 'Honda', 'Fit' , '2010', '$3400') yield ('2019-03-26', 'Ford', 'F150' , '2008', '$2400')

我们可以用下标和切片来处理这个生成器所给出的结果,但是这样写需要很多行代码,而且可读性不好。

# Example 11all_csv_rows = list(generate_csv())header = all_csv_rows[0]rows = all_csv_rows[1:]print('CSV Header:', header)print('Row count: ', len(rows))CSV Header: ('Date', 'Make', 'Model', 'Year', 'Price')Row count: 200

利用带星号的unpacking操作,我们可以把第一行单独放到​​header​​​变量里,同时把迭代器所给出的其余内容合起来表示成​​rows​​变量。这样就很清晰了。

it = generate_csv()header, *rows = itprint('CSV Header:', header)print('Row count: ', len(rows))CSV Header: ('Date', 'Make', 'Model', 'Year', 'Price')Row count: 200

带星号的这部分总是会形成一份列表,所以要注意,可能会耗尽计算机的全部内存。
所以,首先要确认系统有足够的内存可以存储拆分出来的结果数据。

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

列表类型提供了叫​​sort​​​的方法,可以根据多项指标给​​list​​实例中的元素排序。默认按照升序排序。

numbers = [93, 86, 11, 68, 70]numbers.sort()print(numbers) # [11, 68, 70, 86, 93][11, 68, 70, 86, 93]

那么,一般对象该如何排序呢?比如定义以​​Tool​​​类表示各种建筑工具,它带有​​__repr__​​方法:

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​​​方法会报错的,因为排序所需要的特殊方法并没有定义在​​Tool​​类中。

tools.sort()---------------------------------------------------------------------------

TypeError Traceback (most recent call last)

<ipython-input-4-934c749d7824> in <module>
----> 1 tools.sort()


TypeError: '<' not supported between instances of 'Tool' and 'Tool'

很多对象需要在不同的情况下按照不同的标准排序。

这些排序标准通常是针对对象中的某个属性。我们可以把这样的排序逻辑定义成函数,然后将这个函数传给​​sort​​​方法的​​key​​​参数。​​key​​​所表示的函数本身应该带有一个参数,这个参数指代列表中有待排序的对象,函数返回的应该是个可比较的值。以便​​sort​​方法以该值为标准给这些对象排序。

下面用​​lambda​​​关键字定义这样一个函数,把它传给​​sort​​​方法的​​key​​​参数,让我们能按照​​name​​​的字母顺序排列这些​​Tool​​对象。

print('Unsorted:', repr(tools))
tools.sort(key=lambda x: x.name)
print('\nSorted: ', tools)Unsorted: [Tool('level', 3.5), Tool('hammer', 1.25), Tool('screwdriver', 0.5), Tool('chisel', 0.25)]

Sorted: [Tool('chisel', 0.25), Tool('hammer', 1.25), Tool('level', 3.5), Tool('screwdriver', 0.5)]

如果想改用另一项标准,比如用​​weight​​​来排序,那只需要再定义一个​​lambda​​函数:

tools.sort(key=lambda x: x.weight)
print('By weight:', tools)By weight: [Tool('chisel', 0.25), Tool('screwdriver', 0.5), Tool('hammer', 1.25), Tool('level', 3.5)]

在编写传给​​key​​​参数的​​lambda​​​函数时,可以像刚才那样返回对象的某个属性,如果对象时序列、元组或字典,那么还可以返回其中的某个元素。只要是有效的表达式,都可以充当​​lambda​​函数的返回值。

对于字符串这样的基本类型,我们可能需要通过​​key​​​函数先对它的内容做一些变换,并根据变换之后的结果来排序。例如,下面这个​​places​​​列表中存放着表示地点的字符串,如果想在排序的时候忽略大小写,那我们可以先用​​lower​​方法把待排序的字符串处理一下(因为默认的字典顺序,大写字母在小写字母之前)。

places = ['home', 'work', 'New York', 'Paris']
places.sort()
print('Case sensitive: ', places)
places.sort(key=lambda x: x.lower())
print('Case insensitive:', places)Case sensitive: ['New York', 'Paris', 'home', 'work']
Case insensitive: ['home', 'New York', 'Paris', 'work']

有时我们可能需要用多个标准来排序。例如,下面的列表里有一些电动工具,我们想以​​weight​​​为首要指标来排序,在重量相同的情况下,再按​​name​​排序。

power_tools = [
Tool('drill', 4),
Tool('circular saw', 5),
Tool('jackhammer', 40),
Tool('sander', 4),
]

在Python里,最简单的方案是利用元组实现。两个元组之间是可比较的,因为这种类型本身已经定义了自然顺序,即,​​sort​​​方法所要求的特殊方法(例如​​__lt__​​),它都已经定义好了。元组在实现这些特殊方法时会依次比较每个位置的那两个对应元素,直到能够确定大小为止。

下面,我们看看元组是如何比较重量的。

saw = (5, 'circular saw')jackhammer = (40, 'jackhammer')assert not (jackhammer < saw)

如果两个元组的首个元素相等,就比较第二个元素,如果仍然相等,就继续往下比较。

drill = (4, 'drill')sander = (4, 'sander')assert drill[0] == sander[0] # 重量相等assert drill[1] < sander[1] # 字母顺序d < sassert drill < sander # 因此,drill < sander

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

power_tools.sort(key=lambda x: (x.weight, x.name)) # 代表元组的括号不能少print(power_tools)[Tool('drill', 4), Tool('sander', 4), Tool('circular saw', 5), Tool('jackhammer', 40)]

这种做法有个缺点,就是​​key​​​函数所构造的这个元组只能按同一个排序方法来对比它所表示的各项指标。
所以不太好实现​​​weight​​​按降序而​​name​​​按升序的效果。​​sort​​​方法可以指定​​reverse​​参数,这个参数会同时影响元组中的每项指标。

power_tools.sort(key=lambda x: (x.weight, x.name), reverse=True) # 使所有的指标变成降序print(power_tools)[Tool('jackhammer', 40), Tool('circular saw', 5), Tool('sander', 4), Tool('drill', 4)]

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

power_tools.sort(key=lambda x: (-x.weight, x.name))print(power_tools)[Tool('jackhammer', 40), Tool('circular saw', 5), Tool('drill', 4), Tool('sander', 4)]

显然,这个技巧并不适合所有的类型。比如,我们试着对​​name​​运用一元减操作。

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

TypeError Traceback (most recent call last)

<ipython-input-18-bcb3ccff0ae9> in <module>
----> 1 power_tools.sort(key=lambda x: (x.weight, -x.name),
2 reverse=True)


<ipython-input-18-bcb3ccff0ae9> in <lambda>(x)
----> 1 power_tools.sort(key=lambda x: (x.weight, -x.name),
2 reverse=True)


TypeError: bad operand type for unary -: 'str'

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

power_tools.sort(key=lambda x: x.name) # Name 升序

power_tools.sort(key=lambda x: x.weight, # Weight 降序
reverse=True)

print(power_tools)[Tool('jackhammer', 40), Tool('circular saw', 5), Tool('drill', 4), Tool('sander', 4)]

为什么这样可以呢? 我们拆开来看。先看第一轮,也就是按照名称升序排列:

power_tools.sort(key=lambda x: x.name)
print(power_tools)[Tool('circular saw', 5), Tool('drill', 4), Tool('jackhammer', 40), Tool('sander', 4)]

然后执行第二轮,即按重要降序排列。这时,由于’sander’与’drill’所对应的两个​​Tool​​​重量相同,​​key​​​函数会判定这两个对象相等,于是,在​​sort​​​方法的排序结果中,它们之间的先后词序就跟第一轮结束时的次序相同。
所以,我们再实现了按重量降序排序的同时,保留了重量相同的对象在上一轮排序结果时的相对次序,而上一轮是按照名称升序排列的。

power_tools.sort(key=lambda x: x.weight,
reverse=True)
print(power_tools)[Tool('jackhammer', 40), Tool('circular saw', 5), Tool('drill', 4), Tool('sander', 4)]

无论有多少项排序指标都可以按照这种思路来实现,而且每项指标可以分别按照各自的方向来排,不用全部都是升序或降序。只需要倒着写即可,即把最主要的那项排序指标放在最后一轮处理。

但是只调用一次​​sort​​​,还是要比调用多次​​sort​​简单,不到万不得已,不要用多次排序的方式。

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

从Python3.6开始,字典会保留键值对在添加时所用的顺序。

baby_names = { 'cat': 'kitten', 'dog': 'puppy',}print(baby_names){'cat': 'kitten', 'dog': 'puppy'}

在Python3.5之前的版本中,​​dict​​所提供的许多方法都不保证固定的顺序,所以让人觉得好像是随机处理的。在新版的Python中,这些方法可以按照当初添加键值对的顺序来处理了。

print(list(baby_names.keys()))print(list(baby_names.values()))print(list(baby_names.items()))print(baby_names.popitem()) # 最后添加的元素['cat', 'dog']['kitten', 'puppy'][('cat', 'kitten'), ('dog', 'puppy')]('dog', 'puppy')

这项变化对Python中那些依赖字典类型及其实现细节的特性产生了很多影响。
函数的关键字参数,以前是按照近乎随机的顺序出现,现在,这些关键字参数总能保留调用函数时所指定的那套顺序。

def my_func(**kwargs):
for key, value in kwargs.items():
print(f'{key} = {value}')

my_func(goose='gosling', kangaroo='joey')goose = gosling
kangaroo = joey

另外,类也会利用字典来保存这个类的实例所具备的一些数据。在早前抱抱你的Python中,对象中的字段看上去好像是随机出现的。在新版中,我们就可以认为这些字段在​​__dict__​​中出现的顺序应该与当初赋值时的顺序一样。

class MyClass:
def __init__(self):
self.alligator = 'hatchling'
self.elephant = 'calf'

a = MyClass()
for key, value in a.__dict__.items():
print(f'{key} = {value}')alligator = hatchling
elephant = calf

所以,我们可以利用这样的特征来实现一些功能,而且可以把它融入自己给类和函数所设计的API中。

但处理字典的时候,不能总是假设所有的字典都能保留键值对插入时的顺序。在Python中,我们很容易就定义出特制的容器类型,并且让这些容器也像标准的​​list​​​与​​dict​​​等类型那样遵守相关的协议。
Python不是静态类型的语音,大多数代码都以鸭子类型机制运作(即对象支持什么样的行为,就可以当成什么样的数据使用,而不用执着于它在类体系中的地位)。这种特性可能会产生意想不到的问题。

例如,现在要写一个程序,统计各种小动物的受欢迎程度。我们可以设定一个字典,把每种动物和它得到的票数关联起来。

votes = {
'otter': 1281,
'polar bear': 587,
'fox': 863,
}

现在定义一个函数来处理投票数据。用户可以把空的字典传给这个函数,这样的话,它就会把每个动物及其排名放到这个字典中。这种字典可以充当数据模型,给带有用户界面的元素提供数据。

def populate_ranks(votes, ranks):
names = list(votes.keys())
names.sort(key=votes.get, reverse=True)
for i, name in enumerate(names, 1):
ranks[name] =

我们还需要写一个函数查出人气最高的动物。这个函数假定​​populate_ranks​​总是会按照升序向字典写入键值对,这样第一个出现在字典里的就应该是排名最靠前的动物。

def get_winner(ranks):
return next(iter(ranks))

下面来验证刚才设计的函数,看它们能不能实现想要的结果。

ranks = {}
populate_ranks(votes, ranks)
print(ranks)
winner = get_winner(ranks)
print(winner){'otter': 1, 'fox': 2, 'polar bear': 3}
otter

结果没有问题。但是,假设现在需求变了,我们想要按照字母顺序在UI中显示。为了实现这种效果,我们用内置的​​collections.abc​​模块定义这样一个类。这个类的功能和字典一样,而且会按照字母顺序迭代其中的内容。

from collections.abc import MutableMapping

class SortedDict(MutableMapping):
def __init__(self):
self.data = {}

def __getitem__(self, key):
return self.data[key]

def __setitem__(self, key, value):
self.data[key] = value

def __delitem__(self, key):
del self.data[key]

def __iter__(self):
keys = list(self.data.keys())
keys.sort()
for key in keys:
yield key

def __len__(self):
return len(self.data)

原来使用标准库​​dict​​​的地方,现在可以改用这个类的实例。我们这个​​SortedDict​​类与标准的字典遵循同一套协议,因此程序不会出错。但是,我们并没有得到预期的效果。

sorted_ranks = SortedDict()
populate_ranks(votes, sorted_ranks)
print(sorted_ranks.data)
winner = get_winner(sorted_ranks)
print(winner){'otter': 1, 'fox': 2, 'polar bear': 3}
fox

为什么会这样,因为​​get_winner​​​总是假设, 迭代字典时的顺序应该跟​​populate_ranks​​​函数当初向字典中插入数据时的顺序一样。但是这次,我们用的是​​SortedDict​​​实例,而不是标准的​​dict​​实例,所以这项假设不成立。

因此,函数返回的数据是按照字母顺序排列时最先出现的那个数据,也就是’fox’。

这个问题有三种解决方法。第一种是重新实现​​get_winner​​​函数,使它不再假设​​ranks​​字典总是按照固定的顺序来迭代。这是最保险、最稳妥的一种方法。

def get_winner(ranks):
for name, rank in ranks.items():
if rank == 1:
return name

winner = get_winner(sorted_ranks)
print(winner)otter

第二种方法是在函数开头先判断​​ranks​​是不是预期的那种标准字典。如果不是,就抛出异常。这个方法的运行性能要比刚才那个好。

def get_winner(ranks):
if not isinstance(ranks, dict):
raise TypeError('must provide a dict instance')
return next(iter(ranks))

assert get_winner(ranks) == 'otter'

get_winner(sorted_ranks)---------------------------------------------------------------------------

TypeError Traceback (most recent call last)

<ipython-input-33-f71ee8f2b79a> in <module>
6 assert get_winner(ranks) == 'otter'
7
----> 8 get_winner(sorted_ranks)


<ipython-input-33-f71ee8f2b79a> in get_winner(ranks)
1 def get_winner(ranks):
2 if not isinstance(ranks, dict):
----> 3 raise TypeError('must provide a dict instance')
4 return next(iter(ranks))
5


TypeError: must provide a dict instance

第三种方法是通过类型注解来保存传给​​get_winner​​​函数的确是个真正的​​dict​​​实例,而不是那种行为根标准字典类似的​​MutableMapping​​​。下面就采用严格模式,针对含有注解的代码运行​​mypy​​工具。

​​example.py​​:

# python -m mypy <path>

from typing import Dict, MutableMapping

def populate_ranks(votes: Dict[str, int],
ranks: Dict[str, int]) -> None:
names = list(votes.keys())
names.sort(key=votes.get, reverse=True)
for i, name in enumerate(names, 1):
ranks[name] = i

def get_winner(ranks: Dict[str, int]) -> str:
return next(iter(ranks))

from typing import Iterator, MutableMapping

class SortedDict(MutableMapping[str, int]):
def __init__(self) -> None:
self.data: Dict[str, int] = {}

def __getitem__(self, key: str) -> int:
return self.data[key]

def __setitem__(self, key: str, value: int) -> None:
self.data[key] = value

def __delitem__(self, key: str) -> None:
del self.data[key]

def __iter__(self) -> Iterator[str]:
keys = list(self.data.keys())
keys.sort()
for key in keys:
yield key

def __len__(self) -> int:
return len(self.data)

votes = {
'otter': 1281,
'polar bear': 587,
'fox': 863,
}

sorted_ranks = SortedDict()
populate_ranks(votes, sorted_ranks)
print(sorted_ranks.data)
winner = get_winner(sorted_ranks)
print(winner)# 首先按照mypy
$ pip install mypy
$ python -m mypy --strict example.py
example.py:6: error: Argument "key" to "sort" of "list" has incompatible type overloaded function; expected "Callable[[str], SupportsLessThan]"
example.py:44: error: Argument 2 to "populate_ranks" has incompatible type "SortedDict"; expected "Dict[str, int]"
example.py:46: error: Argument 1 to "get_winner" has incompatible type "SortedDict"; expected "Dict[str, int]"
Found 3 errors in 1 file (checked 1 source file)

这样可以检查出类型不相符的问题,​​mypy​​会标出错误的用法。这个方案既能保证静态类型准确,又不会影响程序的运行效率。

#16 用get处理不在字典中的情况,不要使用in与KeyError

假设我们要给一家三明治店设计菜单,所以想先确定大家喜欢吃哪些类型的面包。我们定义一个字典,把每种款式的名字和它当前的得票数关联起来。

counters = {
'pumpernickel': 2,
'sourdough': 1,
}

如果要记录新的一票。首先要判断对应的键在不在字典里。如果不在,那就把这个键的票数设成0,然后增加所得票数。这需要两次访问这个键,第一次是为了判断它是否在字典里,第二次为了用它来获取对应的值,而且还要做一次赋值。
下面我们用​​​if​​语句来实现该逻辑。

key = 'wheat'

if key in counters:
count = counters[key]
else:
count = 0

counters[key] = count + 1

这有个办法也能实现相同的功能,就是利用​​KeyError​​​异常。如果程序抛出了这个异常,那说明要获取的键不在字典里。
这个写法比刚才的简单,因为只需要访问一次键名就可以了。

key = 'brioche'

try:
count = counters[key]
except KeyError:
count = 0

counters[key] = count + 1

获取字典中存在的键,或给字典中不存在的键指定默认值,这两种操作非常常见。
Python的内置字典​​​dict​​​提供了​​get​​​方法,可以指定键不存在时返回的默认值。
这种写法也只需要在查询键值时访问一次键名,然后做一次赋值操作,但要比刚才那种通过​​​KeyError​​实现的方案简单得多。

count = counters.get(key, 0)
counters[key] = count + 1

对于通过​​in​​​表达式与​​KeyError​​​实现的那两种方案来说,确实可以通过各种技巧来简化代码,但不管怎样简化,都无法完全消除重复赋值。所以,优先考虑用​​get​​​方法来实现,因为​​in​​​方案与​​KeyError​​方案无论如何读比它复杂。

if key not in counters:
counters[key] = 0
counters[key] += 1

if key in counters:
counters[key] += 1
else:
counters[key] = 1

try:
counters[key] += 1
except KeyError:
counters[key] = 1

如果字典里保存的数据比较复杂,比如列表,那该怎么办?例如,这次不仅要记录每种面包得的得票数,而且要记录投票的人。那可以像下面这样,把面包的名称(​​key​​)跟一份列表关联起来,而那份列表指的就是喜欢该面包的人。

votes = {
'baguette': ['Bob', 'Alice'],
'ciabatta': ['Coco', 'Deb'],
}

key = 'brioche'
who = 'Elmer'

if key in votes:
names = votes[key]
else:
votes[key] = names = []

names.append(who)

在采用​​in​​​表达式实现的方案里,如果​​key​​​已经存在,那么需要访问两次。一次在​​if​​​语句里,另一次是在获取投票人列表的那条​​names = votes[key]​​语句里。

如果​​key​​​不存在,那就只要在​​if​​​语句中访问一次,然后在​​else​​分支中赋值一次值。这和上面那个单纯统计得票数的例子不同,这次如果发现键名不存在,那么只需要把空白的列表与这个键关联起来就行了。

​​votes[key] = names = []​​​既可以把空白列表赋给​​names​​​变量,又可以把这份列表与​​key​​​相关联,这两项操作,只需要一行语句即可表达出来。
把空白列表(默认值)插入字典后,不需要再用另一条赋值语句给其中的某个元素赋值,一维可以直接在指向这份列表的​​​names​​​变量上调用​​append​​方法把投票人的名字添加进去。

还可以利用​​KeyError​​异常来实现。

key = 'rye'
who = 'Felix'

try:
names = votes[key]
except KeyError:
votes[key] = names = []

names.append(who)

同样,这个列子也能通过​​get​​​方法改写。这样的话,如果键存在,只需要访问一次键名;如果不存在,那么还要在​​if​​​块中用键名​​key​​作为下标赋一次值。

key = 'wheat'
who = 'Gertrude'

names = votes.get(key)
if names is None:
votes[key] = names = []

names.append(who)

这个方案中,无论​​votes.get(key)​​​的结果是不是​​None​​​,都要把这个结果赋给​​names​​​变量,只不过在结果为​​None​​​的时候,还需要在​​if​​块中做一些处理。这种逻辑用赋值表达式,参见第10条,改写可以再节省一行代码。

if (names := votes.get(key)) is None:
votes[key] = names = []
names.append(who)

​​dict​​​类型提供了​​setdefault​​方法,能够继续简化代码。

key = 'cornbread'
who = 'Kirk'

names = votes.setdefault(key, [])
names.append(who)

如果字典里本身有这个​​key​​​,那么这个方法要做的,其实仅仅是返回相关的值而已,这时它不会​​set​​。

在字典里面没有这个键时,​​setdefault​​方法会把默认值直接放到字典里,而不是先给它做副本,然后把副本放到字典中。我们用下面这段代码演示一下默认值为列表时可能出现的问题。

data = {}

key = 'foo'
value = []
data.setdefault(key, value)
print('Before:', data)
value.append('hello')
print('After: ', data)Before: {'foo': []}
After: {'foo': ['hello']}

这意味着每次调用​​setdefault​​​时都要构造一个新的默认值出来。这可能产生较大的性能开销。
回到之前那个只记录票数而不记录投票人的例子。那个例子为什么不用​​​setdefault​​改写呢?比如,可以这样写:

key = 'dutch crunch'

count = counters.setdefault(key, 0)
counters[key] = count + 1

这样写的问题是,根本就没必要调用​​setdefault​​,因为不管字典里有没有这个键,我们都要递增它所对应的值。

count = counters.get(key, 0)
counters[key] = count + 1

无论字典里有没有这个键,之前那种​​get​​​方案只需要一次访问操作与一次赋值操作即可(如上代码,访问​​key​​​,不存在即返回0,第二行赋值一次。),而目前的​​setdefault​​方案(在字典没有键的情况下)需要一次访问操作与两次赋值操作。

只有在少数几种情况下用​​setdefault​​​处理缺失的键才是最简短的方式,例如:与键相关的默认值构造起来开销很低且可以变化,而且不用担心异常问题。在这种特殊的场合,可以用这个​​setdefault​​​方案取代​​get​​​方案。即便如此,一般也应该优先考虑用​​defaultdict​​​取代​​dict​​。

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

如果字典不是自己创建的,那么对其中缺失的键可以考虑用四种办法解决。在这四种办法中,​​get​​​方法要胜过利用​​in​​​表达式和​​KeyError​​​异常来解决的那两种方法。对于某些用例,我们可能觉得​​setdefault​​应该是代码最简短的办法。

例如,要记录去过哪些国家,还要记录在每个国家中到过哪些城市。那可以用这样一个字典。

visits = {
'Mexico': {'Tulum', 'Puerto Vallarta'},
'Japan': {'Hakone'},
}

无论字典中有没有这个国家名,都可以用​​setdefault​​方案把新的城市添加到对应的集合里。

visits.setdefault('France', set()).add('Arles') # 代码简短

if (japan := visits.get('Japan')) is None: # 这种代码就长多了
visits['Japan'] = japan = set()
japan.add('Kyoto')

如果程序所访问的这个字典需要由你自己明确地创建,那又该怎么写?其实这种情况很常见,例如我们经常需要用字典实例来维护对象的内部状态。下面,我们写这样一个类,把刚才那个范例逻辑封装到辅助方法中,使用户可以调用该方法啦访问字典中保存的动态内部状态。

class Visits:
def __init__(self):
self.data = {}

def add(self, country, city):
city_set = self.data.setdefault(country, set())
city_set.add(city)

这个新类把刚才那套复杂的逻辑掩盖了起来,正确地调用了​​setdefault​​方法。

visits = Visits()
visits.add('Russia', 'Yekaterinburg')
visits.add('Tanzania', 'Zanzibar')
print(visits.data){'Russia': {'Yekaterinburg'}, 'Tanzania': {'Zanzibar'}}

问题是,​​Visits.add​​​方法还是写得不够理想,因为它还是调用了​​setdefault​​​方法。这种写法也不够高效,因为每次调用​​add​​​方法时,无论​​country​​​参数所指定的国家名称是否存在,都必须构建新的​​set​​实例。

Python提供了​​defaultdict​​​类,能轻松地实现出刚才那套逻辑。它会在键缺失的情况下,自动添加这个键以及键所对应的默认值。我们只需要在构造这种字典时提供一个函数即可。
每次发现键不存在时,该字典都会调用这个函数返回一份新的默认值。

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')
print(visits.data)defaultdict(<class 'set'>, {'England': {'London', 'Bath'}})

这次​​add​​方法相当简洁。

#18 学会利用__missing__构造依赖键的默认值

内置的​​dict​​​类型提供了​​setdefault​​​方法,在特殊场合可以用这个方法处理缺失的键。然后,对于一般情况,还是应该考虑使用​​defaultdict​​类型。当然,也有一些任务是这二者都处理不好的。

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

pictures = {}
path = 'profile_1234.png'

with open(path, 'wb') as f:
f.write(b'image data here 1234')

if (handle := pictures.get(path)) is None:
try:
handle = open(path, 'a+b')
except OSError:
print(f'Failed to open path {path}')
raise
else:
pictures[path] = handle

handle.seek(0)
image_data = handle.read()

print(pictures)
print(image_data){'profile_1234.png': <_io.BufferedRandom name='profile_1234.png'>}
b'image data here 1234'

如果字典里已经有这个文件句柄,那么这种写法只需要进行一次字典访问。如果没有,那么它会通过​​get​​​方法访问一次字典,然后在​​try/except/else​​​结构的​​else​​分支中做一次赋值。读取数据的代码与打开文件并处理异常的代码可以分开写。

这套逻辑也能用​​in​​​表达式或​​KeyError​​​实现,但那两种方案的字典访问次数与代码嵌套层数都比较多。有人可能认为,既然这套逻辑能用​​get​​​、​​in​​​与​​KeyError​​​这三种方案实现,那么也应该可以用​​setdefault​​方法来实现。

try:
handle = pictures.setdefault(path, open(path, 'a+b'))
except OSError:
print(f'Failed to open path {path}')
raise
else:
handle.seek(0)
image_data = handle.read()

这样写有很多问题。首先,即使图片的路径已经在字典里了,程序还是的调用内置的​​open​​​函数创建文件句柄。
另外,如果​​​try​​​块抛出异常,那我们可能无法判断这个异常是​​open​​​函数导致的,还是​​setdefault​​方法导致的,因为这两次调用全部写在同一行代码里。

如果要把这套逻辑用作内部状态的管理,那么可能还会想到第五种方案,就是用​​defaultdict​​来记录跟踪这些图片。

= 'profile_4555.csv'

with open(path, 'wb') as f:
f.write(b'image data here 9239')

from collections import defaultdict

def open_picture(profile_path):
try:
return open(profile_path, 'a+b')
except OSError:
print(f'Failed to open path {profile_path}')
raise

pictures = defaultdict(open_picture)
handle = pictures[path]
handle.seek(0)
image_data = handle.read()---------------------------------------------------------------------------

TypeError Traceback (most recent call last)

<ipython-input-56-78ce5b3ca9fc> in <module>
14
15 pictures = defaultdict(open_picture)
---> 16 handle = pictures[path]
17 handle.seek(0)
18 image_data = handle.read()


TypeError: open_picture() missing 1 required positional argument: 'profile_path'

出错了,原因在于,传给​​defaultdict​​​的那个函数只能是不需要参数的函数,而我们写的辅助函数需要一个参数。​​defaultdict​​​不知道当前要访问的这个键叫神马,所以无法给辅助函数传递这个参数。
此时,还有一种解决方案,通过继承​​​dict​​​类型并实现​​__missing__​​特殊方法来解决这个问题。我们可以把字典里不存在这个键时所要执行的逻辑写在这个方法中。

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()
print(pictures)
print(image_data){'profile_4555.csv': <_io.BufferedRandom name='profile_4555.csv'>}
b'image data here 9239'

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


上一篇:《Effective Python 2nd》 读书笔记——函数
下一篇:没有了
网友评论