PEP 841 提出为 Python 增加 Frozen Syntax(冻结语法),目标是让开发者在语言层面显式声明不可变类型(Immutable Types),从而给解释器更多优化空间,也让代码意图更清晰。本文会从 Python 不可变对象现状讲起,分析 PEP 841 的设计动机,再用可运行的示例演示如何模拟冻结类型,最后梳理兼容性、排查路径和生产落地的注意点。无论你是写数据结构、配置模型还是领域对象,理解这门语法背后的原理,都能帮助你在未来 Python 版本落地时更快接入。
1. 不可变类型在 Python 中的现状与痛点
1.1 不可变对象的价值
不可变对象创建之后,内部状态不允许被修改。Python 内置的int、str、tuple、frozenset都是不可变类型。正因为不可变,它们可以被安全地作为字典的 key,可以跨线程共享而不需要加锁,也可以被解释器缓存和复用。
在实际业务代码里,不可变类型还有其他收益。
第一,代码可读性更强。一个对象被标记为不可变后,任何读取它的逻辑都不需要担心别人偷偷改了对象里的字段,调用顺序和并发环境下的心智负担都会降低。
第二,比较和哈希更可靠。对象的哈希值如果在生命周期内发生变化,把它放进set或作为dict的 key 之后,就会导致查找异常。不可变对象可以避免这类问题。
第三,为编译器提供优化机会。如果解释器知道一个类型永远不会被修改,它可以复用对象、缓存哈希值、压缩对象内存布局,甚至省略某些拷贝逻辑。
但 Python 目前的问题在于:内置类型有不可变属性,而用户自定义类型并没有一种统一、语法级的“不可变”声明方式。
1.2 用户自定义不可变类型的现状
目前想在 Python 中创建一个不可变的自定义类型,常见做法有这几种:
使用tuple或NamedTuple作为底层结构
from typing import NamedTuple class Point(NamedTuple): x: int y: int这种方式的优点是简单、默认不可变、可以哈希。缺点是字段只能通过下标和属性访问,缺少自定义方法时表现更受限。
使用@dataclass(frozen=True)
from dataclasses import dataclass @dataclass(frozen=True) class Point: x: int y: int这是目前最“像普通类”的不可变方案。frozen=True会让dataclass在生成的__setattr__和__delattr__中抛出异常。
手动重写__setattr__和__delattr__
class Point: __slots__ = ('x', 'y') def __init__(self, x: int, y: int): self.x = x self.y = y def __setattr__(self, name, value): raise AttributeError(f"cannot assign to field {name!r}") def __delattr__(self, name): raise AttributeError(f"cannot delete field {name!r}")这种做法最灵活,但代码冗余,也容易出现遗漏。
这些方案都能达到“运行期修改报错”的效果,但它们本质上是“库级实现”或“代码约定”,而不是“语言级声明”。
1.3 为什么“约定”不足以支撑优化
如果不可变能力只是通过装饰器或手写方法实现,解释器在编译字节码时无法获知类型是否不可变。比如下面的代码:
@dataclass(frozen=True) class Config: host: str port: intPython 解释器只看到这是一个普通类对象,frozen=True只是dataclass装饰器内部的一个参数。解释器不会专门为它生成更紧凑的内存布局,也不会缓存哈希值。
更重要的是,开发者可能使用了完全不同的库来实现不可变对象。有的用attrs,有的用pydantic,有的手写__slots__。这种碎片化让工具链很难统一分析和优化。
PEP 841 的核心动机,就是要把“不可变”从库级约定提升为语言级语法,让解释器和类型检查器都基于同一个信号进行优化。
2. 理解 PEP 841 的 Frozen Syntax 设计
2.1 提案想解决的核心问题
PEP 841 的标题是“Adding Frozen Syntax to Optimize Immutable Types”。从名称看,它希望引入一种专门的“冻结语法”,用于声明不可变类型,最终目的是优化。
这种优化可以体现在四个层面:
内存布局
一个类如果确定不可变,那么它的属性在初始化之后不会再变化。解释器可以为它设计更紧凑的存储结构,例如把所有字段的内存连续排布,甚至消除实例__dict__的开销。
哈希缓存
不可变对象的哈希值可以只计算一次并缓存。之后重复哈希时直接返回缓存结果,而不需要每次重新计算。
拷贝优化
当对象不可变时,很多“复制”操作可以退化为“返回同一个对象”,因为内容相同且不会变化。例如在并发框架中传递数据,可以减少深拷贝的开销。
编译期检查
类型检查器和 IDE 可以识别冻结类型,并在编译期提示对字段的赋值操作,而不是等到运行期才报错。
2.2 可能的语法形态与语义
由于 PEP 841 的具体语法尚未落地,这里只讨论提案中可能出现的形态。实际实现时,语法形式、关键字名称和约束规则都必须以官方文档为准。
从社区讨论和同名提案的常见做法看,一种可能是在类定义前显式加入frozen修饰符:
frozen class Point: x: int y: int另一种可能是保持装饰器风格,但把@dataclass(frozen=True)的语义抽取为独立的装饰器:
@frozen class Point: x: int y: int还有一种更保守的思路是,允许在class关键字后面使用类型参数或标记:
class Point(frozen): x: int y: int注意,这些写法只是用来理解语义的示意。如果 PEP 841 最终被接受,官方会给出唯一的语法形式,并明确它与__slots__、继承、泛型等特性如何交互。
无论采用哪种形态,核心语义都应该包含以下几点:
- 类定义完成后,实例属性不允许新增、修改或删除。
- 冻结是类型级别的信息,可以在编译期被工具链读取。
- 解释器可以对冻结类型执行额外的内存和运行期优化。
- 冻结类型的子类默认也应该继承冻结约束,或者通过明确解除来允许可变。
2.3 Frozen Syntax 与现有机制的关系
PEP 841 不会完全替代现有的@dataclass(frozen=True),而是提供更底层的语言基础设施。
现有机制与 Frozen Syntax 的关系可以这样理解:
| 机制 | 层级 | 当前作用 | 与 PEP 841 的关系 |
|---|---|---|---|
@dataclass(frozen=True) | 库 | 生成不可变数据类的运行期保护 | 可以复用冻结语义,但语法上并不显式 |
NamedTuple | 库 | 创建不可变元组子类 | 同样是库级方案,无法通用到普通类 |
__slots__ | 语言 | 固定实例属性集合,节省内存 | 冻结语法可能要求类默认启用类似机制 |
typing.Final | 类型系统 | 标记变量或属性不可覆盖 | 用于单字段约束,冻结是整个类型约束 |
自定义__setattr__ | 运行期 | 抛异常拒绝修改 | 属于手写保护,PEP 841 可统一并优化 |
一个比较务实的设想是:PEP 841 的frozen语法会隐式引入__slots__、禁用__dict__,并且为对象生成稳定的哈希规则。这样一来,开发者不需要再同时记住@dataclass(frozen=True)和__slots__的组合技巧。
3. 在现有 Python 中模拟 Frozen 类型
虽然 PEP 841 尚未成为正式标准,但我们可以在当前 Python 中模拟它的核心行为,并提前设计好数据模型。下面这些示例都基于 Python 3.10 以上版本,实测时可以根据自己环境调整。
3.1 用 dataclass(frozen=True) 快速实现
最简单的模拟方式是使用dataclass:
from dataclasses import dataclass @dataclass(frozen=True) class Config: host: str port: int debug: bool = False在这个类中,实例的所有字段默认只读。尝试修改字段会抛出dataclasses.FrozenInstanceError:
cfg = Config(host="127.0.0.1", port=8080) cfg.port = 9090运行结果:
dataclasses.FrozenInstanceError: cannot assign to field 'port'这种方式的好处是代码量少,适合快速验证不可变行为。不是是它仍然依赖dataclass装饰器生成的代码,解释器并不知道“这是一个冻结类型”。
如果需要更接近 PEP 841 的“紧凑布局”,可以把slots=True加上:
@dataclass(frozen=True, slots=True) class Config: host: str port: int debug: bool = Falseslots=True会让实例不再拥有__dict__,属性存储在一块更紧凑的空间中。这个组合已经是当前 Python 中模拟冻结类型最实用的方式之一。
3.2 用slots固定对象布局
__slots__可以限制实例允许拥有的属性列表。它不能阻止属性值被替换,但可以阻止新增任意属性:
class Position: __slots__ = ('x', 'y') def __init__(self, x: int, y: int): self.x = x self.y = y尝试新增属性:
p = Position(1, 2) p.z = 3运行结果:
AttributeError: 'Position' object has no attribute 'z'但原有的x仍可以被修改:
p.x = 999 # 不会报错因此,__slots__只解决“字段集合固定”的问题,不解决“字段值不可变”的问题。它更像 PEP 841 可能依赖的内存布局基础。
3.3 自定义冻结基类实现运行期保护
如果想要一个不依赖dataclass的冻结类,可以自己实现一个基类:
class FrozenBase: __slots__ = () def __setattr__(self, name, value): raise AttributeError(f"cannot assign to field {name!r}") def __delattr__(self, name): raise AttributeError(f"cannot delete field {name!r}")子类定义时需要显式声明__slots__,否则每个实例还是会生成__dict__,导致冻结保护不完整:
class Point(FrozenBase): __slots__ = ('x', 'y') def __init__(self, x: int, y: int): object.__setattr__(self, 'x', x) object.__setattr__(self, 'y', y)这里有一个细节:在__init__中不能使用普通的self.x = x,因为__setattr__已经被重写为抛异常。要通过object.__setattr__绕过保护,完成初始化。
这个模式比较接近 PEP 841 想提供的“运行期保护”,但手写成本高。如果每个项目都要写一遍,很容易出错。
3.4 假设 PEP 841 落地后的写法
为了便于理解,下面展示一种假设性的写法。它不是官方语法,只是帮助思考:
frozen class HttpEndpoint: host: str port: int endpoint = HttpEndpoint(host="example.com", port=443) endpoint.port = 8080 # 如果语法落地,预计在编译期或运行期报错如果未来 PEP 841 被接受,这类代码可能不再需要@dataclass(frozen=True),也不需要手动继承任何基类。解释器会直接识别frozen标记,并自动完成属性保护、哈希缓存和内存优化。
4. 运行验证:从行为到性能
模拟不可变类型之后,不能只看“启动不报错”就认为完成了。需要从行为、哈希、内存和类型检查四个维度进行验证。
4.1 验证“修改被拒绝”
先写一个测试文件test_frozen.py:
import pytest from dataclasses import FrozenInstanceError from config_model import Config def test_config_field_cannot_be_reassigned(): cfg = Config(host="localhost", port=8080) with pytest.raises(FrozenInstanceError): cfg.port = 9090如果没有安装pytest,可以先用一段普通脚本验证:
python - <<'PY' from dataclasses import FrozenInstanceError from config_model import Config cfg = Config(host="localhost", port=8080) try: cfg.port = 9090 except FrozenInstanceError as exc: print("frozen protected:", exc) PY预期输出:
frozen protected: cannot assign to field 'port'如果使用自定义FrozenBase,捕获的异常类型应该是AttributeError。为了方便测试,可以在基类中统一抛出一个自定义异常。
4.2 验证哈希行为
不可变对象常被用作字典 key。要验证哈希稳定,可以这样测试:
cfg = Config(host="localhost", port=8080) cache = {} cache[cfg] = "ok" print(cache[cfg])如果Config使用@dataclass(frozen=True),并且所有字段都可哈希,那么默认会生成基于字段的哈希实现。否则,一旦启用了eq=True但没有指定冻结,就会得到TypeError: unhashable instance。
所以验证时需要注意:冻结和可哈希不是一回事。要同时满足“不可变”和“可哈希”,需要确保所有字段类型都可哈希。
4.3 对比内存占用
使用__slots__后,内存占用通常会更低。可以通过sys.getsizeof做粗略对比:
import sys from dataclasses import dataclass @dataclass class A: x: int y: int @dataclass(slots=True) class B: x: int y: int a = A(1, 2) b = B(1, 2) print(sys.getsizeof(a)) print(sys.getsizeof(b))注意:sys.getsizeof不计算内部引用的对象大小,只能作为粗略参考。更准确的内存分析可以用tracemalloc或pympler。在生产环境做优化时,不要只凭一两次getsizeof下结论,应该用可重复的基准测试工具。
4.4 在类型检查器中验证意图
dataclass的frozen约束主要在运行期生效。如果希望编译期就能发现错误,可以使用类型检查器,比如mypy和pyright。
安装mypy:
pip install mypy写一个带类型标注的模块:
from dataclasses import dataclass @dataclass(frozen=True) class Config: host: str port: int def update_port(cfg: Config, new_port: int) -> None: cfg.port = new_port运行检查:
mypy config_model.py如果工具支持frozen=True的赋值保护,就会提示:
error: Cannot assign to attribute "port" for class "Config"这一步很重要。它验证的不仅是运行期行为,还有类型系统能否理解不可变语义。这也是 PEP 841 如果成为语言语法后,最直接能受益的场景之一。
5. 常见问题、原因与排查路径
模拟不可变类型时,经常遇到一些反直觉的问题。下面按现象、原因、排查方式、解决建议展开。
5.1 为什么 frozen dataclass 仍能修改内部列表?
现象:
from dataclasses import dataclass, field @dataclass(frozen=True) class Bag: items: list = field(default_factory=list) bag = Bag() bag.items.append("book") print(bag.items) # ['book']并没有报错。原因是frozen=True只限制属性重新赋值,不限制属性指向的对象内部变化。bag.items是同一个列表对象,列表本身的append操作不在冻结保护范围之内。
排查方式:检查被修改的属性是“替换”还是“原地修改”。如果是obj.field = ...,属于替换;如果是obj.field.append(...)或obj.field[key] = ...,属于对象内部修改。
解决建议:如果希望嵌套数据也不可变,需要使用不可变容器,例如tuple代替list,MappingProxyType代替dict,或使用frozenset。PEP 841 即使落地,也不可能自动把list变成不可变,这个问题仍然要靠数据类型选择解决。
5.2 为什么启用 frozen 后某些对象无法哈希?
现象:
from dataclasses import dataclass @dataclass(frozen=True) class Doc: id: int tags: list[str] doc = Doc(1, ["python", "pep"]) hash(doc)运行结果可能是:
TypeError: unhashable type: 'Doc'原因是dataclass在生成__hash__时,会调用所有字段的哈希值。list是不可哈希的,所以整个对象不可哈希。
排查方式:列出对象所有字段,确认每个字段类型是否可哈希。可以写一个辅助函数递归检查。
解决建议:把tags改成tuple或frozenset,或者在创建时转换成不可变类型。如果某些字段必须可变,就不要把对象放进set或作为dict的 key。
5.3 继承与 mixin 场景下的冻结失效
现象:基类被标记为frozen=True,子类新增了一个字段,但子类实例可以修改该字段。
@dataclass(frozen=True) class Base: x: int @dataclass(frozen=True) class Child(Base): y: int c = Child(x=1, y=2) c.y = 3 # 报错如果Child忘记加frozen=True,那么y字段不会受到冻结保护。这是新手最容易踩的坑。
排查方式:检查dataclass装饰器是否在继承链的每一层都传入了frozen=True。如果使用自定义FrozenBase,要确认子类没有重写__setattr__。
解决建议:在基类中集中声明冻结约束,并通过代码审查或类型检查器约束所有子类。如果未来 PEP 841 提供语法级语义,建议默认规定子类继承父类的冻结约束,避免这类歧义。
5.4 排查清单
针对不可变类型的常见问题,可以整理成一张排查清单:
| 排查项 | 操作 | 预期结果 |
|---|---|---|
检查是否使用了frozen参数 | 查看@dataclass装饰器 | frozen=True已配置 |
检查是否使用了slots | 查看类定义和__slots__ | 实例没有__dict__ |
| 检查嵌套对象可变性 | 列出所有字段类型 | 所有可哈希字段都是不可变类型 |
| 检查子类冻结状态 | 查看完整 MRO | 继承链上的冻结语义一致 |
| 运行类型检查 | 执行mypy或pyright | 无属性赋值错误 |
| 运行内存检查 | 使用tracemalloc或pympler | 内存占用符合预期 |
| 运行哈希测试 | 把对象放入set/dict | 不抛TypeError |
这张清单既适合开发环境自测,也可以作为 Code Review 时的检查项。
6. 生产应用建议与迁移准备
6.1 哪些类型适合标记为 Frozen
不是所有类都应该做成不可变。标记frozen前,先判断类型是否符合以下特征:
适合
- 配置对象:加载后不允许修改。
- 值对象:如坐标、金额、时间区间。
- 请求/响应模型:传输过程中不希望被改。
- 缓存 key:需要可哈希且内容稳定。
不适合
- 实体对象:例如数据库实体,字段会被业务逻辑频繁更新。
- 状态机对象:内部状态需要流转变化。
- 资源管理器:包含连接池、文件句柄等,生命周期管理复杂。
- 性能敏感且频繁构造的大对象:不可变对象的复制策略未必总是更快。
可以用一句话判断:如果一个对象在创建后到销毁前,语义上不应该有任何状态变化,那么它适合被标记为 frozen。
6.2 数据模型设计:值对象与实体对象
在设计数据模型时,可以刻意区分“值对象”和“实体对象”。
值对象关注“是什么”,没有独立身份。两个值对象内容相同,就可以认为相等。例如:
@dataclass(frozen=True) class Money: amount: int currency: str实体对象关注“是谁”,有独立身份和生命周期。即使两个用户的属性一样,它们也是不同的人。实体对象应该允许状态变更:
@dataclass class User: id: int name: str status: str = "active"把值对象做成 frozen,把实体对象保持可变。这种划分会让代码的边界更清晰,也方便未来迁移到 PEP 841 的语法。
6.3 性能优化的取舍
PEP 841 提到的优化依赖于解释器对冻结语义的信任。但在当前版本中,模拟冻结类型并不会有完整编译期优化,不能盲目认为“只要 frozen 就更快”。
实际使用时,需要做取舍。
内存
启用__slots__可以节省每个实例的字典开销。如果项目会创建大量对象,比如百万级缓存,内存收益明显。
哈希缓存
普通dataclass(frozen=True)不会自动缓存哈希值。每次调用hash(obj)仍然会按照生成的__hash__方法计算字段哈希。如果对象很大,重复哈希的成本不可忽略。可以用functools.cached_property或自定义字段来模拟,但要注意缓存本身不能破坏不可变语义。
拷贝
不可变对象在传给多个函数时,理论上可以复用同一个对象,但 Python 并不会自动在所有场景中消除拷贝。如果业务逻辑依赖copy.deepcopy,迁移到 frozen 后需要重新审视是否真的需要拷贝。
生产环境应该用基准测试验证收益,而不是凭直觉。
python -m timeit -s "from data_class import PointA" "PointA(1, 2)" python -m timeit -s "from data_class import PointB" "PointB(1, 2)"对比不同实现的性能,再决定是否全量替换。
6.4 面向未来 Python 的迁移清单
如果 PEP 841 最终进入 Python 正式版本,已有代码可以从下面几条路径逐步迁移。
先梳理现有不可变类型:
- 把
@dataclass(frozen=True)的类整理成清单。 - 把手写
__setattr__的冻结基类整理成清单。 - 把
NamedTuple类整理成清单。 - 把
pydantic、attrs等第三方库中的冻结模型整理成清单。
再按优先级迁移:
| 优先级 | 类型 | 迁移动作 | 风险 |
|---|---|---|---|
| 高 | 纯值对象,少依赖三方库 | 改成frozen语法 | 低 |
| 中 | 内部配置模型 | 保留兼容层,逐步替换 | 中 |
| 低 | 第三方库模型 | 等库作者适配后再迁移 | 高 |
迁移前必须准备:
- 完整的单元测试,覆盖赋值报错、哈希、嵌套可变对象、继承链四个场景。
- 类型检查脚本,确保
mypy/pyright能识别新语法。 - 性能基准,记录迁移前后的内存和耗时。
- 回滚方案,如果新语法在解释器中出现兼容问题时,可以快速切回
dataclass(frozen=True)。
最后要记住,PEP 841 的落地形态、版本号、语法细节都可能在讨论中变化。现在最重要的是理解“不可变类型需要语言级声明”这个设计思路,并把当前代码中适合冻结的类型先整理好。等到官方语法发布时,迁移的主要工作就会从“重新设计”变成“机械替换”,风险也会小很多。