在团队做领域模型重构时,我们经常讨论一个话题:如何用 Python 写出真正不可变的对象。写起来并不难,难的是“性能可接受、代码可读、类型可检查、生态兼容”这几件事同时成立。dataclass(frozen=True)能解决一部分问题,NamedTuple也能,但它们都只是在语言现有能力上“拼”出来的方案,不是解释器原生支持的设计。直到我关注到 PEP 841,它提出了一种叫 Frozen Syntax 的语法方案,专门用于优化 Python 中的不可变类型(Immutable Types)。这篇文章会完整拆解 PEP 841 的动机、设计思路、底层优化空间,以及它在落地之前我们可以在项目中做哪些替代实践。
这篇文章适合对 Python 语法演进感兴趣的中高级开发者阅读,也适合正在设计数据模型、配置对象、事件对象,并希望用不可变类型提升代码安全性的后端工程师。读完你会理解:Python 为什么需要一种原生冻结语法;PEP 841 提出的frozen class与现有的@dataclass(frozen=True)、NamedTuple有何本质区别;以及距离 PEP 正式落地还有多远,这段时间里你可以在工程中做哪些准备。
1. 不可变类型为什么越来越重要
1.1 从函数式编程到并发安全
不可变对象(Immutable Object)是指对象在创建之后,其状态不能被修改。在 Python 中,int、str、tuple、frozenset都是典型的不可变类型。你无法把某个变量的值从3改成4,只能把变量重新绑定到一个新的整数对象上。
不可变类型之所以重要,最直接的原因是并发安全。在多线程环境下,多个线程同时读取一个可变对象时,如果其中一个线程发生了写入,其他线程可能读到中间状态,甚至触发数据竞争。而不可变对象一旦创建完毕,它的所有属性都不会再变化,多个线程可以安全地共享同一个实例,不需要加锁,也不需要复制副本。
除此之外,不可变对象天然适合作为字典的键和集合的元素。Python 字典要求键必须可哈希,而一个“内容可变但哈希值固定”的对象会破坏哈希表的正确性。只有不可变对象才能安全地提供稳定的哈希值。这也是为什么list不能作为字典键,而tuple可以。
近年来函数式编程思想越来越多地渗透到 Python 工程实践中。数据流水线、事件溯源、CQRS 等架构模式,都倾向于把数据对象设计成不可变的。一个事件对象一旦产生,就不应该再被修改,否则历史记录就失去了可信度。可以说,不可变类型已经从“语言特性”变成了“架构需求”。
1.2 Python 创建不可变类型的现状痛点
理论上 Python 开发者有很多办法创建不可变类型,但在实际项目中,每一种方式都有明显的妥协。
第一种是使用dataclasses.dataclass(frozen=True)。它能帮你自动生成__init__、__repr__、__eq__等方法,并且在实例属性被赋值时抛出FrozenInstanceError。但它的冻结机制依赖运行时检查,每当你给一个属性赋值,解释器都要走一遍object.__setattr__,这里存在额外开销。
第二种是使用typing.NamedTuple。它创建的是元组的子类,性能不错,内存占用也小,但它的字段类型定义方式受到限制,也不太容易扩展自定义方法。
第三种是手动实现__setattr__,在属性赋值时抛出异常。这种方式最灵活,但代码重复量大,而且容易遗漏边界情况。
第四种是针对“只读映射”场景使用types.MappingProxyType,但它只能包装映射对象,不能解决普通自定义类的问题。
这些方案都有一个共同点:它们是“在现有机制之上模拟不可变性”,而不是“在语言层面声明不可变性”。解释器并不知道你设计的对象是不可变的,也就无法做针对性的优化。这正是 PEP 841 想要改变的事情。
2. PEP 841 是什么:Frozen Syntax 的提出
2.1 PEP 841 的核心思想
PEP 841 是一份 Python 增强提案(Python Enhancement Proposal),主题是“Adding Frozen Syntax to Optimize Immutable Types”。它提出在 Python 中新增一种语法,用frozen关键字来声明一个不可变类。
从开发者视角来看,这段代码是它的核心表达:
frozen class Point: x: float y: float在 PEP 841 的设想中,frozen class声明的类具有以下特点:类定义完成之后,这个类的实例不能再发生任何属性赋值;解释器可以在底层为该类设置“不可变”标志;类型检查器也可以根据语法直接判断哪些对象是冻结的,而不需要额外解析装饰器参数。
需要特别说明的是,这份提案目前仍处于讨论阶段,具体语法和语义仍然可能调整。本文描述的均是提案中的设计方向,不代表 Python 当前已经支持该语法。如果你在自己的环境中尝试运行上面的代码,会得到SyntaxError,这属于正常现象。
2.2 与现有方案的本质区别
要理解 PEP 841 的意义,关键在于想清楚它和dataclass(frozen=True)的本质区别。
@dataclass(frozen=True)是一个装饰器,它在类定义完成之后对类对象进行包装和修改。frozen=True只是让生成的__setattr__方法抛出异常,对象的底层布局仍然是完全可变的。解释器层面并不知道这个类被“冻结”了,也无法对属性的只读性做任何假设。
PEP 841 的frozen关键字则是一个语法级声明。它发生在类创建过程之中,而不是类创建完成后的一次“事后处理”。理论上讲,解释器可以在创建类时就识别出这是一个冻结类,并做出一系列优化决策,例如:
- 在内存中为冻结类实例分配只读的数据区域;
- 在字节码层面优化属性访问路径,减少运行时检查;
- 安全地缓存实例的哈希值,因为对象内容永远不会改变;
- 在编译期对赋值行为给出更明确的错误提示。
所以 PEP 841 不只是“写起来更简单”的语法糖,它是在为 Python 运行时打开一扇优化的大门。Frozen Syntax 的价值,在于把“不可变性”从库层面的约定提升为语言层面的保证。
3. Python 现有不可变类型方案横向对比
在深入了解 PEP 841 之前,我们有必要把 Python 现有的几种不可变类型实现方案做个系统对比。这样可以更清楚地看到,为什么社区会认为需要一种新的语法。
3.1 dataclass(frozen=True)
dataclass是 Python 3.7 引入的标准库模块,它最常用的功能是自动生成一堆样板代码。当frozen=True时,dataclass会让生成的__setattr__和__delattr__抛出异常。
from dataclasses import dataclass @dataclass(frozen=True) class Point: x: float y: float p = Point(1.0, 2.0) print(p.x) # 1.0 try: p.x = 3.0 except Exception as e: print(type(e).__name__, e) # FrozenInstanceError cannot assign to field 'x'这个方案的优点是书写简单,开箱即用,并且天然支持类型注解。缺点是性能上存在额外开销,因为每次属性赋值和读取都经过动态检查;同时仍然可以通过object.__setattr__强行修改属性,所谓“冻结”更准确地说是“约定上的不可变”。
3.2 typing.NamedTuple
NamedTuple是tuple的子类,它使用元组的存储结构保存字段值。因为元组本身不可变,所以NamedTuple的实例天然不可变。
from typing import NamedTuple class Point(NamedTuple): x: float y: float p = Point(1.0, 2.0) print(p.x) # 1.0 try: p.x = 3.0 except Exception as e: print(type(e).__name__, e) # AttributeError: can't set attributeNamedTuple的优势是性能和内存占用非常优秀,而且因为它继承自tuple,可以直接参与元组的解包、比较和哈希。缺点是它本质上仍然是一个元组,如果要添加自定义方法是可行的,但灵活度不如普通类;字段定义方式也相对机械。
3.3 手动实现setattr
对于无法使用dataclass和NamedTuple的场景,开发者可以手动控制属性赋值:
class Point: def __init__(self, x: float, y: float): object.__setattr__(self, 'x', x) object.__setattr__(self, 'y', y) def __setattr__(self, name, value): raise AttributeError(f"Cannot modify attribute {name!r}") def __delattr__(self, name): raise AttributeError(f"Cannot delete attribute {name!r}")这种方案灵活度最高,但也最容易出错。比如在__init__中如果使用了self.x = x,就会触发__setattr__而抛出异常,所以必须使用object.__setattr__绕过。项目里每个接口都要重复写一遍防御逻辑,代码量一多,维护成本就上去了。
3.4 MappingProxyType 与 frozenset
types.MappingProxyType是标准库提供的只读映射包装器。它可以包装一个现有字典,外部只能读取,无法修改:
from types import MappingProxyType config = {'host': 'localhost', 'port': 8080} read_only_config = MappingProxyType(config) print(read_only_config['host']) # localhost try: read_only_config['host'] = 'example.com' except Exception as e: print(type(e).__name__, e) # TypeError: 'mappingproxy' object does not support item assignment它的局限性也很明显:它只适用于类似字典的映射对象,并不适用于任意自定义类。frozenset则是不可变集合,用途更加专一。这两者解决的都是“某一种特定容器”的只读问题,不能作为通用不可变类的方案。
3.5 各方案横评
| 方案 | 书写成本 | 性能开销 | 类型检查支持 | 自定义方法 | 适用场景 |
|---|---|---|---|---|---|
| dataclass(frozen=True) | 低 | 中 | 较好 | 较好 | 通用数据对象、DTO、配置项 |
| NamedTuple | 低 | 低 | 较好 | 一般 | 轻量数据载体、坐标、键值对 |
| 手动setattr | 高 | 中 | 一般 | 好 | 特殊行为需求 |
| MappingProxyType | 低 | 低 | 一般 | 不支持 | 只读映射、全局配置 |
| PEP 841 frozen class | 低 | 预期较低 | 预期较好 | 待定 | 未来不可变类型标准方案 |
从这个表可以看出来,现有方案各有侧重,但是没有一种方案能同时满足“语法简洁、运行时高效、类型检查可靠、支持自定义方法”这四项要求。PEP 841 正是试图补齐这个缺口。
4. PEP 841 的语法设计与使用示例
4.1 基本语法形式
PEP 841 的核心语法是使用frozen作为类声明的前置修饰词。当前提案中的写法如下:
frozen class Point: x: float y: float这里frozen的位置和final、sealed等修饰词类似,位于class之前。从语义上看,它表示“这个类创建出来的实例是冻结的”。
在提案的设想中,frozen class并不一定自动生成构造函数。也就是说,上面的Point类想被实例化,仍然需要手动写__init__,或者配合数据类相关工具使用:
from dataclasses import dataclass @dataclass frozen class Point: x: float y: float如果这个设计方向最终被接受,那么frozen和@dataclass的职责会变得清晰:frozen负责锁定实例,@dataclass负责生成样板代码。两者互相独立,又可以组合使用。
4.2 字段声明与类型注解
冻结类并不要求所有字段都是常量类型。它的核心约束是“字段绑定关系在实例创建后不可变”。也就是说,一个冻结类可以有list类型的字段,但这个字段本身仍然是可变列表;冻结语法保证的是你不能把这一字段重新赋值成新的列表,并不能保证列表内部不被修改。
from dataclasses import dataclass, field @dataclass frozen class ShoppingCart: owner_id: int items: list = field(default_factory=list)在真正使用这类代码之前,需要理解“浅冻结”与“深冻结”的区别。PEP 841 的目标是解决浅冻结的语法与性能问题,深冻结需要额外约定,不太可能通过语法层面的一个关键字全自动实现。
4.3 哈希与相等性
不可变类型与哈希计算的关系非常密切。一个可变对象如果哈希值由内容决定,那么一旦内容变化,哈希值也会变化,这会导致字典和集合索引错误。不可变对象则天然适合缓存哈希值。
PEP 841 的草案中比较关注的方向,是冻结类实例能否按字段内容自动生成__hash__和__eq__。如果这个能力被纳入提案,那么frozen class Point就不需要开发者自己编写哈希逻辑,解释器可以用字段元组参与哈希计算:
frozen class Point: x: float y: float p1 = Point(1.0, 2.0) p2 = Point(1.0, 2.0) print(p1 == p2) # 预期为 True print(hash(p1) == hash(p2)) # 预期为 True这里有一个潜在的工程价值:因为对象不可变,解释器可以在第一次计算哈希时把结果缓存下来,后续调用hash(p)直接从缓存读取,减少重复计算成本。这是NamedTuple和普通类都没有充分利用的优化点。
4.4 继承与嵌套冻结
冻结类的继承关系是一个复杂话题。目前 PEP 841 的讨论中涉及了两种可能的设计倾向。
一种倾向是冻结类只能继承冻结类。这样整个继承链上所有实例都不可能被修改,规则清晰且安全。
另一种倾向是允许冻结类继承普通非冻结类,但冻结类自身仍然拒绝属性修改。这种方案更灵活,但会带来语义混乱:一个冻结类的实例,某些方法来自普通父类,这些方法内部可能对属性赋值,一旦执行就会抛出异常,容易让调用方困惑。
从长期稳定性来看,第一种倾向更可能被接受。也就是说,frozen关键字一旦使用,整个类的子类体系都天然冻结,不需要每个子类重复声明。这个性质类似于 Java 中final类不能被子类化,但语义方向相反:Python 的frozen关注的是实例不可变,而不是类不可继承。
另外,一个冻结类如果包含一个可变对象字段,那么“嵌套不可变”仍然需要额外设计。比较务实的做法是使用tuple、frozenset、MappingProxyType等容器来承载内部数据,再配合类型注解让调用方清楚边界。
4.5 与 final、sealed 等修饰词的组合
Python 社区在讨论frozen时,经常会把它和final、sealed等可能的修饰词放在一起讨论。它们的侧重点不同:
frozen:实例状态不可变;final:类不可被继承,或者方法不可被重写;sealed:类只能被限定范围内的子类继承。
这些修饰词并不冲突,反而存在组合场景。例如,一个配置对象既希望实例不可变,也希望通过final禁止开发者继承,那么理论上可以写出:
@final frozen class AppConfig: debug: bool timeout: float当然,这需要这些语法特性都正式进入 Python 才能实现。目前final在typing模块中只是类型检查层面的声明,并不会影响运行时行为。PEP 841 落地时,如何与这些修饰词协同,仍需要进一步讨论。
5. 底层机制:Frozen Syntax 如何优化不可变类型
5.1 内存布局与写保护
理解 PEP 841 的优化潜力,需要回到 CPython 的运行时结构。Python 类实例的属性通常存储在实例的__dict__字典中,字典本身是可变哈希表,属性读写都要经过字典查找。这是 Python 动态性的基础,也是性能开销的来源。
PEP 841 如果被实现,冻结类实例理论上可以采用更紧凑、更受限的内存布局。比如在创建实例时直接分配固定大小的内存块,把属性值连续存放,然后用底层内存保护标志标记该内存块为只读。这样一来,属性访问路径不再依赖__dict__字典查找,写操作也能在更底层被拦截。
需要注意的是,这种优化不会自动发生在所有场景。CPython 对类对象布局的优化涉及大量内部细节,包括垃圾回收、弱引用、序列化支持等。PEP 841 的作用是为这些优化提供语法前提:解释器在得知类是冻结类后,才有依据选择更激进的内存布局。
5.2 惰性哈希与缓存
不可变对象在哈希计算上具有天然优势。因为对象内容不会变化,第一次计算得到的哈希值可以永久缓存。
目前的dataclass(frozen=True)和NamedTuple其实已经具备缓存哈希的潜在条件,但实现上并不统一。PEP 841 若能在语法层面明确对象不可变,解释器就有理由为所有冻结类实例统一实现哈希缓存。
具体流程可以这样理解:当程序第一次调用hash(obj)时,解释器根据对象所有字段计算哈希值,然后把它保存到对象内部一个私有字段中;第二次及以后的hash(obj)调用直接返回缓存结果,不再遍历字段。对于一个字段很多的对象,或者一个需要频繁作为字典键的对象,这种优化能显著降低 CPU 开销。
# 伪代码:说明惰性哈希的设想 def __hash__(self): if self._hash_cache is None: self._hash_cache = hash((self.x, self.y)) return self._hash_cache在冻结类中,_hash_cache本身是内部可变字段,但它的变化不会对外部观察者可见,因此不会破坏不可变语义。
5.3 编译期静态检查
PEP 841 的另一个重要优化方向是静态检查。当代码中出现以下写法时,解释器或类型检查器可以直接报错:
frozen class Point: x: float y: float p = Point(1.0, 2.0) p.x = 3.0 # 静态检查即可发现错误目前这类错误主要靠运行时抛异常,开发者写错了要执行到那一行才能发现。如果frozen成为语法关键字,IDE、mypy、Pyright、Ruff 等工具都可以在编码阶段判定属性赋值非法,这能显著减少调试时间。
5.4 运行时性能预期
关于性能提升,需要保持理性预期。PEP 841 的目标是“优化不可变类型”,而不是“让所有不可变类型变快十倍”。性能提升主要来自三个层面:
- 省略运行时冻结检查的重复逻辑;
- 使用更紧凑的属性存储方式;
- 缓存哈希值,减少重复计算。
在具体数值出来之前,任何“提升 X%”的说法都不可靠。但从原理上说,冻结类实例的属性读写路径确实比dataclass(frozen=True)更短,这是值得期待的方向。
6. 对 Python 生态的影响分析
6.1 dataclasses 与 typing 的未来
如果 PEP 841 被接纳,dataclasses模块和typing模块都会受到影响。
dataclasses最有可能的演化方向是把frozen=True变成对frozen class的封装。也就是说,在新版本 Python 中,你可以继续写@dataclass(frozen=True),它的底层实现会逐步迁移到原生冻结语法上;甚至未来会出现@dataclass frozen class这样的组合写法,让职责更清晰。
typing模块则可能新增typing.Frozen或类似的泛型标记,用于在泛型约束中表达“这个类型参数必须是冻结的”。这属于更长远的设计,目前还只是一个讨论方向。
6.2 第三方库适配
第三方库的适配是任何新语法落地时都要面对的大问题。一个最直接的例子是序列化库。
dataclasses的asdict()可以递归地把 dataclass 实例转成字典。pickle、json、pydantic等库都有自己的对象映射机制。如果frozen class自带特定的底层内存布局,这些库需要针对新的类结构识别不可变类,才能正确处理序列化和反序列化。
另一个受影响的方向是 ORM。SQLAlchemy 等 ORM 通常要求模型类是可变的,因为查询后需要把数据库行映射到对象,并在事务提交前通过属性赋值修改字段。如果某个表模型被声明为frozen class,ORM 的“懒加载”和“脏数据检查”机制会如何工作,需要库作者做专门适配。
6.3 序列化与复制
不可变类型在“修改场景”下通常要采用“生成新对象”的策略。也就是说,想修改一个字段,不直接改原对象,而是创建一个新对象并复制其他字段。这个模式称为“函数式更新”。
Python 的dataclasses.replace()已经实现了类似能力:
from dataclasses import dataclass, replace @dataclass(frozen=True) class Point: x: float y: float p = Point(1.0, 2.0) new_p = replace(p, y=3.0) print(new_p) # Point(x=1.0, y=3.0)PEP 841 落地后,这类“复制并更新”的操作很可能成为不可变对象的标准实践。未来甚至可能提供专门的语法或内置函数,让更新不可变对象像普通属性赋值一样自然。
7. 在 PEP 841 正式落地前的实操方案
虽然 PEP 841 尚未落地,但我们在日常工程中仍可以按照它的思想来设计不可变类型。这里给出几种当下就能使用的实践方案。
7.1 方案一:dataclass(frozen=True) + slots=True
如果你的项目使用 Python 3.10 以上版本,推荐把frozen=True和slots=True结合使用。slots=True会让实例使用紧凑的属性描述符存储,不再创建__dict__,减少内存占用,同时让属性访问更快。
from dataclasses import dataclass @dataclass(frozen=True, slots=True) class AppConfig: debug: bool timeout: float log_level: str = "INFO"这里的关键点是:slots=True让实例不再具备动态添加新属性的能力,frozen=True让已有属性也无法修改。两者组合后,实例的弹性被降到最低,也就最接近 PEP 841 设想中的冻结类。
需要注意,slots=True与类继承有一些兼容性问题。如果父类和子类都使用了slots,需要保证字段命名不冲突,否则会抛出ValueError。
7.2 方案二:NamedTuple 用于轻量场景
对于字段数量少、不需要复杂方法、主要做数据传输的场景,NamedTuple依然是值得优先选择的方案。它在很多方面已经接近冻结类的目标。
from typing import NamedTuple class StockPrice(NamedTuple): symbol: str price: float timestamp: int一份行情数据本身就是事件数据,生成后不应再修改。NamedTuple提供了极低的内存开销、自动实现的哈希、自动实现的相等性比较,这些特性都是它在数据流水线场景中表现出色的原因。
7.3 方案三:元类实现的“冻结类”
如果你希望体验“类声明时自动冻结”的语义,可以自己实现一个简单的元类。下面这个元类会在类创建完成后,用__slots__限制实例属性,并强制__setattr__抛出异常:
class FrozenMeta(type): def __new__(mcls, name, bases, namespace, **kwargs): cls = super().__new__(mcls, name, bases, namespace, **kwargs) cls.__slots__ = () original_init = cls.__init__ def __setattr__(self, attr, value): raise AttributeError(f"Cannot set attribute {attr!r} on frozen class") def __delattr__(self, attr): raise AttributeError(f"Cannot delete attribute {attr!r} on frozen class") cls.__setattr__ = __setattr__ cls.__delattr__ = __delattr__ return cls class Point(metaclass=FrozenMeta): def __init__(self, x: float, y: float): object.__setattr__(self, 'x', x) object.__setattr__(self, 'y', y) p = Point(1.0, 2.0) print(p.x) # 1.0 try: p.x = 3.0 except AttributeError as e: print(e) # Cannot set attribute 'x' on frozen class这段代码的目的不是让你在生产环境直接复制使用,而是帮助你理解“类创建后的元编程处理”和“类声明时的原生语法”之间的差别。元类方案能做的是事后修补,PEP 841 想做的是事前约束。
7.4 方案四:不可变配置容器
如果只是需要传递一组全局只读配置,考虑用MappingProxyType包装字典,配合类型别名,既安全又轻量:
from types import MappingProxyType from typing import Mapping RawConfig = dict[str, object] ReadonlyConfig = Mapping[str, object] def build_config(data: RawConfig) -> ReadonlyConfig: return MappingProxyType(dict(data))这种方法非常适合不引入额外依赖、但又想快速提供只读对象的场景。需要注意的是,MappingProxyType只保证映射外壳不可变,如果值本身是可变对象,仍然存在被修改的可能。
7.5 方案五:类型检查器层面的不可变保证
在代码规范层面,可以结合typing.Final和typing.dataclass_transform等机制,告诉类型检查器某些变量或字段不应该被重新赋值:
from typing import Final class AppConfig: debug: Final[bool] timeout: Final[float] def __init__(self, debug: bool, timeout: float) -> None: self.debug = debug self.timeout = timeoutFinal在运行时不会产生任何拦截效果,但 mypy、Pyright 等类型检查器会把它当作“不可重新赋值”的标记来检查。即使 PEP 841 落地,这类静态检查工具也不会消失,它们会在新的语法基础上提供更加细粒度的检查能力。
8. 常见问题与争议
| 问题现象 | 常见原因 | 解决思路 |
|---|---|---|
在自己的环境运行frozen class报SyntaxError | PEP 841 尚未合并到 Python 语言规范 | 改用dataclass(frozen=True)等现有方案 |
frozen=True后还能通过object.__setattr__修改属性 | dataclass 的冻结是运行时约束,不是底层内存约束 | 不要把frozen=True当作安全边界,只作为规范约束 |
| 冻结对象包含 list 字段时,list 内容仍可变 | 冻结语义是浅冻结 | 内部使用 tuple 或其他不可变容器 |
slots=True与继承一起使用时报错 | 多个类之间字段命名冲突 | 统一命名规范,或避免多层继承 |
| 元类冻结方案影响子类初始化 | __slots__或__setattr__被覆盖 | 增加保护逻辑,或回退到 dataclass 方案 |
| 担心 PEP 841 改变现有类行为 | 新语法不会影响旧代码 | 等待正式版本,评估升级路线 |
另一种常见争议是:为什么不用装饰器,非要引入新的关键字?这里的设计理由是,装饰器在类创建完成之后才运行,无法影响类创建过程中的底层行为;关键字则可以更早地介入语法分析阶段,让解释器和类型检查器在第一时间感知到“这是一个冻结类”。
还有一个争议是:frozen关键字会不会造成向后兼容问题?Python 中增加新的软关键字(soft keyword)通常是相对安全的,因为frozen目前并不是有效的类声明前缀。但语法解析器仍然需要处理一些边界场景,例如变量名恰好叫frozen,或者代码中已经存在frozen = something这样的赋值语句。这类兼容性问题需要经过完整的草案评审才能解决。
9. 工程实践与选择建议
9.1 什么时候应该使用不可变类型
不是所有类都适合设计成不可变类型。根据经验,以下场景非常适合:
- 数据传递对象(DTO),尤其是跨进程、跨线程传递的数据;
- 配置对象,创建后不应被业务代码修改;
- 事件对象,在事件溯源架构中需要保留历史原貌;
- 领域模型中的值对象,例如金额、坐标、时间区间等;
- 需要作为字典键或集合元素的对象。
9.2 什么时候谨慎使用
以下场景需要谨慎使用不可变类型:
- 数据量庞大且需要频繁构建新对象的场景,不可变类型可能带来更高的内存分配压力;
- ORM 模型类,数据库对象的字段修改是常态;
- 带有复杂懒加载逻辑的对象,不可变会限制内部状态的更新;
- 没有稳定哈希需求但字段特别多的对象,自动哈希计算可能带来额外开销。
9.3 项目中的落地建议
在 PEP 841 落地之前,可以先把项目中的不可变类型统一到一套规范上。推荐的做法是:定义一个新的类型别名或基类,例如FrozenModel,底层基于dataclass(frozen=True, slots=True),并在文档中明确约定禁止使用object.__setattr__绕过约束。
from dataclasses import dataclass @dataclass(frozen=True, slots=True) class FrozenModel: pass未来 PEP 841 正式发布后,只需要把@dataclass(frozen=True, slots=True)替换为frozen class,然后把FrozenModel基类逐步淘汰即可。这样迁移路径清晰,风险可控。
另外,建议持续关注 PEP 841 在 python-ideas 和 PEP 仓库中的讨论。Python 语法级改动通常要经历数年时间,从草案到实现再到正式发布,中间会有大量细节调整。作为应用开发者,更重要的是理解这个语法背后的思想,并在日常代码中贯彻不可变对象的实践。
10. 总结与后续学习
PEP 841 提出了一种名为 Frozen Syntax 的语法方案,用frozen class声明不可变类型,目标是让 Python 解释器从语言层面识别并优化不可变对象。与dataclass(frozen=True)相比,它不只是写起来更简洁,更是把不可变语义提前到语法分析阶段,为内存布局优化、哈希缓存、静态检查提供了可能性。
即使在 PEP 841 落地之前,我们仍然可以通过组合dataclass(frozen=True)、slots、NamedTuple、MappingProxyType和类型检查器实现“近似冻结”的效果。关键在于理解浅冻结和深冻结的区别,理解运行时约束与语言级约束的差异,并且根据场景选择最合适的方案。
如果想继续深入,建议沿着这几条路线学习:先阅读 CPython 中类型对象和实例对象的内存布局源码,理解__dict__与__slots__的区别;再研究dataclasses标准库的实现细节,认识装饰器如何修改类;最后关注 PEP 841 的讨论邮件列表,了解一个提案从想法变成语法需要经过哪些步骤。理解这些底层原理后,你会发现frozen不只是一个新的关键字,它是 Python 在“动态性”和“可控性”之间寻找平衡的一个缩影。