dataclass 中 init=False 与 default_factory 组合构造派生字段

在 Python 的日常开发里,@dataclass 几乎是定义数据载体的默认选择。但很多人只用到「声明字段、自动生成 __init__」这一步,一旦遇到「字段需要由其他字段计算得来」的场景,就退回去手写 __init__,把 dataclass 带来的简洁性丢掉了。更常见的情况是:给派生字段写了一个 field(init=False),然后直接赋值 fie

在 Python 的日常开发里,@dataclass 几乎是定义数据载体的默认选择。但很多人只用到「声明字段、自动生成 __init__」这一步,一旦遇到「字段需要由其他字段计算得来」的场景,就退回去手写 __init__,把 dataclass 带来的简洁性丢掉了。更常见的情况是:给派生字段写了一个 field(init=False),然后直接赋值 field(init=False, default=0),结果发现这个默认值在所有实例之间共享——当它是 list 或 dict 时,就变成了经典的「可变默认值」灾难,所有实例改的是同一份数据。

这篇文章解决的问题就是:如何用 field(init=False, default_factory=...) 正确地构造派生字段,让它在初始化完成后自动填上计算结果,同时又不会被 __init__ 的参数列表污染,也避免可变对象共享。我们会从 dataclass 的字段分类讲起,把 init=False、default_factory、__post_init__ 三者的协作关系拆开讲清楚,再给出可以直接跑的完整示例、几个容易踩的坑(包括真实的 TypeError 报错文本),最后用一个「订单行汇总」的实战案例把组合用法串起来。

先搞清楚 dataclass 的三类字段

在动手之前,得先建立一个准确的模型。dataclass 在生成 __init__ 时,会看每个字段的 field() 配置,把字段分成三类:

  • 必填字段:有类型注解、没有默认值、init=True。会作为 __init__ 的必需位置/关键字参数。
  • 可选字段:有 default 或 default_factory、init=True。会作为有默认值的参数出现,且必须排在必填字段之后。
  • 不参与初始化的字段:init=False。不会出现在 __init__ 签名里,构造后由 dataclass 或你自己赋值。

关键点在第三类。init=False 只是说「构造函数不收这个参数」,它并不阻止 dataclass 在 __init__ 内部给字段赋值。如果这个字段还带了 default 或 default_factory,dataclass 生成的 __init__ 会在最后把默认值写进去。这正是「派生字段有个初始值」的实现基础。

至于 default_factory,它的语义是「每次创建实例时调用一次这个无参可调用对象,用返回值作为字段初始值」。它和 default 的差别只有在可变对象上才真正暴露:default=[] 是把同一个 list 对象挂在类属性上,所有实例共享;default_factory=list 是每个实例都新建一个 list。

from dataclasses import dataclass, field

@dataclass
class Bad:
    tags: list = []

@dataclass
class Good:
    tags: list = field(default_factory=list)

b1, b2 = Bad(), Bad()
b1.tags.append("x")
print(b1.tags, b2.tags)   # ['x'] ['x']  —— 共享了同一个 list

g1, g2 = Good(), Good()
g1.tags.append("x")
print(g1.tags, g2.tags)   # ['x'] []     —— 各自独立

Python 3.11 之后,直接用 list 作为 default 会抛 ValueError(mutable default <class 'list'> for field tags is not allowed: use default_factory),但构造 dict()、set() 之类的空容器仍可能绕过检查,所以养成用 default_factory 的习惯更稳。

init=False 的字段在什么时机被赋值

理解赋值时机,才能理解为什么「先给默认值、再用 __post_init__ 覆盖」是安全的。dataclass 生成的 __init__ 逻辑大致如下(伪代码):

  1. 依次处理每个 init=True 的字段:有传入值就用传入值,否则用 default_factory() 或 default,然后 self.field_name = 值。
  2. 处理每个 init=False 但带默认值的字段:self.field_name = default_factory()(或 default)。
  3. 如果定义了 __post_init__,调用 self.__post_init__()。

也就是说,__post_init__ 是最后一步,此时所有字段都已经存在,包括 init=False 的字段。所以派生字段的标准写法是:用 field(init=False, default_factory=...) 声明一个「占位初值」,然后在 __post_init__ 里根据其他字段重新计算并覆盖。

这里有个细节值得注意:default_factory 在 init=False 字段上会真的被调用一次。如果你的 factory 有副作用或者开销大(比如去查数据库),它会被执行,然后马上被 __post_init__ 覆盖,纯属浪费。这种情况应该用 field(init=False) 加零开销的占位,或者干脆用 field(init=False, repr=False) 并在 __post_init__ 里赋值。但如果字段类型是可变容器且 __post_init__ 只做追加而非重赋值,就必须用 default_factory,否则又是共享对象问题。

标准组合:default_factory 提供占位,__post_init__ 覆盖

先看一个最典型的场景:一个表示「矩形」的类,面积由宽高派生。

from dataclasses import dataclass, field

@dataclass
class Rect:
    width: float
    height: float
    area: float = field(init=False, default_factory=float)

    def __post_init__(self):
        self.area = self.width * self.height

r = Rect(3, 4)
print(r)                    # Rect(width=3, height=4, area=12)
print(r.area)               # 12
import inspect
print(inspect.signature(Rect.__init__))
# (self, width: float, height: float) -> None

这里 default_factory=float 等价于 default_factory=lambda: 0.0,因为 float() 返回 0.0。它的作用只是让 dataclass 认为「这个字段有默认值」,从而在 __init__ 里为它占个位,避免 __post_init__ 访问时出现 AttributeError。即使你忘了在 __post_init__ 里赋值,属性也是存在的(值为 0.0),不会炸。

再看表示「订单行」的场景,派生字段是列表,这才是 default_factory 真正不可替代的地方:

from dataclasses import dataclass, field
from decimal import Decimal

@dataclass
class OrderLine:
    sku: str
    unit_price: Decimal
    quantity: int
    subtotal: Decimal = field(init=False, default_factory=Decimal)
    tags: list[str] = field(init=False, default_factory=list)

    def __post_init__(self):
        self.subtotal = self.unit_price * self.quantity
        if self.quantity >= 100:
            self.tags.append("bulk")

a = OrderLine("SKU-1", Decimal("19.90"), 3)
b = OrderLine("SKU-2", Decimal("5.00"), 150)
print(a.subtotal, a.tags)   # 59.70 []
print(b.subtotal, b.tags)   # 750.00 ['bulk']

如果把 tags 写成 field(init=False, default=[]),那么 a.tags 和 b.tags 是同一个 list,b 追加的 "bulk" 会出现在 a 上。而在 Python 3.11+ 上这么写甚至直接报错:

ValueError: mutable default <class 'list'> for field tags is not allowed: use default_factory

注意一个容易忽略的点:__post_init__ 里对 tags 用的是 append 而不是重新赋值。如果写成 self.tags = ["bulk"],那 factory 产生的那个 list 就被丢弃了,这时用 default=[] 反而不会出错——但这只是碰巧,换个分支逻辑就又中招。统一用 default_factory 才是可靠做法。

必须注意的四个坑

坑一:init=False 的字段仍然参与 __repr__、__eq__、asdict

init=False 只影响构造函数签名。__repr__ 会打印它,__eq__ 会比较它,dataclasses.asdict() 会包含它。派生字段在 __post_init__ 里必然和源字段一致,通常无害;但如果派生结果依赖外部状态(时间戳、随机数、当前用户),两个「逻辑上相同」的实例就会被判为不等。需要控制时用 field(init=False, compare=False, repr=False)。

from dataclasses import dataclass, field
import time

@dataclass
class Snapshot:
    payload: str
    created_at: float = field(init=False, compare=False, repr=False, default_factory=time.time)

    def __post_init__(self):
        self.created_at = time.time()

s1 = Snapshot("x")
print(s1 == Snapshot("x"))   # True —— created_at 不参与比较

坑二:字段顺序 —— 无默认值字段不能排在 init=True 的默认值字段之后

这是 dataclass 最常见的一类报错。如果把 init=False 的派生字段放在中间,而它后面还有不带默认值的字段,会触发:

TypeError: non-default argument 'quantity' follows default argument

原因在于 dataclass 按声明顺序生成 __init__ 参数,init=False 的字段虽然不进签名,但它带的 default_factory 会让「默认值位置」被提前,破坏后续参数顺序。解决办法有两个:把 init=False 字段统一放到所有 init=True 字段之后;或者给类加上 @dataclass(kw_only=True)(Python 3.10+),让所有字段只能关键字传参,顺序限制消失。

from dataclasses import dataclass, field

@dataclass
class Wrong:
    width: float
    area: float = field(init=False, default_factory=float)
    height: float = 1.0     # 放在 area 后面不会报错,因为它有默认值

@dataclass
class Right:
    width: float
    height: float           # 先放所有 init=True 字段
    area: float = field(init=False, default_factory=float)

坑三:__post_init__ 里调用父类逻辑

如果 dataclass 有继承关系,子类的 __post_init__ 不会自动调用父类的。必须显式 super().__post_init__(),否则父类定义的派生字段不会被计算,但它会因为 default_factory 而保留占位值——这类 bug 非常隐蔽,因为不报错,只是值不对。

@dataclass
class Base:
    a: int
    doubled: int = field(init=False, default_factory=int)

    def __post_init__(self):
        self.doubled = self.a * 2

@dataclass
class Child(Base):
    b: int = 0
    combined: int = field(init=False, default_factory=int)

    def __post_init__(self):
        super().__post_init__()     # 漏了这行,doubled 恒为 0
        self.combined = self.doubled + self.b

print(Child(5, 3))   # Child(a=5, b=3, doubled=10, combined=13)

坑四:frozen=True 时不能直接赋值

如果 dataclass 声明为 @dataclass(frozen=True),__post_init__ 里 self.area = ... 会抛 FrozenInstanceError:

dataclasses.FrozenInstanceError: cannot assign to field 'area'

官方给出的技巧是使用 object.__setattr__:

from dataclasses import dataclass, field

@dataclass(frozen=True)
class Circle:
    radius: float
    area: float = field(init=False, default_factory=float)

    def __post_init__(self):
        object.__setattr__(self, "area", 3.141592653589793 * self.radius ** 2)

c = Circle(2)
print(round(c.area, 4))   # 12.5664
try:
    c.area = 0
except Exception as e:
    print(type(e).__name__, e)   # FrozenInstanceError cannot assign to field 'area'

另一个更干净的替代方案是用 functools.cached_property 配合普通字段,但那要求类不是 frozen 或者同样走 object.__setattr__,且不参与 __eq__。视需求选。

实战:把派生字段组合起来做配置对象

下面是一个更贴近真实项目的例子:一个分页查询参数对象。它接收 page 和 size,派生 offset、limit,以及一个用于缓存请求头的字典。

from dataclasses import dataclass, field, asdict
from typing import Any

@dataclass
class PageQuery:
    page: int
    size: int = 20
    sort_by: str = "id"
    offset: int = field(init=False, default_factory=int)
    limit: int = field(init=False, default_factory=int)
    headers: dict[str, Any] = field(init=False, default_factory=dict)
    _warnings: list[str] = field(init=False, default_factory=list, repr=False, compare=False)

    def __post_init__(self):
        if self.page < 1:
            self._warnings.append(f"page={self.page} 非法,已修正为 1")
            self.page = 1
        if not (1 <= self.size <= 200):
            self._warnings.append(f"size={self.size} 越界,已修正为 20")
            self.size = 20
        self.offset = (self.page - 1) * self.size
        self.limit = self.size
        self.headers.update({
            "X-Page": str(self.page),
            "X-Page-Size": str(self.size),
        })

    @property
    def warnings(self) -> list[str]:
        return list(self._warnings)

q = PageQuery(page=0, size=1000)
print(q.offset, q.limit)        # 0 20
print(q.headers)                # {'X-Page': '1', 'X-Page-Size': '20'}
print(q.warnings)               # ['page=0 非法,已修正为 1', 'size=1000 越界,已修正为 20']
print(asdict(q))
# {'page': 1, 'size': 20, 'sort_by': 'id', 'offset': 0, 'limit': 20,
#  'headers': {'X-Page': '1', 'X-Page-Size': '20'}, '_warnings': [...]}

几个设计决策值得说明:

  • offset 和 limit 用 default_factory=int 占位,值由 __post_init__ 计算。它们出现在 asdict() 结果里,方便直接序列化成查询参数。
  • headers 用 default_factory=dict,在 __post_init__ 里 update 而不是重新赋值,保留了 factory 创建的独立字典语义。如果外部传入时想合并自定义头,可以额外加一个 extra_headers 字段(init=True,default_factory=dict)然后在 __post_init__ 里 self.headers.update(self.extra_headers)。
  • _warnings 设了 compare=False,避免两笔「修正后相同」的查询因为警告信息条数不同被判为不等;repr=False 让日志更干净。
  • 注意在 __post_init__ 里修改 self.page:如果类不是 frozen,这是允许的,而且修改后 dataclass 不会再校验一次,所以人工修正逻辑必须自己写全。

再补一个「派生字段作为缓存」的写法。很多时候派生字段计算开销大,希望延迟到第一次访问再算。default_factory 在实例构造时就会执行,做不到延迟。这种需求应改用 functools.cached_property:

from dataclasses import dataclass, field
from functools import cached_property

@dataclass
class Report:
    rows: list[dict] = field(default_factory=list)

    @cached_property
    def total(self) -> float:
        print("computing total...")
        return sum(r.get("amount", 0.0) for r in self.rows)

r = Report([{"amount": 1.0}, {"amount": 2.5}])
print(r.total)   # 打印 computing total... 然后 3.5
print(r.total)   # 只打印 3.5,缓存生效

cached_property 不进入 __init__、不进入 asdict()、也不参与 __eq__,语义上比 init=False 的字段更「纯派生」。什么时候用哪种?判断标准是:派生值需要在序列化/比较/日志里出现,或者需要在 __post_init__ 里被后续逻辑依赖,就用 field(init=False, default_factory=...);只是懒计算的只读视图,用 cached_property。

小结

把三个东西的职责划清楚,组合就不会出错:init=False 负责把字段从构造函数参数里摘出去;default_factory 负责给字段一个「每实例独立、可被安全后续修改」的占位值(可变容器必须用它);__post_init__ 负责在所有字段就位后执行派生计算并覆盖占位值。三条规则记住了:init=False 字段统一放最后、可变默认值一律 default_factory、继承时记得 super().__post_init__()。frozen 类里赋值走 object.__setattr__。

FAQ

Q1: init=False 的字段还能不能有 default 和 default_factory 同时写?

不能。同时传 default 和 default_factory 会抛 ValueError: cannot specify both default and default_factory。二选一。对 init=False 的派生字段,如果初值是可变容器或需要每次新建,用 default_factory;标量用 default 也行,但推荐统一用 factory,减少心智负担。

Q2: 为什么我写了 default_factory 还是被多个实例共享?

大概率是 factory 返回了同一个对象,比如 default_factory=lambda: SHARED_LIST 或者 default_factory=lambda: make_headers() 而 make_headers 内部返回了模块级常量。factory 每次会被调用,但返回什么由你决定。排查方式:print(a.tags is b.tags),为 True 就说明共享了。

Q3: __post_init__ 里抛异常会不会导致对象「半构造」?

会。此时实例已经分配、部分字段已赋值,但 __init__ 没有正常返回,对象不会被外部引用(除了异常追踪里的 self),一般无害。但如果你把 self 在 __post_init__ 里注册到全局注册表或缓存,务必用 try/except 或延后注册,否则会留下不完整对象。

Q4: 用 asdict() 序列化时,派生字段能不能排除?

dataclasses.asdict() 没有 exclude 参数,所有字段(包括 init=False 的)都会被递归转换。要过滤,自己遍历 dataclasses.fields(obj) 并检查 f.init 或字段名:

import dataclasses

def as_dict_excluding_derived(obj) -> dict:
    return {
        f.name: getattr(obj, f.name)
        for f in dataclasses.fields(obj)
        if f.init or f.name not in {"offset", "limit"}
    }

Q5: kw_only=True 和 init=False 一起用会怎样?

init=False 的字段本来就不进 __init__,kw_only 对它没有任何影响。kw_only=True 主要解决的是「默认值字段顺序」限制,让所有 init=True 字段都必须用关键字传参,因此派生字段放哪都无所谓,是消除 Q 中提到的那类 TypeError 最省心的办法(需要 Python 3.10+)。

Q6: Python 3.13/3.14 对这块有变化吗?

没有破坏性变化。field() 的 init、default_factory、compare、repr、kw_only 参数自 3.10 起稳定。3.12 之后 dataclass 的 __init__ 生成器有性能优化,行为不变。可以放心按本文写法使用,最低版本要求按 kw_only 是否使用决定(3.10)。