0%

列表与字典

第11条:学会对序列进行切片

python支持从序列里面切割出一部分内容,让我们能够放轻松地获取原序列地某个子集合,最简单的用法就是切割内置的list,str与bytes。其实,凡是实现了__getitem____setitem__这两个特殊方法的类都可以进行切割。

切片最基本的用法就是somelist[start:end]这一形式来切割,也就是从start开始一直取到end这个位置,但不包含end本身的元素。如果从头开始切割列表,那就应该省略start,如果一直取到末尾,那就应该省略end。用负数作下标表示从倒数第k个。

1
2
3
4
5
a[:]
a[:5]
a[2:]
a[-3:]
a[1:-1]
使用切片时,即使下标越界也不会有问题,python会自动忽略不存在的元素。使用切片切割处理的列表是一份新的列表,即使把某个元素替换掉,也不会影响原列表中的相应位置。

切片可以出现在赋值符号的左侧,表示用右侧那些元素把原列表中位于这个范围之内的元素换掉。与unpacking形式的赋值不同,这种赋值不要求等号两边所指定的元素个数必须相同,但是如果元素个数不同,列表的长度会发生变化。

1
2
3
a[2:5] = [2, 3, 4] # list's length not change
a[2:5] = [2, 3, 4, 4] # list's length will grow 1
a[2:5] = [2, 3] # list's length will desc 1

第12条:在切片中指定步进

python的切片还支持不仅切片形式,也就是somelist[start:end:stride]。这种形式从start开始取,每n个元素里面选取一个。

1
2
3
4
5
x = ['red', 'orange', 'yellow', 'green', 'blue', 'purple']
print(x[::2])

>>>
['red', 'yellow', 'blue']

当步进值设置为负数时,表示从start开始,从后往前取

1
2
3
4
print(x[-3: 2])

>>>
['green', 'orange']

设置步进值为负数的一个应用就是用于将列表进行反转

1
2
3
4
print(x[::-1])

>>>
['purple', 'blue', 'green', 'yellow', 'orange', 'red']

第13条:通过带星号的unpacking来捕获多个元素

python基本unpacking操作有一项限制,就是必须提前需要确定要拆解的序列的长度。但是如果不事先知道长度,而且想把一些元素仍然以list的形式保存,一种办法是通过获取长度,然后通过下标获取加切片的形式:

1
2
3
4
car_ages = [0, 9, 4, 8, 7, 20, 19, 1, 6, 15]
car_ages_desc = sorted(car_ages, reverse=True)
oldest = car_ages_desc[0]
others = car_ages_desc[1:]

更好的方式是使用带*的unpacking:

1
oldest, *others = car_ages_desc

这种带星号的表达式可以出现在任意位置,所以它能够获取序列中的任何一段元素:

1
oldest, *others, youngest = car_ages_desc
但是带星号的unpacking要求必须至少有一个普通的接受变量和它匹配,并且同一级unpacking里面至多只能有一个带星号的变量
1
2
*all = car_ages_desc # syntax error
first, *middle, *second_middle, last = [1, 2, 3, 4] # syntax error

另外,如果要拆分的列表里以及没有元素留给带*的变量,那么该变量会是一个长度为0的列表

1
2
3
4
5
6
short_list = [1, 2]
first, second, *rest = short_list

print(short_list)
>>>
[]

使用带星号的unpacking需要注意一点,带星号的这部分总是会形成一份列表,这有可能会耗尽计算机的全部内存并导致程序崩溃,尤其是在和生成器(yield方法)一起使用的时候。

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

内置的列表类型提供了名叫sort的方法,可以按照多项指标给list实例中的元素进行排序。在默认情况下,sort方法总是按照自然升序排列列表内的元素。例如,如果列表中的元素都是整数,那么它就按数值从小到大排列

1
2
3
4
5
6
numbers = [93, 86, 11, 68, 70]
numbers.sort()
print(numbers)

>>>
[11, 68, 70. 86, 93]

凡是具备自然顺序的内置类型几乎都可以用sort方法进行排列,例如字符串、浮点数等。但是一般的对象又该如何排序呢?比如,假如这里定义了一个People类:

1
2
3
4
5
6
7
8
9
10
11
12
class People(object):
def __init__(self, name, age, gender):
self.name = name
self.age = age
self.gender = gender


peoples = [
People('Tom', 23, 'male'),
People('Jane', 22, 'female'),
People('henry', 24, 'male'),
]

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

1
2
3
4
5
perples.sort()

>>>
Traceback ...
TypeError: '<' not supported between instances of 'People' and 'People'

虽然我们可以在People类中定义一些特殊的方法让我们在无须额外参数的情况下就能直接在这些类的实例所构成的列表上进行排序(参见第73条)。但是更为常见的情况是,很多对象需要在不同的情况下按照不同的标准排序,此时定义自然排序实际上没有意义。这些排序标准通常是针对对象中的某个属性,我们可以把这样的排序逻辑定义成函数,然后将这个函数传给sort方法的key参数。这个函数只有一个参数,用于指代列表中有待排序的对象,函数返回的应该是一个可比较的,具有自然顺序的值。

1
peoples.sort(key=lambda p:p.name)

有些时候我们可能需要用多个标准来排序。例如,在名字位首要标准的情况下,再按年龄进行排序,这种怎么实现呢?最简单的办法是利用元组类型来实现。两个元组是可以进行比较的,因为元组类型本身已经定义了自然顺序,也就是说,sort方法所要求的特殊方法(例如__it__方法),它都已经定义好了。元组在实现这些特殊方法时会依次比较每个位置的那两个对应元素,直到能够确定大小为止,注意,对应位置的元素要能够比较大小,否则也会报异常。

利用元组的特性,我们来对peoples数组先按名称排序,再按年龄排序:

1
peoples.sort(key=lambda p: (p.name, p.age))
但是,利用元组有一种功能不能实现,就是key函数所构造的这个元组只能按照同一个排序方向来对比它所表示的各项指标(要是升序,就都得是升序;要是降序,就都得是降序),所以不太好实现name按降序排序,而age按升序排序的效果。sort方法可以指定reverse参数,这个参数会同时影响元组中的各项指标。 一种解决方法是,如果其中一项是数字,那么可以在实现key函数时,利用取反操作符让该指标对应的值取反,以此达到按照不同方向排序的目的。
1
peoples.sort(key=lambda p: (p.name, -p.age))
但是,这个技巧并不适合所有的类型,例如,对字符串类型就无法应用取反操作符。 这时候,我们就应该考虑sort方法的一项特性,那就是这个方法是个稳定的排序算法。这意味着,如果key函数认定两个值相等,那么这两个值在排序结果中的先后顺序会与它们在排序前一致,于是,我们可以在同一个列表上多次调用sort方法,每次指定不同的排序指标,但是需要把次要指标放在第一轮排序,把首要指标放在第二轮。
1
2
peoples.sort(key=lambda p: p.age, reverse=True)
peoples.sort(key=lambda p: p.name)

无论有多少项排序指标都可以按照这种思路来实现,而且每项指标可以分别按照各自的方向来排,也就是越主要的那项排序指标放在越后一轮处理。

尽管两种思路都能实现两种的效果,但是只调用一次sort,还是要比多次调用sort更为简单,所以,在实现多个指标按不同方向排序时,应该优先考虑让key函数返回元组,并按需对元组中的相应指标进行取反,只有在万不得已的时候,才考虑多次调用sort方法

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

S在python3.5与之前的版本中,迭代字典(dict)时所看到的顺序是任意的,即不一定与当初把这些键值对添加到字典时的顺序相同,而且每次迭代的顺序也不固定。

1
2
3
4
5
6
7
8
9
# python 3.5
baby_names = {
'cat': 'kitten',
'dog': 'puppy'
}
print(baby_names)

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

从Python3.6开始,字典会保留这些键值对在添加时所用的顺序,而且python3.7版本的语言规范正式确立了这条规则。于是在新版的python里,总是能够按照当初创建字段时的那套顺序来遍历这些键值对。

1
2
3
4
5
6
7
8
9
# python 3.7
baby_names = {
'cat': 'kitten',
'dog': 'puppy'
}
print(baby_names)

>>>
{'cat': 'kitten', 'dog': 'puppy'}
在python3.5与之前的版本中,dict所提供的许多方法(包括keys,values,items与popitem等)都不保证固定的顺序。在3.6之后的python版本中,这些方法也已经可以按照当初添加键值对时的顺序来处理了。

这项变化对Python中那些依赖字典类型及其实现细节的特性产生了很多影响:

  • 函数的关键字参数(包括万能的**kwargs参数),以前是按照几乎随机的顺序出现的,现在,这些关键字参数总是能够保留嗲用函数时所指定的那套顺序

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

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

goose = gosling
kangraoo = joey

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
* 类也会利用字典来保存这个类的实例所具备的一些数据,在早前版本的Python中,遍历对象(object)中的`__dict__`也是按随机顺序出现的,同样,在新版的Python中,我们可以认为这些字段在`__dict__`中出现的顺序应该与当初赋值的顺序一样。

```python
class MyClass(object):
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
但是,我们在写代码时,不能假设所有的字典类型的参数都能保证键值对插入时的顺序,因为,我们很容易就自己可以构造一个与标准dict相似的类型(拥有标准dict支持的所有方法,但是方法的行为可能与标准dict不同)。

例如:

1
2
def get_first(the_dict):
return the_dict.items[0]

在实际调用get_first函数时,我们不知道传入的是标准的dict类型,还是一个实现了items方法的类。解决这个问题有以下几种方法:

  • 在函数开头判断是否是标准dict

    1
    2
    3
    4
    def get_first(the_dict):
    if not isinstance(the_dict, dict):
    raise TypeError('must provide a dict instance')
    return the_dict.items[0]
  • 通过类型注解,在程序时,使用mypy模块进行静态分析

    1
    2
    3
    from typing import Dict
    def get_first(the_dict: Dict[str, int]):
    return the_dict.items[0]
    1
    python3 -m mypy --strict xxx.py

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

字典有三种基本的交互操作:访问、赋值以及删除键值对。字典的内容经常变动,所以完全由可能会出现你想访问或删除的键以及不在字段中了,所以大多数情况我们访问字段都要先判断一下key是否还在dict中

1
2
3
4
5
6
7
8
9
10
11
counter = {
'sourdough': 1
}

key = 'wheat'
if key not in counter:
count = 0
else:
count = counter[key]

counter[key] = count + 1

使用if表达式需要访问key两次,并且进行赋值操作一次,还有一种方法也可以实现相同的功能,就是利用KeyError异常:

1
2
3
4
5
try:
count = counter[key]
except KeyError:
count = 0
counter[key] = count + 1

这种方式比用if表达要稍稍高效一点,因为只需要一次访问和一次赋值操作。更好的方法是使用dict的get方法,get方法第一参数指定自己想要查的键,第二个参数指定这个键不存在时返回的值:

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

虽然这种方法也需要一次访问和一次赋值操作,但是这比捕获KeyError的方式代码更简洁。

如果字典里的数据属于比较简单的类型,那么代码最简单、表达最清晰的方案就是使用dict内置的get方法。

假设dict里面的value不是简单类型,而是例如列表list这样的复杂类型时,修改可能存在的key时应该怎么处理呢:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
votes = {
'baguette': ['Bob', 'Alice'],
'ciabatta': ['Coco', 'Deb']
}

key = 'brioche'
who = 'Elmer'

# use if expressin
if key in votes:
names = votes[key]
else:
votes[key] = names = []
names.append(who)

# use try catch
try:
names = votes[key]
except KeyError:
votes[key] = name = []
names.append(who)

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

在采用if表达式的实现方案里,如果键名已经存在,那么需要访问两次(一次是在if语句里,另外一次是在获取列表的语句里);如果键名不存在,那么就只需要在if语句中访问一次,然后再else语句中赋值一次。

在再采用捕获KeyError的方案里,如果键已经再字典中,那么只需要在try块里访问一次键名;如果不在字典中,那么要先在try块里访问一次键名,然后在except块中做一次赋值。

在使用get方法的方案里,由于get方法在key不存时,虽然会返回设置的默认返回值,但是不会将对应的值和字典关联起来,所以在操作复杂类型时,为了减少赋值操作,更好的方式是先将key和value关联起来,再对value进行操作

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

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

使用setdefault方法可以达到预期的效果,并且代码也很简洁。但是代码读起来会有歧义,setdefault的表现和它的名称似乎有点不相符:它实际上是在获取value,但是却叫做set。另外,当key不存在时,默认值会直接简单赋值给对应的key,而不是进行深拷贝,这样就可能存在问题。

1
2
3
4
5
6
7
8
9
10
data = {}
value = []
data.setdefault('foo', value)
print('Before:', data)
value.append('hello')
print('After:', data)

>>>
Before: {'foo', []}
After: {'foo': ['hello']}

由于这个问题存在,就意味着必须保证每次调用setdefault时,默认值参数都必须重新构造,这也导致不论key是否存在,都会进行一次默认值构造的开销。

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

但其实更好的解决方法是使用defaultdict类,见下面的第17条。

第17条:用defaultdict处理内部状态中缺失的元素

deafultdict类是collections包中内置的模块,相比于setdefault要求提供默认值,它需要提供的是一个函数,注意,该函数不能有任何必填参数。

1
2
3
4
5
6
7
8
from collections import defaultdict
data = defaultdict(list) # list as a construct function
data["foo"].append('hello')

print(data)

>>>
defaultdict(<class 'list'>, {'foo': ['hello']})

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

前面介绍了dict的setdefault方法和内置的defaultdict类来解决key缺失的情况,但是还有些情况是这两个方法也不好解决的。

例如,有一个key为文件路径,value文件句柄的dict,用于文件的重复读写,当key在dict不存在时,需要打开文件并将句柄添加到dict中

1
2
3
4
5
6
7
8
9
10
11
12
13
pictures = {}
path = 'profile_1234.png'

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

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

使用get方法,如果字典中已经有这个handle了,那么这种写法只需要进行一次字典访问。如果没有,那么它会通过get方法访问一次字典,然后在try/except/else结构的else分支中做一次赋值。

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

1
2
3
4
try:
handle = pictures.setdefault(path, open(path, 'a+b'))
except OSError:
print(f'failed to open path {path}')

这样写有很多问题,因为即使图片的路径名已经在字典中了,程序还是得调用内置得open函数创建文件句柄,并且这个handle也没有显示地close。

如果考虑使用defaultdict来实现,由于defaultdict要求传入的构造函数不能有任何必填参数,所以在这种情况下,使用defaultdict也是不太好的:

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

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

prictures = defaultdict(open_picture)
handle = pictures[path]
>>>
Traceback ...
TypeError: open_picture() missing 1 required positional

幸运的是,python还提供了一个内置的解决方法,那就是我们可以自定义一个类并继承自dict类型,并重写__missing__方法来自定义key缺失的情况怎么处理。

1
2
3
4
5
6
7
class Picture(dict):
def __missing__(self, key):
value = open_picture(key)
self[key] = value
return value
pictures = Pictures()
handle = pictures[path]

__missing__方法必须给key创建一个default值,并插入到自身中,在调用self[key]时是不会再次触发__missing__方法的。

总结一下,目前有以下几种方法去处理访问dict key缺失的情况: 1. 使用dict内置的`get`方法,提供key缺失时返回默认值,该方法不会修改dict本身 2. 使用`setdefault`方法,在key缺失时修改dict并返回对应的值 3. 使用`defaultdict`类型,需要提供一个没有任何必填参数的函数作为key缺失时,用于构造对象的构造函数 4. 自定义类继承自dict类型,并重写`__mising__`方法 考虑使用的方法的优先级为1>3>2>4