Python 3.15 多了 frozendict,默认配置还要死守 MappingProxyType 吗?

isinstance(cfg, dict) 忽然红了,上一行才刚把模块级默认配置从普通 dict 换成 frozendict。换的人说 3.15 内置了、还能当字典键。另一些老代码还在用 types.MappingProxyType 包一层只读视图。新类型来了,旧写法要不要连夜扔掉,先看官方怎么定义行为。

文档树是 Python 3.15.2。What’s New in Python 3.15 的 PEP 814 一节,和 PEP 814 正文,是下面说法的来源。

frozendict 实际保证什么

What’s New 写得很短:frozendict 进 builtins。创建后不能改。不是 dict 子类,直接继承 object。键和值都可哈希时,整个映射可哈希。保留插入顺序,但相等比较不看顺序。官方示例里,对 frozendict 做 a['z'] = 3 会 TypeError。调换构造顺序的两个实例可以 == 且 hash 相同。

PEP 814 补了使用场景:当字典键或 set 元素、丢给 @functools.lru_cache 的参数、以及函数默认参数——避免可变默认值坑。构造 API 接近 dict:空、关键字、从映射或键值对可迭代对象创建。| 合并、|= 都会得到新的 frozendict,不会原地改左边那个。查找复杂度按 PEP 的说法是 O(1) 量级(相对曾经提案里另一种有序度不同的 frozen map)。

标准库一侧,What’s New 列了已接受 frozendict 的模块,包括 json、copy、pickle、pprint、marshal、decimal、plistlib(序列化)、xml.etree.ElementTree 等。eval / exec 的 globals、以及 type() / str.maketrans() 也能吃。新代码里把「默认配置 / 常量映射」建成 frozendict,和这些路径是对齐的。

isinstance 为什么会翻车

官方点名提醒:靠 isinstance(arg, dict) 做分支的代码,要改成 isinstance(arg, (dict, frozendict)),或者直接 isinstance(arg, collections.abc.Mapping),后者连 MappingProxyType 等其它映射也能认。

这不是小疏忽。大量序列化辅助、配置合并、dict(cfg) 浅拷贝假设,都默认「映射就是 dict」。frozendict 故意不继承 dict,PEP 里 Rejected Ideas 写过原因:若继承,dict.__setitem__(frozendict, …) 这类通道很难堵干净。所以类型检查要跟着改,而不是抱怨「为什么不像 tuple 对 list 那样」。

值如果是 list 之类可变对象,frozendict 只保证浅层不可改。PEP 的 copy 例子里,浅拷贝仍共享那个 list,deepcopy 才会断开——和「不可变映射里塞可变值」的老坑一样,别指望一层 frozendict 冻住整棵对象树。线程安全一节也只承诺浅层不可变创建后可跨线程共享。值本身若还能被别的线程改,问题仍在值上。

和 frozenset 的类比只在「不可变、可哈希」这一层相近:一个冻的是集合,一个冻的是映射。需要「一组不可变键」时仍用 frozenset。需要「键值对常量、还可能当别的 dict 的键」时才轮到 frozendict。别把所有常量容器都改成同一种类型。

MappingProxyType 先别踩一脚踢开

PEP 专门提了和 types.MappingProxyType 的关系:3.3 就有的只读视图,不可哈希,也不能当继承基类。通过 gc.get_referents() 之类还能摸回原来的可变 dict。它适合「已有一个 dict,临时借只读外壳」的存量模式,比如函数返回「别改我内部表」的视图。

新代码若目标版本已经是 3.15+,默认配置、要进 cache 键的复合映射,优先 frozendict 更合适。库要兼容 3.14 及更早,继续对外返回 MappingProxyType、或文档写明「3.15+ 可用 frozendict」,都比强行在旧解释器假造 builtins 干净。存量模块里已经用 Proxy 包住的常量,没有哈希需求就不必为了「追新」整页替换——行为语义和 frozendict 并不完全同一套,硬换只会制造无收益的 diff。

升级检查可以很短:搜 isinstance(..., dict) 和「假定 .update / __setitem__ 一定存在」的分支。模块级可变全局 dict 改成 frozendict 或构造后立刻冻住。需要当键的嵌套结构确认值也可哈希。第三方包若仍只认 dict,就在边界 dict(frozen) 出一份可变副本,别在库内部偷偷解冻。

你们仓库里,配置常量是已经冻住了,还是还在依赖「大家约定别改」?