第三章 字典和集合

目录

本章的新内容

现代 dict 语法

字典推导式

字典拆包

使用 | 合并映射

字典的模式匹配

映射类型的标准API

什么是可散列的

常见的映射方法概述

插入或更新可变值

找不到的键的自动处理

defaultdict:解决找不到的键的问题的另一种方案

__missing__ 特殊方法

__missing__ 在标准库中的使用不一致

dict的变体

collections.OrderedDict

collections.ChainMap

collections.Counter

shelve.Shelf

子类化 UserDict 而不是 dict

不可变映射

字典视图

dict 工作原理带来的实际后果

集合论

集合字面量

集合推导式

集合的工作原理带来的实际后果

集合的操作

dict view的集合操作

散列表内部的原理

散列和相等的关系

散列冲突

散列表算法

在散列表中查找元素

字典(dict)中散列表的使用

紧凑的dict如何节省空间并保持顺序

将项目添加到紧凑字典的算法

处理冲突的步骤

紧凑型字典如何增长

共享键的字典


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

                        ----Lalo Martins, early digital nomad and Pythonista.

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

由于字典所起到的关键作用,Python 的dicts 得到了高度优化——并且不断的进行改进。散列表是 Python 高性能字典背后的引擎。

其他基于散列表的内置类型是 set 和frozenset。这两种集合类型可能比您在其他流行语言中使用的集合具有更丰富的 API 和运算符。特别是,Python 集合实现了数学集合的所有基本操作,例如取并集、取交集、测试是否是子集等。有了这些操作,我们可以用更明确的方式表示算法,避免在代码中使用大量嵌套循环和条件语句。

以下是本章的简要概述:

  • 用于构建和处理字典和映射的现代语法,包括增强的拆包和模式匹配。
  • 映射类型的常用方法。
  • 对查找不到的键的特殊处理。
  • 标准库中 dict 的变体。
  • set 和frozenset 类型。
  • 散列表实现对集合和字典行为的影响。

本章的新内容

第二版中的大多数更改都涵盖了与映射类型相关的新功能:

dict 和 set 的底层实现仍然依靠散列表,但是 dict的实现有两个重要的优化-----节省内存和保留 dict 中键的插入顺序。 “Practical Consequences of How dict Works”“Practical Consequences of How Sets Work”总结了这些内容。

Note:

在第二版中添加了 200 多页之后,我将可选部分 Internals of sets and dicts 移到了 fluentpython.com 配套网站。更新和扩展的 18 页帖子包括关于以下内容的解释和图表:

  • 散列表算法和数据结构,先从它在set中的使用说起,比较容易理解。
  • 在 dict 实例中保留键插入顺序的内存优化(自 Python 3.6 起)。
  • 保存实例属性的字典的键共享布局——用户定义对象的 __dict__(在 Python 3.3 中实现的优化)。

现代 dict 语法

接下来的部分描述了用于构建、拆包和处理映射的高级语法功能。其中一些功能在python中并不新鲜,但对您来说可能是全新的。其他需要 Python 3.9(如 | 运算符)或 Python 3.10(如match/case)。让我们从最经典的功能开始。

字典推导式

从 Python 2.7 开始,listcomps 和 genexps 的语法适用于 dict 推导式(以及集合推导式,我们很快就会看到)。dictcomp 通过从任何可迭代对象中获取key:value对来构建 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}  2
>>> 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()                                          3
...     for country, code in sorted(country_dial.items())
...     if code < 70}
{55: 'BRAZIL', 62: 'INDONESIA', 7: 'RUSSIA', 1: 'UNITED STATES'}
  1. 像 dial_codes 这样的键值对的可迭代对象可以直接传递给 dict 构造函数,但是……
  2. ...在这里我们交换对:key是country,value是code。 
  3. 按名称对 country_dial 进行排序,再次反转键值对,将值转换为大写,并过滤code < 70 的项。

如果您习惯了 listcomps,那么很自然就掌握了dictcomps 。如果不是这样,理解语法的传播意味着现在比以往任何时候都更能流利地掌握它。

字典拆包

从 Python 3.5 开始,PEP 448—Additional Unpacking Generalizations以两种方式增强了对映射拆包的支持。

首先,我们可以将 ** 应用于函数调用中的多个参数,需要键都是字符串并且在所有参数中都是唯一的(因为禁止重复的关键字参数)。

>>> 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 --但如果涉及用户定义的类型,它可以是第二个操作对象的类型,根据我们在第 16 章中探讨的运算符重载规则。

要就地更新现有映射,请使用 |=。继续前面的例子,d1 没有改变,但现在是:

>>> d1
{'a': 1, 'b': 3}
>>> d1 |= d2
>>> d1
{'a': 2, 'b': 4, 'c': 6}

TIP:

如果您需要维护在 Python 3.8 或更早版本上运行的代码,PEP 584—Add Union Operators To dictMotivation 部分提供了合并映射的其他方法的很好的总结。 

现在让我们看看模式匹配如何应用于映射。

字典的模式匹配

match/case 语句支持映射对象的主题。映射的模式看起来像字典的字面量,但它们可以匹配 collections.abc.Mapping 的任何实际或虚拟子类的实例。

在第 2 章中,我们只关注序列模式,但不同类型的模式是可以组合和嵌套的。由于解构的支持,模式匹配是处理嵌套映射和序列等结构化记录的强大工具,我们经常需要从 JSON API 和具有半结构化模式的数据库中读取这样的数据,例如 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]}:  1
            return names
        case {'type': 'book', 'api': 1, 'author': name}:  2
            return [name]
        case {'type': 'book'}:  3
            raise ValueError(f"Invalid 'book' record: {record!r}")
        case {'type': 'movie', 'director': name}:  4
            return [name]
        case _:  5
            raise ValueError(f'Invalid record: {record!r}')
  1. 匹配任何具有 'type': 'book', 'api' :2 和 'authors' 键映射到序列的映射。返回序列中的项,作为一个新列表。 
  2. 匹配具有 'type': 'book', 'api' :1 和'author' 键映射到任何对象的任何映射。返回包含该对象的列表。
  3. 任何其他带有 'type': 'book' 的映射无效,抛出 ValueError异常。
  4. 匹配任何映射到 'type': 'movie' 和映射到单个对象的 'director' 键。返回包含该对象的列表。
  5. 任何其他主题无效,抛出ValueError异常。

示例 3-2 展示了一些处理半结构化数据(例如 JSON 记录)的实用做法:

  • 包括描述记录类型的字段(例如“type”:“movie”);
  • 包括一个标识模式版本的字段(例如'api': 2'),给公共 API 的未来扩容保留空间;
  • 有 case 子句来处理特定类型的无效记录(例如“book”),以及一个匹配所有主题的case子句。

现在让我们看看 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 主题包含一个“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}

“Automatic Handling of Missing Keys” 中,我们将研究 defaultdict 和其他映射,有时即时键不存在通过 __getitem__(即 d[key])进行键查找会成功,因为找不到的键可以即时创建。在模式匹配的上下文中,只有当顶部的match主题已经具有匹配语句所需的键时,匹配才会成功。

TIP:

模式匹配中查找不到键的情况不会触发字典的自动处理,因为模式匹配总是使用 d.get(key, sentinel) 方法——其中默认的 sentinel 是一个特殊的标记值,sentinel不会出现在用户的数据里面。

从语法和结构继续,让我们研究映射的 API。

映射类型的标准API

collections.abc模块中有Mapping和MuteableMapping两个抽象基类,用来定义字典和其他类似类型的接口,请参见图 3-1。

ABC 的主要价值是记录和形式化一个映射类型的标准接口,然后它们还可以和isinstance一起被用来判定某个数据是不是广义上的映射类型:

>>> my_dict = {}
>>> isinstance(my_dict, abc.Mapping)
True
>>> isinstance(my_dict, abc.MutableMapping)
True

TIP:

在 isinstance 内使用 ABC 通常比检查函数参数是否是具体的 dict 类型更好 ,因为这样就可以使用替代映射类型。我们将在第 13 章详细讨论这一点

实现一个自定义映射,扩展 collections.UserDict或者通过组合包装一个dict比子类化ABC抽象类要更容易一些。标准库中的 collections.UserDict 类和所有具体的映射类在它们的实现中封装了一个基本的 dict,而dict是建立在散列表上的。因此,它们都有一个限制,即键必须是可散列的(值不需要是可散列的,只有键需要是可散列的)。如果您需要复习,下一节将进行说明。

什么是可散列的

以下是改编自 Python Glossary 的 可散列定义的一部分:

如果一个对象在其生命周期中,它的散列码永远不会改变(这个对象需要实现 __hash__() 方法),并且可以与其他对象进行比较(它需要一个 __eq__() 方法),那么它就是可散列的。相等的可散列对象必须具有相同的散列值。

数字类型和不可变扁平类型 str 和 bytes 都是可散列的。如果容器类型是不可变的并且所有包含的对象也是可散列的,则这个容器类型是可散列的。frozenset始终是可散列的,因为它包含的每个元素根据定义都必须是可散列的。只有当tuple的所有项都是可散列的时,tuple才是可散列的。参见元组 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 中最常用的映射类型的 API:dict、defaultdict 和 OrderedDict。

常见的映射方法概述

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

Table 3-1. Methods of the mapping types dict, collections.defaultdict, and collections.OrderedDict (common object methods omitted for brevity); optional arguments are enclosed in […]

dict defaultdict OrderedDict

d.clear()

移除所有元素

d.__contains__(k)

k in d

d.copy()

浅复制

d.__copy__()

支持 copy.copy

d.default_factory

Callable invoked by __missing__ to set missing valuesa

d.__delitem__(k)

del d[k]—remove item with key k

d.fromkeys(it, [initial])

New mapping from keys in iterable, with optional initial value (defaults to None)

d.get(k, [default])

Get item with key k, return default or None if missing

d.__getitem__(k)

d[k]—get item with key k

d.items()

Get view over items—(key, value) pairs

d.__iter__()

Get iterator over keys

d.keys()

Get view over keys

d.__len__()

len(d)—number of items

d.__missing__(k)

Called when __getitem__ cannot find the key

d.move_to_end(k, [last])

Move k first or last position (last is True by default)

d.__or__(other)

Support for `d1|d2` to create new dict merging d1 and d2 (Python ≥ 3.9)

d.__ior__(other) <
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值