Python dataclasses 数据类相关问题

从我学得Python基础计起,已快有一年时间,也写下数个草芥般的小项目,总觉了解的知识浅薄轻浮。若想开发更加自由,本应继续学习深入,或是参悟官方文档,或是拜读知名项目源码,奈何琐事不断,又贪念玩乐,最终重蹈覆辙,只是浅学了其他知识。到头来所学技术样样有听闻,而样样不精深,实属惭愧。

自省之余,正逢暑期休假,近来又因疫情隔离在家,便略读了数个项目的源码。其间,我时常发现未曾见过的Python用法和设计方式,并在开发过程中使用,以优化代码和实现种种特殊需求。Python作为一门被广泛使用的语言,其语法和思想相对简单,但对于某些人抨击Python(或许还有GO语言)的简单易用,称其“内容匮乏”之云云,显然是偏颇的。若是只需编写小工具,Python简单使用足矣,但要究其奥妙,深有cpython底层,广有众多模块,最寻常的,也要了解标准库及其文档。如此看来,Python并非想象中那般温驯。


Python在3.7版本提供了dataclasses模块,用于方便地创建数据类,并自动生成__init__()__repr__()等方法:

from dataclasses import dataclass, field

@dataclass
class Student:
    name:str
    age:int
    scores:list[int]
    GPA:list[float] = field(default_factory=list)

alice = Student("Alice",13,[99,98,97])

此处只是简单展示dataclass的使用方法,关于模块dataclasses的使用,可直接参见Python官方文档dataclasses模块部分,或搜索相关资料(本文末尾有相关参考页面),本文将不再赘述。

这里记录了我在使用dataclass时的所遇问题和对应的解决办法。


dataclass数据类的继承

在实际使用中,dataclass并非如namedtuple一般单纯,我们常常是将其作为一个类来进行管理和操作,某些情况下也无可避免的需要使用类的继承。

与普通的类不同,dataclass类因为其自动生成__init__()方法的缘故,继承会遇到这样的问题——若父类设定了默认参数,子类要在__init__()方法中加入新的位置参数,将比较麻烦:

@dataclass
class Student:
    name:str
    age:int
    scores:list[int]
    GPA:list[float] = field(default_factory=list)

@dataclass # 此处若不使用dataclass,便相当于直接从Student处继承来__init__
class StudentDetail(Student):
    demerit:int

# 发生错误
# TypeError: non-default argument 'demerit' follows default argument

这就如同在正常的函数中,将位置参数写在了关键字参数之后,不过彼时是SyntaxError,而此处是引发TypeError.

为此,我想出以下几种方案来解决这个问题,或许适用于不同的使用场景:

手动重写__init__()

一个显而易见的解决方法如下,子类不使用dataclass(或者让子类的dataclass装饰器不再自动生成__init__()方法,并手动重写__init__()

@dataclass(init=False) # 也可直接去掉本行,即不使用dataclass
class StudentDetail(Student):
    def __init__(
    self,
    name:str,
    age:int,
    scores:list[int],
    demerit:int,
    GPA:list[float]=[]):
        self.name = name
        self.age = age
        self.scores = scores
        self.demerit = demerit
        self.GPA = GPA

但就同其显而易见的复杂写法,已然违背了我们使用dataclass语法糖的初心,仅在参数少时尚可一试。

全部使用关键字参数

全部使用关键词参数的方法同样是一种简单的办法,但相对上文的重写方案更方便一些。这里提供这篇问答里给出的一种方案:

_no_default = object()

@dataclass
class Child(Parent):
    school: str = _no_default # 将想新增的位置参数给予默认值
    ugly: bool = True

    def __post_init__(self):
        if self.school is _no_default:
            # 在`__post_init__()`方法中对其进行判断,达到如同位置参数一般的报错效果。
            # 关于`__post_init__()`方法的效果请参见文档。
            raise TypeError("__init__ missing 1 required argument: 'school'")

当参数不能为None时,也可用None作为参数默认值,看上去或许更标致一些:

@dataclass
class Child(Parent):
    school: Optional[str] = None
    ugly: bool = True

    def __post_init__(self):
        if self.school is None:
            raise TypeError("__init__ missing 1 required argument: 'school'")

编写多个基类并进行组合

在上面提到的这篇问答里,还提到了一个办法,即编写多个基类来组合出父类与子类:

@dataclass
class _ParentBase:
    name:str
    age:int
    scores:list[int]

@dataclass
class _ParentDefaultsBase:
    GPA:list[float] = field(default_factory=list)

@dataclass
class _ChildBase(_ParentBase):
    demerit:int

@dataclass
class _ChildDefaultsBase(_ParentDefaultsBase):
    GPA:list[float] = field(default_factory=list)

@dataclass
class Parent(_ParentDefaultsBase, _ParentBase):
    pass

@dataclass
class Child(Parent, _ChildDefaultsBase, _ChildBase): # 注意继承先后
    pass

请注意ParentChild的继承顺序,利用了类继承的逆MRO(Method Resolution Order)顺序(在PEP 557中有提到:

When the Data Class is being created by the @dataclass decorator, it looks through all of the class’s base classes in reverse MRO (that is, starting at object) and, for each Data Class that it finds, adds the fields from that base class to an ordered mapping of fields. After all of the base class fields are added, it adds its own fields to the ordered mapping. All of the generated methods will use this combined, calculated ordered mapping of fields. Because the fields are in insertion order, derived classes override base classes.

容易看出,在Child继承中,Parent的位置并不重要,但需要保证_ChildBase_ParentBase的顺序要在_ChildDefaultsBase_ParentDefaultsBase之前,此时Child的继承顺序如下:

_ParentBase
_ChildBase
_ParentDefaultsBase
_ChildDefaultsBase
Parent

可通过输出 Child.__mro__ 进行验证。

要使用dataclass、要从父类继承、要给子类新增位置参数,这个方法可谓面面俱到。只是唯一的可惜之处是其简洁程度仍然不够,对设计者的思想要求较高。

为父类加上一个存储额外参数的字典参数

如果各子类的参数相似,或者参数量并不固定,可以尝试在父类中加入一个字典参数用于保存各项其他参数,并在 __post_init__ 方法中进行检查,或遍历字典使用setattr()将参数转化为属性。虽不太优雅,但也不失为一种解决方案。

如NoneBot2框架新增的PluginMetadata类中,提供了一个字典属性extra

@dataclass(eq=False)
class PluginMetadata:
    """插件元信息,由插件编写者提供"""

    name: str
    """插件可阅读名称"""
    description: str
    """插件功能介绍"""
    usage: str
    """插件使用方法"""
    config: Optional[Type[BaseModel]] = None
    """插件配置项"""
    extra: Dict[Any, Any] = field(default_factory=dict)

该办法并不算切题,但其子类无需改写__init__(),在某些场景下或许更加适用。当需要对额外参数进行检查时,也可将字典换为pydantic的BaseModel,能得到更简洁的实现效果。

Dataclass数据类实现**kwargs的效果

如果不多加思考,便直接在dataclass中加入形如*args,**kwargs的参数,必然会得到SyntaxError: invalid syntax

下面是StackOverflow相关问题提供的几种办法。

提供一个字典用以存放已知多余参数

如同上文NoneBot2提供的写法,当你知道多余的参数时,可以直接放入这个字典中。这是最朴素的做法,但你不能真正像**kwargs一样使用,你不能func(**dict)将字典内容作为参数传入,也不能通过.来访问属性。当然,你可以在__post_init__()中手动将其设置为属性:

from dataclasses import dataclass, field
from typing import Optional, Dict


@dataclass
class MyDataclass:
    data1: Optional[str] = None
    data2: Optional[Dict] = None
    data3: Optional[Dict] = None

    kwargs: field(default_factory=dict) = None

    def __post_init__(self):
        [setattr(self, k, v) for k, v in self.kwargs.items()]

用法如下,但dataclasses.asdict()并不会输出这些属性:

>>> data = MyDataclass(data1="data1", kwargs={"test": 1, "test2": 2})
>>> data.test
1
>>> data.test2
2

>>> from dataclasses import asdict
>>> asdict(data)
{'data1': 'data1', 'data2': None, 'data3': None, 'kwargs': {'test': 1, 'test2': 2}}

同理,也可编写pydantic的BaseModel达到规定与验证额外属性的目的。

提供一个特殊方法专用于处理**kwargs

回答中提供了另一个办法:

from dataclasses import dataclass
from inspect import signature


@dataclass
class Container:
    user_id: int
    body: str

    @classmethod
    def from_kwargs(cls, **kwargs):
        # 获取构造函数签名
        cls_fields = {field for field in signature(cls).parameters}

        # 分辨出提及到的参数和多余出的新参数
        native_args, new_args = {}, {}
        for name, val in kwargs.items():
            if name in cls_fields:
                native_args[name] = val
            else:
                new_args[name] = val

        # 用提及到的参数创建新类 ...
        ret = cls(**native_args)

        # ... 然后逐步将新参数设为属性
        for new_name, new_val in new_args.items():
            setattr(ret, new_name, new_val)
        return ret

用起来像下面这样:

params = {'user_id': 1, 'body': 'foo', 'bar': 'baz', 'amount': 10}
# Container(**params)  # 不能这样使用,会引发TypeError 
c = Container.from_kwargs(**params)
print(c.bar)  # 输出:'baz'

手动改写__new__()方法

由于dataclass只是生成__init__(),我们可以改写__new__()的规则:

@dataclass
class Container:
    user_id: int
    body: str

    def __new__(cls, *args, **kwargs):
        try:
            initializer = cls.__initializer
        except AttributeError:
            # 将生成的原有的__init__()放在另一个地方
            cls.__initializer = initializer = cls.__init__
            # 用一种合适的方式改写它
            cls.__init__ = lambda *a, **k: None

        # 这位答者引用了上面一种方法的代码来阐述
        added_args = {}
        for name in list(kwargs.keys()):
            if name not in cls.__annotations__:
                added_args[name] = kwargs.pop(name)

        ret = object.__new__(cls)
        initializer(ret, **kwargs)
        # ... 然后逐步将新参数设为属性
        for new_name, new_val in added_args.items():
            setattr(ret, new_name, new_val)

        return ret

if __name__ == "__main__":
    params = {'user_id': 1, 'body': 'foo', 'bar': 'baz', 'amount': 10}
    c = Container(**params)
    print(c.bar)  # 输出: 'baz'
    print(c.body)  # 输出: 'baz'`

文章参考文献

dataclasses --- 数据类 — Python 文档

PEP 557 – Data Classes | peps.python.org

dataclass数据类 - 巴蜀秀才 - 博客园

Python: データ保持用のクラスを定義する (dataclasses) - け日記

python — Python 3.7データクラスのクラス継承

Python: 【詳解】 Pythonのdataclasses

python - python3 dataclass with **kwargs(asterisk) - Stack Overflow

Python3.7中dataclass模块简单说明 - 知乎