Python 3.15 的 frozendict:让配置映射真正不可变、可哈希

2026-09-21 34 预计阅读时间: 1 分钟
来源: realpython.com AI 摘要 Original link

Disclaimer: This article is an AI-assisted summary. Read it together with the original source when precision matters. The summary may omit context, version differences, or edge cases and is not official documentation.

预计阅读时间:8 分钟

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 预览版本为准。

小心“外层冻结、内层可变”

不可变容器通常只约束自身结构,并不会自动递归冻结内部对象。如果值中包含 listdict 等可变且不可哈希的对象,外层映射即使不能增删条目,也未必能够成功哈希。

例如,下面这种配置需要特别注意:

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 能让原本依赖约定的“请勿修改”变成由类型直接执行的约束。


相关推荐