Python 3.15 预览中的 frozendict 补上了一个长期存在的空位:dict 适合动态更新,却不能被哈希;MappingProxyType 可以提供只读视图,但并不是为充当字典键设计的。frozendict 则把“不可变映射”和“可哈希值”结合起来,让一组命名参数、配置项或状态标签可以直接成为集合成员和字典键。
它解决的不只是“禁止赋值”
普通字典不能作为另一个字典的键:
options = {"format": "json", "compress": True}
# TypeError: unhashable type: 'dict'
cache = {options: "cached-result"}
原因并不只是语法限制。字典键放入哈希表后,其哈希值必须保持稳定;如果键自身还能被修改,查找结构就可能失效。
frozendict 的核心价值正是稳定性:创建后不能增删或替换条目,因此可以计算哈希,并安全地用在依赖可哈希对象的场景中。
典型用途包括:
- 把函数选项组合成缓存键;
- 在集合中保存去重后的配置;
- 表示图算法中的状态或标签;
- 把结构化参数作为其他映射的键;
- 明确表达“这份映射从此不应变化”的接口契约。
在 Python 3.15 预览环境中动手验证
下面的示例假设当前 Python 3.15 预览版本已经提供内置 frozendict。预览阶段的细节仍可能调整,运行前应确认解释器版本。
将代码保存为 demo_frozendict.py:
import sys
if sys.version_info < (3, 15):
raise SystemExit("This example requires a Python 3.15 preview build")
options = frozendict({
"format": "json",
"compress": True,
"indent": 2,
})
print("mapping:", options)
print("hash:", hash(options))
result_cache = {
options: b"precomputed payload",
}
print("cache hit:", result_cache[options])
same_options = frozendict({
"indent": 2,
"compress": True,
"format": "json",
})
print("equal:", same_options == options)
print("same cache entry:", result_cache[same_options])
try:
options["indent"] = 4
except TypeError as exc:
print("mutation rejected:", exc)
可以这样运行:
python3.15 demo_frozendict.py
这个例子验证了三个关键性质:映射不能原地修改、可以调用 hash(),并且可以直接作为普通字典的键。对于映射而言,键值关系比插入顺序更重要,因此用不同顺序创建的等价映射应当能命中同一个缓存项;具体行为仍应以所使用的 Python 3.15 预览版本为准。
小心“外层冻结、内层可变”
不可变容器通常只约束自身结构,并不会自动递归冻结内部对象。如果值中包含 list、dict 等可变且不可哈希的对象,外层映射即使不能增删条目,也未必能够成功哈希。
例如,下面这种配置需要特别注意:
config = frozendict({
"regions": ["eu-west", "ap-south"],
})
# 列表不可哈希,因此对整个映射求哈希通常会失败。
print(hash(config))
如果输入来自 JSON 或动态配置,可以在边界处执行递归规范化。下面是一种可以改造的实现:
import sys
if sys.version_info < (3, 15):
raise SystemExit("This example requires a Python 3.15 preview build")
def deep_freeze(value):
if isinstance(value, dict):
return frozendict(
(key, deep_freeze(item))
for key, item in value.items()
)
if isinstance(value, list):
return tuple(deep_freeze(item) for item in value)
if isinstance(value, tuple):
return tuple(deep_freeze(item) for item in value)
if isinstance(value, set):
return frozenset(deep_freeze(item) for item in value)
return value
raw_config = {
"model": "example-v1",
"regions": ["eu-west", "ap-south"],
"limits": {
"retries": 3,
"timeout_seconds": 10,
},
"features": {"batching", "compression"},
}
frozen_config = deep_freeze(raw_config)
print(frozen_config)
print("hash:", hash(frozen_config))
cache = {frozen_config: "compiled execution plan"}
print(cache[frozen_config])
这里把列表转换为元组、集合转换为 frozenset、嵌套字典转换为 frozendict。这样得到的对象图才更接近“深度不可变”。不过,生产代码还要考虑自定义类、循环引用以及键本身不可哈希等问题,不能把这个简化函数直接视为通用序列化方案。
用作缓存键时,先定义规范化规则
frozendict 很适合表达命名参数,但缓存是否可靠仍取决于业务语义。假设某个渲染函数接收选项,可以这样组织:
import sys
if sys.version_info < (3, 15):
raise SystemExit("This example requires a Python 3.15 preview build")
_cache = {}
def render(document_id, **options):
normalized = frozendict({
"format": options.get("format", "html").lower(),
"minify": bool(options.get("minify", False)),
})
cache_key = (document_id, normalized)
if cache_key not in _cache:
_cache[cache_key] = (
f"rendered:{document_id}:"
f"{normalized['format']}:"
f"minify={normalized['minify']}"
)
return _cache[cache_key]
print(render(42, format="HTML"))
print(render(42, format="html", minify=False))
print("cache entries:", len(_cache))
重点不是简单地把 options 包进 frozendict,而是先统一大小写、补齐默认值并转换布尔值。否则,语义相同但表示不同的输入可能生成多份缓存。
此外,缓存键中不要放入访问令牌、密码或完整用户隐私数据。即使不可变对象不会自动泄密,它仍可能出现在调试输出、异常信息或内存转储中。
是否应该立即采用
如果项目已经在测试 Python 3.15,frozendict 值得优先放进以下位置试用:缓存键、不可变配置快照、状态去重,以及需要明确只读语义的内部 API。
采用前可以检查:
- 运行环境是否确实提供目标版本的
frozendict; - 所有键和值是否都可哈希;
- 是否需要递归冻结嵌套结构;
- 输入是否经过稳定、明确的规范化;
- 公共库是否仍需兼容 Python 3.14 及更早版本;
- 测试是否覆盖相等性、哈希、查找和修改失败等行为。
对于生产库,预览期更适合做兼容性实验和 CI 验证,而不是立刻提高最低 Python 版本。对于应用代码,若部署环境完全可控,frozendict 能让原本依赖约定的“请勿修改”变成由类型直接执行的约束。