Python dataclass 语法详解:field、default_factory、slots 与常见坑

面向只学过 class 和 __init__() 的新手,从 dataclass 的作用讲起,逐步解释 __repr__()、__eq__()、field、default_factory、kw_only、slots、frozen、match_args 与常用辅助函数。

27 分钟阅读 Pythondataclass

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__(),先掌握下面三项就够了:

  1. __init__() 负责创建后的初始化;
  2. __repr__() 决定对象怎样显示;
  3. __eq__() 决定 == 怎样比较。

哈希、模式匹配和复杂继承属于进阶内容,第一次阅读时可以先跳过,不影响基础使用。

建议按三层学习

层级 建议掌握的内容 原因
基础必学 @dataclass、字段、默认值、field()default_factory 足以完成大多数简单数据类
常用进阶 __post_init__()kw_onlyslotsfrozenasdict()replace() 解决校验、参数可读性、对象修改和转换问题
可先跳过 unsafe_hashmatch_argsweakref_slotInitVarClassVar、继承细节 只有特定语法或框架场景才需要

文中不同小节的代码块主要用于独立演示当前语法点。同名示例类可能会在后文重新定义,练习时建议按小节运行,并连同该小节的导入和类定义一起复制。

版本要求

常见语法分布在不同 Python 版本中:

写法 引入版本 作用
name: str Python 3.6 变量类型注解
@dataclassfield() Python 3.7 数据类与字段配置
match_argskw_onlyslots Python 3.10 模式匹配、仅关键字字段和槽位
str | None Python 3.10 联合类型的简写语法
weakref_slot Python 3.11 为槽位实例增加弱引用支持

本文示例整体以 Python 3.10 或更高版本为基线。查看当前版本:

python --version

Python 3.9 可以使用基础的 @dataclassfield(),但不能直接使用 slots=Truekw_only=TrueT | 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 换成 strstr | 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)

它解决的是“调用方不传这个字段时用什么值”。简单默认值优先使用第一种,代码更短;需要同时设置 reprcompare 等配置时,再写进 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 通常跟随 comparehash=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)
# []

firstsecond 分别调用了一次 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,先记住 listdictset 这三个常见工厂即可。

__post_init__() 与运行时校验

类型注解不会自动检查运行时数据:

@dataclass
class LooseProduct:
    name: str


product = LooseProduct(name=123)

这段代码默认可以创建实例。类型注解主要服务于编辑器、静态检查工具和阅读代码的人。

__post_init__() 的目的,是在自动生成的 __init__() 已经把字段赋值完成后,再做一次补充处理。常见用途包括:

  1. 根据已有字段计算另一个字段;
  2. 检查或规范化字段值;
  3. 在继承普通类时调用父类的初始化方法。

需要初始化后计算或校验时,可以定义 __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:类变量不会成为数据类字段

实例字段属于每个对象,例如不同坐标可以有不同的 xy。类变量属于整个类,所有实例共享同一份定义。

使用 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)

_ 是约定俗成的伪字段名,它本身会被数据类忽略。labelvisible 只能通过关键字传入。

同一个数据类中最多只能放一个 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

FrozenInstanceErrordataclasses 专门用于表示“冻结实例禁止修改”的异常。删除字段同样会被阻止。

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)。普通入门代码不需要这样写。

slotsfrozen 解决的不是同一个问题:

配置 主要限制 已声明字段能否重新赋值
slots=True 通常不允许新增未声明属性
frozen=True 不允许字段重新赋值或删除 不能
两者同时使用 同时获得两种限制 不能

eqorder 与哈希规则

相等比较

普通类如果没有定义 __eq__()== 通常比较的不是“字段内容是否相同”,而是两个变量是否指向同一个对象。eq=True 会让数据类生成 __eq__(),改为按字段比较。

比较时要求两个实例的具体类型完全相同,再按照参与比较的字段判断:

@dataclass
class Point:
    x: int
    y: int


print(Point(1, 2) == Point(1, 2))
# True

这里左右两边是两个不同实例,但 xy 相同,所以结果是 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=Trueeq=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.tagsold.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() 返回的是字段的定义信息,不是某个实例当前保存的字段值。结果不包含 ClassVarInitVar 伪字段。

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) 数据类希望保存的一项数据,例如 nameprice
装饰器(decorator) 使用 @名称 写在类或函数上方,对定义结果进行加工
类型注解 name: str 中的 : str,主要用于说明和静态检查
默认值 调用方省略参数时自动采用的值
位置参数 依靠先后顺序传入的参数,例如 Point(1, 2)
关键字参数 明确写出名称的参数,例如 Point(x=1, y=2)
可调用对象 可以在后面加括号执行的对象,例如 list()、普通函数和类
工厂函数 专门负责创建并返回一个值的函数,例如作为 default_factorylist
可变对象 创建后内容还能变化的对象,例如列表、字典和集合
双下划线方法 前后都有两个下划线、由 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) 当成默认值

它只修改打印配置。没有 defaultdefault_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) 后字段还是必填吗

是。除非同时设置 defaultdefault_factory

slots=True 会让对象不可修改吗

不会。它主要控制实例属性布局。阻止字段重新赋值要使用 frozen=True

asdict() 会忽略 repr=False 字段吗

不会。repr 配置只影响自动生成的 __repr__()

数据类可以排序吗

可以。设置 order=True,并保持 eq=True。字段按定义顺序参与比较,compare=False 字段除外。

数据类可以作为字典键吗

取决于 eqfrozen、显式哈希设置以及字段本身是否可哈希。常见的不可变值对象可使用 frozen=True,但不要盲目启用 unsafe_hash=True

Python 3.9 如何写 T | None

使用 typing.Optional[T],并去掉 Python 3.10 才支持的 slots=Truekw_only 参数。

官方资料

Practice

读完这一节,去靶场里验证一下。

去挑战广场练习