一、写到手酸的样板代码:一个类要抄多少遍

但凡用类装数据的 Python 程序员,都写过这种东西:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class Point:
def __init__(self, x, y):
self.x = x
self.y = y

def __repr__(self):
return f"Point(x={self.x}, y={self.y})"

def __eq__(self, other):
if not isinstance(other, Point):
return NotImplemented
return self.x == other.x and self.y == other.y

p1 = Point(1, 2)
p2 = Point(1, 2)
print(p1) # Point(x=1, y=2)
print(p1 == p2) # True

输出:

1
2
Point(x=1, y=2)
True

就为了"装两个字段、能打印、能比较",你得手写 __init____repr____eq__ 三件套。字段一多,__init__self.a = a 要抄十遍,__repr__ 要把字段名再抄一遍,__eq__ 还要再抄一遍——同一份字段名,在三个地方重复出现。改一个字段名,三处都得改,漏一处就是 bug。

问题来了:这些代码模式固定、毫无智力含量,为什么不让解释器替我生?

答案是——@dataclass 就是干这个的。

二、dataclass 是什么:声明字段,剩下的它包了

@dataclass 是 Python 3.7(PEP 557)引入的标准装饰器。你只声明"有哪些字段、什么类型",它自动生成 __init____repr____eq__ 等一整套 dunder 方法:

1
2
3
4
5
6
7
8
9
10
11
12
from dataclasses import dataclass

@dataclass
class Point:
x: int
y: int

p1 = Point(1, 2)
p2 = Point(1, 2)
print(p1) # Point(x=1, y=2)
print(p1 == p2) # True
print(p1.x, p1.y) # 1 2

输出和上面手写的完全一致,但类体空空如也。

它到底帮你生成了哪些方法?看这张图:

1
2
3
4
5
6
7
8
9
10
11
你写的                           @dataclass 替你生成的
───────── ────────────────────────
@dataclass def __init__(self, x, y):
class Point: ──────► self.x = x
x: int    self.y = y
y: int def __repr__(self):
return f"Point(x={self.x!r}, y={self.y!r})"
def __eq__(self, other):
if other.__class__ is self.__class__:
return (self.x, self.y) == (other.x, other.y)
return NotImplemented

关键区别:手写版字段名在三个方法里各抄一遍;dataclass 版字段只声明一次,生成逻辑由解释器统一维护——字段增删改,只需动类体那两行。

三、深入:field()、默认值坑、frozen 与继承

@dataclass 远不止"少写 __init__",这几处约束和陷阱必须看清。

(1) 可变默认值的天坑:别用 = [],用 field(default_factory=...) 普通类里 def __init__(self, items=[]) 会让所有实例共享同一个列表;dataclass 更严格——直接用可变对象作默认值会直接报错:

1
2
3
4
5
from dataclasses import dataclass, field

@dataclass
class Query:
terms: list = [] # ValueError: mutable default! 直接拒绝

正确写法:

1
2
3
@dataclass
class Query:
terms: list = field(default_factory=list) # 每次新建实例都给一份新 list

(2) field() 精细控制。field 可分别控制:是否进 __init__(init=False)、是否进 __repr__default_factory、比较时是否参与(compare=False):

1
2
3
4
5
6
7
8
from dataclasses import dataclass, field

@dataclass
class Doc:
doc_id: int
title: str
score: float = 0.0
_cached: dict = field(default_factory=dict, repr=False, init=False) # 内部缓存,不显示不传参

(3) frozen=True 变不可变。frozen=True 后实例字段只读,任何赋值抛 FrozenInstanceError——相当于轻量 namedtuple,但能挑字段:

1
2
3
4
5
6
7
@dataclass(frozen=True)
class Config:
host: str
port: int = 8080

c = Config("localhost")
c.port = 9090 # dataclasses.FrozenInstanceError

(4) eqhash 的联动。 默认 eq=True 会让你定义的类不可哈希(__hash__ 被置 None),因此不能进 set/dict 键;要可哈希,要么 frozen=True(此时自动给 __hash__),要么显式 eq=Falseunsafe_hash=True

(5) 继承时字段顺序。 子类字段追加在父类之后,且所有字段默认值必须排在非默认值之后(和普通 __init__ 规则一致),否则报 TypeError

四、实战:C++ 视角、BM25 检索的真实血案

C++ 等价物:手写 struct + 自补方法。 C++ 的 struct/class 只管存数据,没有自动 __repr__、没有自动 ==:

1
2
3
4
5
6
7
8
9
10
11
12
#include <iostream>
struct Point {
int x, y;
};
// 想打印?自己写 operator<<
std::ostream& operator<<(std::ostream& os, const Point& p) {
return os << "Point(x=" << p.x << ", y=" << p.y << ")";
}
// 想比较?自己写 operator==
bool operator==(const Point& a, const Point& b) {
return a.x == b.x && a.y == b.y;
}

差异在于:C++ 这些也得手写,且标准没有"按字段自动生成"的机制(直到 C++20 的 = default 比较仍要显式声明);而 Python 的 @dataclass 按字段声明一次生成全部,且默认还带类型注解当文档。C++ 侧更偏"零成本抽象、运行时无开销",Python 侧更偏"开发期少写样板"——两者取舍不同:性能敏感的热路径用 C++ struct,业务数据用 dataclass 更省心。

前司检索系统的真实踩坑。 我们做 OA 全文检索(基于 BM25 + 倒排索引)时,用 dataclass 定义"单条查询配置":

1
2
3
4
@dataclass
class QueryCfg:
field_weights: dict = {} # 踩坑写法!本意"默认空权重表"
top_k: int = 20

结果不同查询之间权重互相污染:field_weights 是可变默认值,dataclass 直接 ValueError 拦住了还好;但早期我们是用普通类 + = {} 写法的,所有 QueryCfg 实例共享同一份 field_weights。A 查询给"标题"加了 2.0 权重,B 查询一建出来继承的就是已被 A 改过的权重表——这正是那套系统"权重大却不相关"的老毛病之外的又一处病灶:权重不是查不出来,是跨查询被悄悄改写了。改成 field(default_factory=dict) 后,每个查询独立持有一份权重表,排序结果才稳定。这再次印证前司检索"权重大却不相关"的根源之一——不是算法算错,是装数据的容器把状态漏给了别人

五、对比总结:手写类 / dataclass / namedtuple / C++ struct

维度 手写类 @dataclass namedtuple C++ struct
是否要手写 __init__ 否(自动) 否(自动) 否(聚合)
是否自动 __repr__ 否(需自写)
是否自动 __eq__ 是(可关) 是(按序) 否(需自写)
可变默认支持 有坑(共享) field(default_factory) 不可变 无此概念
可变性 可变 默认可变 / frozen 不可变 默认可变
可哈希 默认可 frozen/显式
字段可挑(部分隐藏) 任意 field(repr/init=False) 任意
继承字段合并 手动 自动追加 不支持 支持
运行时开销 极低(启动期生成) 零(编译期)
适合场景 要完全控制 业务数据载体 轻量不可变记录 高性能/零开销

一句话本质:@dataclass 是把"声明字段 → 生成样板 dunder 方法"这件固定模式的事,交给装饰器在类定义时一次性完成。 它用"字段只写一次"换来了告别 __init__/__repr__/__eq__ 三处手抄,但前提是躲开可变默认值的共享陷阱(用 field(default_factory=...))、并想清 eq/hash/frozen 的组合——一旦用 dataclass 装会被多处共享、又要改状态的数据,踩的就是前司 BM25 那类"容器漏状态"的坑。

小结:@dataclass(Python 3.7, PEP 557)按字段声明自动生成 __init__/__repr__/__eq__,字段只写一次、三处不重抄;可变默认值必须用 field(default_factory=list/dict) 而非 = [](否则共享或 ValueError);field(repr=False, init=False) 可隐藏内部字段,frozen=True 变不可变且自动可哈希,默认 eq=True 会让类不可哈希;继承时子类字段追加、默认值须排在非默认值后;C++ struct 无自动 ==/repr 需自写、偏零开销,dataclass 偏省样板;前司 BM25 检索曾因查询配置用可变默认 = {} 导致跨查询权重互相污染、排序"权重大却不相关",改 default_factory 后才稳定——装会被共享又要改状态的数据,先想清容器会不会漏状态。