目录
一、泛映射类型
collections.abc模块中有Mapping和MutableMapping两个抽象基类,他们的作用是为dict和其它泛映射类型定义形式接口。
可以用isinstance函数来判定某个对象是不是泛映射类型
import collections
my_dict = {}
print(isinstance(my_dict, collections.Mapping))
# 输出True
所谓泛映射类型即键值对类型。泛映射类型中键必须是可散列的,可散列对象有三点要求:
第一:在此对象的生命周期中,其散列值不变
第二:需要实现特殊方法__hash__
第三:要有__qe__方法
若两个可散列对象相等,则其散列值一定相等。
原子不可变数据类型,如str、bytes和数值类型都是可散列类型,frozenset也可散列。当元组类型中的元素都是可散列类型时,元组也可散列,如果元组中有不可散列的元素,则元组不可散列。
一般自定义类型可散列,散列值即id()返回值,即其在内存中的地址。
二、字典构建与字典推导式
字典构建方式有很多种,可以用{}也可以用dict()。
可以用字典推导式来构建字典,基本形式类似列表推导式和生成器表达式。字典推导式可以从任何以键值对为元素的可迭代对象中构建出字典。
如上例子中,用于列表推导式的可迭代对象是一个列表,其元素是含有两个元素的元组,当然这两个元素可以一个用来做键,一个做值。
三、常见泛映射类型的方法
常见方法
用setdefault处理找不到的键
当使用d[k]时如果字典d没有键k,python会抛出异常。可以用d.get(k, default)来代替d[k],这样当找不到键k的时候就返回默认值default。但是当我们的目的不是打印出键k对应的值,而是为了更新值得时候,用get()方法就会显得很不自然,d.get(k, default) = x,相当于给一个默认值default赋值了。因此get()并不是处理找不到的键的最好方法。
下面看一个例子。
这个例子的作用是获取一个文件中各个单词出现的位置。字典index中的元素是各个单词及其出现位置的映射,由于单词重复出现位置可能有很多个,因此每个单词都映射到一个列表,这个列表中的元素是该单词出现的位置元组。如下所示,Although出现的位置有三个。
明确目的,我们这个例子是为了观察字典的setdefault方法相比get方法在处理不存在的键时优在哪。
word即单词,location即当次遍历单词出现的位置。首先我们看上图中用get方法时,在标号1处会先进行一次对word的查找,如果word不存在,则标号1处会返回一个空列表关联到occurrences,然后标号2处把word此次出现的位置添加到列表occurrences中,然后在标号3处,实际上又对word进行了一次查找工作,然后把word和occurrences映射存入index。相当于进行了两次键的查询。产生两次查找的主要原因是在标号1处用get方法时只是返回了一个默认的空列表,但是没有把单词和列表之间的映射直接存到index中。
再来看看用setdefault方法时的代码。只需要把上边标号1 2 3处的三行代码替换成如下一行代码:
index.setdefault在第一次查找word时直接就将 "word" : [ ] 的映射加到index字典中了,因此只要再把location放进空列表中即可,省去了第二次查找。即上边用setdefault的写法跟下边等价:
如果键不存在,则把键值对加入字典,然后更新键对应的值。
四、映射的弹性键查询
上一节中sefdefault方法主要的用处是在于查找并插入新值,如果只查找的话,希望给不存在的键一个默认的返回值,本节说了两种方法,一是通过一种特殊的字典类型defaultdict,一种是自定义一个dict的子类,并子类中实现特殊方法__missing__。(我寻思直接用get方法也行啊)
defaultdict:处理找不到的键的一种选择
defaultdict类在collections模块中。
现在用defaultdict类来解决上一节中的问题。如下图所示,只需要在标号1中把原先的字典替换成defaultdict,注意在定义这个类时需要给其构造函数传入一个可调用对象,如图中传递的是list(defaultdict类型的对象有一个对象属性default_factory = list)。则如下所示,如果index[word]的时候特殊方法__getitem__找不到word,则index会默认调用default_factory,下图实例中即调用传递来的可调用对象list会word创造一个默认的值,list函数创造出来的默认值当然就是一个空列表,然后这个空列表被赋值给index[word](本来就是为键word创造出来的值),然后再用append方法就能成功加入location了。
可以看到创建defaultdict的时候一般必须指定default_factory,如果不指定的话,如果查询了一个不存在的键则会触发KeyError。
需要注意的是default_factory只在__getitem__中调用,即用index[word]时如果word查不到则会自动调用default_factory,但是如果用index.get(word)时会直接返回None,不会调用default_factory。
本质上以上机制是由特殊方法__missing__实现的,defaultdict实现了__missing__方法,则方键找不到时会自动调用default_factory,这个特性一般所有映射类型都会去支持。
特殊方法__missing__
映射类型在通过__getitem__找不到键的时候(比如用方括号时d[k]找不到键k),就会自动调用__missing__,但是在其它找键的情况比如get或者__contains__(in运算符底层调用__contains__)找不到键的时候不会调用__missing__,还是会返回异常,即明确__missing__只被__getitem__调用。
虽然键可以是很多类型,比如数字,字符串等,但是有些情况下我们希望在查询的时候,把键全部转化成str。
下边这个例子中,虽然字典StrKeyDict0的对象d中的键都是字符串,但是当我们用数值2或者4来查找时还是能找到正确的值。
下面是StrKeyDict0的定义。
class StrKeyDict0(dict): # StrKeyDict0继承自dict
def __missing__(self, key):
if isinstance(key, str):
raise KeyError(key) # __missing__是用来处理找不到的键的,如果找不到的键本身就是字符串,那么会触发KeyError异常
return self[str(key)] # 如果找不到的键本身不是字符串,那么就转成字符串再查找一次
def get(self, key, default=None):
try:
return self[key] # get方法这里实际上把查找工作委托给__getitem__了,因为这里用了【】索引
except KeyError: # 如果抛出了KeyError,那么说明__missing__也失败了,就直接返回default了
return default
def __contains__(self, key): # 先按照传入的键来查找,如果找不到就转换成字符串再找一次
return key in self.keys() or str(key) in self.keys()
上边定义中可以看到在__missing__中一定要先判断传入的键是不是字符串,如果是字符串还找不到,那就直接抛出异常。这样做是为了防止无限递归调用。试想如果__missing__中只有
return self[str(key)] 这一行,那么当StrKeyDict0对象传入一个键key,就会调用__getitem__方法,__getitem__找不到这个键,就会调用__missing__,__missing__中返回self[str(key)]又会调用__getitem__,这样就会导致无限递归调用了。
还有__contains__中的实现,直接判定key或者str(key)是否在self.keys()中,而不是用 k in my_dict这种python风格的代码,因为 in 运算符会调用__contains__,因此如果那样用的话又会形成无限递归调用。
五、字典的变种
标准库模块collections中,除了defaultdict外还有很多不同的字典类型。
collections.OrderedDict
顾名思义,有序字典,但是这个有序不是说按照键的字典序排序之类的,而是说保持插入顺序,即在迭代的时候,先插入的就先访问到,后插入的就后访问到。OrderedDict的popitem方法默认删除并返回插入的最后一个元素,但是如果popitem(last=Falst)则删除并返回第一个插入的元素。
普通字典是没办法保存插入顺序的。
collections.ChainMap
没用到过,用到再说。
collections.Counter
用到再说。
collections.UserDict
就是把标准dict用纯python又实现了一遍
六、子类化UserDict
继承UserDict重写StrKeyDict0
collections.UserDict是专门用于自定义映射类型的基类。
继承自UserDict主要是为了方便,本例中对于__missing__的实现跟上例一样没有改变,因为当__getitem__找不到键的时候一定会调用__missing__,所以if语句不能避免,否则还是会无线递归。
__contains__的实现直接判断key字符串之后是否在self.data中,这里的self.data实质上是一个dict类型的字典对象,是UserDict的一个属性,也是UserDict真正存储数据的地方。 这里就可以直接用python风格判断str(key)是不是在self.data中,不用担心递归调用的问题,因为self.data找不到键会调用自己的__contains__,而不是我们自定义类的__contains__。
__setitem__把所有键都转换成了字符串,这样在查询时候和储存时的键就都是字符串了。
UserDict不是从dict继承,而是继承自MutableMapping。MutableMapping又继承自Mapping,Mapping是一个抽象基类,但是提供了好几个使用方法。如下所示。
MutableMapping.update
此方法不但能直接用,还默认用在__init__中,当构造函数中传入各种参数(键值对序列就行)时会用这个方法新建实例。这个方法用self[key] = value来添加新值,本质上是在使用__setitem__方法。
Mapping.get
其源码实现跟StrKeyDict一样。目的是为了让get的行为跟__getitem__保持一致。
七、不可变映射类型
标准库中的所有映射类型都是可变的。types模块中有一个封装类型MappingProxyType,其初始化参数接受一个映射对象,返回一个只读的映射视图,这个映射视图是动态的,这个映射视图不能更改,但是原初始化参数的更改可以反应到该映射视图上。
如下图例子,用MappingProxyType返回一个字典d的映射视图d_proxy,d_proxy不能更改,但是更改d的话可以反映到d_proxy上。
八、集合论
set & frozenset
在python中集合类主要是set和frozenset。
集合的本质是许多唯一对象的集合,因此集合可以用于去重。且因为集合的背后是散列表,因此查询速度极快。
集合中的元素必须可散列,如果我们要定义嵌套集合对象,里边的集合不能是set对象,因此set对象是不可散列的,但是frozenset是可散列的,因此可以创建包含不同frozenset的set。
集合实现了交(a & b)、并(a | b),差(a - b)等操作。
集合字面量
定义集合有两种方式。
一种是直接用{ }来定义,如 s = {1, 2, 3}。
一种是用set([1, 2, 3])。
前者比后者要快一点,因为用set()定义的时候,python会先去查询set的构造函数,然后新建一个列表,最后把列表传入到构造函数里,但是直接用 { }的话python会利用一个专门的叫做BUILD_SET的字节码来创建集合。
需要注意的一点是空集合的定义,应该用 s = set() 而不是 s = { },因为后者实际上是定义了一个空字典。
frozenset只能用构造函数来定义。
集合推导式
学了前边的列表推导式和生成器表达式、字典推导式等,再看集合推导式就是水到渠成了。
集合的操作
九、dict与set的背后
散列表
散列表是一个稀疏数组,其中的单元叫做表元(bucket),在dict中,每个键值对占用一个表元,每个表元分为两个部分,一个是对键的引用,一个是对值的引用。python会保证三分之一的表元是空的,因此快达到这个阈值的时候,原有散列表会被复制到一个更大的空间中去。散列表中每个表元的大小都是一样的,因此可以根据键的散列表来确定一个偏移量,通过偏移量来定位到某个表元。
要把一个对象放入散列表,首先要计算这个元素键的散列值,python中用hash()方法实现。
内置hash()方法可以用于所有的内置类型对象,如果对自定义类型对象调用hash(),则实际运行的是自定义的特殊方法__hash__,在这个特殊方法里边应该还是计算内置类型的hash吧。如果两个对象在比较的时候是相等的,那么他们的散列值必须相等。如果 1 == 1.0为真,那么hash(1) == hash(1.0)也必须为真。
为了让散列值能够充当散列表索引,散列值在索引空间中应该尽量分散开来。最理想的情况下越是相似但不想等的对象,其散列值差别应该越大。
散列表算法
- 给定一个search_key,为了获取其对应的值my_dict[search_key]
- python首先调用hash(search_key)计算键search_key的散列值,以这个值的最低几位数字(位数跟当前散列表大小有关,散列表越大,需要的位数越多)作为偏移量,在散列表中查找表元。
- 若找到表元为空,则返回KeyError,即不存在该键search_key
- 若找到表元非空,则表元中存储了两个引用,found_key和found_value。检验search_key == found_key是否为真,如果为真则返回found_value。如果为假,则发生了散列冲突。
- 散列冲突是因为两个键的散列值的最后几位相等,因此造成在散列表中偏移量相同。解决散列冲突有很多种方法,一般可以在散列值上再取几位,然后处理一下得到新的偏移量再次寻找表元。
如果是插入新值而非简单查找,则python会根据散列表的拥挤程度决定是否扩容,扩容以后散列表增大,则散列值位数与用作索引的位数都会增加,这样做可以减少发生散列冲突的概率。值得注意的是散列冲突并不经常发生,是很偶然的事件。
dict实现导致的后果
- 键必须是可散列的。可散列对象要满足三个条件:(1)支持hash()函数,并且通过__hash__方法得到的散列值是不变的。(2)支持通过 __eq__方法来检测相等性。(3)若a == b 为真,则hash(a) == hash(b)也为真。
- 字典在内存上开销巨大。字典使用散列表,而散列表必须是稀疏的,因此空间效率低下。要节省空间可以用元组取代字典,优化原因有两个:其一是避免了散列表所耗费的空间,其二是无需把记录中字段的名字在每个元素里存一遍。在用户自定义的类中,通过__slots__属性可以改变实例属性的存储方法,由dict变成tuple。
- 键查询很快。dict实现是典型空间换时间,无视数据量大小常数时间复杂度访问。
- 键的次序取决于添加顺序。当往字典里添加新键,而且发生散列冲突,则新键会被安排到另一个位置。于是当两个键被添加到字典中的顺序不同,虽然得到的是相同的字典,但是这两个键出现在字典中的顺序是不一样的。
- 往字典里添加新键会改变已有键的顺序。无论何时往字典里添加新键,python解释器都可能为字典扩容,导致新建一个更大的散列表,这个过程可能发生新的散列冲入,导致新散列表中键的次序变化。因此不要在循环字典的时候同时修改,因为修改以后字典中键的顺序可能已经变了,因此可能循环会跳过一些键。
set的实现以及导致的结果
set和frozenset也是基于散列表实现的,知识其表元中只存储有键的引用,没有值的引用。