PEP 603 – 在 collections 中添加 frozenmap 类型
- 作者:
- Yury Selivanov <yury at edgedb.com>
- 讨论列表:
- Discourse 帖子
- 状态:
- 草稿
- 类型:
- 标准跟踪
- 创建:
- 2019年9月12日
- 发布历史:
- 2019年9月12日
摘要
持久数据结构定义为在修改数据时保留数据先前版本的数据结构。此类数据结构实际上是不可变的,因为对其的操作不会就地更新结构,而是始终会产生一个新的更新结构(有关更多详细信息,请参见[0])。
本 PEP 提案是在 collections
模块中添加一个名为 frozenmap
的新的完全持久且不可变的映射类型。
frozenmap
的大部分参考实现已在 CPython 中用于实现 contextvars
模块。
基本原理
Python 具有两种不可变集合类型:tuple
和 frozenset
。这些类型可用于表示不可变列表和集合。但是,尚不存在表示不可变映射的方法,本 PEP 提案使用 frozenmap
来实现不可变映射。
提议的 frozenmap
类型
- 实现了
collections.abc.Mapping
协议, - 支持 pickle,以及
- 提供了一个用于高效创建“修改版”的 API。
以下用例说明了为什么不可变映射是可取的
- 不可变映射是可散列的,这允许将其用作字典键或集合元素。
此可散列属性允许使用
@functools.lru_cache()
装饰的函数接受不可变映射作为参数。与不可变映射不同,将普通dict
传递给此类函数会导致错误。 - 不可变映射可以保存复杂的状态。由于不可变映射可以通过引用进行复制,因此可以高效地实现状态的事务性修改。
- 不可变映射可用于安全地在线程和异步任务边界之间共享字典。不可变性使推理线程和异步任务变得更容易。
最后,CPython [1] 已经包含了 frozenmap
实现所需的大部分 C 代码。C 代码已经存在以实现 contextvars
模块(有关更多详细信息,请参见PEP 567)。通过公共集合类型公开此 C 代码极大地增加了代码的用户数量。这通过发现错误和提高性能来提高代码质量,如果没有 frozenmap
集合,这将非常具有挑战性,因为大多数程序间接使用 contextvars
模块。
规范
一个新的公共不可变类型 frozenmap
被添加到 collections
模块中。
构造
frozenmap
实现了类似 dict
的构造 API
frozenmap()
创建一个新的空不可变映射;frozenmap(**kwargs)
从**kwargs
创建一个映射,例如frozenmap(x=10, y=0, z=-1)
frozenmap(collection)
从传递的collection
对象创建一个映射。传递的collection
对象可以是- 一个
dict
, - 另一个
frozenmap
, - 一个具有
items()
方法的对象,该方法预计会返回一系列键/值元组,或者 - 一个键/值元组的可迭代对象。
- 一个
数据访问
frozenmap
实现了 collection.abc.Mapping
协议。因此,getter、成员资格检查和迭代的工作方式与 dict
的工作方式相同
m = frozenmap(foo='bar')
assert m['foo'] == 'bar'
assert m.get('foo') == 'bar'
assert 'foo' in m
assert 'baz' not in m
assert m.get('baz', 'missing') == 'missing'
assert m == m
assert m != frozenmap() # m is not equal to an empty frozenmap
assert len(m) == 1
# etc.
修改
frozenmap
实例是不可变的。也就是说,可以有效地生成不可变实例的修改副本。
修改操作的复杂度为 O(log N),并且由于使用了结构共享,生成的 frozenmap
副本通常消耗很少的额外内存(有关更多详细信息,请阅读[6])。
frozenmap.including(key, value)
该方法创建一个新的 frozenmap
副本,其中包含新的键/值对
m = frozenmap(foo=1)
m2 = m.including('bar', 100)
print(m) # will print frozenmap({'foo': 1})
print(m2) # will print frozenmap({'foo': 1, 'bar': 100})
frozenmap.excluding(key)
该方法生成 frozenmap
的副本,该副本不包含已删除的键
m = frozenmap(foo=1, bar=100)
m2 = m.excluding('foo')
print(m) # will print frozenmap({'foo': 1, 'bar': 100})
print(m2) # will print frozenmap({'bar': 1})
m3 = m.excluding('spam') # will throw a KeyError('spam')
frozenmap.union(mapping=None, **kw)
该方法生成 frozenmap
的副本,并为创建的副本添加或修改多个键/值。该方法的签名与 frozenmap
构造函数的签名匹配
m = frozenmap(foo=1)
m2 = m.union({'spam': 'ham'})
print(m2) # will print frozenmap({'foo': 1, 'spam': 'ham'})
m3 = m.union(foo=100, y=2)
print(m3) # will print frozenmap({'foo': 100, 'y': 2})
print(m) # will print frozenmap({'foo': 1})
调用 union()
方法添加/替换 N 个键比调用 including()
方法 N 次更有效。
frozenmap.mutating()
该方法允许高效地复制应用了多个修改的 frozenmap
实例。当相关的 frozenmap 包含数千个键/值对并且需要在代码的关键性能部分更新其中的许多键/值对时,此方法特别有用。
frozenmap.mutating()
方法返回 frozenmap
对象的可变类似字典的副本:collections.FrozenMapCopy
的实例。
FrozenMapCopy
对象
- 是它们创建自的
frozenmap
实例数据的写时复制视图; - 是可变的,尽管对它们进行的任何修改都不会影响它们创建自的
frozenmap
实例; - 可以传递给
frozenmap
构造函数;从FrozenMapCopy
对象创建 frozenmap 是一个 O(1) 操作; - get/set 操作的复杂度为 O(log N);创建它们的复杂度为 O(1);
- 具有一个
FrozenMapCopy.close()
方法,该方法可以阻止对数据的任何进一步访问/修改; - 可以用作上下文管理器。
下面的示例说明了如何使用上下文管理器使用 mutating()
numbers = frozenmap((i, i ** 2) for i in range(1_000_000))
with numbers.mutating() as copy:
for i in numbers:
if not (numbers[i] % 997):
del copy[i]
numbers_without_997_multiples = frozenmap(copy)
# at this point, *numbers* still has 1_000_000 key/values, and
# *numbers_without_997_multiples* is a copy of *numbers* without
# values that are multiples of 997.
for i in numbers:
if not (numbers[i] % 593):
del copy[i]
numbers_without_593_multiples = frozenmap(copy)
print(copy[10]) # will print 100.
print(copy[10]) # This will throw a ValueError as *copy*
# has been closed when the "with" block
# was executed.
迭代
由于 frozenmap
实现了标准 collections.abc.Mapping
协议,因此支持所有预期的迭代方法
assert list(m) == ['foo']
assert list(m.items()) == [('foo', 'bar')]
assert list(m.keys()) == ['foo']
assert list(m.values()) == ['bar']
与 dict
不同,frozenmap
中的迭代不会保留插入顺序。
散列
frozenmap
实例可以像 tuple
对象一样可散列
hash(frozenmap(foo='bar')) # works
hash(frozenmap(foo=[])) # will throw an error
类型提示
可以使用标准类型提示语法为 frozenmap 进行类型提示
m: frozenmap[str, int] = frozenmap()
实现
提议的 frozenmap
不可变类型使用哈希数组映射 Trie (HAMT) 数据结构。函数式编程语言(如 Clojure)使用 HAMT 来有效地实现不可变哈希表、向量和集合。
HAMT
HAMT 的关键设计约定是保证在给定键的哈希值时获得可预测的值。对于一对键和值,键的哈希值可用于确定哈希映射树中值的位置。
使用 HAMT 实现的不可变映射对于 set()
和 get()
操作具有 O(log N) 的性能。这是可能的,因为修改操作仅影响树的一个分支,从而可以重用未修改的分支,因此避免复制未修改的数据。
性能

图 1. 基准测试代码可以在这里找到:[3]。
上图表明
- 使用 HAMT 实现的
frozenmap
对所有基准测试的字典大小都显示接近 O(1) 的性能。 dict.copy()
在使用大约 100-200 个项目时效率降低。

图 2. 基准测试代码可以在这里找到:[4]。
图 2 比较了 dict
与基于 HAMT 的不可变映射的查找成本。HAMT 查找时间平均比 Python dict 查找慢约 30%。这种性能差异存在,因为遍历浅层树的效率低于在平面连续数组中查找。
此外,引用[6]:“(使用 HAMT)意味着在实践中,虽然对持久哈希数组映射 Trie 的插入、删除和查找具有 O(log n) 的计算复杂度,但对于大多数应用程序来说,它们实际上是常数时间,因为需要极大量的条目才能使任何操作花费超过十几步。”
设计考虑
为什么是“frozenmap”而不是“FrozenMap”
小写“frozenmap”与内置的 frozenset
以及 collections.defaultdict
等类型很好地共鸣。
为什么是“frozenmap”而不是“frozendict”
“Dict”在 Python 中具有非常具体的含义
- dict 是
abc.MutableMapping
的具体实现,具有 O(1) 的 get 和 set 操作(frozenmap
的复杂度为 O(log N)); - Python 字典保留插入顺序。
提议的 frozenmap
没有这些特性。相反,frozenmap
的 set/get 操作的复杂度为 O(log N),并且它只实现了 abc.Mapping
协议。
实现
提议的 frozenmap
类型的完整实现可在 [2] 中找到。该软件包包含该类型的 C 和纯 Python 实现。
另请参阅 CPython 项目树中 HAMT 集合的实现:[1]。
参考文献
致谢
感谢 Carol Willing、Łukasz Langa、Larry Hastings 和 Guido van Rossum 提供的反馈、想法、编辑和关于此 PEP 的讨论。
版权
本文档放置在公共领域或根据 CC0-1.0-Universal 许可证,以两者中较宽松者为准。
来源:https://github.com/python/peps/blob/main/peps/pep-0603.rst
上次修改时间:2023-09-09 17:39:29 GMT