Python 3.15 的 frozendict:让配置可哈希、可缓存,但别误解“冻结”

2026-09-21 24 预计阅读时间: 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:一种不可修改、可哈希的映射类型。它适合表达“创建后不应再变化”的配置、作为缓存键,或者在线程之间共享只读参数。不过,它冻结的是映射本身,而不是递归冻结其中的所有对象。

由于这是 Python 3.15 的预览功能,正式发布前仍应关注 API 和行为是否调整。下面的示例假设使用提供内置 frozendict 的 Python 3.15 预览版本。

它解决的不是“只读视图”,而是稳定值

普通 dict 可以随时修改,因此不能直接作为集合元素或缓存键:

config = {'model': 'compact', 'temperature': 0.2}

# TypeError: unhashable type: 'dict'
hash(config)

frozendict 的目标不同:构造完成后,不能再增加、删除或替换键值。只要其中的键和值都满足哈希要求,它本身就能参与哈希。

config = frozendict({
    'model': 'compact',
    'temperature': 0.2,
})

print(config['model'])
print(hash(config))

try:
    config['temperature'] = 0.8
except TypeError as exc:
    print(type(exc).__name__, exc)

这与只读代理存在关键区别。只读代理通常只是阻止调用方通过当前引用修改底层字典;如果其他代码持有原始字典,它仍然可能改变。frozendict 则表示一个独立的不可变映射值。

构造时也会形成一个映射快照:

source = {'region': 'ap-southeast-1'}
config = frozendict(source)

source['region'] = 'eu-west-1'

assert config['region'] == 'ap-southeast-1'
assert source['region'] == 'eu-west-1'

实战:把配置直接作为缓存键

缓存函数通常要求所有参数都可哈希。过去常见的做法是把字典转换成排序后的元组,或者手工定义配置类。frozendict 能让代码继续保留映射接口。

将下面内容保存为 demo.py,然后使用 Python 3.15 预览版运行:

from functools import lru_cache
import sys

if sys.version_info < (3, 15):
    raise RuntimeError('This example requires a Python 3.15 preview build')

@lru_cache(maxsize=128)
def build_endpoint(config: frozendict) -> str:
    print('building endpoint')
    return f"https://{config['region']}.example.test/{config['service']}"

config = frozendict({
    'region': 'ap-southeast-1',
    'service': 'billing',
})

print(build_endpoint(config))
print(build_endpoint(config))
print(build_endpoint.cache_info())

运行命令:

python3.15 demo.py

building endpoint 应当只打印一次,第二次调用会命中缓存。这里不需要约定键排序,也不必在函数入口把元组重新转换成字典。

同样的模式适用于解析编译选项、构建 HTTP 客户端、生成查询计划等场景:输入配置一旦成为不可变值,就更容易安全地复用结果。

不过,不要把 Python 的 hash() 结果写入数据库或当作跨进程稳定标识。Python 对字符串等对象的哈希可能因进程而变化;frozendict 可作为当前进程内的哈希键,并不意味着它天然提供持久化摘要。需要稳定指纹时,应当先做规范化序列化,再使用 SHA-256 等算法。

冻结到哪里为止:这是浅层不可变

frozendict 禁止修改映射结构,但不会递归复制并冻结内部对象。下面的字典外壳不可变,嵌套的 dict 仍然可变:

headers = {'X-Trace': 'off'}
config = frozendict({'headers': headers})

headers['X-Trace'] = 'on'

assert config['headers']['X-Trace'] == 'on'

try:
    hash(config)
except TypeError as exc:
    print('not hashable:', exc)

这段代码揭示了两个边界:

  • 不能执行 config['headers'] = ...,但可以通过原引用修改内部的 headers
  • 一个 frozendict 只有在其内容可哈希时才能真正用于哈希场景;嵌套的 dictlistset 会破坏这一点。

如果目标是构造一个适合作为缓存键、也便于跨线程共享的配置,可以显式使用不可变容器:

config = frozendict({
    'regions': ('ap-southeast-1', 'eu-west-1'),
    'headers': frozendict({
        'Accept': 'application/json',
        'X-Client': 'report-worker',
    }),
    'retry_delays': (0.1, 0.5, 1.0),
})

print(hash(config))

还要警惕自定义对象:一个对象“可哈希”并不自动意味着它在语义上不可变。如果值对象内部能够变化,就可能出现缓存结果与当前状态不一致的问题。

线程共享更轻松,但不是万能的线程安全

当映射及其值都不可变时,多个线程可以共享同一个配置对象,而不必为映射修改加锁。这减少了防御性复制,也让函数的输入契约更清楚。

frozendict 不会保护以下内容:

  • 映射中的可变值;
  • 映射之外的全局状态;
  • 使用该配置访问的文件、连接或客户端对象;
  • 多步骤业务操作所需的原子性。

换句话说,它能消除“某个线程突然改了这份配置”这一类问题,但不会把整个程序自动变成线程安全的。

是否应该立即采用

评估 frozendict 时,可以检查以下几点:

  • 配置是否在创建后就不应改变;
  • 是否需要把整个映射作为缓存键或集合元素;
  • 内部值是否也采用了元组、frozensetfrozendict 等不可变结构;
  • 是否错误地把 hash() 当成跨进程稳定摘要;
  • 项目是否能够接受 Python 3.15 预览功能及潜在调整。

frozendict 最有价值的地方,不只是省掉一次 tuple(sorted(mapping.items()))。它把“不应再修改”写进了数据类型,让缓存、并发共享和接口约束都更直接。真正采用时,重点不是看到一个 frozen 名字就放心,而是确认整个值图是否真的满足不可变和可哈希要求。


相关推荐