12

Fluent Python 笔记 —— 字典与集合

 4 years ago
source link: https://rollingstarky.github.io/2020/11/08/fluent-python-dicts-and-sets/
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client

Fluent Python 笔记 —— 字典与集合

2020-11-08

| Python

|

|

6.3k

|

0:06

一、映射类型

标准库里的所有映射类型都是利用 dict 实现的,它们有个共同的限制:其中的键必须是可散列的数据类型
关于可散列的数据类型的定义:
若某对象是可散列的,则它的散列值在其整个生命周期中是保持不变的。该对象需要实现 __hash__ 方法和 __qe__ 方法(跟其他键做比较)。如果两个可散列对象是相等的,那么他们的散列值一定相等。

不可变数据类型中的 strbytes 和数字都是可散列类型。
虽然元组本身是不可变序列,但元组中的元素有可能是其他可变类型的引用。只有当一个元组中包含的所有元素都是可散列类型的情况下,该元组才是可散列的。

1
2
3
4
5
6
7
8
>>> tt = (1, 2, (30, 40))
>>> hash(tt)
-3907003130834322577
>>> tl = (1, 2, [30, 40])
>>> hash(tl)
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
TypeError: unhashable type: 'list'

一般用户自定义类型的对象都是可散列的,因此这些对象在比较时都是不相等的。
若某个对象实现了 __eq__ 方法,并且在方法中用到了该对象的内部状态,则只有当这些内部状态都是不可变类型的情况下,该对象才是可散列的。

二、字典推导

1
2
3
4
5
6
7
8
9
10
11
12
>>> DIAL_CODES = [
... (86, 'China'),
... (91, 'India'),
... (1, 'America'),
... (55, 'Brazil'),
... (7, 'Russia')
... ]
>>> country_code = {country: code for code, country in DIAL_CODES}
>>> country_code
{'China': 86, 'India': 91, 'America': 1, 'Brazil': 55, 'Russia': 7}
>>> {code: country.upper() for country, code in country_code.items() if code < 66}
{1: 'AMERICA', 55: 'BRAZIL', 7: 'RUSSIA'}

三、setdefault 处理找不到的键

以下代码的写法:

1
my_dict.setdefault(key, []).append(new_value)

其效果等同于如下代码:

1
2
3
if key not in my_dict:
my_dict[key] = []
my_dict[key].append(new_value)

都是获取 my_dict 中 key 键对应的值(如果该 key 不存在,则新增 key 并令其值为 []),然后向 key 对应的值(列表)中添加新元素。
只不过后者至少要进行两次键查询(如果键不存在,则执行三次键查询),而使用 setdefault 只需要一次键查询就可以完成整个操作。setdefault 在获取 key 键对应的值时,如果该 key 不存在,则把 key 和空列表直接放进映射并返回空列表,因而不需要执行第二次键值查找。

四、弹性键查询

defaultdict

collections.defaultdict 可以做到即便某个键在映射里不存在,也能够在读取这个键的时候得到一个默认值。只需要用户在初始化 defaultdict 对象时,为其指定一个创建默认值的方法。
即在实例化 defaultdict 的时候,向构造方法提供一个可调用对象,该对象会在 __getitem__ 碰到找不到的键的时候被调用,让 __getitem__ 返回某种默认值。

dd = defaultdict(list)。若键 new-key 在 dd 中不存在,表达式 dd['new-key'] 会执行以下操作:

  • 调用 list() 创建一个新列表
  • 把新列表作为值,new-key 作为键存放到 dd 中
  • 返回新列表的引用

这个用来生成默认值得可调用对象(list())存放在名为 default_factory 的实例属性里。若创建 defaultdict 的时候没有指定 default_factory,则查询不存在的键会触发 KeyError。
这些特性背后依赖的是 __missing__ 特殊方法,这个特殊方法是所有映射类型都可以选择性地去支持的。

missing

所有的映射类型处理找不到的键的时候,都会涉及到 __missing__ 方法。虽然基类 dict 并没有定义这个方法,但如果有一个类继承了 dict,该类提供 __missing__ 方法,则 __getitem__ 找不到键的时候,会自动调用 __missing__ 而不会抛出 KeyError 异常。

__missing__ 方法只会被 __getitem__ 调用(比如在表达式 d[k] 中)。提供 __missing__ 方法对 get__contains__ 方法的使用没有影响。

如需要实现一种自定义的映射类型,在查询的时候将映射里的键都转换成 str。示例代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class StrKeyDict(dict):
def __missing__(self, key):
if isinstance(key, str):
raise KeyError(key)
return self[str(key)]

def get(self, key, default=None):
try:
return self[key]
except KeyError:
return default

def __contains__(self, key):
return key in self.keys() or str(key) in self.keys()

运行结果如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
>>> from strkeydict import StrKeyDict
>>> d = StrKeyDict([('2', 'two'), ('4', 'four')])
>>> d['2']
'two'
>>> d[4]
'four'
>>> d[1]
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
File "/home/starky/program/python/algorithm/strkeydict.py", line 5, in __missing__
return self[str(key)]
File "/home/starky/program/python/algorithm/strkeydict.py", line 4, in __missing__
raise KeyError(key)
KeyError: '1'
>>> d.get('2')
'two'
>>> d.get(4)
'four'
>>> d.get(1, 'N/A')
'N/A'
>>> 2 in d
True
>>> 1 in d
False

StrKeyDict 继承自 dict,如果找不到的键本身是字符串,抛出 KeyError 异常;如果找不到的键不是字符串,则将其转换成字符串后再进行查找。
get 方法把查找工作用 self[key] 的形式委托给 __getitem__,这样在确定查找失败之前,还能通过 __missing__ 在给某个键一个机会。

isinstance(key, str)__missing__ 中是必须的,若 str(k) 不是一个存在的键,代码就会陷入无限递归。因为 __missing__ 最后一行中的 self[str(key)] 会调用 __getitem__,而 str(key) 又不存在,则 __missing__ 又会被调用。

__contains__ 方法也是必须的,因为从 dict 继承到的 __contians__ 方法不会在找不到键时调用 __missing__ 方法。

不可变 Map

标准库里所有的映射类型都是可变的。从 Python 3.3 开始,types 模块引入了一个名为 MappingProxyType 的封装类,可以从普通映射创建一个只读的映射视图。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
>>> from types import MappingProxyType
>>> d = {1: 'A'}
>>> d_proxy = MappingProxyType(d)
>>> d_proxy
mappingproxy({1: 'A'})
>>> d_proxy[1]
'A'
>>> d_proxy[2] = 'x'
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
TypeError: 'mappingproxy' object does not support item assignment
>>> d[2] = 'B'
>>> d_proxy
mappingproxy({1: 'A', 2: 'B'})
>>> d_proxy[2]
'B'

集合的本质是许多唯一对象的聚集,可用于去重:

1
2
3
>>> l = ['spam', 'spam', 'eggs', 'spam']
>>> set(l)
{'spam', 'eggs'}

除了保证唯一性,集合还实现了很多基础的中缀运算符。比如 a | b 求并集,a & b 求交集,a - b 求差集等。合理使用这些运算符可以省去不必要的循环和逻辑操作,使代码行数更少且更易读。

比如有一个电子邮件地址的集合 haystack,还要维护一个较小的电子邮件集合 needles,然后求出 needles 中有多少地址同时也出现在了 heystack 里。
求 needles 的元素在 heystack 中出现的次数,两个变量都是集合类型,则只用一行代码即可实现:

1
found = len(needles & haystack)

若不使用交集操作的话,则需要通过以下代码实现:

1
2
3
for n in needles:
if n in haystack:
found += 1

散列表(Hash Map)是一种稀疏数组(即总是有空白元素的数组),散列表中的单元通常叫做表元(bucket)。在 dict 背后的散列表中,每个键值对都占用一个表元,每个表元都包含两个部分,对键的引用和对值的引用。因为所有表元的大小一致,可以通过偏移量来读取某个表元。

Python 会保证大概三分之一的表元是空的,当快要达到这个阈值时,原有的散列表会被复制到一个更大的空间里。把对象存入散列表中之前,需要先计算该元素键的散列值。

内置的 hash() 函数可用于计算所有内置类型对象的散列值。若自定义对象调用 hash(),实际上运行的是自定义的 __hash__ 方法。若两个对象比较时大小相等,则它们的散列值也必须相等。即 1 == 1.0 为真,则 hash(1) == hash(1.0) 也必须为真。

为了让散列值能够作为散列表索引使用,这些散列值必须在索引空间内尽量分散开。意味着在理想状态下,越是相似但不相等的对象,其散列值差别也越大。

1
2
3
4
5
6
>>> hash(1.0001)
230584300921345
>>> hash(1.0002)
461168601842689
>>> hash(1.0003)
691752902764033

散列表算法
为了获取 my_dict[search_key] 背后的值,Python 首先会调用 hash(search_key) 来计算 search_key 的散列值,将该值最低的几位数字作为偏移量(具体几位作为偏移量,需依据当前散列表的大小),在散列表里查找表元。若查找出的表元为空,则抛出 KeyError 异常。若表元非空,该表元中会有一对 found_key: found_value。Python 会检查 search_key == found_key 是否为真,若为真则返回 found_value。
若 search_key 与 found_key 不匹配,这种情况称为散列冲突。算法会在散列值中另外再取几位数字,用特殊方法处理一下,把新得到的数字作为索引继续寻找表元并重复以上步骤。

字典的特性

键必须是可散列的
可散列对象满足以下三个要求:

  • 支持 hash() 函数,且通过 __hash__() 得到的散列值是不变的
  • 支持通过 __eq__() 方法检测相等性
  • a == b 为真,则 hash(a) == hash(b) 也为真

所有用户自定义对象默认都是可散列的,其散列值有 id() 获取,且都不相等。

字典在内存上开销巨大
字典使用了散列表,散列表又必须是稀疏的,从而导致字典在空间的使用上效率低下。
若需要存放数量巨大的记录,放在由元组或具名元组构成的列表中会是比较好的选择。
用元组取代字典存储记录,一是避免了散列表所耗费的空间,二是无需把记录中字段的名字在每个元素里都存一遍,从而节省空间。
在用户自定义类型中,__slots__ 属性可以改变实例属性的存储方式,由 dict 变为 tuple。

字典的键查询很快
dict 的实现是典型的空间换时间。字典类型有着巨大的内存开销,但是它提供了无视数据量大小的快速访问。

键的次序取决于添加顺序

集合的特性

集合里的元素必须是可散列的

集合很消耗内存

可以很高效地判断元素是否存在于某个集合中

元素的次序取决于被添加到集合里的次序

Fluent Python


Recommend

About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK