KBEngine 内置 Python 3.7.3 数据类详解:dataclasses 模块从原理到实战

原创2026-10-16 09:37:291,399 阅读
文章标签:游戏开发后端

KBEngine 内置 Python 3.7.3 数据类详解:dataclasses 模块从原理到实战

KBEngine 服务端脚本引擎基于 Python 实现,仓库中随附了完整的 CPython 3.7.3 源码与标准库(见 Include/patchlevel.h 中 PY_VERSION "3.7.3")。本文以仓库自带的 dataclasses.rst 官方文档为主体,结合 Lib/dataclasses.py 源码实现,系统讲解 dataclasses 模块如何通过类型注解自动生成 __init__、__repr__、__eq__ 等魔术方法,帮助开发者掌握 Python 3.7+ 中定义轻量级数据容器的核心手段,并理解每个参数在底层生成代码中的真实行为。

模块定位与基本用法

:mod:dataclasses`` 模块提供装饰器和函数,用于自动为用户自定义类添加生成的特殊方法(special methods)。模块最初由 PEP 557 提出,成员变量(field)的定义依赖 PEP 526 的类型注解。仓库中的实现入口在 Lib/dataclasses.py,其模块导出列表 __all__ 为:

__all__ = ['dataclass',
           'field',
           'Field',
           'FrozenInstanceError',
           'InitVar',
           'MISSING',

           # Helper functions.
           'fields',
           'asdict',
           'astuple',
           'make_dataclass',
           'replace',
           'is_dataclass',
           ]

基本示例(继承自原文档):

@dataclass
class InventoryItem:
    '''Class for keeping track of an item in inventory.'''
    name: str
    unit_price: float
    quantity_on_hand: int = 0

    def total_cost(self) -> float:
        return self.unit_price * self.quantity_on_hand

@dataclass 会(除其他方法外)为该类自动添加如下的 __init__:

def __init__(self, name: str, unit_price: float, quantity_on_hand: int=0):
    self.name = name
    self.unit_price = unit_price
    self.quantity_on_hand = quantity_on_hand

注意:这个方法是自动添加到类上的,而非在 InventoryItem 的定义中直接写出。从源码结构看,类体执行完毕后 _process_class(dataclasses.py#L789)会收集所有带注解的字段并按顺序构建生成方法;生成 __init__ 的函数是 _init_fn(dataclasses.py#L475),它最终通过 _create_fn 用 AST 构造字节码,因此生成的方法在 dis 反汇编下与手写方法几乎一致。

@dataclass 装饰器:六个参数全解析

dataclass 的完整签名为:

dataclass(*, init=True, repr=True, eq=True, order=False, unsafe_hash=False, frozen=False)

它检查类中的字段(field)——即带类型注解的类变量。除下文提到的两个例外情况外,dataclass 不检查注解中指定的类型本身。所有生成方法中字段的顺序,就是它们在类定义中出现的顺序。装饰器返回的是同一个类,不会创建新类。

以下三种写法等价:

@dataclass
class C: ...

@dataclass()
class C: ...

@dataclass(init=True, repr=True, eq=True, order=False, unsafe_hash=False, frozen=False)
class C: ...

各参数语义如下(与 dataclasses.py#L968 中 def dataclass(_cls=None, *, init=True, repr=True, eq=True, order=False, unsafe_hash=False, frozen=False) 的默认值一一对应):

参数 默认值 作用 冲突行为
init True 生成 __init__ 方法 类已定义 __init__ 时该参数被忽略
repr True 生成 __repr__,输出 类名(name=值, ...) 形式,按类定义顺序 类已定义 __repr__ 时该参数被忽略
eq True 生成 __eq__,将类视为字段元组逐元素比较,比较双方必须类型完全一致 类已定义 __eq__ 时该参数被忽略
order False 生成 __lt__、__le__、__gt__、__ge__,同样按字段元组顺序比较 order=True 且 eq=False 抛 ValueError;类已定义任一比较方法则抛 TypeError
unsafe_hash False 强制生成 __hash__(见下文规则) 与显式定义的 __hash__ 同时出现时抛 TypeError
frozen False 对字段赋值抛出异常,模拟只读冻结实例 类中已定义 __setattr__ 或 __delattr__ 时抛 TypeError

repr 生成的字符串形如:InventoryItem(name='widget', unit_price=3.0, quantity_on_hand=10)。

默认值与字段顺序约束

字段可以用普通 Python 语法指定默认值:

@dataclass
class C:
    a: int       # 'a' 没有默认值
    b: int = 0   # 为 'b' 分配默认值

此时生成的 __init__ 为 def __init__(self, a: int, b: int = 0):。若无默认值的字段出现在有默认值的字段之后,将抛出 TypeError——无论是发生在单个类内部,还是由类继承导致。

hash 的隐式生成规则

__hash__ 用于内建 hash() 以及字典、集合等哈希容器,拥有 __hash__ 意味着实例应视为不可变。默认情况下 dataclass 只有在安全时才隐式添加 __hash__,且不会添加或修改类中显式定义的 __hash__(注意把类属性设为 __hash__ = None 在 Python 中有特定含义,即标记对象不可哈希)。

当 __hash__ 未显式定义(或被设为 None)时,dataclass 可能 添加隐式 __hash__;不推荐但可用 unsafe_hash=True 强制生成,适用于"逻辑上不可变但确实可被修改"的特殊场景,需要谨慎评估。规则总结:

  • eq=True 且 frozen=True:默认生成 __hash__;
  • eq=True 且 frozen=False:__hash__ 被设为 None(实例可变,标记为不可哈希);
  • eq=False:不改动 __hash__,沿用超类的方法(若超类是 object 则回退到基于 id 的哈希)。

在源码中对应 _hash_add、_hash_set_none、_hash_exception 三个内部函数(dataclasses.py#L747-L787),由 _process_class 在 eq/frozen 组合判定的分支里调用。

field():单字段级精细控制

对常见简单场景,装饰器参数已经够用;但当需要按字段附加信息时,可以用 field() 调用替换默认值的位置:

@dataclass
class C:
    mylist: List[int] = field(default_factory=list)

c = C()
c.mylist += [1, 2, 3]

field 的签名为:

field(*, default=MISSING, default_factory=MISSING, repr=True, hash=None, init=True, compare=True, metadata=None)

MISSING 是一个哨兵对象(dataclasses.py#L167-L169 中 _MISSING_TYPE 的实例),用于检测 default 与 default_factory 是否被提供——之所以不用 None 做判断,是因为 None 本身就是合法的 default 值。用户代码不应直接使用 MISSING。

参数详解:

  • default:该字段的默认值。由于 field() 调用本身占据了默认值的语法位置,所以必须在这里显式给出;
  • default_factory:若提供,必须是无参可调用对象,在需要默认值时被调用来生成默认值。典型用途是可变默认值(见下文"可变默认值"一节)。同时指定 default 和 default_factory 是错误;
  • init:若为 True(默认),该字段包含在生成的 __init__ 参数中;
  • repr:若为 True(默认),该字段包含在 __repr__ 输出中;
  • compare:若为 True(默认),该字段参与生成的相等与比较方法(__eq__、__gt__ 等);
  • hash:可为 bool 或 None。为 True 时该字段包含在 __hash__ 中;为 None(默认)时取 compare 的值。官方不鼓励将其设为 None 以外的值;一个可能的场景是:某字段计算哈希代价昂贵、但又需要参与相等性判断、且还有其他字段贡献哈希值。即便字段被排除在哈希之外,它仍会用于比较;
  • metadata:可为 mapping 或 None(按空 dict 处理)。该值会被包进 types.MappingProxyType 使其只读,并暴露在 Field 对象上。dataclasses 模块本身完全不使用它,它是为第三方扩展准备的机制——多个第三方可以各用自己的键作为命名空间。

装饰运行后类属性的最终状态

若字段的默认值由 field() 调用指定,则该字段对应的类属性会被替换为给定的 default 值;若没有提供 default,类属性会被删除。设计意图是:dataclass 装饰器运行完毕后,类属性应全部包含字段的默认值,如同直接写默认值一样。例如:

@dataclass
class C:
    x: int
    y: int = field(repr=False)
    z: int = field(repr=False, default=10)
    t: int = 20

装饰完成后,类属性 C.z 为 10,C.t 为 20,而 C.x 和 C.y 不会被设置。

Field 类与模块级函数

Field 对象

Field 对象描述每个已定义的字段。它们在内部创建,由模块级函数 fields() 返回;用户不应直接实例化 Field。文档承诺的公开属性:

  • name:字段名;
  • type:字段类型;
  • default、default_factory、init、repr、``hash、compare、metadata:与 field()` 声明中含义和取值完全一致。

其他属性可能存在,但属于私有,不应被检查或依赖。源码中 Field 类定义在 dataclasses.py#L220。

fields()

fields(class_or_instance)

返回一个 Field 对象元组,定义该 dataclass 的字段。接受 dataclass 类或其实例,否则抛 TypeError。不返回 ClassVar 或 InitVar 这类伪字段。

asdict() / astuple()

asdict(instance, *, dict_factory=dict)

将 dataclass 实例 instance 用工厂函数 dict_factory 转换为 dict。每个 dataclass 被转换为 name: value 对的 dict,并递归深入 dataclass、dict、list 和 tuple。示例:

@dataclass
class Point:
     x: int
     y: int

@dataclass
class C:
     mylist: List[Point]

p = Point(10, 20)
assert asdict(p) == {'x': 10, 'y': 20}

c = C([Point(0, 0), Point(10, 4)])
assert asdict(c) == {'mylist': [{'x': 0, 'y': 0}, {'x': 10, 'y': 4}]}

instance 不是 dataclass 实例时抛 TypeError。

astuple(instance, *, tuple_factory=tuple)

将实例用 tuple_factory 转换为元组,每个 dataclass 被转换为其字段值元组,同样递归深入 dataclass、dict、list 和 tuple:

assert astuple(p) == (10, 20)
assert astuple(c) == ([(0, 0), (10, 4)],)

这两个函数在实现上分别由 _asdict_inner(dataclasses.py#L1047)和 _astuple_inner(dataclasses.py#L1112)完成递归,并借助 copy.deepcopy 保护字段值不被调用方修改。

make_dataclass()

make_dataclass(cls_name, fields, *, bases=(), namespace=None,
               init=True, repr=True, eq=True, order=False,
               unsafe_hash=False, frozen=False)

创建名为 cls_name 的新 dataclass:字段由 fields 定义(可迭代对象,元素可为 name、(name, type) 或 (name, type, Field);只给 name 时类型用 typing.Any),基类由 bases 给出,命名空间由 namespace 初始化。init/repr/eq/order/unsafe_hash/frozen 的含义与 dataclass 装饰器相同:

C = make_dataclass('C',
                   [('x', int),
                     'y',
                    ('z', int, field(default=5))],
                   namespace={'add_one': lambda self: self.x + 1})

等价于:

@dataclass
class C:
    x: int
    y: 'typing.Any'
    z: int = 5

    def add_one(self):
        return self.x + 1

官方说明:该函数并非必需——任何能创建带 __annotations__ 新类的 Python 机制,都可以再对其应用 dataclass 函数;make_dataclass 只是便捷工具。

replace()

replace(instance, **changes)

创建与 instance 同类型的新对象,用 changes 中的值替换字段。instance 不是 dataclass 时抛 TypeError;changes 中指定的不是字段时也抛 TypeError。

新对象通过调用 dataclass 的 __init__ 方法创建,这保证了 __post_init__(若存在)同样被调用。无默认值的 init-only 变量若存在,必须在 replace 调用时指定,以便传递给 __init__ 和 __post_init__。

特别注意:changes 中包含 init=False 的字段是错误,会抛 ValueError。另外要警惕 init=False 字段在 replace 中的行为——它们不会从源对象拷贝,而是在 __post_init__ 中(如果初始化的话)才被初始化。init=False 字段应少而谨慎地使用;如果确实用了,可以考虑提供备用类构造器,或自定义一个 replace()(或类似命名)方法来处理实例拷贝。

is_dataclass()

is_dataclass(class_or_instance)

参数是 dataclass 或其实例时返回 True,否则返回 False。如果需要判断某类是否是 dataclass 的实例(而非 dataclass 本身),加上 not isinstance(obj, type) 的检查:

def is_dataclass_instance(obj):
    return is_dataclass(obj) and not isinstance(obj, type)

后初始化处理:post_init

生成的 __init__ 代码会在类上定义了 __post_init__ 方法时调用它。通常以 self.__post_init__() 的方式调用;但若定义了 InitVar 字段,它们会按在类中定义的顺序一并传给 __post_init__。若没有生成 __init__,则 __post_init__ 不会被自动调用。

其用途之一是初始化依赖其他一个或多个字段的字段值:

@dataclass
class C:
    a: float
    b: float
    c: float = field(init=False)

    def __post_init__(self):
        self.c = self.a + self.b

源码层面,_init_fn(dataclasses.py#L475-L522)在生成的 __init__ 末尾追加对 __post_init__ 的调用,并把 InitVar 伪字段作为位置参数传入。

ClassVar 与 InitVar:仅有的两处类型检查

dataclass 真正检查字段类型的地方只有两处:

类变量(ClassVar)

其一,判断字段是否是 PEP 526 定义的类变量:检查字段类型是否为 typing.ClassVar。若是,该字段被排除在字段考量之外,dataclass 机制完全忽略它;这类伪字段不会被 fields() 返回。

init-only 变量(InitVar)

其二,判断字段是否为 init-only 变量:检查字段类型是否为 dataclasses.InitVar。若是,该字段被视为伪字段(init-only 字段),不是真正的字段,fields() 不会返回它。Init-only 字段会作为参数加入生成的 __init__,并传递给可选的 __post_init__,dataclasses 不再做其他用途。

典型场景——字段若创建类时未提供值,就从数据库初始化(原文档示例):

@dataclass
class C:
    i: int
    j: int = None
    database: InitVar[DatabaseType] = None

    def __post_init__(self, database):
        if self.j is None and database is not None:
            self.j = database.lookup('j')

c = C(10, database=my_database)

此时 fields() 只返回 i 和 j 的 Field 对象,不返回 database。源码中对应判断函数 _is_classvar 与 _is_initvar(dataclasses.py#L578-L591),它们通过字符串匹配注解的模块与限定名实现,以规避 typing 模块尚未完全加载时的循环导入问题。

Frozen 冻结实例

严格意义上,无法创建真正不可变的 Python 对象;但给 dataclass 装饰器传 frozen=True 可以模拟不可变性。此时 dataclasses 会向类添加 __setattr__ 和 __delattr__ 方法,调用这两个方法会抛出 FrozenInstanceError。

存在微小的性能代价:frozen=True 时,__init__ 不能用简单赋值初始化字段,必须改用 object.__setattr__。源码中 _frozen_get_del_attr(dataclasses.py#L533-L557)生成这两个方法,_field_assign(dataclasses.py#L390)则根据 frozen 与否选择 self.name = value 还是 object.__setattr__(self, 'name', value) 两种赋值代码。

继承:字段合并的 MRO 规则

dataclass 装饰器创建 dataclass 时,会按反向 MRO(即从 object 开始)遍历所有基类,对每个发现的 dataclass,把它的字段加入一个有序映射;所有基类字段加入完毕后,再加入自身字段。所有生成的方法都使用这份合并计算出的有序字段映射。由于字段按插入顺序排列,派生类字段会覆盖基类字段(但位置仍由基类首次出现决定)。示例:

@dataclass
class Base:
    x: Any = 15.0
    y: int = 0

@dataclass
class C(Base):
    z: int = 10
    x: int = 15

最终字段列表按顺序为 x、y、z;x 的最终类型是 int(按类 C 中的声明)。C 的生成 __init__ 为:

def __init__(self, x: int = 15, y: int = 0, z: int = 10):

对应源码即 _process_class 开头对 cls.__mro__ 反转遍历的逻辑(dataclasses.py#L789-L810):"derived class fields overwrite base class fields, but the order is defined by the base class, which is found first"。

默认工厂函数与可变默认值

default_factory

若 field 指定了 default_factory,当字段需要默认值时,该工厂以零参数被调用。例如创建新的 list 实例:

mylist: list = field(default_factory=list)

若字段被排除出 __init__(init=False)且同时指定了 default_factory,那么默认工厂函数总会被生成 __init__ 调用——因为这是给该字段赋初值的唯一途径。

可变默认值陷阱

Python 把成员变量的默认值存放在类属性中。看这个不用 dataclasses 的例子:

class C:
    x = []
    def add(self, element):
        self.x.append(element)

o1 = C()
o2 = C()
o1.add(1)
o2.add(2)
assert o1.x == [1, 2]
assert o1.x is o2.x

两个实例按预期共享同一个类变量 x。

若用 dataclasses 写 x: List = [],假如这是合法的,生成的代码近似于:

class D:
    x = []
    def __init__(self, x=x):
        self.x = x
    def add(self, element):
        self.x += element

assert D().x is D().x

这与原例子同样的问题:两个未指定 x 的实例会共享同一份 x。dataclasses 使用普通 Python 类创建机制,因此也共享此行为,且没有通用手段检测这种情况。作为部分解决方案,dataclasses 在检测到类型为 list、dict 或 set 的默认参数时会抛出 TypeError,这能防住许多常见错误。

正确的做法是使用默认工厂函数为可变类型字段创建新的默认值实例:

@dataclass
class D:
    x: list = field(default_factory=list)

assert D().x is not D().x

在源码中,_get_field(dataclasses.py#L651)对 list/dict/set 类型的默认值直接执行 raise TypeError(' mutable default ... is not allowed: use default_factory' ...)。

异常

FrozenInstanceError

在定义了 frozen=True 的 dataclass 上,调用其隐式定义的 __setattr__ 或 __delattr__ 时抛出。源码中它继承自 AttributeError(dataclasses.py#L155:class FrozenInstanceError(AttributeError): pass),因此现有代码中捕获 AttributeError 的分支同样能兜住冻结实例的修改异常。

在 KBEngine 仓库中的印证

小结

dataclasses 用类型注解作为"契约",把样板方法交给装饰器生成:@dataclass 的六个参数控制生成哪些方法,field() 提供字段级开关与元数据,fields/asdict/astuple/replace/make_dataclass/is_dataclass 构成完整的运行期工具链,__post_init__、InitVar、ClassVar 覆盖后处理与伪字段需求,frozen=True 模拟不可变对象。在 KBEngine 这类基于 Python 脚本层的服务器引擎中,掌握该模块意味着可以用更少的样板代码定义结构化的配置、消息与状态容器,并准确理解其默认值、哈希与继承规则在底层生成代码中的真实走向。

登录后查看全文
kbengine