第11条:学会对序列进行切片
python支持从序列里面切割出一部分内容,让我们能够放轻松地获取原序列地某个子集合,最简单的用法就是切割内置的list,str与bytes。其实,凡是实现了__getitem__与__setitem__这两个特殊方法的类都可以进行切割。
切片最基本的用法就是somelist[start:end]这一形式来切割,也就是从start开始一直取到end这个位置,但不包含end本身的元素。如果从头开始切割列表,那就应该省略start,如果一直取到末尾,那就应该省略end。用负数作下标表示从倒数第k个。
1 | a[:] |
切片可以出现在赋值符号的左侧,表示用右侧那些元素把原列表中位于这个范围之内的元素换掉。与unpacking形式的赋值不同,这种赋值不要求等号两边所指定的元素个数必须相同,但是如果元素个数不同,列表的长度会发生变化。
1 | a[2:5] = [2, 3, 4] # list's length not change |
第12条:在切片中指定步进
python的切片还支持不仅切片形式,也就是somelist[start:end:stride]。这种形式从start开始取,每n个元素里面选取一个。
1 | x = ['red', 'orange', 'yellow', 'green', 'blue', 'purple'] |
当步进值设置为负数时,表示从start开始,从后往前取
1 | print(x[-3: 2]) |
设置步进值为负数的一个应用就是用于将列表进行反转
1 | print(x[::-1]) |
第13条:通过带星号的unpacking来捕获多个元素
python基本unpacking操作有一项限制,就是必须提前需要确定要拆解的序列的长度。但是如果不事先知道长度,而且想把一些元素仍然以list的形式保存,一种办法是通过获取长度,然后通过下标获取加切片的形式:
1 | car_ages = [0, 9, 4, 8, 7, 20, 19, 1, 6, 15] |
更好的方式是使用带*的unpacking:
1 | oldest, *others = car_ages_desc |
这种带星号的表达式可以出现在任意位置,所以它能够获取序列中的任何一段元素:
1 | oldest, *others, youngest = car_ages_desc |
1 | *all = car_ages_desc # syntax error |
另外,如果要拆分的列表里以及没有元素留给带*的变量,那么该变量会是一个长度为0的列表
1 | short_list = [1, 2] |
使用带星号的unpacking需要注意一点,带星号的这部分总是会形成一份列表,这有可能会耗尽计算机的全部内存并导致程序崩溃,尤其是在和生成器(yield方法)一起使用的时候。
第14条:用sort方法的key参数来表示复杂的排序逻辑
内置的列表类型提供了名叫sort的方法,可以按照多项指标给list实例中的元素进行排序。在默认情况下,sort方法总是按照自然升序排列列表内的元素。例如,如果列表中的元素都是整数,那么它就按数值从小到大排列
1 | numbers = [93, 86, 11, 68, 70] |
凡是具备自然顺序的内置类型几乎都可以用sort方法进行排列,例如字符串、浮点数等。但是一般的对象又该如何排序呢?比如,假如这里定义了一个People类:
1 | class People(object): |
如果仅仅这样写,那么这个由该类的对象所构成的列表是没办法用sort方法排序的,因为sort方法发现,排序所需要的特殊方法并没有在People类中实现
1 | perples.sort() |
虽然我们可以在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)) |
1 | peoples.sort(key=lambda p: (p.name, -p.age)) |
1 | peoples.sort(key=lambda p: p.age, reverse=True) |
无论有多少项排序指标都可以按照这种思路来实现,而且每项指标可以分别按照各自的方向来排,也就是越主要的那项排序指标放在越后一轮处理。
尽管两种思路都能实现两种的效果,但是只调用一次sort,还是要比多次调用sort更为简单,所以,在实现多个指标按不同方向排序时,应该优先考虑让key函数返回元组,并按需对元组中的相应指标进行取反,只有在万不得已的时候,才考虑多次调用sort方法
第15条:不要过分依赖给字典添加条目时所用的顺序
S在python3.5与之前的版本中,迭代字典(dict)时所看到的顺序是任意的,即不一定与当初把这些键值对添加到字典时的顺序相同,而且每次迭代的顺序也不固定。
1 | # python 3.5 |
从Python3.6开始,字典会保留这些键值对在添加时所用的顺序,而且python3.7版本的语言规范正式确立了这条规则。于是在新版的python里,总是能够按照当初创建字段时的那套顺序来遍历这些键值对。
1 | # python 3.7 |
这项变化对Python中那些依赖字典类型及其实现细节的特性产生了很多影响:
函数的关键字参数(包括万能的**kwargs参数),以前是按照几乎随机的顺序出现的,现在,这些关键字参数总是能够保留嗲用函数时所指定的那套顺序
1
2
3def 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 | * 类也会利用字典来保存这个类的实例所具备的一些数据,在早前版本的Python中,遍历对象(object)中的`__dict__`也是按随机顺序出现的,同样,在新版的Python中,我们可以认为这些字段在`__dict__`中出现的顺序应该与当初赋值的顺序一样。 |
例如:
1 | def get_first(the_dict): |
在实际调用get_first函数时,我们不知道传入的是标准的dict类型,还是一个实现了items方法的类。解决这个问题有以下几种方法:
在函数开头判断是否是标准dict
1
2
3
4def 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
3from 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 | counter = { |
使用if表达式需要访问key两次,并且进行赋值操作一次,还有一种方法也可以实现相同的功能,就是利用KeyError异常:
1 | try: |
这种方式比用if表达要稍稍高效一点,因为只需要一次访问和一次赋值操作。更好的方法是使用dict的get方法,get方法第一参数指定自己想要查的键,第二个参数指定这个键不存在时返回的值:
1 | count = counter.get(key, 0) |
虽然这种方法也需要一次访问和一次赋值操作,但是这比捕获KeyError的方式代码更简洁。
假设dict里面的value不是简单类型,而是例如列表list这样的复杂类型时,修改可能存在的key时应该怎么处理呢:
1 | votes = { |
在采用if表达式的实现方案里,如果键名已经存在,那么需要访问两次(一次是在if语句里,另外一次是在获取列表的语句里);如果键名不存在,那么就只需要在if语句中访问一次,然后再else语句中赋值一次。
在再采用捕获KeyError的方案里,如果键已经再字典中,那么只需要在try块里访问一次键名;如果不在字典中,那么要先在try块里访问一次键名,然后在except块中做一次赋值。
在使用get方法的方案里,由于get方法在key不存时,虽然会返回设置的默认返回值,但是不会将对应的值和字典关联起来,所以在操作复杂类型时,为了减少赋值操作,更好的方式是先将key和value关联起来,再对value进行操作
dict类型还提供了setdefault方法,能够继续优化代码。这个方法会查询字典里有没有这个键,如果有,就返回对应的值,如果没有,就先把用户提供的默认值跟这个键关联起来并插入字典,然后返回这个值。总之,这个方法所返回的值肯定已经跟键关联起来。
1 | names = votes.setdefault(key, []) |
使用setdefault方法可以达到预期的效果,并且代码也很简洁。但是代码读起来会有歧义,setdefault的表现和它的名称似乎有点不相符:它实际上是在获取value,但是却叫做set。另外,当key不存在时,默认值会直接简单赋值给对应的key,而不是进行深拷贝,这样就可能存在问题。
1 | data = {} |
由于这个问题存在,就意味着必须保证每次调用setdefault时,默认值参数都必须重新构造,这也导致不论key是否存在,都会进行一次默认值构造的开销。
但其实更好的解决方法是使用defaultdict类,见下面的第17条。
第17条:用defaultdict处理内部状态中缺失的元素
deafultdict类是collections包中内置的模块,相比于setdefault要求提供默认值,它需要提供的是一个函数,注意,该函数不能有任何必填参数。
1 | from collections import defaultdict |
第18条:学会利用__missing__构造依赖键的默认值
前面介绍了dict的setdefault方法和内置的defaultdict类来解决key缺失的情况,但是还有些情况是这两个方法也不好解决的。
例如,有一个key为文件路径,value文件句柄的dict,用于文件的重复读写,当key在dict不存在时,需要打开文件并将句柄添加到dict中
1 | pictures = {} |
使用get方法,如果字典中已经有这个handle了,那么这种写法只需要进行一次字典访问。如果没有,那么它会通过get方法访问一次字典,然后在try/except/else结构的else分支中做一次赋值。
这套逻辑也能用in表达式或KeyError实现,但那两种方案的字典访问次数与代码嵌套层数都比较多。有人可能觉得,既然这套逻辑能用get、in与KeyError这三种方案实现,那么也应该可以用第四种方案,也就是setdefault方法来是实现:
1 | try: |
这样写有很多问题,因为即使图片的路径名已经在字典中了,程序还是得调用内置得open函数创建文件句柄,并且这个handle也没有显示地close。
如果考虑使用defaultdict来实现,由于defaultdict要求传入的构造函数不能有任何必填参数,所以在这种情况下,使用defaultdict也是不太好的:
1 | from collections import defaultdict |
幸运的是,python还提供了一个内置的解决方法,那就是我们可以自定义一个类并继承自dict类型,并重写__missing__方法来自定义key缺失的情况怎么处理。
1 | class Picture(dict): |
__missing__方法必须给key创建一个default值,并插入到自身中,在调用self[key]时是不会再次触发__missing__方法的。






如果所有的key可以放进内存,只需要进行一次磁盘寻址,就可以将value从磁盘加载到内存。如果那部分的数据文件已经在文件系统的缓存中,则读取根本不需要任何的磁盘I/O。
但是,如果数据只追加到一个文件,那么如果避免最终用尽磁盘空间?一个好的解决方法是将日志分解成一定大小的段,当文件达到一定大小时就关闭它,并将后续写入到新的段文件中。同时,对于已经关闭的段文件,可以在每个段文件内部执行压缩操作,丢弃重复的键,只保留每个键在在段文件中最新的值,一个段文件压缩之后通常会使得段文件变得更小。在多个已经关闭的段文件直接可以执行合并操作,将多个压缩过的段文件合并成一个段文件。
这些冻结段的合并和压缩过程可以在后台线程中完成,而且运行时,仍可以使用未合并和压缩的段文件继续正常的读取。当合并过程完成后,再将读取请求切换到新的压缩合并完成的段文件上,旧的段文件则可以安全的删除。
每个段文件都有自己的内存hash map,用于将键映射到段文件中的偏移量。为了找到键的值,需要将所有段文件的hash表加载进内存,然后首先检查最新段的hash map,如果键不存在,检查第二新的段,以此类推。由于合并后可以维持较少的段数量,因此查询通常不需要检查很多的hash map。
还有很多的细节方面的考虑才能使得这个简单的想法在实际中行之有效。简而言之,在真正地实现中有以下重要问题:
* 如果要删除键和它关联地值,则必须在数据文件中追加一个特殊的删除记录。当合并日志段时,一旦发现删除记录,则会丢弃这个已删除键的所有值。
* 由于写入以严格的先后顺序追加到文件中,通常的实现方式是只有一个写线程。由于数据文件段是追加的,而且已经写入的数据是不可变的,所以它们可以被多个线程同时读取。
一个追加式的存储看起来似乎很浪费空间:为什么不原地更新文件,用新值覆盖旧值?但是,事实上,追加式的设计是一个不错的设计,原因有以下几点:
* 追加主要是顺序写,它通常比随机写快很多,特别是在旋转式磁性硬盘上。在某种程度上,属性写入在基于闪存的固态硬盘上也是合适的。
* 如果段文件是追加或不可变的,则并发和崩溃恢复要简单得多。里如果不必担心在重写值时发生崩溃的情况,留下一个包含部分旧值和部分新值混杂在一起的文件。
* 合并旧段可以避免随着时间推移数据文件出现碎片化的问题。
但是,哈希表索引也有其局限性:
* hash map必须全部放入内存,如果有大量的键,那么很可能超内存。原则上,可以在磁盘上维护hash map,但是,很难使磁盘上的hash map表现良好。它需要大量的随机访问I/O,并且哈希冲突时需要复杂的处理逻辑
## SSTable
对于单纯的将key-value按顺序添加到文件尾部的方式,如果在写入时对key进行排序,索引结构还是hash map。这样的存储形式就被成为SSTable(排序字符串表)。SSTable相对于单纯顺序添加的形式有以下几个优点:
1、在合并冻结段时更高效,由于每个段中的key都是有序的,合并时方法类似与归并排序merge sort的方式
2、不需要把段文件中每个key的偏移量都存进内存的hash map中,例如,如果当前查找的键handiwork,且不知道该键在段文件中的确切偏移量,但是如果知道键handbag和键handsome的偏移量,由于键在段文件中时有序的,可以跳到handbag的偏移,从那里开始顺序扫描,直到找到handiwork。所以,虽然仍然需要内存的hash map做索引,但是它可以是稀疏的,而且由于顺序扫描可以很快的扫描几千个字节,所以通常对于一个段文件,只需要每隔几千个字节选一个key添加到内存中的hash map做索引即可。






