PEP 603 – 在 collections 中添加 frozenmap 类型
- 作者:
- Yury Selivanov <yury at edgedb.com>
- 讨论至:
- Discourse 帖子
- 状态:
- 草案
- 类型:
- 标准跟踪
- 创建日期:
- 2019年9月12日
- 发布历史:
- 2019年9月12日
摘要
A 持久化数据结构 被定义为一种数据结构,它在数据被修改时保留数据的先前版本。这类数据结构实际上是不可变的,因为对其进行的操作不会原地更新结构,而是始终生成一个新的更新后的结构(有关更多详细信息,请参阅 [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) 操作; - 获取/设置操作的复杂度为 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']
frozenmap 中的迭代,与 dict 不同,不保留插入顺序。
哈希
frozenmap 实例可以像 tuple 对象一样是可哈希的
hash(frozenmap(foo='bar')) # works
hash(frozenmap(foo=[])) # will throw an error
类型标注
可以使用标准的类型标注来表示 frozenmaps
m: frozenmap[str, int] = frozenmap()
实施
提议的 frozenmap 不可变类型使用哈希数组映射树(HAMT)数据结构。函数式编程语言,如 Clojure,使用 HAMT 来高效地实现不可变哈希表、向量和集合。
HAMT
HAMT 的关键设计契约是当给定键的哈希值时,保证可预测的值。对于一对键和值,键的哈希值可以用来确定值在哈希映射树中的位置。
使用 HAMT 实现的不可变映射在 set() 和 get() 操作中具有 O(log N) 的性能。这种效率之所以可能,是因为修改操作只影响树的一个分支,从而可以重用未修改的分支,因此避免了复制未修改的数据。
性能
图 1。基准测试代码可以在这里找到:[3]。
以上图表表明
- 使用 HAMT 实现的
frozenmap对所有基准测试的字典大小都显示出接近 O(1) 的性能。 - 当使用大约 100-200 个项目时,
dict.copy()变得效率较低。
图 2。基准测试代码可以在这里找到:[4]。
图 2 比较了 dict 与基于 HAMT 的不可变映射的查找成本。HAMT 查找时间平均比 Python 字典查找慢约 30%。这种性能差异的存在是因为遍历浅层树不如在扁平连续数组中查找高效。
此外,引用 [6]:“[使用 HAMT] 意味着在实践中,虽然对持久化哈希数组映射树的插入、删除和查找具有 O(log n) 的计算复杂度,但对于大多数应用程序来说,它们实际上是常数时间,因为需要极其大量的条目才能使任何操作花费超过十几个步骤。”
设计考虑
为什么是 “frozenmap” 而不是 “FrozenMap”
小写的“frozenmap”与内置的 frozenset 以及 collections.defaultdict 等类型很好地呼应。
为什么是 “frozenmap” 而不是 “frozendict”
“Dict”在 Python 中有非常具体的含义
- dict 是
abc.MutableMapping的具体实现,具有 O(1) 的获取和设置操作(frozenmap具有 O(log N) 的复杂度); - Python 字典保留插入顺序。
提议的 frozenmap 不具备上述属性。相反,frozenmap 的设置/获取操作成本为 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