第三章:字典和集合
Python 基本上是用大量语法糖包装的字典。
Lalo Martins,早期数字游牧民和 Pythonista
我们在所有的 Python 程序中都使用字典。即使不是直接在我们的代码中,也是间接的,因为dict
类型是 Python 实现的基本部分。类和实例属性、模块命名空间和函数关键字参数是内存中由字典表示的核心 Python 构造。__builtins__.__dict__
存储所有内置类型、对象和函数。
由于其关键作用,Python 字典经过高度优化,并持续改进。哈希表是 Python 高性能字典背后的引擎。
其他基于哈希表的内置类型是set
和frozenset
。这些提供比您在其他流行语言中遇到的集合更丰富的 API 和运算符。特别是,Python 集合实现了集合理论中的所有基本操作,如并集、交集、子集测试等。通过它们,我们可以以更声明性的方式表达算法,避免大量嵌套循环和条件语句。
以下是本章的简要概述:
- 用于构建和处理
dicts
和映射的现代语法,包括增强的解包和模式匹配 - 映射类型的常见方法
- 丢失键的特殊处理
- 标准库中
dict
的变体 set
和frozenset
类型- 哈希表在集合和字典行为中的影响。
本章的新内容
这第二版中的大部分变化涵盖了与映射类型相关的新功能:
- “现代字典语法”介绍了增强的解包语法以及合并映射的不同方式,包括自 Python 3.9 起由
dicts
支持的|
和|=
运算符。 - “使用映射进行模式匹配”演示了自 Python 3.10 起使用
match/case
处理映射。 - “collections.OrderedDict”现在专注于
dict
和OrderedDict
之间的细微但仍然相关的差异——考虑到自 Python 3.6 起dict
保留键插入顺序。 - 由
dict.keys
、dict.items
和dict.values
返回的视图对象的新部分:“字典视图”和“字典视图上的集合操作”。
dict
和set
的基础实现仍然依赖于哈希表,但dict
代码有两个重要的优化,可以节省内存并保留键在dict
中的插入顺序。“dict 工作原理的实际后果”和“集合工作原理的实际后果”总结了您需要了解的内容,以便很好地使用它们。
注意
在这第二版中增加了 200 多页后,我将可选部分“集合和字典的内部”移至fluentpython.com伴随网站。更新和扩展的18 页文章包括关于以下内容的解释和图表:
- 哈希表算法和数据结构,从在
set
中的使用开始,这更容易理解。 - 保留
dict
实例中键插入顺序的内存优化(自 Python 3.6 起)。 - 用于保存实例属性的字典的键共享布局——用户定义对象的
__dict__
(自 Python 3.3 起实现的优化)。
现代字典语法
接下来的部分描述了用于构建、解包和处理映射的高级语法特性。其中一些特性在语言中并不新鲜,但对您可能是新的。其他需要 Python 3.9(如|
运算符)或 Python 3.10(如match/case
)的特性。让我们从其中一个最好且最古老的特性开始。
字典推导式
自 Python 2.7 起,列表推导和生成器表达式的语法已经适应了 dict
推导(以及我们即将讨论的 set
推导)。dictcomp(dict 推导)通过从任何可迭代对象中获取 key:value
对来构建一个 dict
实例。示例 3-1 展示了使用 dict
推导从相同的元组列表构建两个字典的用法。
示例 3-1. dict
推导示例
>>> dial_codes = ![1 ... (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_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() # ③ ... for country, code in sorted(country_dial.items()) ... if code < 70} {55: 'BRAZIL', 62: 'INDONESIA', 7: 'RUSSIA', 1: 'UNITED STATES'}
①
可以直接将类似 dial_codes
的键值对可迭代对象传递给 dict
构造函数,但是…
②
…在这里我们交换了键值对:country
是键,code
是值。
③
按名称对 country_dial
进行排序,再次反转键值对,将值大写,并使用 code < 70
过滤项。
如果你习惯于列表推导,那么字典推导是一个自然的下一步。如果你不熟悉,那么理解推导语法的传播意味着现在比以往任何时候都更有利可图。
解包映射
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}
通常,新映射的类型将与左操作数的类型相同—在示例中是 d1
,但如果涉及用户定义的类型,则可以是第二个操作数的类型,根据我们在第十六章中探讨的运算符重载规则。
要就地更新现有映射,请使用 |=
。继续前面的例子,d1
没有改变,但现在它被改变了:
>>> d1 {'a': 1, 'b': 3} >>> d1 |= d2 >>> d1 {'a': 2, 'b': 4, 'c': 6}
提示
如果你需要维护能在 Python 3.8 或更早版本上运行的代码,PEP 584—为 dict 添加 Union 运算符 的 “动机” 部分提供了其他合并映射的方法的简要总结。
现在让我们看看模式匹配如何应用于映射。
使用映射进行模式匹配
match/case
语句支持作为映射对象的主题。映射的模式看起来像 dict
字面量,但它们可以匹配 collections.abc.Mapping
的任何实际或虚拟子类的实例。¹
在第二章中,我们只关注了序列模式,但不同类型的模式可以组合和嵌套。由于解构,模式匹配是处理结构化为嵌套映射和序列的记录的强大工具,我们经常需要从 JSON API 和具有半结构化模式的数据库(如 MongoDB、EdgeDB 或 PostgreSQL)中读取这些记录。示例 3-2 演示了这一点。get_creators
中的简单类型提示清楚地表明它接受一个 dict
并返回一个 list
。
示例 3-2. creator.py:get_creators()
从媒体记录中提取创作者的名称
def get_creators(record: dict) -> list: match record: case {'type': 'book', 'api': 2, 'authors': [*names]}: # ① return names case {'type': 'book', 'api': 1, 'author': name}: # ② return [name] case {'type': 'book'}: # ③ raise ValueError(f"Invalid 'book' record: {record!r}") case {'type': 'movie', 'director': name}: # ④ return [name] case _: # ⑤ raise ValueError(f'Invalid record: {record!r}')
①
匹配任何具有 'type': 'book', 'api' :2
的映射,并且一个 'authors'
键映射到一个序列。将序列中的项作为新的 list
返回。
②
匹配任何具有 'type': 'book', 'api' :1
的映射,并且一个 'author'
键映射到任何对象。将对象放入一个 list
中返回。
③
具有'type': 'book'
的任何其他映射都是无效的,引发ValueError
。
④
匹配任何具有'type': 'movie'
和将'director'
键映射到单个对象的映射。返回list
中的对象。
⑤
任何其他主题都是无效的,引发ValueError
。
示例 3-2 展示了处理半结构化数据(如 JSON 记录)的一些有用实践:
- 包括描述记录类型的字段(例如,
'type': 'movie'
) - 包括标识模式版本的字段(例如,`‘api’: 2’)以允许公共 API 的未来演变
- 有
case
子句来处理特定类型(例如,'book'
)的无效记录,以及一个全捕捉
现在让我们看看get_creators
如何处理一些具体的 doctests:
>>> 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'
注意,模式中键的顺序无关紧要,即使主题是OrderedDict
,如b2
。
与序列模式相比,映射模式在部分匹配上成功。在 doctests 中,b1
和b2
主题包括一个在任何'book'
模式中都不出现的'title'
键,但它们匹配。
不需要使用**extra
来匹配额外的键值对,但如果要将它们捕获为dict
,可以使用**
前缀一个变量。它必须是模式中的最后一个,并且**_
是被禁止的,因为它是多余的。一个简单的例子:
>>> 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
模块提供了描述dict
和类似类型接口的Mapping
和MutableMapping
ABCs。参见图 3-1。
ABCs 的主要价值在于记录和规范映射的标准接口,并作为需要支持广义映射的代码中isinstance
测试的标准:
>>> my_dict = {} >>> isinstance(my_dict, abc.Mapping) True >>> isinstance(my_dict, abc.MutableMapping) True
提示
使用 ABC 进行isinstance
通常比检查函数参数是否为具体dict
类型更好,因为这样可以使用替代映射类型。我们将在第十三章中详细讨论这个问题。
图 3-1。collections.abc
中MutableMapping
及其超类的简化 UML 类图(继承箭头从子类指向超类;斜体名称是抽象类和抽象方法)。
要实现自定义映射,最好扩展collections.UserDict
,或通过组合包装dict
,而不是继承这些 ABCs。collections.UserDict
类和标准库中的所有具体映射类在其实现中封装了基本的dict
,而dict
又建立在哈希表上。因此,它们都共享一个限制,即键必须是可哈希的(值不需要是可哈希的,只有键需要是可哈希的)。如果需要复习,下一节会解释。
什么是可哈希的
这里是从Python 术语表中适应的可哈希定义的部分:
如果对象具有永远不会在其生命周期内更改的哈希码(它需要一个
__hash__()
方法),并且可以与其他对象进行比较(它需要一个__eq__()
方法),则该对象是可哈希的。比较相等的可哈希对象必须具有相同的哈希码。²
数值类型和扁平不可变类型str
和bytes
都是可哈希的。如果容器类型是不可变的,并且所有包含的对象也是可哈希的,则它们是可哈希的。frozenset
始终是可哈希的,因为它包含的每个元素必须根据定义是可哈希的。仅当元组的所有项都是可哈希的时,元组才是可哈希的。参见元组tt
、tl
和tf
:
>>> tt = (1, 2, (30, 40)) >>> hash(tt) 8027212646858338501 >>> tl = (1, 2, [30, 40]) >>> hash(tl) Traceback (most recent call last): File "<stdin>", line 1, in <module> TypeError: unhashable type: 'list' >>> tf = (1, 2, frozenset([30, 40])) >>> hash(tf) -4118419923444501110
对象的哈希码可能因 Python 版本、机器架构以及出于安全原因添加到哈希计算中的盐而有所不同。³ 正确实现的对象的哈希码仅在一个 Python 进程中保证是恒定的。
默认情况下,用户定义的类型是可哈希的,因为它们的哈希码是它们的id()
,并且从object
类继承的__eq__()
方法只是简单地比较对象 ID。如果一个对象实现了一个考虑其内部状态的自定义__eq__()
,那么只有当其__hash__()
始终返回相同的哈希码时,它才是可哈希的。实际上,这要求__eq__()
和__hash__()
只考虑在对象生命周期中永远不会改变的实例属性。
现在让我们回顾 Python 中最常用的映射类型dict
、defaultdict
和OrderedDict
的 API。
常见映射方法概述
映射的基本 API 非常丰富。表 3-1 显示了dict
和两个流行变体:defaultdict
和OrderedDict
的方法,它们都定义在collections
模块中。
表 3-1. 映射类型dict
、collections.defaultdict
和collections.OrderedDict
的方法(为简洁起见省略了常见对象方法);可选参数用[…]
括起来
dict | defaultdict | OrderedDict | ||
d.clear() |
● | ● | ● | 移除所有项 |
d.__contains__(k) |
● | ● | ● | k in d |
d.copy() |
● | ● | ● | 浅拷贝 |
d.__copy__() |
● | 支持copy.copy(d) |
||
d.default_factory |
● | __missing__ 调用的可调用对象,用于设置缺失值^(a) |
||
d.__delitem__(k) |
● | ● | ● | del d[k] —删除键为k 的项 |
d.fromkeys(it, [initial]) |
● | ● | ● | 从可迭代对象中的键创建新映射,可选初始值(默认为None ) |
d.get(k, [default]) |
● | ● | ● | 获取键为k 的项,如果不存在则返回default 或None |
d.__getitem__(k) |
● | ● | ● | d[k] —获取键为k 的项 |
d.items() |
● | ● | ● | 获取项的视图—(key, value) 对 |
d.__iter__() |
● | ● | ● | 获取键的迭代器 |
d.keys() |
● | ● | ● | 获取键的视图 |
d.__len__() |
● | ● | ● | len(d) —项数 |
d.__missing__(k) |
● | 当__getitem__ 找不到键时调用 |
||
d.move_to_end(k, [last]) |
● | 将k 移动到第一个或最后一个位置(默认情况下last 为True ) |
||
d.__or__(other) |
● | ● | ● | 支持d1 | d2 创建新的dict 合并d1 和d2 (Python ≥ 3.9) |
d.__ior__(other) |
● | ● | ● | 支持d1 |= d2 更新d1 与d2 (Python ≥ 3.9) |
d.pop(k, [default]) |
● | ● | ● | 移除并返回键为k 的值,如果不存在则返回default 或None |
d.popitem() |
● | ● | ● | 移除并返回最后插入的项为(key, value) ^(b) |
d.__reversed__() |
● | ● | ● | 支持reverse(d) —返回从最后插入到第一个插入的键的迭代器 |
d.__ror__(other) |
● | ● | ● | 支持other | dd —反向联合运算符(Python ≥ 3.9)^© |
d.setdefault(k, [default]) |
● | ● | ● | 如果k 在d 中,则返回d[k] ;否则设置d[k] = default 并返回 |
d.__setitem__(k, v) |
● | ● | ● | d[k] = v —在k 处放置v |
d.update(m, [**kwargs]) |
● | ● | ● | 使用映射或(key, value) 对的可迭代对象更新d |
d.values() |
● | ● | ● | 获取视图的值 |
^(a) default_factory 不是一个方法,而是在实例化defaultdict 时由最终用户设置的可调用属性。^(b) OrderedDict.popitem(last=False) 移除第一个插入的项目(FIFO)。last 关键字参数在 Python 3.10b3 中不支持dict 或defaultdict 。^© 反向运算符在第十六章中有解释。 |
d.update(m)
处理其第一个参数m
的方式是鸭子类型的一个典型例子:它首先检查m
是否有一个keys
方法,如果有,就假定它是一个映射。否则,update()
会回退到迭代m
,假设其项是(key, value)
对。大多数 Python 映射的构造函数在内部使用update()
的逻辑,这意味着它们可以从其他映射或从产生(key, value)
对的任何可迭代对象初始化。
一种微妙的映射方法是setdefault()
。当我们需要就地更新项目的值时,它避免了冗余的键查找。下一节将展示如何使用它。
插入或更新可变值
符合 Python 的失败快速哲学,使用d[k]
访问dict
时,当k
不是现有键时会引发错误。Python 程序员知道,当默认值比处理KeyError
更方便时,d.get(k, default)
是d[k]
的替代方案。然而,当您检索可变值并希望更新它时,有一种更好的方法。
考虑编写一个脚本来索引文本,生成一个映射,其中每个键是一个单词,值是该单词出现的位置列表,如示例 3-3 所示。
示例 3-3. 示例 3-4 处理“Python 之禅”时的部分输出;每行显示一个单词和一对出现的编码为(行号
,列号
)的列表。
$ python3 index0.py zen.txt a [(19, 48), (20, 53)] Although [(11, 1), (16, 1), (18, 1)] ambiguity [(14, 16)] and [(15, 23)] are [(21, 12)] aren [(10, 15)] at [(16, 38)] bad [(19, 50)] be [(15, 14), (16, 27), (20, 50)] beats [(11, 23)] Beautiful [(3, 1)] better [(3, 14), (4, 13), (5, 11), (6, 12), (7, 9), (8, 11), (17, 8), (18, 25)] ...
示例 3-4 是一个次优脚本,用于展示dict.get
不是处理缺失键的最佳方式的一个案例。我从亚历克斯·马特利的一个示例中进行了改编。⁴
示例 3-4. index0.py 使用dict.get
从索引中获取并更新单词出现列表的脚本(更好的解决方案在示例 3-5 中)
"""Build an index mapping word -> list of occurrences""" import re import sys WORD_RE = re.compile(r'\w+') index = {} with open(sys.argv[1], encoding='utf-8') as fp: for line_no, line in enumerate(fp, 1): for match in WORD_RE.finditer(line): word = match.group() column_no = match.start() + 1 location = (line_no, column_no) # this is ugly; coded like this to make a point occurrences = index.get(word, []) # ① occurrences.append(location) # ② index[word] = occurrences # ③ # display in alphabetical order for word in sorted(index, key=str.upper): # ④ print(word, index[word])
①
获取word
的出现列表,如果找不到则为[]
。
②
将新位置附加到occurrences
。
③
将更改后的occurrences
放入index
字典中;这需要通过index
进行第二次搜索。
④
在sorted
的key=
参数中,我没有调用str.upper
,只是传递了对该方法的引用,以便sorted
函数可以使用它来对单词进行规范化排序。⁵
示例 3-4 中处理occurrences
的三行可以用dict.setdefault
替换为一行。示例 3-5 更接近亚历克斯·马特利的代码。
示例 3-5. index.py 使用dict.setdefault
从索引中获取并更新单词出现列表的脚本,一行搞定;与示例 3-4 进行对比
"""Build an index mapping word -> list of occurrences""" import re import sys WORD_RE = re.compile(r'\w+') index = {} with open(sys.argv[1], encoding='utf-8') as fp: for line_no, line in enumerate(fp, 1): for match in WORD_RE.finditer(line): word = match.group() column_no = match.start() + 1 location = (line_no, column_no) index.setdefault(word, []).append(location) # ① # display in alphabetical order for word in sorted(index, key=str.upper): print(word, index[word])
①
获取word
的出现列表,如果找不到则将其设置为[]
;setdefault
返回值,因此可以在不需要第二次搜索的情况下进行更新。
换句话说,这行的最终结果是…
my_dict.setdefault(key, []).append(new_value)
…等同于运行…
if key not in my_dict: my_dict[key] = [] my_dict[key].append(new_value)
…除了后者的代码至少执行两次对key
的搜索—如果找不到,则执行三次—而setdefault
只需一次查找就可以完成所有操作。
一个相关问题是,在任何查找中处理缺失键(而不仅仅是在插入时)是下一节的主题。
缺失键的自动处理
有时,当搜索缺失的键时返回一些虚构的值是很方便的。有两种主要方法:一种是使用defaultdict
而不是普通的dict
。另一种是子类化dict
或任何其他映射类型,并添加一个__missing__
方法。接下来将介绍这两种解决方案。
defaultdict:另一种处理缺失键的方法
一个collections.defaultdict
实例在使用d[k]
语法搜索缺失键时按需创建具有默认值的项目。示例 3-6 使用defaultdict
提供了另一个优雅的解决方案来完成来自示例 3-5 的单词索引任务。
它的工作原理是:在实例化defaultdict
时,你提供一个可调用对象,每当__getitem__
传递一个不存在的键参数时产生一个默认值。
例如,给定一个创建为dd = defaultdict(list)
的defaultdict
,如果'new-key'
不在dd
中,表达式dd['new-key']
会执行以下步骤:
- 调用
list()
来创建一个新列表。 - 使用
'new-key'
作为键将列表插入dd
。 - 返回对该列表的引用。
产生默认值的可调用对象保存在名为default_factory
的实例属性中。
示例 3-6。index_default.py:使用defaultdict
而不是setdefault
方法
"""Build an index mapping word -> list of occurrences""" import collections import re import sys WORD_RE = re.compile(r'\w+') index = collections.defaultdict(list) # ① with open(sys.argv[1], encoding='utf-8') as fp: for line_no, line in enumerate(fp, 1): for match in WORD_RE.finditer(line): word = match.group() column_no = match.start() + 1 location = (line_no, column_no) index[word].append(location) # ② # display in alphabetical order for word in sorted(index, key=str.upper): print(word, index[word])
①
使用list
构造函数创建一个defaultdict
作为default_factory
。
②
如果word
最初不在index
中,则调用default_factory
来生成缺失值,这种情况下是一个空的list
,然后将其分配给index[word]
并返回,因此.append(location)
操作总是成功的。
如果没有提供default_factory
,则对于缺失的键会引发通常的KeyError
。
警告
defaultdict
的default_factory
仅在为__getitem__
调用提供默认值时才会被调用,而不会为其他方法调用。例如,如果dd
是一个defaultdict
,k
是一个缺失的键,dd[k]
将调用default_factory
来创建一个默认值,但dd.get(k)
仍然返回None
,k in dd
为False
。
使defaultdict
工作的机制是调用default_factory
的__missing__
特殊方法,这是我们接下来要讨论的一个特性。
__missing__
方法
映射处理缺失键的基础是名为__missing__
的方法。这个方法在基本的dict
类中没有定义,但dict
知道它:如果你子类化dict
并提供一个__missing__
方法,标准的dict.__getitem__
将在找不到键时调用它,而不是引发KeyError
。
假设你想要一个映射,其中键在查找时被转换为str
。一个具体的用例是物联网设备库,其中一个具有通用 I/O 引脚(例如树莓派或 Arduino)的可编程板被表示为一个Board
类,具有一个my_board.pins
属性,它是物理引脚标识符到引脚软件对象的映射。物理引脚标识符可能只是一个数字或一个字符串,如"A0"
或"P9_12"
。为了一致性,希望board.pins
中的所有键都是字符串,但也方便通过数字查找引脚,例如my_arduino.pin[13]
,这样初学者在想要闪烁他们的 Arduino 上的 13 号引脚时不会出错。示例 3-7 展示了这样一个映射如何工作。
示例 3-7。当搜索非字符串键时,StrKeyDict0
在未找到时将其转换为str
Tests for item retrieval using `d[key]` notation:: >>> d = StrKeyDict0([('2', 'two'), ('4', 'four')]) >>> d['2'] 'two' >>> d[4] 'four' >>> d[1] Traceback (most recent call last): ... KeyError: '1' Tests for item retrieval using `d.get(key)` notation:: >>> d.get('2') 'two' >>> d.get(4) 'four' >>> d.get(1, 'N/A') 'N/A' Tests for the `in` operator:: >>> 2 in d True >>> 1 in d False
示例 3-8 实现了一个通过前面的 doctests 的StrKeyDict0
类。
提示
创建用户定义的映射类型的更好方法是子类化collections.UserDict
而不是dict
(正如我们将在示例 3-9 中所做的那样)。这里我们子类化dict
只是为了展示内置的dict.__getitem__
方法支持__missing__
。
示例 3-8。StrKeyDict0
在查找时将非字符串键转换为str
(请参见示例 3-7 中的测试)
class StrKeyDict0(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() # ⑥
①
StrKeyDict0
继承自dict
。
②
检查key
是否已经是str
。如果是,并且它丢失了,那么引发KeyError
。
③
从key
构建str
并查找它。
④
get
方法通过使用self[key]
符号委托给__getitem__
;这给了我们的__missing__
发挥作用的机会。
⑤
如果引发KeyError
,则__missing__
已经失败,因此我们返回default
。
⑥
搜索未修改的键(实例可能包含非str
键),然后搜索从键构建的str
。
花点时间考虑一下为什么在__missing__
实现中需要测试isinstance(key, str)
。
没有这个测试,我们的__missing__
方法对于任何键k
——str
或非str
——都能正常工作,只要str(k)
产生一个现有的键。但是如果str(k)
不是一个现有的键,我们将会有一个无限递归。在__missing__
的最后一行,self[str(key)]
会调用__getitem__
,传递那个str
键,然后会再次调用__missing__
。
在这个例子中,__contains__
方法也是必需的,因为操作k in d
会调用它,但从dict
继承的方法不会回退到调用__missing__
。在我们的__contains__
实现中有一个微妙的细节:我们不是用通常的 Python 方式检查键——k
in my_dict
——因为str(key) in self
会递归调用__contains__
。我们通过在self.keys()
中明确查找键来避免这种情况。
在 Python 3 中,像k in my_dict.keys()
这样的搜索对于非常大的映射也是高效的,因为dict.keys()
返回一个视图,类似于集合,正如我们将在“dict 视图上的集合操作”中看到的。然而,请记住,k in my_dict
也能完成同样的工作,并且更快,因为它避免了查找属性以找到.keys
方法。
我在示例 3-8 中的__contains__
方法中有一个特定的原因使用self.keys()
。检查未修改的键——key in self.keys()
——对于正确性是必要的,因为StrKeyDict0
不强制字典中的所有键都必须是str
类型。我们这个简单示例的唯一目标是使搜索“更友好”,而不是强制类型。
警告
派生自标准库映射的用户定义类可能会或可能不会在它们的__getitem__
、get
或__contains__
实现中使用__missing__
作为回退,如下一节所述。
标准库中对__missing__
的不一致使用
考虑以下情况,以及缺失键查找是如何受影响的:
dict
子类
一个只实现__missing__
而没有其他方法的dict
子类。在这种情况下,__missing__
只能在d[k]
上调用,这将使用从dict
继承的__getitem__
。
collections.UserDict
子类
同样,一个只实现__missing__
而没有其他方法的UserDict
子类。从UserDict
继承的get
方法调用__getitem__
。这意味着__missing__
可能被调用来处理d[k]
和d.get(k)
的查找。
具有最简单可能的__getitem__
的abc.Mapping
子类
一个实现了__missing__
和所需抽象方法的最小的abc.Mapping
子类,包括一个不调用__missing__
的__getitem__
实现。在这个类中,__missing__
方法永远不会被触发。
具有调用__missing__
的__getitem__
的abc.Mapping
子类
一个最小的abc.Mapping
子类实现了__missing__
和所需的抽象方法,包括调用__missing__
的__getitem__
的实现。在这个类中,对使用d[k]
、d.get(k)
和k in d
进行的缺失键查找会触发__missing__
方法。
在示例代码库中查看missing.py以演示这里描述的场景。
刚才描述的四种情况假设最小实现。如果你的子类实现了__getitem__
、get
和__contains__
,那么你可以根据需要让这些方法使用__missing__
或不使用。本节的重点是要表明,在子类化标准库映射时要小心使用__missing__
,因为基类默认支持不同的行为。
不要忘记,setdefault
和update
的行为也受键查找影响。最后,根据你的__missing__
的逻辑,你可能需要在__setitem__
中实现特殊逻辑,以避免不一致或令人惊讶的行为。我们将在“Subclassing UserDict Instead of dict”中看到一个例子。
到目前为止,我们已经介绍了dict
和defaultdict
这两种映射类型,但标准库中还有其他映射实现,接下来我们将讨论它们。
流畅的 Python 第二版(GPT 重译)(二)(2)https://developer.aliyun.com/article/1484409