Python dataclass 语法详解:field、default_factory、slots 与常见坑
面向只学过 class 和 __init__() 的新手,从 dataclass 的作用讲起,逐步解释 __repr__()、__eq__()、field、default_factory、kw_only、slots、frozen、match_args 与常用辅助函数。
dataclass 到底解决什么问题
假设你已经学过普通类和 __init__(),要定义一个只保存名称、价格和库存的类,通常会这样写:
class Product:
def __init__(self, name: str, price: float, stock: int = 0):
self.name = name
self.price = price
self.stock = stock
这段代码可以工作,但它只解决了“创建对象并保存字段”:
first = Product("Python Guide", 79.0)
second = Product("Python Guide", 79.0)
print(first)
# 类似:<__main__.Product object at 0x...>
print(first == second)
# False
默认打印结果不展示字段;两个实例即使字段值相同,普通类默认也不会因此相等。如果希望对象打印得清楚、== 可以按字段比较,就要继续手写 __repr__() 和 __eq__():
class Product:
def __init__(self, name: str, price: float, stock: int = 0):
self.name = name
self.price = price
self.stock = stock
def __repr__(self) -> str:
return f"Product(name={self.name!r}, price={self.price}, stock={self.stock})"
def __eq__(self, other: object) -> bool:
if not isinstance(other, Product):
return False
return (
self.name == other.name
and self.price == other.price
and self.stock == other.stock
)
!r 表示使用字段值的 repr() 形式,因此字符串会保留引号。这不是理解 dataclass 的重点,看懂输出结果即可。
-> str、-> bool 是返回值类型注解;other: object 表示参数可以接收任意 Python 对象;isinstance(other, Product) 会在运行时确认对方是不是 Product。这些写法不属于 dataclass 专有语法,暂时不熟也不影响理解这段对照。
上面是便于入门理解的简化实现。这些方法的逻辑往往只是重复字段列表。字段一多,新增或删除字段时还要同步修改多个方法。
dataclass 的作用就是:你负责声明“这个类有哪些字段”,Python 帮你生成以字段为基础的常用方法。 它特别适合以保存数据为主的类,可以减少重复代码。
它只负责生成和字段有关的类方法,不会自动校验运行时类型,也不会自动完成 JSON 编码或数据持久化。
同一个类改成数据类后,只需要:
from dataclasses import dataclass
@dataclass
class Product:
name: str
price: float
stock: int = 0
这里的 @dataclass 是一个装饰器。装饰器可以理解为“类定义完成后,再交给一个函数加工”。下面两种思路可以近似理解为同一件事:
@dataclass
class Product:
...
class Product:
...
Product = dataclass(Product)
dataclass 会读取带类型注解的字段,并按配置补上常用方法。
这次加工发生在类定义时,不是每次创建实例时重新执行装饰器。它不会改变 Python 类的基本用法,你仍然可以给类添加普通方法、属性和继承关系。
最小语法逐行解释
上面的数据类可以按下面四部分理解:
| 代码 | 含义 | 目的 |
|---|---|---|
from dataclasses import dataclass |
从标准库导入装饰器 | 让当前文件可以使用 @dataclass |
@dataclass |
加工紧随其后的类 | 自动生成初始化、打印、比较等方法 |
name: str |
声明一个没有默认值的字段 | 创建对象时必须传入 name |
stock: int = 0 |
声明一个默认值为 0 的字段 |
创建对象时可以省略 stock |
创建实例时,没有默认值的字段必须传入,有默认值的字段可以省略:
book = Product(name="Python Guide", price=79.0)
pen = Product(name="Pen", price=5.0, stock=20)
: str、: float、: int 是类型注解。dataclass 用它们识别字段,但默认不会在运行时检查实际值的类型。
字段会成为实例属性:name 字段在创建对象后可以通过 book.name 读取,这相当于普通 __init__() 中的 self.name。
上面使用的是关键字参数,因为写出了 name= 和 price=。也可以按字段顺序使用位置参数:
book = Product("Python Guide", 79.0)
关键字参数通常更容易看懂;字段较少、顺序很明确时,位置参数更简短。
先认识双下划线方法
__init__()、__repr__()、__eq__() 这类名称前后都有两个下划线,通常称为双下划线方法,英文常写作 dunder method。它们不是让你频繁手动调用的普通方法,而是 Python 在特定语法出现时自动调用的“接口”。
| 名称 | Python 什么时候使用 | 解决什么问题 |
|---|---|---|
__init__() |
执行 Product(...) 创建实例后 |
给新对象设置初始字段 |
__repr__() |
执行 repr(obj)、在交互式环境查看对象;没有自定义 __str__() 时也会影响 print(obj) |
给开发者一个清晰、可调试的对象文本 |
__eq__() |
执行 left == right |
定义两个对象在什么条件下相等 |
__lt__() 等 |
执行 <、<=、>、>= |
定义对象如何排序 |
__hash__() |
执行 hash(obj),或把对象放入集合、用作字典键 |
为哈希容器提供当前运行期间用于快速定位的整数 |
__post_init__() |
数据类生成的 __init__() 完成后 |
补充计算或校验;它是 dataclass 约定的钩子 |
这些缩写可以按英文含义记忆:
init来自 initialize,表示初始化;repr来自 representation,表示对象的文本表示;eq来自 equal,表示相等;lt来自 less than,对应<;hash表示哈希值;post_init表示“初始化之后”。
__repr__() 和 __str__() 都能返回对象文本,但用途不同:__repr__() 更偏向开发和调试,__str__() 更偏向给最终用户阅读。数据类会自动生成 __repr__(),不会自动生成 __str__();没有自定义 __str__() 时,print(obj) 通常会回落到 __repr__() 的结果。
还有一个容易混淆的名称:__match_args__。它不是方法,而是一个元组属性,用来告诉 Python:在 match ... case 模式匹配中,位置参数分别对应哪些字段。装饰器参数 match_args=True 控制是否自动生成这个属性。
如果你现在只学过 __init__(),先掌握下面三项就够了:
__init__()负责创建后的初始化;__repr__()决定对象怎样显示;__eq__()决定==怎样比较。
哈希、模式匹配和复杂继承属于进阶内容,第一次阅读时可以先跳过,不影响基础使用。
建议按三层学习
| 层级 | 建议掌握的内容 | 原因 |
|---|---|---|
| 基础必学 | @dataclass、字段、默认值、field()、default_factory |
足以完成大多数简单数据类 |
| 常用进阶 | __post_init__()、kw_only、slots、frozen、asdict()、replace() |
解决校验、参数可读性、对象修改和转换问题 |
| 可先跳过 | unsafe_hash、match_args、weakref_slot、InitVar、ClassVar、继承细节 |
只有特定语法或框架场景才需要 |
文中不同小节的代码块主要用于独立演示当前语法点。同名示例类可能会在后文重新定义,练习时建议按小节运行,并连同该小节的导入和类定义一起复制。
版本要求
常见语法分布在不同 Python 版本中:
| 写法 | 引入版本 | 作用 |
|---|---|---|
name: str |
Python 3.6 | 变量类型注解 |
@dataclass、field() |
Python 3.7 | 数据类与字段配置 |
match_args、kw_only、slots |
Python 3.10 | 模式匹配、仅关键字字段和槽位 |
str | None |
Python 3.10 | 联合类型的简写语法 |
weakref_slot |
Python 3.11 | 为槽位实例增加弱引用支持 |
本文示例整体以 Python 3.10 或更高版本为基线。查看当前版本:
python --version
Python 3.9 可以使用基础的 @dataclass 和 field(),但不能直接使用 slots=True、kw_only=True 与 T | None。
dataclass 装饰器参数:每项有什么目的
@dataclass 后面的参数控制整个类怎样生成方法。以 Python 3.10 的常用参数为例,不带参数的 @dataclass 可以理解为下面这些默认设置:
@dataclass(
init=True,
repr=True,
eq=True,
order=False,
unsafe_hash=False,
frozen=False,
match_args=True,
kw_only=False,
slots=False,
)
class SimpleProduct:
name: str
price: float
weakref_slot 是 Python 3.11 才加入的参数,因此没有放进这段 Python 3.10 基线代码。
| 参数 | 默认值 | 目的 | 新手怎么选 |
|---|---|---|---|
init |
True |
是否生成 __init__() |
通常保持默认;只有准备完全手写构造方法时才设为 False |
repr |
True |
是否生成 __repr__() |
通常保持默认,让对象打印结果更清楚 |
eq |
True |
是否生成 __eq__() |
希望 == 按字段比较时保持默认 |
order |
False |
是否生成 <、<=、>、>= |
确实需要排序时再打开 |
unsafe_hash |
False |
是否强制生成 __hash__() |
进阶参数;不了解哈希不变量时不要开启 |
frozen |
False |
是否阻止字段重新赋值 | 需要近似只读对象时设为 True |
match_args |
True |
是否生成 __match_args__ |
只影响 match ... case;没学模式匹配可忽略 |
kw_only |
False |
是否让所有字段只能用关键字传入 | 字段多、位置参数难读时很有用 |
slots |
False |
是否生成槽位、限制常见动态属性 | 确认兼容性后再开启,不是必选性能开关 |
weakref_slot |
False |
是否为槽位实例增加弱引用支持 | Python 3.11+ 的进阶参数,且必须和 slots=True 一起用 |
几个同名配置容易混淆,可以这样对照:
| 类级配置 | 字段级配置 | 区别 |
|---|---|---|
@dataclass(init=False) |
field(init=False) |
前者不生成整个 __init__();后者只让一个字段不进入生成的 __init__() |
@dataclass(repr=False) |
field(repr=False) |
前者不生成整个 __repr__();后者只从自动表示中排除一个字段 |
@dataclass(eq=False) |
field(compare=False) |
前者不生成整个 __eq__();后者只让一个字段不参与比较 |
@dataclass(kw_only=True) |
field(kw_only=True) |
前者作用于所有字段;后者只作用于当前字段 |
其中最常用的三个生成结果是:
__init__():按照字段定义接收参数并赋值;__repr__():输出类名、字段名和字段值;__eq__():按照字段值比较两个同类型实例。
first = SimpleProduct("Python Guide", 79.0)
second = SimpleProduct("Python Guide", 79.0)
print(first)
# SimpleProduct(name='Python Guide', price=79.0)
print(first == second)
# True
上面的 print(first) 最终使用了数据类生成的对象文本;first == second 则触发了生成的 __eq__()。一般不需要直接写 first.__eq__(second),让 == 调用它更清楚。
数据类默认不会生成 <、<=、>、>=。需要排序比较时,要显式设置 order=True。
如果类里已经定义了 __init__() 或 __repr__(),数据类不会覆盖对应方法。@dataclass(...) 配置整个类,后面介绍的 field(...) 则只配置某一个字段,这是两者最重要的区别。
字段声明:必填字段、默认值与顺序
字段的常见形式是:
字段名: 类型 = 默认值
字段定义顺序会影响自动生成的 __init__() 参数顺序、__repr__() 显示顺序、比较顺序,以及 __match_args__ 中的位置映射。因此不要把字段顺序只当成排版问题。
下面三个字段分别表示必填、带默认值和允许 None:
from dataclasses import dataclass
@dataclass
class Document:
title: str
pages: int = 0
subtitle: str | None = None
没有默认值的字段必须排在普通默认字段前面
下面的代码会在定义类、执行 @dataclass 加工时立刻抛出 TypeError,不是等到创建实例才报错:
@dataclass
class WrongDocument:
pages: int = 0
title: str
原因是自动生成的构造方法会近似于:
def __init__(self, pages=0, title):
...
Python 不允许无默认值参数跟在有默认值参数后面。该规则不只作用于同一个类,也可能跨数据类继承触发。
T | None 不等于参数可以省略
这里的 T 只是“任意类型”的占位写法。例如把 T 换成 str,str | None 就表示值可以是字符串或 None。
比较下面两种声明:
subtitle: str | None
字段允许字符串或 None,但调用构造方法时仍然必须传入。
subtitle: str | None = None
字段不仅允许 None,而且省略参数时默认就是 None。
Python 3.9 及更早版本通常写成:
from typing import Optional
subtitle: Optional[str] = None
Optional[str] 与 str | None 表达相同的类型范围;是否可以省略,仍然取决于有没有默认值。
field() 的完整用法
@dataclass(...) 控制整个类,field(...) 控制单独一个字段。普通默认值可以直接写在字段后面;需要改变某个字段在初始化、打印、比较或默认值创建中的行为时,再使用 field():
from dataclasses import dataclass, field
参数可以先按“它会影响哪个自动生成的方法”来记:
| 参数 | 解决的问题 | 什么时候用 |
|---|---|---|
default |
给字段设置固定默认值 | 默认值是数字、字符串、None 等简单值 |
default_factory |
每次创建实例时动态生成默认值 | 默认值是列表、字典、集合,或需要调用函数生成 |
init |
控制字段是否进入 __init__() 参数 |
字段要在类内部计算时使用 init=False |
repr |
控制字段是否进入 __repr__() |
字段太长、不适合出现在对象打印结果中时使用 repr=False |
compare |
控制字段是否参与 == 和排序 |
时间戳等字段不应影响对象比较时使用 compare=False |
hash |
控制字段是否参与生成的哈希值 | 进阶性能或语义调整;通常保持 None |
metadata |
给第三方工具附加字段信息 | 框架需要读取单位、别名等元数据时使用 |
kw_only |
控制字段是否只能用关键字传入 | 希望调用代码更清楚、避免位置参数混淆时使用 |
doc |
给字段保存说明文字 | Python 3.14+,供文档或检查工具读取 |
官方签名里会看到 MISSING。它是 dataclasses 内部用来区分“没有传参数”和“显式传了 None”的标记,新手代码不需要直接使用它。
default
下面两种写法等价:
stock: int = 0
stock: int = field(default=0)
它解决的是“调用方不传这个字段时用什么值”。简单默认值优先使用第一种,代码更短;需要同时设置 repr、compare 等配置时,再写进 field()。
repr=False
@dataclass
class BuildResult:
status: str
debug_details: str = field(repr=False)
result = BuildResult(status="success", debug_details="line=42")
print(repr(result))
# BuildResult(status='success')
print(result)
# BuildResult(status='success')
repr(result) 会主动调用 __repr__();因为类没有定义 __str__(),这里的 print(result) 也会显示同样的内容。
debug_details 仍然是必填字段,也可以通过 result.debug_details 访问。repr=False 只控制这个字段是否进入数据类生成的对象文本,不会提供默认值,也不会让字段从 asdict() 中消失。
compare=False
@dataclass
class Measurement:
value: float
measured_at: float = field(compare=False)
执行 first == second 时,Python 会调用数据类生成的 __eq__()。设置 compare=False 后,measured_at 不参与这个比较;如果类还设置了 order=True,它也不参与排序比较。
这个参数的目的不是“忽略错误”,而是表达“这个字段不决定两个对象是否相同”。
hash
哈希值是对象放入 set 或作为 dict 键时使用的整数。它不保证唯一,也不保证在不同 Python 进程中保持相同。
hash=None 通常跟随 compare;hash=False 会排除该字段;hash=True 会强制让该字段参与生成的哈希。hash=False, compare=True 可能增加哈希碰撞,但仍可保持相等规则正确;更危险的是随意组合 hash=True, compare=False,可能让相等对象得到不同哈希。
大多数代码应保持 hash=None。只有真正理解 __eq__()、__hash__() 和可变对象之间的关系后,再单独配置它。
init=False
@dataclass
class Rectangle:
width: float
height: float
area: float = field(init=False)
def __post_init__(self) -> None:
self.area = self.width * self.height
init=False 的意思是“不把这个字段交给调用方初始化”。因此 area 不会出现在构造方法参数里,而是在 __post_init__() 中计算:
rectangle = Rectangle(width=3.0, height=4.0)
print(rectangle.area)
# 12.0
下面的调用会报错,因为 area 不是构造参数:
Rectangle(width=3.0, height=4.0, area=12.0)
# TypeError
kw_only=True
@dataclass
class Point:
x: float
y: float
color: str = field(default="black", kw_only=True)
正确:
point = Point(1.0, 2.0, color="blue")
错误:
point = Point(1.0, 2.0, "blue")
# TypeError
“仅关键字”表示必须写出参数名。它的目的主要是提高可读性:color="blue" 比孤立的第三个位置参数 "blue" 更容易理解。
仅关键字字段会被放到生成的 __init__() 中普通参数之后,也不会进入 __match_args__。
metadata
metadata 可以理解为“附在字段定义上的额外说明”。dataclass 自己不会根据它改变行为,主要供第三方框架、文档生成器或检查工具读取:
@dataclass
class Temperature:
value: float = field(metadata={"unit": "celsius"})
读取方式:
from dataclasses import fields
unit = fields(Temperature)[0].metadata["unit"]
print(unit)
# celsius
fields(Temperature) 返回由字段说明对象组成的元组,[0] 取第一个字段,.metadata 再读取这个字段附带的元数据。
如果当前没有使用会读取元数据的框架,可以先跳过这个参数。
doc(Python 3.14+)
field(doc="...") 用于给字段保存文档说明。它不会校验字段,也不会改变 __init__()、__repr__() 或比较行为。使用 Python 3.13 及更早版本时不能传这个参数。
default_factory:可变默认值的正确写法
这里的 factory 可以理解为“创建值的函数”。default_factory=list 不是把 list 函数本身存进字段,而是告诉数据类:需要默认值时,请调用一次 list()。
注意要传 list,不要写成 default_factory=list()。前者是“把创建函数交给数据类”,后者是“立刻创建一个列表并把结果传进去”,不符合 default_factory 的要求。
列表、字典和集合应通过 default_factory 创建:
@dataclass
class TagCollection:
tags: list[str] = field(default_factory=list)
aliases: dict[str, str] = field(default_factory=dict)
list[str] 表示“元素是字符串的列表”,dict[str, str] 表示“键和值都是字符串的字典”。它们仍然只是类型注解。
创建实例且没有显式传入字段时,工厂函数才会被调用:
first = TagCollection()
second = TagCollection()
first.tags.append("python")
print(first.tags)
# ['python']
print(second.tags)
# []
first 和 second 分别调用了一次 list(),所以它们拥有不同的列表。修改 first.tags 不会影响 second.tags。
如果创建实例时已经显式传入字段,工厂不会被调用:
collection = TagCollection(tags=["python", "typing"])
有一个进阶例外:如果字段同时设置了 init=False,调用方根本无法传入该字段,因此自动生成的 __init__() 每次都会调用它的 default_factory。
不要直接写:
@dataclass
class WrongCollection:
tags: list[str] = []
当前 Python 会在处理数据类时拒绝这种可变默认值,并抛出 ValueError。正确写法是 field(default_factory=list),而不是 field(default=[])。
default_factory 必须是零参数可调用对象,且不能和 default 同时设置。可调用对象就是能写成 对象() 执行的对象,例如函数、list 和类:
sequence: list[int] = field(default_factory=lambda: [1, 2, 3])
这里的 lambda 是一个不接收参数、每次返回新列表的小函数。如果暂时没学过 lambda,先记住 list、dict、set 这三个常见工厂即可。
__post_init__() 与运行时校验
类型注解不会自动检查运行时数据:
@dataclass
class LooseProduct:
name: str
product = LooseProduct(name=123)
这段代码默认可以创建实例。类型注解主要服务于编辑器、静态检查工具和阅读代码的人。
__post_init__() 的目的,是在自动生成的 __init__() 已经把字段赋值完成后,再做一次补充处理。常见用途包括:
- 根据已有字段计算另一个字段;
- 检查或规范化字段值;
- 在继承普通类时调用父类的初始化方法。
需要初始化后计算或校验时,可以定义 __post_init__():
@dataclass
class ValidatedProduct:
name: str
price: float
def __post_init__(self) -> None:
if not isinstance(self.name, str) or not self.name.strip():
raise ValueError("name 必须是非空字符串")
if not isinstance(self.price, (int, float)) or self.price < 0:
raise ValueError("price 必须是非负数")
isinstance() 在运行时检查类型;raise ValueError(...) 会立即终止本次对象创建,并报告具体值不符合要求。
创建 ValidatedProduct("Python Guide", 79.0) 时,可以把执行顺序理解为:
调用 ValidatedProduct(...)
→ 自动生成的 __init__() 给 name、price 赋值
→ 自动调用 __post_init__()
→ 校验通过后返回实例
__post_init__() 不是普通类都会自动调用的魔法方法,而是数据类生成的 __init__() 主动调用的约定钩子。
如果类自己定义了 __init__(),数据类不会再生成构造方法,也不会自动调用 __post_init__():
@dataclass
class ManualInit:
value: int
def __init__(self, value: int) -> None:
self.value = value
self.__post_init__()
def __post_init__(self) -> None:
if self.value < 0:
raise ValueError("value 不能小于 0")
InitVar:只参与初始化、不保存为字段
有时创建对象时需要一个临时参数来参与计算,但不希望把它长期保存在实例上。InitVar[T] 就是为这种情况准备的。
它会进入生成的 __init__(),并按定义顺序传给 __post_init__(),但不会成为实例字段:
from dataclasses import InitVar, dataclass, field
@dataclass
class ScaledRectangle:
width: float
height: float
scale: InitVar[float]
area: float = field(init=False)
def __post_init__(self, scale: float) -> None:
if scale <= 0:
raise ValueError("scale 必须大于 0")
self.area = self.width * self.height * scale**2
rectangle = ScaledRectangle(3.0, 4.0, scale=2.0)
print(rectangle.area)
# 48.0
传入的 scale 只在创建实例时参与计算,不会保存为实例字段。不要通过 self.scale 读取它;需要长期保存时,应把它声明为普通字段。它也不会出现在 fields(ScaledRectangle) 或 asdict(rectangle) 的结果中。
这是进阶语法。普通字段加 init=False 已经能解决问题时,不必为了使用 InitVar 增加复杂度。
ClassVar:类变量不会成为数据类字段
实例字段属于每个对象,例如不同坐标可以有不同的 x、y。类变量属于整个类,所有实例共享同一份定义。
使用 typing.ClassVar 可以明确告诉数据类:“这是类变量,不要把它当成实例字段”:
from typing import ClassVar
@dataclass
class Coordinate:
dimension: ClassVar[int] = 2
x: float
y: float
dimension 不会进入生成的 __init__()、__repr__()、fields() 或 asdict()。
point = Coordinate(1.0, 2.0)
print(point)
# Coordinate(x=1.0, y=2.0)
可以通过 Coordinate.dimension 读取这个共享值。只有需要在数据类中声明类级常量或共享配置时,才需要 ClassVar。
KW_ONLY:批量声明仅关键字字段
field(kw_only=True) 每次只配置一个字段。如果希望“从这里开始,后面的字段全部只能用关键字传入”,可以放一个 KW_ONLY 标记:
from dataclasses import KW_ONLY, dataclass
@dataclass
class Vector:
x: float
y: float
_: KW_ONLY
label: str = ""
visible: bool = True
vector = Vector(1.0, 2.0, label="A", visible=False)
_ 是约定俗成的伪字段名,它本身会被数据类忽略。label 和 visible 只能通过关键字传入。
同一个数据类中最多只能放一个 KW_ONLY 标记。
第一次学习时,掌握单字段的 field(kw_only=True) 就足够了;字段很多时再使用 KW_ONLY。
slots=True:生成槽位
先定义一个普通数据类:
@dataclass
class NormalPoint:
x: float
y: float
普通 Python 实例通常把自己的属性保存在一个名为 __dict__ 的字典中:
point = NormalPoint(1.0, 2.0)
print(point.__dict__)
# {'x': 1.0, 'y': 2.0}
因为这个字典可以继续添加键,所以普通数据类实例通常也能动态增加新属性:
point.z = 3.0
使用 slots=True:
@dataclass(slots=True)
class SlottedPoint:
x: float
y: float
对于没有从基类继承 __dict__ 的类,增加未声明属性会抛出 AttributeError:
point = SlottedPoint(1.0, 2.0)
point.z = 3.0
# AttributeError
需要注意:
slots=True会生成并返回一个新类;- 类中已经定义
__slots__时,再设置slots=True会抛出TypeError; - 如果基类提供了
__dict__,子类实例仍可能动态增加属性; - 不要通过
__slots__获取数据类字段,应使用fields(); - Python 3.11+ 需要弱引用支持时,可同时设置
weakref_slot=True。
这里的 __slots__ 是 Python 在类中声明“实例允许保存哪些属性”的底层机制;基类就是当前类继承的父类。这些继承边界属于进阶内容,基础使用先记住“不要再手写一份 __slots__”即可。
slots=True 不会让字段只读。下面的赋值仍然有效:
point.x = 10.0
它主要解决两个问题:大量小实例可能占用较多内存,以及拼错属性名时可能意外创建新属性。新手项目不需要默认开启,先确认使用的框架不依赖 __dict__。
弱引用是一种“不阻止对象被回收的引用”,多数入门代码不会直接使用。只有外部库明确要求弱引用槽位时,才需要 Python 3.11+ 的 weakref_slot=True。
frozen=True:阻止字段重新赋值
frozen=True 的目的,是让实例创建后不能再把字段重新赋成其他值。它适合表示创建后不应改变的值。
@dataclass(frozen=True)
class FrozenPoint:
x: float
y: float
point = FrozenPoint(1.0, 2.0)
point.x = 10.0
# FrozenInstanceError
FrozenInstanceError 是 dataclasses 专门用于表示“冻结实例禁止修改”的异常。删除字段同样会被阻止。
frozen=True 只是模拟只读实例,不会递归冻结字段里的对象:
@dataclass(frozen=True)
class FrozenCollection:
values: list[int] = field(default_factory=list)
collection = FrozenCollection()
collection.values.append(1) # 列表内容仍然可以修改
需要深度不可变时,应继续使用元组、frozenset 等不可变字段类型。
进阶提示:冻结数据类如果确实要在 __post_init__() 中设置 init=False 字段,普通的 self.field = value 也会失败,只能谨慎使用 object.__setattr__(self, "field", value)。普通入门代码不需要这样写。
slots 与 frozen 解决的不是同一个问题:
| 配置 | 主要限制 | 已声明字段能否重新赋值 |
|---|---|---|
slots=True |
通常不允许新增未声明属性 | 能 |
frozen=True |
不允许字段重新赋值或删除 | 不能 |
| 两者同时使用 | 同时获得两种限制 | 不能 |
eq、order 与哈希规则
相等比较
普通类如果没有定义 __eq__(),== 通常比较的不是“字段内容是否相同”,而是两个变量是否指向同一个对象。eq=True 会让数据类生成 __eq__(),改为按字段比较。
比较时要求两个实例的具体类型完全相同,再按照参与比较的字段判断:
@dataclass
class Point:
x: int
y: int
print(Point(1, 2) == Point(1, 2))
# True
这里左右两边是两个不同实例,但 x、y 相同,所以结果是 True。如果某个字段设置了 compare=False,它不会参与判断。
排序比较
排序比较指 <、<=、>、>=。设置 order=True 后,数据类会按照字段定义顺序依次比较,可以近似理解为先比较 major,相同再比较 minor,最后比较 patch:
@dataclass(order=True)
class Version:
major: int
minor: int
patch: int = 0
print(Version(3, 10) < Version(3, 11))
# True
生成的方法与运算符对应如下:__lt__() 对应 <,__le__() 对应 <=,__gt__() 对应 >,__ge__() 对应 >=。
如果 order=True 但 eq=False,数据类会抛出 ValueError。
哈希规则
字典和集合不会逐个扫描所有对象来查找元素,而是先使用 hash(obj) 得到一个整数,再据此快速定位。要放进 set,或用作 dict 的键,对象通常必须可哈希。
这里的哈希用于容器查找,不是密码学中的加密或摘要。
哈希值必须与相等规则保持一致:如果 a == b,那么 hash(a) 与 hash(b) 也必须相同。对象作为字典键后如果字段变化,哈希值也可能变化,因此可变数据类默认通常不可哈希。
常见组合如下:
| 设置 | 默认哈希结果 |
|---|---|
eq=True, frozen=False |
__hash__ = None,实例通常不可哈希 |
eq=True, frozen=True |
通常生成 __hash__() |
eq=False |
保留父类的哈希行为 |
__hash__ = None 是 Python 明确标记“这个类不可哈希”的方式,此时调用 hash(obj) 会抛出 TypeError。
即使 frozen=True,参与哈希的字段本身也必须可哈希。包含列表的冻结数据类仍可能在调用 hash() 时失败。
unsafe_hash=True 会强制生成哈希方法,只适合非常明确的特殊情况。可变对象参与哈希后再发生变化,会破坏字典和集合依赖的哈希不变量。
如果你还没学过集合、字典键或 hash(),这一小节可以先跳过。日常可变数据类保持默认设置即可。
结构化模式匹配与 match_args(进阶,可先跳过)
Python 3.10 加入了 match ... case 结构化模式匹配。它不只是比较一个固定值,还能把对象中的字段拆出来:
@dataclass
class Point:
x: int
y: int
point = Point(0, 5)
match point:
case Point(0, y):
print(f"位于 Y 轴,y={y}")
case Point(x, y):
print(x, y)
case Point(0, y) 的意思是:匹配一个 Point,要求第一个位置字段等于 0,并把第二个位置字段保存到变量 y。
Python 怎么知道第一个位置是 x、第二个位置是 y?答案是类属性:
print(Point.__match_args__)
# ('x', 'y')
因此,位置模式:
case Point(0, y):
...
可以理解为按下面的字段名映射:
case Point(x=0, y=value):
...
这里要区分两个名字:
match_args=True是@dataclass(...)的配置参数;__match_args__是数据类根据该配置生成的元组属性,不是方法。
设置 match_args=False 后,数据类不会自动生成 __match_args__,因此不能再自动使用 case Point(0, y) 这种位置模式;case Point(x=0, y=value) 这种按字段名匹配的关键字模式仍然可以使用。仅关键字字段也不会进入这个元组。
如果当前没有使用 match ... case,完全可以忽略 match_args,它不会影响普通的创建、打印和 == 比较。
数据类继承(进阶)
继承表示子类复用父类的字段和方法。MRO 全称是方法解析顺序(Method Resolution Order),用于决定多层或多父类继承时的查找顺序。
面向新手,可以先把数据类字段规则记成:先收集数据类父类的字段,再追加子类字段;同名字段由子类覆盖。更准确地说,数据类会按反向 MRO 处理数据类基类:
@dataclass
class BaseValue:
value: float = 0.0
@dataclass
class IntegerValue(BaseValue):
value: int = 0
label: str = ""
继承时仍要遵守“无默认值普通字段不能跟在有默认值普通字段后面”。例如父类已有默认字段,子类新增无默认值普通字段,就可能在创建子类时抛出 TypeError。
普通非数据类基类的 __init__() 不会被数据类生成的构造方法自动调用。需要时可在 __post_init__() 中显式调用:
class Base:
def __init__(self) -> None:
self.ready = True
@dataclass
class Child(Base):
value: int
def __post_init__(self) -> None:
super().__init__()
super().__init__() 会调用父类的初始化方法,因此示例中的 ready 才会被设置为 True。
asdict()、astuple()、replace() 与 fields()
这些是 dataclasses 模块提供的辅助函数。它们不会自动运行,需要写成 asdict(obj)、replace(obj, ...) 主动调用,而不是写成 obj.asdict()。
为了让这一节可以独立运行,先定义一个专用示例类:
from dataclasses import dataclass, field
@dataclass
class BookRecord:
title: str
price: float
stock: int = 0
tags: list[str] = field(default_factory=list)
asdict()
目的:把数据类实例转换成以字段名为键的字典,方便后续读取或交给其他代码处理。
from dataclasses import asdict
data = asdict(BookRecord("Python Guide", 79.0, 5))
print(data)
# {'title': 'Python Guide', 'price': 79.0, 'stock': 5, 'tags': []}
“递归转换”表示字段里如果还套着数据类,也会继续转换。asdict() 还会处理字典、列表和元组,并对其他对象使用深拷贝。深拷贝会继续复制嵌套对象,修改转换结果通常不会影响原对象。
它会包含 repr=False 字段,也不保证结果能直接编码为 JSON。例如字段里有 datetime 时,仍然需要额外转换。
astuple()
目的:按照字段顺序把数据类转换成元组。只有接收方明确需要位置序列时才使用;大多数需要保留字段名的场景,asdict() 更直观。
from dataclasses import astuple
values = astuple(BookRecord("Python Guide", 79.0, 5))
print(values)
# ('Python Guide', 79.0, 5, [])
astuple() 同样会递归处理嵌套对象。
replace()
目的:以旧实例为基础,只替换少数字段,创建一个新的同类型实例。它对 frozen=True 的数据类尤其有用,因为冻结实例不能直接改字段。
from dataclasses import replace
old = BookRecord("Python Guide", 79.0, 5, tags=["python"])
new = replace(old, price=69.0)
print(old.price)
# 79.0
print(new.price)
# 69.0
replace() 会调用同类型的 __init__() 创建新对象,因此会再次执行 __post_init__()。它不是深拷贝:未替换的列表、字典等字段可能仍与旧对象引用同一个可变对象。
“共享引用”表示旧、新实例可能指向同一个列表。下面修改 new.tags,old.tags 也会看到变化:
new.tags.append("sale")
print(old.tags)
# ['python', 'sale']
设置了 init=False 的字段不能作为 replace() 的更新字段,也不会简单地从旧对象复制,而是按新对象的初始化过程重新得到值。如果类有一个没有默认值的 InitVar,调用 replace() 时也必须再次传入它。
fields()
目的:让程序读取“这个数据类声明了哪些字段,以及每个字段的类型、默认值和配置”。这种让代码检查代码结构的做法叫反射或自省。
from dataclasses import fields
for item in fields(BookRecord):
print(item.name, item.type, item.default)
fields() 返回的是字段的定义信息,不是某个实例当前保存的字段值。结果不包含 ClassVar 和 InitVar 伪字段。
is_dataclass()
目的:当函数接收到一个未知对象时,判断它是不是数据类或数据类实例。编写通用工具、序列化器或调试代码时会用到。
from dataclasses import is_dataclass
print(is_dataclass(BookRecord))
print(is_dataclass(BookRecord("Python Guide", 79.0)))
# True
# True
它对数据类本身和数据类实例都返回 True。类对象本身也是 type 的实例,因此若只想判断普通数据类实例,还要排除 isinstance(obj, type)。
如果只是定义和使用自己的数据类,通常不需要调用 is_dataclass()。
遇到什么问题,该用哪种写法
| 需求 | 推荐写法 |
|---|---|
| 少写重复的初始化、打印和相等比较代码 | @dataclass |
| 给简单字段设置固定默认值 | count: int = 0 |
| 给列表、字典、集合设置独立默认值 | field(default_factory=list) 等 |
| 字段由其他字段计算,不允许调用方传入 | field(init=False) + __post_init__() |
| 初始化后校验或规范化字段 | __post_init__() |
| 不在对象打印结果中显示某个字段 | field(repr=False) |
某个字段不参与 == 或排序 |
field(compare=False) |
| 某个字段必须明确写参数名 | field(kw_only=True) |
| 所有字段都必须明确写参数名 | @dataclass(kw_only=True) |
| 通常不允许新增未声明属性 | @dataclass(slots=True) |
| 实例创建后不允许重新赋值或删除字段 | @dataclass(frozen=True) |
按字段顺序支持 <、<=、>、>= |
@dataclass(order=True) |
| 把实例递归转换成字典 | asdict(instance) |
| 基于旧实例替换少数字段 | replace(instance, field=value) |
| 读取数据类的字段定义信息 | fields(ClassOrInstance) |
| 判断类或实例是不是数据类 | is_dataclass(obj) |
初学阶段最值得优先掌握的是前五项。其余写法等实际遇到对应问题时再使用,更容易记住。
新手术语速查
| 术语 | 简单解释 |
|---|---|
| 类(class) | 创建对象的模板,规定对象有哪些数据和行为 |
| 实例(instance) | 根据类实际创建出来的对象,例如 Product(...) 的结果 |
| 字段(field) | 数据类希望保存的一项数据,例如 name、price |
| 装饰器(decorator) | 使用 @名称 写在类或函数上方,对定义结果进行加工 |
| 类型注解 | name: str 中的 : str,主要用于说明和静态检查 |
| 默认值 | 调用方省略参数时自动采用的值 |
| 位置参数 | 依靠先后顺序传入的参数,例如 Point(1, 2) |
| 关键字参数 | 明确写出名称的参数,例如 Point(x=1, y=2) |
| 可调用对象 | 可以在后面加括号执行的对象,例如 list()、普通函数和类 |
| 工厂函数 | 专门负责创建并返回一个值的函数,例如作为 default_factory 的 list |
| 可变对象 | 创建后内容还能变化的对象,例如列表、字典和集合 |
| 双下划线方法 | 前后都有两个下划线、由 Python 特定语法触发的方法 |
| 钩子(hook) | 在固定执行时机被调用、留给开发者补充逻辑的方法 |
| 递归转换 | 对象里嵌套对象时继续逐层执行同一种转换 |
| 哈希 | 把对象映射成整数,供字典和集合快速查找 |
| 元数据 | 描述字段的附加信息,本身通常不改变数据类行为 |
| 伪字段 | 写在类中参与部分处理,但不作为普通实例字段保存的声明 |
| 继承 | 子类复用父类字段和方法的机制 |
| MRO | Python 决定多个父类查找顺序的规则 |
TypeError |
操作方式或调用参数形式不符合要求时常见的异常 |
ValueError |
参数形式正确,但具体值不符合要求时常见的异常 |
AttributeError |
访问或设置对象不存在的属性时常见的异常 |
FrozenInstanceError |
尝试修改冻结数据类字段时抛出的异常 |
10 个常见报错与误区
1. non-default argument follows default argument
无默认值普通字段出现在默认字段后面。调整顺序,或把适合显式传入的字段改为仅关键字字段。
2. mutable default ... is not allowed
列表、字典或集合直接作为字段默认值。改用 field(default_factory=list) 等工厂。
3. 把 field(repr=False) 当成默认值
它只修改打印配置。没有 default 或 default_factory 时,字段仍然必填。
4. 以为 Optional[T] 代表参数可省略
它只表示值可以是 None。参数能否省略取决于默认值。
5. 以为类型注解会自动校验
数据类默认不会根据注解拒绝错误类型。需要静态检查或显式运行时校验。
6. 自定义 __init__() 后等待自动执行 __post_init__()
只有数据类生成的 __init__() 会自动调用 __post_init__()。自定义构造方法时要自行调用。
7. 把 slots=True 当成只读
槽位限制实例布局,不阻止已有字段重新赋值。只读语义由 frozen=True 控制。
8. 把 frozen=True 当成深度不可变
冻结只阻止字段重新绑定,不会冻结列表等嵌套可变对象。
9. 把 replace() 当成深拷贝
它通过构造方法创建新实例,未替换的可变字段仍可能共享引用。
10. 通过 __slots__ 枚举字段
继承场景下 __slots__ 可能不完整。使用 dataclasses.fields()。
常见问题
Python dataclass 需要安装吗
Python 3.7 起,dataclasses 属于标准库,可以直接导入。
field(repr=False) 后字段还是必填吗
是。除非同时设置 default 或 default_factory。
slots=True 会让对象不可修改吗
不会。它主要控制实例属性布局。阻止字段重新赋值要使用 frozen=True。
asdict() 会忽略 repr=False 字段吗
不会。repr 配置只影响自动生成的 __repr__()。
数据类可以排序吗
可以。设置 order=True,并保持 eq=True。字段按定义顺序参与比较,compare=False 字段除外。
数据类可以作为字典键吗
取决于 eq、frozen、显式哈希设置以及字段本身是否可哈希。常见的不可变值对象可使用 frozen=True,但不要盲目启用 unsafe_hash=True。
Python 3.9 如何写 T | None
使用 typing.Optional[T],并去掉 Python 3.10 才支持的 slots=True 与 kw_only 参数。
官方资料
Practice