在所有的Python程序中都会使用到字典。即便没在代码中直接使用,也是间接用到,因为dict
类型是Python实现的一个基础。类和实例发不发、模块命名空间以及函数关键词参数都是在内存以及字典表示的核心Python结构。__builtins__.__dict__
存储着所有的内置类型、对象和函数。
因其作用重大,Python的字典进行了高度的优化,并且还在不断改进。Python高性能字典背后的引擎是哈希表。
其它基于哈希表的内置类型有set
和frozenset
。它们提供了比其它流行编程语言中集合更多的API和运算符。具体来说,Python集合实现了集合理论中的所有基础运算,如并集、交集、子集测试等。借助于它们,我们以更为声明式的方式表达算法,避免大量的嵌套循环和条件语句。
以下本章的概述:
- 构建、处理
dicts
和映射的现代语法,包括增强的解包和模式匹配。 - 映射类型的常用方法。
- 缺失键的特殊处理。
- 标准库中
dict
的变体。 set
和frozenset
类型。- 集合和字典行为中哈希表的内涵。
现代字典语法
下面的小节中讲解构建、解包和处理映射的高级语法。其中一些特性并不是语言中新增的,但对于读者而言可能第一次听到。有一部分语法要求使用Python 3.9(比如管道运算符 |
) 或Python 3.10(如 match/case
)。我们先从一个优秀而又古老的特性开始。
dict推导式
从Python 2.7开始,列表推导式和生成式表达式就进行了dict
推导式(以及set
推导式,稍后会讲解)的适配。字典推导式通过从任意迭代对象接收key:value
对构造一个dict
实例。例3-1演示了使用dict
推导式通过同一个元组列表构造两个字典。
例3-1:字典推导式的示例
>>> dial_codes = [ # dial_codes键值对可迭代对象可直接传递给dict构造函数,但是...
... (880, 'Bangladesh'),
... (55, 'Brazil'),
... (86, 'China'),
... (91, 'India'),
... (62, 'Indonesia'),
... (81, 'Japan'),
... (234, 'Nigeria'),
... (92, 'Pakistan'),
... (7, 'Russia'),
... (1, 'United States'),
... ]
>>> country_dial = {country: code for code, country in dial_codes} # ...此处进行键值互调:country为键,code为值
>>> country_dial
{'Bangladesh': 880, 'Brazil': 55, 'China': 86, 'India': 91, 'Indonesia': 62,
'Japan': 81, 'Nigeria': 234, 'Pakistan': 92, 'Russia': 7, 'United States': 1}
>>> {code: country.upper() # 按名称对country_dial排序,键值互调,值置为大写,然后使用code < 70进行过滤
... for country, code in sorted(country_dial.items())
... if code < 70}
{55: 'BRAZIL', 62: 'INDONESIA', 7: 'RUSSIA', 1: 'UNITED STATES'}
复制代码
如果会使用列表推导式的话,就自然会使用字典推导式。如若不会,推导式语法的广泛传播表明流畅使用它能带来诸多好处。
映射解包
PEP 448—解包综述增补自Python 3.5开始增强了对两种映射解包的支持。
首先可在函数调用对一个以上的参数使用**
。在所有参数的键为字符串且唯一时可进行使用(因为允许使用重复的关键字参数)。
>>> def dump(**kwargs):
... return kwargs
...
>>> dump(**{'x': 1}, y=2, **{'z': 3})
{'x': 1, 'y': 2, 'z': 3}
复制代码
其次可在dict
字面量内部使用**
,同样可多次使用。
>>> {'a': 0, **{'x': 1}, 'y': 2, **{'z': 3, 'x': 4}}
{'a': 0, 'x': 4, 'y': 2, 'z': 3}
复制代码
上例中出现了重复的键,这是允许的。后出现的会重写先出现的,可以看下本例中x
的值。
这种语法也可用于合并映射,但还有其它的方式。请听后文分解。
使用管道符|合并映射
Python 3.9中支持使用|
和|=
合并映射。这很符合逻辑,国类这两者同时也是集合并集运算符。
使用|
运算符新建映射:
>>> d1 = {'a': 1, 'b': 3}
>>> d2 = {'a': 2, 'b': 4, 'c': 6}
>>> d1 | d2
{'a': 2, 'b': 4, 'c': 6}
复制代码
译者注:映射(mapping)是一种由键和关联值的集合组成的数据类型。目前 Python 唯一内置的映射类型是字典。
本文中会用到 Python 3.9和 Python3.10,当然可以安装最新版,或是在本地安装多个版本的Python,但也可以通过 Docker 进行测试(通过指定镜像版本就可以使用对应版本的 Python,以下默认使用最新版)
docker run -d --name python-alpine python:alpine watch "date >> /var/log/date.log" docker exec -it python-alpine sh # 用完就退出也可使用 docker run -it python:alpine sh 复制代码
通常新映射的类型与左项的类型一致,上例为d1
,但如果存在用户自定义类型则亦可为第二项的类型,在第16章中的运算符重载规则部分会进行讲解。
就地更新已有映射可使用|=
。继续对前例进行操作,上例中的d1
未发生修改,但下例中则不然:
>>> d1
{'a': 1, 'b': 3}
>>> d1 |= d2
>>> d1
{'a': 2, 'b': 4, 'c': 6}
复制代码
小贴士:如需维护Python 3.8或更早版本中运行的代码,PEP 584—添加并集运算符的动机一节中总结了几种合并映射类型的方法。
下面来学习映射的模式匹配。
映射的模式匹配
match/case
语句支持映射对象主体。映射的模式类似于字典字面量,但可以匹配任意实例或collections.abc.Mapping
的虚拟子类。
在第2章中,我们只讨论了序列的模式,但不同的类型的模式可进行合并、内嵌。借助于解构,模式匹配是一种处理映射和序列嵌套之类结构记录的强大工具,通常用于读取JSON API和半结构化模式(schema)数据库,如MongoDB、EdgeDB或PostgreSQL。例3-2中进行了演示。get_creators
中的简单类型提示表明接收了一个字典,返回了一个列表。
例3-2:creator.py: get_creators()从媒体记录中提取创作者名称
def get_creators(record: dict) -> list:
match record:
case {'type': 'book', 'api': 2, 'authors': [*names]}: # 匹配任意带'type': 'book', 'api' :2以及映射序列的'authors'键映射。以新的列表进行返回
return names
case {'type': 'book', 'api': 1, 'author': name}: # 匹配任意带'type': 'book', 'api' :2以及映射对象的'authors'键映射。在列表内部返回对象。
return [name]
case {'type': 'book'}: # 其它带'type': 'book的映射均无效,抛出ValueError
raise ValueError(f"Invalid 'book' record: {record!r}")
case {'type': 'movie', 'director': name}: # 匹配任意带'type': 'movie', 'api' :2以及映射单个对象的'director'键映射。在列表内部返回对象
return [name]
case _: # 其它均为无效,抛出ValueError
raise ValueError(f'Invalid record: {record!r}')
复制代码
例3-2很好地演示了在处理JSON之类的半结构化数据:
- 包含一个描述记录类型的字段(如
'type': 'movie'
) - 包含一个标识模式版本的字段(如
'api': 2'
),方便未来仅有API的演进 case
从句处理具体类型的无效记录(如'book'
),以及异常捕获
下面我们来看get_creators
是如何处理具体的文档测试的:
>>> b1 = dict(api=1, author='Douglas Hofstadter',
... type='book', title='Gödel, Escher, Bach')
>>> get_creators(b1)
['Douglas Hofstadter']
>>> from collections import OrderedDict
>>> b2 = OrderedDict(api=2, type='book',
... title='Python in a Nutshell',
... authors='Martelli Ravenscroft Holden'.split())
>>> get_creators(b2)
['Martelli', 'Ravenscroft', 'Holden']
>>> get_creators({'type': 'book', 'pages': 770})
Traceback (most recent call last):
...
ValueError: Invalid 'book' record: {'type': 'book', 'pages': 770}
>>> get_creators('Spam, spam, spam')
Traceback (most recent call last):
...
ValueError: Invalid record: 'Spam, spam, spam'
复制代码
注意模式中键的排序不重要,像b2
中的的有序字典也一样没关系。
不同于序列模式,映射只需要部分匹配即可成功。在文档测试中b1
和b2
包含'title'
键在所有的'book'
模式中都没有,但仍能匹配成功。
无需使用**extra
来匹配其它的键值对,但如果希望以字典来捕获,可以在一个变量名前添加**
。这个变量必须是模式中最后的那个,不允许使用**_
,因为这有点画蛇添足。举个简单的例子:
>>> food = dict(category='ice cream', flavor='vanilla', cost=199)
>>> match food:
... case {'category': 'ice cream', **details}:
... print(f'Ice cream details: {details}')
...
Ice cream details: {'flavor': 'vanilla', 'cost': 199}
复制代码
在自动处理无键返回值一节我们会学习defaultdict
和其它通过__getitem__
(即 d[key]
) 来查询键的映射,因其会实时创建缺失项,所以执行成功。在模式匹配中,只有在match
语句所需要的键存在时匹配才会成功。
小贴士:没有触发对缺失键的自动处理,原因是模式匹配总会使用
d.get(key, sentinel)
方法,其中默认的sentinel
是一个无法在用户数据中出现的特殊标记值。
语法和结构暂时讲到这,下面学习映射的API。
映射类型的标准API
collections.abc
模块中提供了Mapping
和MutableMapping
抽象基类,描述dict
和类似类型的接口。参见图3-1。
抽象基础类的主要价值是记录及统一映射的标准接口,并对需要支持映射的类型在代码中进行isinstance
测试时作为其条件:
>>> my_dict = {}
>>> isinstance(my_dict, abc.Mapping)
True
>>> isinstance(my_dict, abc.MutableMapping)
True
复制代码
小贴士:使用抽象基类的
isinstance
通常比检测函数参数是否为dict
类型要好,因为这样可以使用其它映射类型。我们会在第13章中详细讨论
图3-1:collections.abc中MutableMapping及其父类的简化UML类图(继承箭头由子类指向父类,斜体名称为抽象类和抽象方法)
要实现自定义映射,继承collections.UserDict
或通过组合封装dict
会比抽象基类的这些子类更容易。collections.UserDict
类和所有标准库中的所有具体映射类在实现时封装了基础的dict
,然后根据哈希表构建。因此,这些类型的键都必须为可哈希对象(值并无此求,仅针对键)。如果需要复习可哈希的概念,下一节中会进行讲解。
什么是可哈希对象
下面是Python词汇表中节略的对可哈希对象的定义。
一个对象可哈希的意思是在其生命周期内哈希码都不发生改变(使用
__hash__()
方法),并可与其它对象进行比较(使用__eq__()
方法)。相等的可哈希对象必须要有一致的哈希码。
数据类型和普通的不可变类型str
及bytes
都是可哈希对象。容器类型在不可变及的所含对象也均不可变时是可哈希的。frozenset
一定是可哈希的,因为其所含的每个元素在定义上都必须是可哈希的。tuple
仅在所有元素都可哈希时才可哈希。参见元组tt
、tl
和tf
:
>>> tt = (1, 2, (30, 40))
>>> hash(tt)
-3907003130834322577
>>> tl &#