登录
推荐 文章 Go 技术 课程 下载 专题 AI
首页 >  文章 >  python教程

Python frozen dataclass 里怎么保存派生字段

来源:17golang原创

时间:2026-09-08 09:07:22 213浏览 收藏

在订单行、配置快照这类对象里,totalcache_key 之类的字段通常不是调用方直接传入,而是由输入字段计算出来。使用 @dataclass(frozen=True) 后,普通的 self.total = ... 会触发 FrozenInstanceError。稳妥的写法是把派生字段声明为 init=False,在 __post_init__ 中用 object.__setattr__ 完成一次初始化;默认列表则交给 default_factory,不要把可变对象写成类级共享默认值。

核心做法:输入字段参与生成构造参数,派生字段用 field(init=False) 排除出构造签名;frozen=True 下只在 __post_init__ 里通过 object.__setattr__ 写入一次。
要点速览
  • __post_init__ 适合计算依赖多个输入的字段。
  • default_factory 每次构造时创建独立默认值,避免列表互相污染。
  • frozen 只限制属性重新绑定,不会自动冻结列表内部内容。

为什么派生字段要放进 __post_init__

生成的 __init__ 只负责接收真实输入。把派生值标记为 init=False,可以让调用方不能绕过计算逻辑直接传入;__post_init__ 又紧跟在生成构造方法之后,适合集中处理字段之间的关系。

下面的例子模拟订单行:税率是构造阶段的临时输入,含税金额是派生字段。frozen=True 时要用 object.__setattr__,这只解决初始化写入,不代表后续代码可以随意修改实例。

from dataclasses import InitVar, dataclass, field
from decimal import Decimal

@dataclass(frozen=True)
class OrderLine:
    name: str
    base_price: Decimal
    quantity: int = 1
    tax_rate: InitVar[Decimal] = Decimal("0.13")
    total: Decimal = field(init=False)

    def __post_init__(self, tax_rate: Decimal) -> None:
        # 派生值不进入构造签名,只由输入字段统一计算
        amount = self.base_price * self.quantity
        total = amount * (Decimal("1") + tax_rate)
        # frozen 实例禁止普通赋值,初始化阶段用底层写入
        object.__setattr__(self, "total", total)

line = OrderLine("键盘", Decimal("199"), quantity=2)
print(line.total)  # 449.74

这里的 tax_rateInitVar,只参与生成的 __init____post_init__,不会成为实例字段。total 则是真正的字段,因此可被 fields() 和序列化逻辑看到。

Python frozen dataclass 中输入字段、__post_init__ 与 total 派生字段的静态关系
图1:输入字段汇入 __post_init__ 计算 total,冻结边界内只通过 object.__setattr__ 完成初始化写入。

default_factory 和字段顺序怎么一起检查

默认值和字段位置会共同决定生成的 __init__。没有默认值的字段必须放在有默认值的字段前面,这条规则也适用于继承后的字段合并。对于列表、集合等容器,写成 tags: list[str] = [] 会让多个实例共享同一个对象;应改用零参数工厂。

from dataclasses import InitVar, dataclass, field, fields

@dataclass(frozen=True)
class Job:
    name: str
    quantity: int = 1
    tags: list[str] = field(default_factory=list)
    config: InitVar[dict] = None

    def __post_init__(self, config: dict | None) -> None:
        # config 只用于初始化判断,不保存为实例字段
        if config and config.get("tag"):
            self.tags.append(config["tag"])

first = Job("build", config={"tag": "release"})
second = Job("test")
print(first.tags, second.tags)  # ['release'] []
print([item.name for item in fields(Job)])  # name, quantity, tags

示例中的 tags 是列表,所以 frozen=True 并不能阻止 append;它冻结的是属性绑定,而不是对象内部的可变内容。如果目标是更强的不可变边界,可以把类型改成 tuple[str, ...],并用 default_factory=tuple

声明进入 __init__出现在 fields()适用边界
field(init=False)保存派生字段
default_factory=list可带默认值每个实例独立容器
InitVar[dict]只在构造阶段使用的输入
frozen=True不改变参数规则不改变字段集合阻止普通属性重新赋值

生成签名不稳定时,先按这三个边界排查

第一,看必填字段是否误放在默认字段之后;第二,看 InitVar 是否只是临时依赖,若需要持久化就应改成普通字段;第三,看派生字段是否错误地保留了 init=True。需要检查真实字段集合时,用 fields(),不要把类的注解字典当成最终 dataclass 结构。

如果类使用继承,基类 dataclass 字段会参与最终顺序,子类同名字段还可能覆盖基类定义。生产代码可以把构造参数尽量改成关键字调用,减少字段新增或重排带来的调用方风险。

Python dataclass 中 default_factory、字段顺序、InitVar 和 fields 的静态关系
图2:字段顺序影响 __init__ 参数,default_factory=list 提供独立容器,InitVar 只连接初始化链路而不进入 fields()

常见问题

frozen=True 下能不能在 __post_init__ 里直接赋值?

不能。直接写 self.total = value 会触发冻结异常,应使用 object.__setattr__ 完成派生字段的初始化。

default_factory 每次都会重新调用吗?

当该字段没有显式传值时,生成的构造方法会为当前实例调用工厂,因此适合创建独立列表、集合或元组。

InitVar 适合保存数据库连接吗?

它适合接收连接、配置等构造期依赖,再在 __post_init__ 中提取需要的值;连接对象本身不会自动成为实例字段。

派生字段应该用 property 还是 init=False?

需要序列化、比较或缓存结果时可用 init=False;必须始终随输入实时变化且不想保存副本时,通常更适合用只读 property

声明:本文转载于:17golang原创 如有侵犯,请联系study_golang@163.com删除
相关阅读
更多>
最新阅读
更多>
课程推荐
更多>