在 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__ 逻辑大致如下(伪代码):
- 依次处理每个
init=True的字段:有传入值就用传入值,否则用default_factory()或default,然后self.field_name = 值。 - 处理每个
init=False但带默认值的字段:self.field_name = default_factory()(或default)。 - 如果定义了
__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)。