流畅的 Python 第二版(GPT 重译)(二)(1)

简介: 流畅的 Python 第二版(GPT 重译)(二)

第三章:字典和集合

Python 基本上是用大量语法糖包装的字典。

Lalo Martins,早期数字游牧民和 Pythonista

我们在所有的 Python 程序中都使用字典。即使不是直接在我们的代码中,也是间接的,因为dict类型是 Python 实现的基本部分。类和实例属性、模块命名空间和函数关键字参数是内存中由字典表示的核心 Python 构造。__builtins__.__dict__存储所有内置类型、对象和函数。

由于其关键作用,Python 字典经过高度优化,并持续改进。哈希表是 Python 高性能字典背后的引擎。

其他基于哈希表的内置类型是setfrozenset。这些提供比您在其他流行语言中遇到的集合更丰富的 API 和运算符。特别是,Python 集合实现了集合理论中的所有基本操作,如并集、交集、子集测试等。通过它们,我们可以以更声明性的方式表达算法,避免大量嵌套循环和条件语句。

以下是本章的简要概述:

  • 用于构建和处理dicts和映射的现代语法,包括增强的解包和模式匹配
  • 映射类型的常见方法
  • 丢失键的特殊处理
  • 标准库中dict的变体
  • setfrozenset类型
  • 哈希表在集合和字典行为中的影响。

本章的新内容

这第二版中的大部分变化涵盖了与映射类型相关的新功能:

  • “现代字典语法”介绍了增强的解包语法以及合并映射的不同方式,包括自 Python 3.9 起由dicts支持的||=运算符。
  • “使用映射进行模式匹配”演示了自 Python 3.10 起使用match/case处理映射。
  • “collections.OrderedDict”现在专注于dictOrderedDict之间的细微但仍然相关的差异——考虑到自 Python 3.6 起dict保留键插入顺序。
  • dict.keysdict.itemsdict.values返回的视图对象的新部分:“字典视图”和“字典视图上的集合操作”。

dictset的基础实现仍然依赖于哈希表,但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 中,b1b2主题包括一个在任何'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和类似类型接口的MappingMutableMapping 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.abcMutableMapping及其超类的简化 UML 类图(继承箭头从子类指向超类;斜体名称是抽象类和抽象方法)。

要实现自定义映射,最好扩展collections.UserDict,或通过组合包装dict,而不是继承这些 ABCs。collections.UserDict类和标准库中的所有具体映射类在其实现中封装了基本的dict,而dict又建立在哈希表上。因此,它们都共享一个限制,即键必须是可哈希的(值不需要是可哈希的,只有键需要是可哈希的)。如果需要复习,下一节会解释。

什么是可哈希的

这里是从Python 术语表中适应的可哈希定义的部分:

如果对象具有永远不会在其生命周期内更改的哈希码(它需要一个__hash__()方法),并且可以与其他对象进行比较(它需要一个__eq__()方法),则该对象是可哈希的。比较相等的可哈希对象必须具有相同的哈希码。²

数值类型和扁平不可变类型strbytes都是可哈希的。如果容器类型是不可变的,并且所有包含的对象也是可哈希的,则它们是可哈希的。frozenset始终是可哈希的,因为它包含的每个元素必须根据定义是可哈希的。仅当元组的所有项都是可哈希的时,元组才是可哈希的。参见元组tttltf

>>> 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 中最常用的映射类型dictdefaultdictOrderedDict的 API。

常见映射方法概述

映射的基本 API 非常丰富。表 3-1 显示了dict和两个流行变体:defaultdictOrderedDict的方法,它们都定义在collections模块中。

表 3-1. 映射类型dictcollections.defaultdictcollections.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的项,如果不存在则返回defaultNone
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移动到第一个或最后一个位置(默认情况下lastTrue
d.__or__(other) 支持d1 &#124; d2创建新的dict合并d1d2(Python ≥ 3.9)
d.__ior__(other) 支持d1 &#124;= d2更新d1d2(Python ≥ 3.9)
d.pop(k, [default]) 移除并返回键为k的值,如果不存在则返回defaultNone
d.popitem() 移除并返回最后插入的项为(key, value) ^(b)
d.__reversed__() 支持reverse(d)—返回从最后插入到第一个插入的键的迭代器
d.__ror__(other) 支持other &#124; dd—反向联合运算符(Python ≥ 3.9)^©
d.setdefault(k, [default]) 如果kd中,则返回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 中不支持dictdefaultdict。^© 反向运算符在第十六章中有解释。

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进行第二次搜索。

sortedkey=参数中,我没有调用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']会执行以下步骤:

  1. 调用list()来创建一个新列表。
  2. 使用'new-key'作为键将列表插入dd
  3. 返回对该列表的引用。

产生默认值的可调用对象保存在名为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

警告

defaultdictdefault_factory仅在为__getitem__调用提供默认值时才会被调用,而不会为其他方法调用。例如,如果dd是一个defaultdictk是一个缺失的键,dd[k]将调用default_factory来创建一个默认值,但dd.get(k)仍然返回Nonek in ddFalse

使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__,因为基类默认支持不同的行为。

不要忘记,setdefaultupdate的行为也受键查找影响。最后,根据你的__missing__的逻辑,你可能需要在__setitem__中实现特殊逻辑,以避免不一致或令人惊讶的行为。我们将在“Subclassing UserDict Instead of dict”中看到一个例子。

到目前为止,我们已经介绍了dictdefaultdict这两种映射类型,但标准库中还有其他映射实现,接下来我们将讨论它们。

流畅的 Python 第二版(GPT 重译)(二)(2)https://developer.aliyun.com/article/1484409

相关文章
|
13天前
|
存储 安全 测试技术
流畅的 Python 第二版(GPT 重译)(四)(3)
流畅的 Python 第二版(GPT 重译)(四)
6 1
|
13天前
|
存储 IDE JavaScript
流畅的 Python 第二版(GPT 重译)(四)(2)
流畅的 Python 第二版(GPT 重译)(四)
40 1
|
13天前
|
存储 缓存 Java
流畅的 Python 第二版(GPT 重译)(六)(2)
流畅的 Python 第二版(GPT 重译)(六)
58 0
|
13天前
|
存储 程序员 API
流畅的 Python 第二版(GPT 重译)(七)(2)
流畅的 Python 第二版(GPT 重译)(七)
60 0
|
13天前
|
存储 测试技术 Python
流畅的 Python 第二版(GPT 重译)(九)(3)
流畅的 Python 第二版(GPT 重译)(九)
29 0
|
13天前
|
Java Go C++
流畅的 Python 第二版(GPT 重译)(七)(3)
流畅的 Python 第二版(GPT 重译)(七)
38 2
|
13天前
|
人工智能 安全 程序员
流畅的 Python 第二版(GPT 重译)(一)(3)
流畅的 Python 第二版(GPT 重译)(一)
13 2
|
13天前
|
存储 API 芯片
流畅的 Python 第二版(GPT 重译)(九)(2)
流畅的 Python 第二版(GPT 重译)(九)
63 1
|
13天前
|
存储 API uml
流畅的 Python 第二版(GPT 重译)(七)(1)
流畅的 Python 第二版(GPT 重译)(七)
84 1
|
XML JSON API
流畅的 Python 第二版(GPT 重译)(八)(1)
流畅的 Python 第二版(GPT 重译)(八)
37 1

热门文章

最新文章