Python中的“冻结”字典
我们期望 frozendict 通过设计实现安全性,因为它能防止任何意外修改。此新增不仅惠及 CPython 标准库,第三方维护者也能借助这种可靠的不可变字典类型。
字典在Python代码中无处不在,是处理各类任务的首选数据结构。但字典具有可变性,这使得它们在并发代码中共享数据时存在问题。过去十年间,Python为语言添加了多种并发特性——异步编程、无全局解释器锁的自由线程 (GIL)以及独立子解释器——但用户仍需自行解决如何创建可安全共享的不可变字典。现有模块虽可替代,但近期提案PEP 814(“添加frozendict内置类型”)旨在将该特性直接纳入语言本体。
Victor Stinner 于11月13日在Python讨论论坛的PEP板块发布公告,宣布他与Donghee Na共同起草了该提案。该构想早有先例,包括斯廷纳2012年提出的PEP 416,其标题与814基本相同。该提案当时被Guido van Rossum否决,部分原因在于其目标——一个从未真正实现的Python沙箱。
frozendict
该方案相当直观:在Python语言的内置模块中新增不可变类型frozendict。正如Stinner所述:
我们期望 frozendict 通过设计实现安全性,因为它能防止任何意外修改。此新增不仅惠及 CPython 标准库,第三方维护者也能借助这种可靠的不可变字典类型。
尽管frozendict与内置类型dict存在诸多共通点,但它并非dict的子类;它实际上继承自基础的object类型。可通过多种方式使用 frozendict() 构造函数创建:
fd = frozendict() # 空字典
fd = frozendict(a=1, b=2) # 冻结字典 { ‘a’ : 1, ‘b’ : 2 }
d = { ‘a’ : 1, ‘b’ : 2 }
fd = frozendict(d) # 相同
l = \[ ( ‘a’, 1 ), ( ‘b’, 2 ) \]
fd = frozendict(l) # 相同
fd2 = frozendict(fd) # 相同
assert d == fd == fd2 # True
与字典类似,frozendict的键必须不可变(因此需可哈希),但值可变也可不变。例如列表作为字典值是合法类型,但其可变性会导致整个字典(无论是否冻结)成为可变对象。然而,若frozendict中所有值均为不可变类型,则该字典本身也具有不可变性,因此可进行哈希处理并应用于需要此特性的场景(如字典键、集合元素或functools.lru_cache中的条目)。
如上例最后一行所示,可哈希的冻结字典可与任意类型的字典进行相等性比较。此外,其hash()值与相等性测试均不依赖字典插入顺序(尽管冻结字典会保留原始顺序 (常规字典亦然)。因此:
d = { ‘a’ : 1, ‘b’ : 2 }
fd = frozendict(d)
d2 = { ‘b’ : 2, ‘a’ : 1 }
fd2 = frozendict(d2)
assert d == d2 == fd == fd2
# 根据PEP规范,冻结字典的并集操作同样有效
>>> frozendict(x=1) | frozendict(y=1)
frozendict({‘x’: 1, ‘y’: 1})
>>> frozendict(x=1) | dict(y=1)
frozendict({‘x’: 1, ‘y’: 1})
对于并集操作,两种情况都会创建新的冻结字典;“|=”并集赋值运算符同样通过生成新的冻结字典作为结果来实现。
对冻结字典的迭代符合预期;该类型实现了collections.abc.Mapping抽象基类,因此.items()返回键值元组的可迭代对象,而.keys()和.values()分别提供冻结字典的键和值。总体而言,frozendict 表现得如同不可变的Python字典;两者具体差异详见PEP文档。该文档还包含一份详尽清单,列举了标准库中可将dict替换为frozendict以“增强安全性并防止意外修改”的场景。
讨论
对PEP的反应总体积极,主要围绕常规的微调建议和提案的实质性补充展开。斯廷纳(Stinner)始终将讨论聚焦于当前提案。其中一项提案引发部分人担忧:将字典转换为冻结字典被描述为O(n)的浅拷贝操作。Daniel F Moisset 认为,采用原地转换实现O(1)操作更为合理。他提议新增.freeze()方法,本质上仅将字典对象类型转换为frozendict。
然而,如布雷特·坎农所述,修改现有对象类型存在诸多隐患:
但此时你已将该字典对所有持有引用者冻结,这意味着可能引发意料之外的远程副作用(例如线程上下文切换后,突然尝试修改微秒前还是字典、此刻却已冻结的对象时触发异常)。这简直是在为优化创建时间而自找麻烦,最终可能引发极其棘手的调试问题。
他继续强调,该PEP并非以性能为目标,而是旨在“减少并发代码中的错误”。莫伊塞特指出,字典本就可能通过.clear() 或 .update() 引发意外变化,因此调试问题本就存在。他承认提案作者可能不愿将此纳入PEP讨论范围,但希望确保未来不会排除O(1)转换的可能性。
Cannon对直接改变对象类型的做法提出强烈反对。Ben Hsing与“Nice Zombies” 提出了两种无需浅拷贝即可构建新 frozendict 的方案——从而实现 O(1) 操作:一是将哈希表移入新创建的 frozendict 并清空原字典,二是采用表的写时复制机制。正如 Steve Dower 所指出的所言,只要PEP未强制要求操作必须为O(n)(这显然是愚蠢的),该优化可留待后续添加——他在脚注中坦言,此类强制要求有时只是“为了让人们停止抱怨”。基于上述讨论,该PEP明确推迟的优化工作,暗示该优化也可应用于其他冻结类型(元组和冻结集合也可能适用,或许可通过复活PEP 351(“冻结协议”)实现。
12月1日,Stinner宣布 该提案已提交至指导委员会审议。鉴于Na身为理事会成员(尽管他很可能回避对此PEP的表决),他应该对该提案在理事会中的接受度有相当准确的预判。因此该PEP获批的可能性相当大。随着无全局解释器锁(GIL)的自由线程版本语言的普及,更多多线程Python程序正在涌现。因此,提供线程间安全共享字典的方法将极具价值。
本文文字及图片出自 A "frozen" dictionary for Python
不知Raymond Hettinger对这个PEP有何看法。很久以前他曾写道:“冻结字典会引发诸多问题,且实用性有限”。
https://mail.python.org/pipermail/python-dev/2006-February/0…
这正是我欣赏Rust处理方式的原因——几乎是偶然间让借用检查机制发挥了作用。每个引用要么可变要么不可变,且(在安全代码中)你无法通过不可变引用在链条中获取可变引用。因此你可以通过可变引用逐步构建映射,但最终在函数中以不可变形式返回,至此便不可再变。它从此不再可变,其键值也永远不可变。无需另行创建名为 FrozenHashMap 的对象,更不必设计 FrozenList、FrozenSet 等变体。String 作为可变类型本就无需 StringBuilder,除非你刻意限制其可变性——这一切都已内置于语言特性中。
Kotlin也“某种程度上”实现了类似特性,但若在Kotlin中持有不可变映射的引用,你仍可随意修改其值(甚至键值)。
你无法返回不可变版本。你可以返回拥有权(此时可随时将其赋值/重新赋值给可变变量),或接受可变引用并返回不可变引用——但拥有者几乎总能以可变方式访问它。
哎呀,你说得对。当时我怎么想的呢。我认为我的观点依然成立,因为你获得了不可变性的好处,不过确实没解释清楚。
我的意思是,若返回不可变引用,所有者实际上无法修改该引用,除非释放该引用。
若实际返回如 Rc::new(thing) 或 Arc::new(thing) 之类的对象,则永久不可变(当然最后一次解包时可以修改!)
仅当你返回引用或将其封装在仅返回引用的结构中时成立。若按值返回对象(即“拥有”),则可随意操作该对象,此时mut仅是对该对象名称的轻量级保护机制。
Rust最令我欣赏的特性,正是你所描述的这种对可变性的显式控制。
这个链接太棒了,强烈推荐阅读。
它深入阐释了Python容器类的设计理念,揭示了多态性/鸭子类型在容器间的边界,以及容器间的变异机制。
我虽不总认同Python容器API的设计选择…但始终渴望尽可能理解其背后的逻辑。
值得注意的是认知会随时间演变。还记得GvR和核心开发者们曾坚决反对有序字典吗?哈哈!好时光啊!幸好他们最初的认知并非最终定论。2006年Python的并发与并行只是个微不足道的问题,如今却成为语言演进的核心议题。不可变性作为设计理念也取得了长足进步,即便在完全拥抱状态变迁的语言中亦是如此。
> 值得注意的是,认知会随时间演变。还记得GvR和其他核心开发者曾坚决反对有序字典吗?哈哈!好时光啊!
新实现节省了空间,但通过提供排序保证,他们现在放弃了进一步节省空间的机会(尤其在删除键之后)。
排序特性如同排序中的稳定性,是极其有用的属性。若需付出些许代价,那便如此吧。
此优化针对常见场景——内存通常充裕且字典增长多于缩减。Python存在如此之多的内存低效问题,字典内部结构中偶尔出现的“墓碑”效应不太可能造成重大影响。若真有顾虑,可在大量删除后执行
d = dict(d)。我尚未发现依赖它的充分理由。过去也鲜少使用
OrderedDict。相比保留插入顺序,我更常需要实际排序功能。确定性有时确实有益。
我通常不在意具体顺序,只要求每次结果保持一致。
事先思考这个问题时,我其实在想:除了相等性比较之外,这种特性还有什么用处?
诚然,我在TypeScript环境中工作,无法直接用
===比较两个对象。但我能理解这种确定性行为能让某种语言更轻松地比较对象——尤其当相等性比较依赖于生成的哈希值时。另一项是迭代顺序的保证,当你依赖可迭代对象的索引-内容关系时尤为重要。虽然我们讨论的是基于键的字典,但将此概念扩展到列表时,我发现某些场景下它仍具实用价值。
除此之外,我不确定是否还有其他意义,但我也意识到自己当前可能缺乏足够的想象力去思考其他优势。
将数据解析为字典后再序列化回原形式会稍显便捷。不过优势并不显著。
我从事构建系统(Bazel)开发,或许比多数人更关注这点。
但归根结底可能都取决于相等性比较——只是这种比较未必总发生在自身代码内部。
个人而言,我发现有诸多理由偏好有序字典而非无序字典。即便像“调试输出能以固定顺序呈现,便于对比”这样细微的优势,在许多场景中也足以成为选择动机。
看来对此项的看法确实存在分歧。我钟爱映射结构中的插入排序顺序,Python对此的支持曾让我豁然开朗。核心原因在于键值需要某种序列:插入顺序→迭代顺序远优于伪随机顺序(基于哈希的排序)。
对我而言,这能让程序和脚本更具可复现性,即便是简单的脚本也是如此。
深有同感。最近看到面试反馈,有人抱怨候选人使用OrderedDict而非现在默认排序的内置dict,但表示可以容忍…仿佛编写会因Python小版本差异而静默改变行为的代码是明智之举。
其实从2018年发布的3.7版本起就已保证兼容性,而3.6在2021年已终止支持,这都过去好几年了。如果编写面向公众的代码(库、应用程序),我能理解这种优势;但比如在我工作中,我的代码永远不会在Python 3.6或更早版本上运行。
没错,既然有这种保证,我不会责怪任何人使用dict,同样也不会对OrderedDict提出异议。
确实!我不明白为什么标准库不更常包含按键排序的映射和集合。这比按插入顺序排序实用得多。
大概是因为涉及不同的性能特性。
排序对测试至关重要。
比如今天早上,我测试通过JSON API序列化的对象时,发现测试数据每次运行结果都不一致。
后来才意识到:某个对象使用了集合类型,API将其转换为JSON数组时,数组顺序会因Python虚拟机初始状态而变化。
三天前我曾用itertools.groupby分组数据,但该函数仅适用于按分组键排序的可迭代对象。
诚然,这些近期案例都与字典无关,但字典并非特例——它同样需要常规迭代处理。
排序性是集合特有的属性(无论是否实用),而偏序集才具备排序特性。
若需有序集合,我通常会选用其他数据结构。
你的代码真的依赖这个特性吗?我从未遇到过这种需求。
这已是19年(近20年)前的事了。正如lwn.net文章所述,Python已新增大量并发特性,或许现在正是引入类似frozendict功能的时机。
2006年无用的东西,到2026年可能就大有可为啦 ;P
不过和您一样,我也好奇他对此有何见解。
我认为Raymond Hettinger在此被特别提及,是因为他曾发表过一场著名的演讲[Modern Dictionaries](https://youtu.be/p33CVV29OG8),其中32:00至35:00处他调侃道:年轻开发者总以为需要新数据结构解决新问题,最终却只是重造/重发现1960年代的解决方案。
“已有的事,后必再有;已行的事,后必再行。日光之下并无新事。”
此后HAMT被发明并成功应用于Scala和Clojure,因此该演讲观点已显陈旧。
维基百科(https://en.wikipedia.org/wiki/Hash_array_mapped_trie)链接至描述HAMT的论文(https://infoscience.epfl.ch/server/api/core/bitstreams/f66a3…) 的论文,并声称该技术诞生于2000年。但该演讲实为2016年发表。
您是否了解任何注释详尽、易于理解的实现方案?
HAMT在Clojure出现前并非不可变/持久化:https://en.wikipedia.org/wiki/Persistent_data_structure#Pers…
这仍远早于该演讲。
有趣的是,他仅探讨了字典作为键的使用场景后,就断言冻结字典“并非特别有用”。
他忽略了2025年我们大多数人首先想到的原因:当明确某些值创建后不可变时,代码逻辑更易于推导。
二十年间文化观念竟有如此巨变!
但实际情况未必如此。字典本身冻结了,内部值却可能变化。C++曾试图用const性解决这个问题,但其本身存在局限性,导致部分人反对使用它。
确实如此。所以我实在不明白这个提案想实现什么。它甚至明确指出dict→frozendict会是O(n)的浅拷贝,争议点仅在于O(n)部分。所以…没错,我确信它们在某些场景下有用,但正如Raymond所言——这似乎没什么特别价值,我不明白帖内讨论者为何如此兴奋。
或许是将Python视为系统语言,因此套用了C++和Rust中const的相同逻辑
> 另一种PEP 351视角认为元组可充当冻结列表;但这种观点违反了Liskov原则(元组不支持相同方法)。这个想法每隔几个月就会冒出来,然后再次被驳倒。
…确实如此;它不支持用于变异的方法。将ImmutableFoo视为Foo的子类永远行不通。而且事实上,
set和frozenset之间不存在继承关系。我通常认为赫廷格见解深刻,这次却令人失望。但人非圣贤,时移世易(基础条件亦然)。不过我确实觉得frozendict缺失已久。实际上我认为,即使不采用默认不可变的方案,若能引入更正式的不可变性概念(例如更明确地关联哈希属性;明确支持“缓存”属性等),语言设计会更完善。
苹果(或NeXT)已在Objective-C中解决了这个问题。看看NSArray和NSMutableArray,或是NSData和NSMutableData。将可变版本设为不可变版本的子类既符合直觉又满足里氏法则。反之则明显错误。
鉴于Python的动态特性,这种子类关系在C层面上不必显式体现。完全可以利用PEP 3119规范,让实现独立于某类的类成为该类的子类。这既保留了本体论层面的子类关系,又赋予实现层面的完全灵活性。
> ImmutableFoo作为Foo的子类永远行不通。事实上,
set与frozenset之间也不存在继承关系。理论上,
set能否成为frozenset的子类(dict能否成为frozendict的子类)?其他语言是否采用这种设计?> 将[不可变性]与哈希能力更明确地关联
据我所知,对于语言的“核心”类型,不可变性与哈希能力是等价的。鉴于可变性及
__hash__的实现完全由程序员控制,是否可能强制要求用户定义类型也保持这种等价性?当然可以。其他语言就有类似实现,参见Objective-C中的NSMutableSet和NSSet。
> 理论上,
set能否成为frozenset的子类(而dict成为frozendict的子类)?极端情况:当然可以,若我们愿意,任何东西都能成为其他任何东西的子类。
另一极端情况:不行,因为里氏替换原则是难以企及的高标准;尤其在Python这样动态/松散的语言中。例如考虑表达式’“pop” in dir(mySet)"
> 考虑表达式 ‘“pop” in dir(mySet)’
我不明白为什么 hasattr(mySet, ‘pop’) 在这里会成为问题?
> 我不明白为什么 hasattr(mySet, ‘pop’) 在这里会成为问题?
我从未说这是个问题(也从未说这不是问题!)。我特别指出了两点:
– 我引用的问题具有“理论性”(即忽略了主观性、实用性、惯例等其他方面)
– 以及本讨论串更早部分引用的“Liskov违背原则”相关论述。
补充背景:以下是Liskov对该原则的定义(摘自https://en.wikipedia.org/wiki/Liskov_substitution_principle):
> 巴芭拉·利斯科夫和珍妮特·温在1994年的论文中简洁地阐述了该原则如下:[1]
> > 子类型要求:设ϕ(x)是关于类型T对象x可证明的属性。则对于类型S的对象y,当S是T的子类型时,ϕ(y)应为真。
我的表达式 `“pop” in dir(mySet)` 明确说明了 `set` 与 `frozenset` 彼此并非子类型(无论语言如何编码,使用“子类”或其他概念)。在此情境下,ϕ(x) 对应的属性可表述为 “‘‘pop’ in dir(x)’ = ‘False’”,该属性对类型为 frozenset 的对象 x 成立,但对类型为 set 的对象 y 不成立。
你举例的 hasattr(mySet, ‘pop’) 则揭示了另一项会被违反的属性。
我的观点是,避免“Liskov违反”在理论上是不可能的,尤其在Python中(该语言允许程序通过’dir’、’hasattr’等机制对值进行内省/反射)。
(顺便提一句,读完https://okmij.org/ftp/Computation/Subtyping后,我对Liskov替换原则已相当厌倦)
> 读完https://okmij.org/ftp/Computation/Subtyping后,我对里氏替换原则颇感厌倦
问题的根源在于,里氏替换原则仅将ϕ(x)视为某类对象满足的属性。它并未区分哪些属性是类设计者刻意要求满足的,哪些属性只是在当前实现中偶然满足。但海伦定律也指出:偶然成立的属性可能被依赖,并随时间推移成为内在属性。这让我意识到问题的症结在于人们未能充分沟通代码中哪些是不变量、哪些是可变量。
> > 子类型要求:设 ϕ(x) 是关于类型 T 的对象 x 可证明的属性。则对于类型 S 的对象 y(其中 S 是 T 的子类型),ϕ(y) 应为真。
这相当于“若hasattr(父类, ‘pop’) == True,则hasattr(子类, ‘pop’)必须为True”。本例未违反该要求,因hasattr(父类, ‘pop’)为False。若要扩展上述定义,要求父类的否定性证明同样适用于子类,则子类型化将变得不可能——按定义,所有父类与子类类型必须完全一致。
此处的属性为
hasattr(x, “pop”) is False。> 若将上述定义扩展为:父类的否定性证明也应适用于子类,则子类型化将无法成立——因定义要求所有父子类型必须完全相同。
区别不在于“否定性证明”,但确实是他们的核心观点。在Python中,必须划定哪些可观察属性具备继承资格。
此外,就本提案而言,
dict与frozendict之间同样不存在继承关系。ImmutableFoo 不能成为 Foo 的子类,因为它会失去修改器方法。但 Foo 同样不能成为 ImmutableFoo 的子类,因为它会丧失 ImmutableFoo 具备的不可变性公理(如线程安全)。
正确理解里氏替换原则后会发现,几乎不存在任何能完全替换其他类型的场景,使得该特性形同虚设。因此只需基于实际需求选择最佳方案,在合理范围内尽可能实现里氏替换即可。Python本就是鸭子类型语言。
这确实是个不错的指导原则——Set和ImmutableSet的可替代性强于Set和Map,因此Set从ImmutableSet派生比从Map派生更合理。只是这种理想状态永远无法真正实现。
我同意,frozenset也是如此。若真要用这类对象作键,转换为元组即可。这些操作或许存在特定场景需求,但既不需要语言本身支持,标准库也无需提供此类功能。
问题在于集合本身不具备一致排序性,转换为元组会导致单个集合关联的潜在键数量呈指数级(具体而言是阶乘级)暴增。况且并非所有对象都能排序。虽然能安全地将集合转换为元组用作键值,但目前我所知的唯一方法需要辅助存储对象(将对象映射至初始观察顺序),这种方案难以实现并行化。
使用
tuple(sorted(s))即可解决排序问题,若连值都无法排序,那它们很可能不具备哈希属性。我明白这涉及复制操作,但 frozenset 同样如此,若真成为问题,可通过多种方式解决。以下类型支持哈希:
除了整型和浮点型之外,你无法对不同类型的对象进行比较。此外,复数根本无法排序。
我已经尝试过这种方法,现在再次告诉你:排序元组并非通用解决方案。
我并非否认元组存在缺陷,而是认为无需内置解决方案。若因特殊情况需要将混合类型集合用作字典键值,编写辅助函数即可解决。
不可变性是种愉悦的体验。问问任何用过Clojure字典的人就知道了。
Python在尝试实现不可变数据时搞砸了。Frozendict是个笨拙的工具——它并非提供可变的自由空间,而是将数据锁死整个生命周期。准备迎接满屏深拷贝的代码浪潮吧,它们会自豪地宣称自己多么函数式。
若想获得真正的不可变数据结构而非廉价仿制品,请关注pyrsistent库。
除了复制,你还能怎么“修改”不可变数据?
不必复制所有内容。
查阅持久化数据结构[0]
核心技巧是将所有内容存储在分支因子较大的树中。
元素位于叶节点。编辑时只需创建从根节点到目标叶节点的子树,返回新根节点即可。其余节点均可共享。
这是日志结构操作,通常采用32分支实现。
该方案兼容向量与字典。创建副本的时间复杂度为常数级,不可变特性确保数据可自由共享(尤其在带垃圾回收的语言中)。
插入/删除操作复杂度为O(log32(n)),而非完整复制所需的O(n)。
[0] https://en.wikipedia.org/wiki/Persistent_data_structure#Pers…
然而,函数式语言似乎从古至今都在实现这种特性?开个玩笑!这可是独立的研究领域!我虽非专家,但设想单链表仅允许push_back()和pull_back()操作(禁止insert())。现在让多个所有者操作这个列表。当A执行“pull_back()”时,实际发生的是:A的局部“尾部”指针沿列表末端回溯,从而更新了对列表末端的认知。当A执行“push_back()”时,它开始在其新尾部添加链表节点。由于这些节点仅“向后指向”而未修改旧节点,因此不会改变原始列表结构,仅更新A持有的列表版本。此时若B也在访问该列表,它同样可以执行“push_back()”添加自己的“尾部”。最终形成的数据结构本质上是“一丛共享的单链表”,其丛状结构更接近树而非单链表。
二叉树等结构也可实现类似机制。虽然操作更复杂,且单所有权确实能在某些场景下通过“底层可变性”提升实际性能,但核心原理如上所述。
有意思,我之前没这么想过,所以本质上是按写复制,但粒度更细
本质上是代理机制。无需深度复制,只需返回代理对象——当请求的键未被找到或修改时,该对象会回退到原始字典。
函数式数据结构本质上会在每次写入时创建代理。若采用批量写入操作,且仅需批次间保持不可变性,这种机制可能效率低下。
若字典已冻结,则无需深度复制。不可变性的核心在于:若需基于现有字典创建新冻结字典,只需重建顶层的间接数据结构,而被引用的值保持不变。
你完全正确:“间接数据结构”是必需的。冻结数据本身毫无意义——它并未提供函数式语言中不可变数据结构的典型优势。这正是我的观点:Python提供的是半吊子方案,却被误认为完整解决方案。
你认为Python开发者会基于frozendicts自行实现HAMT?还是说他们只会复制数据?就我个人而言,我会直接使用pyrsistent库,它似乎做对了这件事。
现在断言Python“已推出”相关功能为时尚早。该PEP仍处于草案阶段,可能被修改(三小时前就经历过修订)甚至被否决。文章虽称其获批可能性大,但尚未最终定案。这也意味着您仍有机会在相关讨论中对PEP提出意见。
https://peps.python.org/pep-0814/
不过pyrsistent运行速度极慢。刚做了个快速基准测试:
除非涉及1万+项的批量处理、10万+项的批量更新或插入100个键,否则实际操作中很少出现这种情况。大多数字典和dict操作规模较小。
除非处理1万+项数据、10万+项批量更新日期或插入100个键值。
实际场景中这种情况很少见,大多数字典及其操作规模都很小。若遇到超大字典,你应该采用分批加载或交由基础设施处理。
更何况pyrsistent的API与字典不兼容,未经转换无法直接传递给外部代码。
除非回报率惊人,否则不值得为此付出代价。
> 不过pyrsistent确实慢得离谱
Python何时讲究过速度?
> 刚做了个快速基准测试
代码呢?你观察到瓶颈调用了吗?
> 除非处理1万+项数据、10万+项批量更新日期或插入100个键值。
> 实际场景中这种情况很少见
实际应用的统计数据呢?
> 除非回报率惊人,否则不值得这么做。
投资回报率体现在:无畏的API设计中实现:1) 高级组件的多实例真正独立且可轻松并行化;2) 调用方确知原始数据完整无损,被调用方严格遵循不可变约束;3) 默认函数参数与全局对象天然不可变,无需额外实现PEP规范;4) 集合类型普遍具备哈希能力。
显然这个投资回报率对你来说完美无缺。
我不再浪费你的时间了。
实际上这对大多数Python开发者都很完美,并非仅限于我个人——这与你所谓“实际应用中”的说法相悖。
冻结字典功能将非常受欢迎。你现在已能通过MappingProxyType实现类似效果[0]
[0]: https://docs.python.org/3/library/types.html#types.MappingPr…
> 你已经可以实现类似功能
前提是禁止访问底层真实字典。
没错,这仅能阻止调用方修改数据,无法强力保证底层映射不会在上游被更改(因此MappingProxyType无法实现可洗性)。
特别推荐 pyrsistent 库——这是我个人最爱的冻结/不可变/可哈希数据结构库,尤其适用于将列表、集合或字典用作字典键,或创建此类元素的集合时:https://github.com/tobgu/pyrsistent
并发是重要动因,但即使在顺序代码中它也极具价值。可能修改传入字典的函数与绝对不会修改的函数存在本质差异。使用Mapping固然优秀,但这只是表面保证——运行时仍可能被破坏。
能否用通俗语言解释这个与命名元组的核心区别?ChatGPT的回答概括为:无序(此)与有序(命名元组)的区别,“运行时决定的任意键”与“定义时确定的固定字段集”的区别 (命名元组的键难道不能从运行时值插值生成?),以及不同的API(
.keys()vs.items())等(顺便说明,这些内容仅作为背景补充,不确定是否存在不准确之处)。那么是否也可以从另一角度切入,即为无序命名元组添加映射API支持?字典、命名元组和结构体(跨多种语言)之间的界限对我而言始终有些模糊,因此希望通过这次讨论厘清全貌。
> “运行时动态决定的任意键” vs “定义时固定的字段集”(命名元组的键难道不能从运行时值插值生成吗?)
若要在运行时创建字段名可变的命名元组,必须先定义新的命名元组类型再创建实例。虽然Python作为动态语言支持这种操作,但效率不高,更重要的是感觉不太对劲。难道要缓存类型并在字段名匹配时复用?相比直接将条目传递给frozendict类型,这套方案显得有些混乱。
关键区别正如GPT所述:元组的字段具有顺序,而命名元组并非通过名称索引,而是使用字段访问器(即foo.bar而非foo[“bar”])。此外命名元组可像元组那样按顺序索引(如foo[0]),这显然无法在字典中实现——若字典使用整数键,这种操作将极易引发混淆。
因此我认为差异虽不大,但已足够。frozendict 无法通过整数索引。Python 对此已有抽象类型,我猜主要是用于类型检查:https://docs.python.org/3/glossary.html#term-mapping
命名元组文档:https://docs.python.org/3/library/collections.html#collectio…
正如quietbritishjim所言,动态生成类型不仅效率低下且存在缺陷,而“恰巧也是有效标识符的字符串值”作为字典键的限制性要求更是极端苛刻。
元组中的值不可变。而冻结字典中键所指向的值可以改变?
不过我确实支持引入类似命名元组的结构,但需支持可变值并提供[name]访问方式。当然还应像当前字典和集合使用大括号那样,提供优雅的语法糖。
> 元组中的值不可变。冻结字典中键所指向的值可以改变吗?
元组条目不可赋值,但其值可被修改。冻结字典同样如此(根据PEP规范,它们不支持
__setitem__操作,但“值可以是可变的”)。元组元素必须可哈希,这意味着(就标准库而言)它们必须不可变。
啊,原来如此…这正是用元组作为冻结字典的变通方案能成立的根本原因。今天早上反应迟钝啊!
我觉得你完全可以不提ChatGPT就问同样的问题,这样就不会在3分钟内被刷到负分了
你提问求解本身没什么问题啊
这地方就是讨厌ChatGPT和AI嘛。笑死。
编辑:果然如我所料被刷屏了。笑死。
这里讨厌的是懒惰和不严谨。用ChatGPT编辑或找灵感没问题,只要你亲自核对准确性与完整性——毕竟别人在乎这个,就像你在意别人宣布用了拼写检查器一样。
粘贴ChatGPT回复违反站规。
这规定早在GPT出现前就存在了
https://news.ycombinator.com/item?id=46206457
合理,因为那些不是你的原创。我修改下原意:它确实能像拼写检查那样提供帮助——比如我认识的非英语母语者就觉得它在编辑时很有用,尽管他们完全理解主题的学术内涵。
你发表了无实质内容的评论,所以被点踩
评论不必深入,但应能引发更多回应或有回应价值。你的评论不具备这些特质。面对“这个地方[及其用户]讨厌[某事物]”这种表述,你能给出什么有意义的回复?
本想让你具体说明,但这种表述根本无法具体化
> 因此能安全地在线程间共享字典将大有裨益
由于仅键是const而值不是,frozendict本身并非线程安全。需要在值的获取器和设置器周围添加小型锁。
字典操作是线程安全的,但值操作并非如此。元组等其他不可变结构也存在相同问题。加锁无济于事,因为不安全性源于获取值后的操作。
哇,我竟产生了曼德拉效应。我清楚记得这个功能曾被实现并实际使用过。
你可能想的是内置的
frozenset()或第三方Python模块[frozendict](https://pypi.org/project/frozendict/)?我个人在项目需要时,会用
collections.namedtuple作为底层数据结构,通过封装器创建冻结字典。当你需要创建字符串到任意类型的字典时,使用数据类或命名元组反而更合适。
这种方案适用于键值已知的场景(即静态类型语言中的结构体)。但若需要在运行时动态获取键值(如查找表),该方案便失效。
不过我确实喜欢数据类。随着时间推移,它们正越来越多地出现在我的代码中。声明属性集非常实用,且语法更优雅的特性也令人欣喜。
此前曾有过标题完全相同的PEP提案(2012年):
https://peps.python.org/pep-0416/
2019年还有一份关于“frozenmap”的提案:
https://peps.python.org/pep-0603/
或许您使用了来自 pip 可安装库 boltons 的冻结字典实现:https://boltons.readthedocs.io/en/latest/dictutils.html#bolt…
或许是immutabledict?
https://pypi.org/project/immutabledict/
需注意Python已提供MappingProxy类,它通过封装字典来隐藏可变方法。
但人们几乎不使用这个工具,这表明对 frozendict 的需求并不大。
这是我会频繁使用的类型。
例如,我常编写进行可缓存分析的类,其结果为字典(如类存储由点定义的瓦片列表,用户为方便需要点到瓦片的映射)。缓存这些转换值得尝试(如使用@functools.cached_property),但存在风险:调用方可能通过修改破坏缓存值。目前我选择牺牲安全性(缓存字典)而非性能(为每次调用创建副本)。缓存frozendict将是更优的折中方案。
建议参考pyrsistent库。它能创建冻结的“映射”结构,支持将映射“演化”为新版本对象,同时在底层保留未变更键值的引用,从而兼顾速度与内存效率。
下一步:从frozendict自动推断出正确的(最窄的)TypedDict类型(类似TypeScript中的
const foo = { … } as const)。我每天都在怀念Python缺少这个TS特性。具体会实现什么功能?自动生成TypedDict类吗?
提到TypedDict颇有意思,因为若在运行时强制执行PEP 705中type.ReadOnly的约束(而非仅作为静态类型检查的提示),冻结字典本可通过该规范实现。
在类型检查阶段自动生成TypedDict类型,运行时不做任何操作。
我认为这要求并不高,各类类型检查器在发布后很快就会实现类似功能。
哪个版本之后?类型检查器早已支持各类TypedDict PEP规范,甚至包括尚未通过的提案。但至今没有一个能推断TypedDict类型。
好吧,本质上只需实现“内联推断”:
或推断类型提示:
更合理的方式应是:
其中空字典时typing.Const默认类型为Any。
不确定我们是否在讨论同一件事。内联类型字典(Inline TypedDicts)已进入规范化流程,详见https://peps.python.org/pep-0764/,并在Pyright等工具中获得实验性支持。
我的本意是:
应被推断为
啊,我之前不知道PEP 795。不过我其实不太确定是否喜欢它。这看起来像是有人为了避免编写类,或是出于某种原因特别想要像字典那样访问数据结构而发明的东西。
它本质上提供了数据类型,却又确保你无法轻松复用这些类型,而且还会遗漏两个数据结构恰好具有相同签名的情况。
当我们同时拥有class、namedtuple和TypedDict这三种功能高度重叠的类型时,体系就显得有些混乱了。
好奇这对TypeScript意味着什么;老实说我很少用as const。
同意…但实现这个功能不该依赖frozendict
个人认为Python的TypedDict本质上已然失效/毫无用处
真正需要的应该是TS风格的结构化匹配,比如为字典设计协议规范
我也超级喜欢这个特性。尤其当你将键类型组合成联合类型以简化索引操作时。
能否给出Python的具体应用示例?
我发现C#的冻结字典很有用:https://learn.microsoft.com/en-us/dotnet/api/system.collecti…
它通过牺牲昂贵的创建成本来优化快速读取性能。
我很好奇:当字典引用计数为1时,dict->frozendict操作为何不是O(1)操作?这既解决了提出的“幽灵般的远距离作用”问题,又优化了最常见使用场景的性能 (逐步构建字典,转换为frozendict以支持并发读取)。
若该特性获得广泛应用,开发者可针对此类代码添加优化:
JIT 检测到
frozendict构造函数、dict输入参数以及立即被覆盖的单一引用相对容易。不确定这种优化是否值得。采用引用计数的 CPython 难道不会自动知道 a 只有单一引用吗?这样不就无需智能 JIT 也能实现优化?
我认为GP指的是优化掉最后一行O(N)的调用。垃圾回收会处理旧的(可变)字典引用,但根据当前提案,从可变字典构造新frozendict会是O(N)的浅拷贝。
此外还存在其他潜在优化方案(不限于dict/frozendict),可针对特定“f”函数值降低“a = f(a)”等操作的内存开销。
确实,Tcl 实现就采用了这种方式,例如
set d [dict] ; dict set d key value可就地修改 d 而非创建副本(因所有内容均为不可变)。初步想法:鉴于字符串连接操作的类似实现,我本以为它能进行此类优化。
但实际上,我怀疑它无法实现这种优化,仅仅是因为名称
frozendict可能被遮蔽。同意该功能早该内置了。其他函数式包如JAX [0]已采用该概念,但它们是从头开始将其构建到库中的。
[0] https://flax.readthedocs.io/en/latest/api_reference/flax.cor…
我希望Python能摆脱“类型决定可变性”的设计。或者通过封装实现:所有类型都提供可变/不可变变体,通过“to_mut()”和“to_immut()”方法切换。同理,函数也应明确控制是否就地修改变量或创建局部副本。Python是我的第一门语言,至今仍难以系统性地理解这些机制。
我钟爱 Ruby 的 .freeze 特性
若能默认将 *kwargs 设为冻结字典将极具价值。这有助于装饰器缓存以加速键构造,例如自然键可直接定义为
(args, kwargs)。Ruby自1.0版本起便支持冻结字典,普遍认为在可能的情况下使用它们是明智之选。
SQLAlchemy 拥有自主开发的冻结字典,我们已使用多年。如今它已演变为性能优异的 Cython 实现,我将其广泛应用于各类场景。若能纳入标准库将极具价值。
鉴于该提案的重要性,我已在讨论线程中详细展示 SQLAlchemy 如何运用此模式:
https://discuss.python.org/t/pep-814-add-frozendict-built-in…
这个话题总在有序键与无序键的争论中陷入僵局,在我看来这完全无关紧要。却无人提及显而易见的缺陷:由于字典键必须可哈希,Python 陷入了荒谬的困境——字典本身不能作为字典键,例如…
{{‘foo’: ‘bar’}: 1, {3:4, 5:6}: 7}
…而内置机制根本无法合理规避此限制!
你或许会问:“谁会需要用字典作为键的字典?”
更普遍的情况是:当你拥有某个数组时,出于各种原因需要将其元素用作键。而当该数组恰巧是字典数组时,咔嚓一声——突然就无法用数组元素当键了!真不知该气谁更甚:这种设计缺陷本身,还是Python社区那种“这种情况根本不会发生/不需要,所以不给你们frozendict”的集体态度。
将字典转换为元组的元组 `((k1, v1), (k2, v2), …)`,这难道不是合理方案吗?
若需哈希映射键值,只需考虑哈希算法和相等性比较机制即可。当然会遇到复杂情况,比如浮点数具有微妙的相等性概念,或Python中那些不愿成为哈希键的可变集合。
这个论点同样适用于集合,但frozenset却是内置类型。
关于字典
.freeze()方法引发的“幽灵般的远距离作用”担忧:`.freeze()` 方法应直接返回 frozendict 实例而非就地修改字典,二者应作为独立类型存在。底层实现中,创建 frozendict 时本就需要构建哈希表;既然要执行这项工作,不如直接构建一个包含哈希表的对象,使其与原始字典分离。
原始字典和冻结字典所引用的值可以是相同的值;无需克隆它们。
PMap与PVector,功能型Python库,
“PEP 351 – 冻结协议”(2005年,未通过)https://peps.python.org/pep-0351/;据我理解,该冻结协议主要提出:
/? “关于常量及其应用的现有讨论”
今日使用双引号搜索词检索python-ideas邮件列表存档时,未能找到该帖子(2021年)的URL; 最终不得不使用Python邮件列表搜索引擎?是否邮件列表爬取功能出现故障?旧版Mailman的HTML存档本可轻松爬取…增强建议:pypa应为邮件列表存档及论坛添加sitemap.xml;@pypa请咨询搜索引擎索引方案:“如何确保Python邮件列表存档能被搜索引擎收录?”(延续传统收录机制)
如今如何查找邮件列表存档的.txt文件?
摘自“[Python-ideas] Re: 在Python中引入常量变量” (2021) https://mail.python.org/archives/list/python-ideas@python.or… :
– pyrsistent: PMap, PVector
我没时间处理这个了;(为适应HN格式重新排版,URL将自动转为超链接但换行符不被删除)完整邮件以.txt形式附后,邮件列表存档提供保留换行符的超链接版本。GH Markdown和CommonMark Markdown同样保留换行符并自动转链接:
https://en.wikipedia.org/wiki/Design_by_contract
https://en.wikipedia.org/wiki/Invariant_(mathematics)#Invari… [ https://en.wikipedia.org/wiki/Class_invariant ]
> “不变量”、“常量”和“final”有什么区别?
没错!再也不用搞什么`MappingProxyType`黑科技了
>但字典是可变的,这使得它们在并发代码中共享数据时存在问题。
其实不然,C# 有 ConcurrentDictionary。
这叫 dataclass
能否给出独立内置类的充分理由?因为“防止意外修改”这个理由稍显薄弱。
若键值固定,使用冻结数据类即可。若非固定,可先创建普通字典d,再存储排序后的元组(sorted(d.items())),既保证不可变性又实现高效查找(二分搜索),最后丢弃d即可。
太棒了!现在让
set保持稳定顺序就大功告成了。集合不是定义上就是无序的吗?还是说重复访问但不修改会得到不同结果?
这可能指的是字典自Python 3.6起就作为语言特性保留插入顺序。数学上集合没有定义顺序,字典本质上只是伪装的集合,但为语言添加这个不变量能带来确定性优势。
集合刻意采用不同实现(即它们并非“无值字典”),正是因为预期其存在不同应用场景(如并集/交集运算)。
当集合具备可预测顺序时,调试体验将截然不同且更高效。否则每次打印、分析或比较字典前都需预先排序——即便在哲学层面可辩护,这种操作仍属不必要的负担。
虽与Python无关,但集合(即序列上的等价类)的一种可能实现是排序数组(消除重复项,除非是允许多重元素的集合,此时排序数组可包含非唯一元素),这与能存储任意序列的无序数组形成对比。
因此集合可视为隐式排序结构,其元素顺序无法用于区分两个集合。
集合内部采用排序机制来确保不同顺序元素的等价性,并不意味着存在按指定顺序检索元素或返回小于/大于阈值子集的操作。当需要此类操作时,必须在集合外部定义排序关系。
因此集合与多重集合可定义为排序数组(可选取元素唯一性),而序列则是未排序数组(同样可约束元素唯一性)。但标准集合运算不提供对内部排序的外部访问——该排序仅存在于集合元素所附的任意标识符之间,对外无实际意义。
稳定顺序不等同于排序顺序。若将同一集合两次转换为列表,两次结果的顺序可能相同,但绝无保证,且该顺序可能因Python实现与版本不同而改变。随着集合增减,顺序也可能发生变化。
字典键也是如此,但Python最终决定赋予其插入顺序(此前数十年间它们与集合元素一样保持无序)。集合本可定义顺序,根本不存在技术障碍——JavaScript等语言正是如此实现的。
Python规范中将字典键设为有序的决策是个错误。这或许是迄今最佳的实现方案,却扼杀了未来的改进空间。
赞同。唯一需要排序的原因是人们会错误地认为它们本应排序。你可以主张编程语言不应存在意外行为——显然未排序的字典键值令许多人意外——但另一方面我认为这是教育体系的失职。
问题在于:虽然键值排序的假设在多数情况下成立,但并非绝对保证。另一种解决方案是增加随机性,但这可能导致大量旧代码失效。若用户本就不期待键值排序,排序本身并无意义;但当切换语言时,这种差异反而会带来更大冲击。
欢迎来到starlark 🙂