0%

Python 3.15 新内置数据类型:frozendict

转载声明:本文翻译转载自 mathspp.com 的文章 frozendict built-in,作者 Rodrigo Girão Serrão,原文发布于 2026 年 10 月 14 日。本文为翻译转载,版权归原作者所有。

Python 3.15 引入了一种新的内置数据类型:frozendict。frozendict 是一个不可变字典,可以用与它同名的内置函数来创建:

1
2
3
version_info = frozendict(
{"major": 3, "minor": 15, "patch": 0}
)

花括号 {} 的字典字面量语法创建的是普通可变字典,所以创建冻结字典时必须始终使用内置的 frozendict。

在这篇文章里,你将了解这种不可变字典的特性——比如它是可哈希的——以及如何在类型标注语境中把它与 dict 配合使用。

「Python 3.15 的 15 天」系列

为了庆祝 Python 3.15 发布,作者将在接下来的 15 个工作日里,每天讲解一个 Python 3.15 的新特性——解释清晰、附带示例,让你不必自己去翻变更日志。

创建与使用

你用内置的 frozendict 创建不可变字典,并可以用普通的字典语法访问键/值对:

1
2
3
version_info = frozendict({"major": 3, "minor": 15, "patch": 0})

print(version_info["major"]) # 3

你也可以遍历 frozendict 的键、值或键值对:

1
2
3
4
5
6
7
for name, number in version_info.items():
print(name, number)
"""
major 3
minor 15
patch 0
"""

如果你不需要支持 Python 3.14 及更早版本,那么每当你创建或返回一个并非明确要作为可变对象来使用的字典时,都应当使用 frozendict。

frozendict 的不可变性

frozendict 的核心前提是它不可变:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
version_info = frozendict({"major": 3, "minor": 15, "patch": 0})

try:
version_info["year"] = 2026
except TypeError:
print("Can't add a new key to a frozendict.")

try:
version_info["major"] = 4
except TypeError:
print("Can't modify an existing key.")

try:
del version_info["patch"]
except TypeError:
print("Can't delete an existing key.")

上面的代码表明:给 frozendict 添加新键、修改已有键或删除已有键,在运行时都会抛出 TypeError。

frozendict 支持可变字典所支持的全部方法,除了那些会改动字典内部状态的:

  • clear:用于清空字典的所有条目
  • __delitem__:用于以 del mapping[key] 语法删除字典条目
  • pop / popitem:用于移除字典的某个键/值对
  • __setitem__:用于以 mapping[key] = value 语法给字典键赋值
  • update / __ior__:用于用另一个字典的条目更新字典

frozendict 的不可变性只作用于它的键和值的身份(identity)。如果某个值本身是可变对象,那么这个值仍然可以被原地修改,看起来就像是 frozendict 也被改动了一样。

例如,一个 frozendict 实例可以把列表作为值保存,而这个列表依然是可变的:

1
2
3
4
5
6
7
8
9
icecream_flavours = ["vanilla", "chocolate"]
savoury_snacks = ["cheese", "toast"]
food_offered = frozendict({"icecream": icecream_flavours, "savoury": savoury_snacks})

print(food_offered["icecream"]) # ['vanilla', 'chocolate']

icecream_flavours.append("strawberry")

print(food_offered["icecream"]) # ['vanilla', 'chocolate', 'strawberry']

这种行为与元组类似:元组本身不可变,但它内部可以存放可变对象。

应用场景示例

如前所述,每当你创建或返回一个并非明确要作为可变对象来使用的字典时,都应当使用 frozendict。

查找表

例如,每当你把字典当作简单的查找表来用时,就应该用 frozendict 而不是可变字典:

1
2
3
4
5
6
7
8
9
10
# json/decoder.py from the 3.15 std library
NaN = float('nan')
PosInf = float('inf')
NegInf = float('-inf')

_CONSTANTS = frozendict({
'-Infinity': NegInf,
'Infinity': PosInf,
'NaN': NaN,
})

上面的代码片段摘自标准库的 json 模块,展示了一个用不可变字典实现的查找表。由于 _CONSTANTS 并不打算被修改,把它做成不可变对象既清晰地传达了这层含义,也能防止意外的失误。

加载不可变的 JSON 数据

json 模块提供了 load 函数从文件加载 JSON 数据,它的可选参数 object_hook 可以配合 frozendict 使用,把加载出来的字典变成不可变字典。

假设文件 version.json 里有这样一段 JSON 数据:

1
2
3
4
5
{
"major": 3,
"minor": 15,
"patch": 0
}

如果在 json.load 里把 object_hook 设为 frozendict,你得到的就是已加载数据的不可变视图:

1
2
3
4
5
6
import json

with open("version.json", "r") as f:
version_info = json.load(f, object_hook=frozendict)

print(version_info) # frozendict({'major': 3, 'minor': 15, 'patch': 0})

当 JSON 代表一个不可变的数据源时,使用 frozendict 能防止你在不知不觉中错误地改动数据。

作为对比,普通的 JSON 加载会创建可变字典:

1
2
3
4
5
6
import json

with open("version.json", "r") as f:
version_info = json.load(f)

print(version_info) # {'major': 3, 'minor': 15, 'patch': 0}

为 frozendict 做类型标注

泛型类型

在类型系统中,frozendict 是一个泛型类,标注方式与 dict 一致:

1
2
3
4
5
6
7
8
import sys

def version_info() -> frozendict[str, int]:
return frozendict({
"major": sys.version_info.major,
"minor": sys.version_info.minor,
"patch": sys.version_info.micro,
})

语法 frozendict[str, int] 表示一个从字符串映射到整数的不可变字典。

Mapping 协议

注意,frozendict 和 dict 的类继承体系互不相关。从 3.15 起,使用 collections.abc.Mapping 和 collections.abc.MutableMapping 这两个协议(protocol)会变得更重要。

以内置函数 eval 为例,它求值一个 Python 表达式,并接受一个字典 globals 来指定全局变量的值:

1
print(eval("3 + x", globals={"x": 5}))  # 8

在 Python 3.14 中,eval 函数的类型标注[^1]是这样的:

1
2
3
4
from typing import Any

def eval(source: str, globals: dict[str, Any]) -> Any:
...

由于 dict 和 frozendict 是互不相关的类,这意味着 eval 无法接受不可变字典。为了在 Python 3.15 中修复这一点,你需要写一个类型联合(type union):

1
2
3
4
from typing import Any

def eval(source: str, globals: dict[str, Any] | frozendict[str, Any]) -> Any:
...

如果你的函数需要一个映射(mapping)但并不需要修改它,那么在大多数场景下,你应该使用协议 collections.abc.Mapping 而不是类型联合。协议更通用一些,可变与不可变字典都能接受:

1
2
3
4
5
from collections.abc import Mapping
from typing import Any

def eval(source: str, globals: Mapping[str, Any]) -> Any:
...

把 globals 的类型标注为 Mapping 之后,下面两种调用在类型检查器眼里都是合法的:

1
2
3
4
5
variables = {"x": 5}
eval("3 + x", globals=variables)

variables = frozendict(variables)
eval("3 + x", globals=variables)

再举一个例子,作者的博客工具定义了一个函数 get_file_stats_and_tags,它接受一个缓存字典,把文件夹名映射为对应博文计算出的统计信息:

1
2
3
4
type Stats = ...  # A custom type.

def get_file_stats_and_tags(cache: dict[str, Stats], filepath: Path):
...

get_file_stats_and_tags 的函数体只把缓存当作信息来源,并不修改它。这意味着将来我可能想给它传一个不可变字典来代表缓存。为了让函数支持这种用法,最好的修复方式是把 dict 换成 Mapping:

1
2
3
4
5
6
7
from collections.abc import Mapping

type Stats = ... # A custom type.

# swapped out `dict` vvvvvvv
def get_file_stats_and_tags(cache: Mapping[str, Stats], filepath: Path):
...

可哈希性

只要一个 frozendict 的所有值都可哈希,它本身就是可哈希的。这意味着 frozendict 实例可以用作字典的键或集合的元素,也可以作为参数传给带缓存(cached)的函数。

1
2
3
4
5
6
version_info = frozendict({"major": 3, "minor": 15, "patch": 0})

versions = {version_info: "favourite version"}

print(versions)
# {frozendict({'major': 3, 'minor': 15, 'patch': 0}): 'favourite version'}

由于整数是可哈希的,frozendict 实例 version_info 就可以被哈希,从而能作为字典 versions 的键使用。

比较 frozendict 实例

frozendict 实例之间可以相互比较:

1
2
3
4
print(
frozendict({"major": 3, "minor": 15, "patch": 0})
== frozendict({"patch": 0, "major": 3, "minor": 15})
) # True

在相等性比较中,条目的顺序无关紧要。

frozendict 实例也可以与可变字典比较:

1
2
3
4
print(
frozendict({"major": 3, "minor": 15, "patch": 0})
== {"patch": 0, "major": 3, "minor": 15}
) # True

在 frozendict 实例与可变字典的比较中,可变性会被忽略。只要条目匹配,无论顺序如何,两个映射就被视为相等。

给库维护者的采纳建议

如果你使用 Python 3.15+,请始终优先用 frozendict 而不是 dict,除非你确实需要修改正在创建的映射。

如果你使用 Python 3.14 或更早版本,无法直接使用 frozendict。但如果你在使用类型标注,请开始在函数里用协议 collections.abc.Mapping 代替 dict。

很多函数接受 dict[K, V] 类型的参数,但并不需要修改传入的字典。如果你的函数不需要修改字典,就把类型标注换成 collections.abc.Mapping。这样一来,你的代码对 Python 3.15+ 的用户就是「向前兼容」的,他们的类型检查器也不会因为使用你的代码而报错。

Python 3.15 的精彩远不止于此

从两个新内置的 frozendict 和 sentinel,到惰性导入(lazy imports)、包启动配置文件、typing 改进,以及更多色彩支持,Python 3.15 还有很多值得一看的东西。如果你想进一步了解 Python 3.15 的新特性,可以看看作者正在连载的「Python 3.15 的 15 天」系列——15 天里每天讲解一个 Python 3.15 新特性。


[^1]: 这里做了一些简化。你可以在 GitHub 上查看内置 eval 的完整类型标注。

参考资料

扫码加入技术交流群🖱️
QR code