Class Python 3.7 数据类中的继承

Class inheritance in Python 3.7 dataclasses

我目前正在尝试使用 Python 3.7 中引入的新数据class 结构。我目前一直在尝试对父 class 进行一些继承。看起来参数的顺序被我当前的方法搞砸了,因此子 class 中的 bool 参数在其他参数之前传递。这导致类型错误。

from dataclasses import dataclass

@dataclass
class Parent:
    name: str
    age: int
    ugly: bool = False

    def print_name(self):
        print(self.name)

    def print_age(self):
        print(self.age)

    def print_id(self):
        print(f'The Name is {self.name} and {self.name} is {self.age} year old')

@dataclass
class Child(Parent):
    school: str
    ugly: bool = True


jack = Parent('jack snr', 32, ugly=True)
jack_son = Child('jack jnr', 12, school = 'havard', ugly=True)

jack.print_id()
jack_son.print_id()

当我 运行 这个代码时,我得到这个 TypeError:

TypeError: non-default argument 'school' follows default argument

我该如何解决这个问题?

您看到此错误是因为在具有默认值的参数之后添加了没有默认值的参数。继承字段插入数据的顺序 class 与 Method Resolution Order 相反,这意味着 Parent 字段排在第一位,即使它们后来被其子项覆盖。

来自 PEP-557 - Data Classes 的示例:

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

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

The final list of fields is, in order,x, y, z. The final type of x is int, as specified in class C.

不幸的是,我认为没有任何解决办法。我的理解是,如果父 class 有默认参数,那么子 class 不能有非默认参数。

dataclasses 组合属性的方式使您无法在基础 class 中使用具有默认值的属性,然后在子[=] 中使用没有默认值的属性(位置属性) 100=].

这是因为属性是从MRO的底部开始组合的,并按照先见顺序构建属性的有序列表;覆盖保留在其原始位置。所以 Parent['name', 'age', 'ugly'] 开始,其中 ugly 有一个默认值,然后 Child['school'] 添加到该列表的末尾(ugly 已经在列表中)。这意味着您最终得到 ['name', 'age', 'ugly', 'school'] 并且因为 school 没有默认值,这导致 __init__.

的参数列表无效

这记录在 PEP-557 Dataclasses, under inheritance:

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.

Specification以下:

TypeError will be raised if a field without a default value follows a field with a default value. This is true either when this occurs in a single class, or as a result of class inheritance.

您在这里有几个选项可以避免这个问题。

第一个选项是使用单独的基础 classes 将具有默认值的字段强制置于 MRO 订单中的较晚位置。不惜一切代价,避免直接在 classes 上设置字段作为基础 classes,例如 Parent.

以下 class 层次结构有效:

# base classes with fields; fields without defaults separate from fields with.
@dataclass
class _ParentBase:
    name: str
    age: int

@dataclass
class _ParentDefaultsBase:
    ugly: bool = False

@dataclass
class _ChildBase(_ParentBase):
    school: str

@dataclass
class _ChildDefaultsBase(_ParentDefaultsBase):
    ugly: bool = True

# public classes, deriving from base-with, base-without field classes
# subclasses of public classes should put the public base class up front.

@dataclass
class Parent(_ParentDefaultsBase, _ParentBase):
    def print_name(self):
        print(self.name)

    def print_age(self):
        print(self.age)

    def print_id(self):
        print(f"The Name is {self.name} and {self.name} is {self.age} year old")

@dataclass
class Child(Parent, _ChildDefaultsBase, _ChildBase):
    pass

通过将字段拉出到 separate base classes 中,字段没有默认值和字段有默认值,以及精心选择的继承顺序,您可以生成一个 MRO将所有没有默认值的字段放在有默认值的字段之前。 Child 的反向 MRO(忽略 object)是:

_ParentBase
_ChildBase
_ParentDefaultsBase
_ChildDefaultsBase
Parent

请注意,Parent 没有设置任何新字段,因此它在字段列表顺序中以 'last' 结尾并不重要。带有没有默认值的字段(_ParentBase_ChildBase)的 classes 在带有默认值的字段(_ParentDefaultsBase_ChildDefaultsBase)的 classes 之前。

结果是 ParentChild class 有一个更老的理智字段,而 Child 仍然是 [=17] 的子class =]:

>>> from inspect import signature
>>> signature(Parent)
<Signature (name: str, age: int, ugly: bool = False) -> None>
>>> signature(Child)
<Signature (name: str, age: int, school: str, ugly: bool = True) -> None>
>>> issubclass(Child, Parent)
True

因此您可以创建两个 classes:

的实例
>>> jack = Parent('jack snr', 32, ugly=True)
>>> jack_son = Child('jack jnr', 12, school='havard', ugly=True)
>>> jack
Parent(name='jack snr', age=32, ugly=True)
>>> jack_son
Child(name='jack jnr', age=12, school='havard', ugly=True)

另一种选择是仅使用具有默认值的字段;您仍然可以通过在 __post_init__:

中提出一个错误来不提供 school
_no_default = object()

@dataclass
class Child(Parent):
    school: str = _no_default
    ugly: bool = True

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

但这确实改变了字段顺序; schoolugly 之后结束:

<Signature (name: str, age: int, ugly: bool = True, school: str = <object object at 0x1101d1210>) -> None>

和类型提示检查器 抱怨 _no_default 不是字符串。

您也可以使用 attrs project,它是激发 dataclasses 灵感的项目。它使用不同的继承合并策略;它将 subclass 中的覆盖字段拉到字段列表的末尾,因此 Parent class 中的 ['name', 'age', 'ugly'] 变为 [=20= 中的 ['name', 'age', 'school', 'ugly'] ] class;通过使用默认值覆盖字段,attrs 允许覆盖而无需执行 MRO 舞蹈。

attrs 支持定义没有类型提示的字段,但让我们通过设置 auto_attribs=True:

坚持 supported type hinting mode
import attr

@attr.s(auto_attribs=True)
class Parent:
    name: str
    age: int
    ugly: bool = False

    def print_name(self):
        print(self.name)

    def print_age(self):
        print(self.age)

    def print_id(self):
        print(f"The Name is {self.name} and {self.name} is {self.age} year old")

@attr.s(auto_attribs=True)
class Child(Parent):
    school: str
    ugly: bool = True

基于 Martijn Pieters 解决方案,我执行了以下操作:

1) 创建混音实现 post_init

from dataclasses import dataclass

no_default = object()


@dataclass
class NoDefaultAttributesPostInitMixin:

    def __post_init__(self):
        for key, value in self.__dict__.items():
            if value is no_default:
                raise TypeError(
                    f"__init__ missing 1 required argument: '{key}'"
                )

2) 然后在类 与继承问题:

from src.utils import no_default, NoDefaultAttributesChild

@dataclass
class MyDataclass(DataclassWithDefaults, NoDefaultAttributesPostInitMixin):
    attr1: str = no_default

编辑:

一段时间后,我也发现这个解决方案与 mypy 有问题,下面的代码解决了这个问题。

from dataclasses import dataclass
from typing import TypeVar, Generic, Union

T = TypeVar("T")


class NoDefault(Generic[T]):
    ...


NoDefaultVar = Union[NoDefault[T], T]
no_default: NoDefault = NoDefault()


@dataclass
class NoDefaultAttributesPostInitMixin:
    def __post_init__(self):
        for key, value in self.__dict__.items():
            if value is NoDefault:
                raise TypeError(f"__init__ missing 1 required argument: '{key}'")


@dataclass
class Parent(NoDefaultAttributesPostInitMixin):
    a: str = ""

@dataclass
class Child(Foo):
    b: NoDefaultVar[str] = no_default

下面的方法在使用纯 python dataclasses 并且没有太多样板代码的情况下解决了这个问题。

ugly_init: dataclasses.InitVar[bool]作为pseudo-field just to help us do initialization and will be lost once the instance is created. While ugly: bool = field(init=False) is an instance member which will not be initialized by __init__ method but can be alternatively initialized using __post_init__ method (you can find more here.).

from dataclasses import dataclass, field

@dataclass
class Parent:
    name: str
    age: int
    ugly: bool = field(init=False)
    ugly_init: dataclasses.InitVar[bool]

    def __post_init__(self, ugly_init: bool):
        self.ugly = ugly_init

    def print_name(self):
        print(self.name)

    def print_age(self):
        print(self.age)

    def print_id(self):
        print(f'The Name is {self.name} and {self.name} is {self.age} year old')

@dataclass
class Child(Parent):
    school: str

jack = Parent('jack snr', 32, ugly_init=True)
jack_son = Child('jack jnr', 12, school='havard', ugly_init=True)

jack.print_id()
jack_son.print_id()

如果您想使用 ugly_init 可选的模式,您可以在 Parent 上定义一个 class 方法,其中包括 ugly_init 作为可选参数:

from dataclasses import dataclass, field, InitVar

@dataclass
class Parent:
    name: str
    age: int
    ugly: bool = field(init=False)
    ugly_init: InitVar[bool]

    def __post_init__(self, ugly_init: bool):
        self.ugly = ugly_init
    
    @classmethod
    def create(cls, ugly_init=True, **kwargs):
        return cls(ugly_init=ugly_init, **kwargs)

    def print_name(self):
        print(self.name)

    def print_age(self):
        print(self.age)

    def print_id(self):
        print(f'The Name is {self.name} and {self.name} is {self.age} year old')

@dataclass
class Child(Parent):
    school: str

jack = Parent.create(name='jack snr', age=32, ugly_init=False)
jack_son = Child.create(name='jack jnr', age=12, school='harvard')

jack.print_id()
jack_son.print_id()

现在您可以使用 create class 方法作为工厂方法来创建 Parent/Child classes,默认值为 ugly_init。请注意,您必须使用命名参数才能使此方法起作用。

一种可能的解决方法是使用猴子修补附加父字段

import dataclasses as dc

def add_args(parent): 
    def decorator(orig):
        "Append parent's fields AFTER orig's fields"

        # Aggregate fields
        ff  = [(f.name, f.type, f) for f in dc.fields(dc.dataclass(orig))]
        ff += [(f.name, f.type, f) for f in dc.fields(dc.dataclass(parent))]

        new = dc.make_dataclass(orig.__name__, ff)
        new.__doc__ = orig.__doc__

        return new
    return decorator

class Animal:
    age: int = 0 

@add_args(Animal)
class Dog:
    name: str
    noise: str = "Woof!"

@add_args(Animal)
class Bird:
    name: str
    can_fly: bool = True

Dog("Dusty", 2)               # --> Dog(name='Dusty', noise=2, age=0)
b = Bird("Donald", False, 40) # --> Bird(name='Donald', can_fly=False, age=40)

也可以前置非默认字段, 通过检查 if f.default is dc.MISSING, 但这可能太脏了。

虽然 monkey-patching 缺少一些继承功能, 它仍然可以用于向所有伪子 类.

添加方法

为了更细粒度的控制,设置默认值 使用 dc.field(compare=False, repr=True, ...)

如果您将属性从 init 函数中排除,则可以在父 类 中使用具有默认值的属性。如果您需要覆盖初始默认值的可能性,请使用 Praveen Kulkarni 的答案扩展代码。

from dataclasses import dataclass, field

@dataclass
class Parent:
    name: str
    age: int
    ugly: bool = field(default=False, init=False)

@dataclass
class Child(Parent):
    school: str

jack = Parent('jack snr', 32)
jack_son = Child('jack jnr', 12, school = 'havard')
jack_son.ugly = True

甚至

@dataclass
class Child(Parent):
    school: str
    ugly = True
    # This does not work
    # ugly: bool = True

jack_son = Child('jack jnr', 12, school = 'havard')
assert jack_son.ugly

您可以使用数据classes 的修改版本,它只会生成关键字__init__ 方法:

import dataclasses


def _init_fn(fields, frozen, has_post_init, self_name):
    # fields contains both real fields and InitVar pseudo-fields.
    globals = {'MISSING': dataclasses.MISSING,
               '_HAS_DEFAULT_FACTORY': dataclasses._HAS_DEFAULT_FACTORY}

    body_lines = []
    for f in fields:
        line = dataclasses._field_init(f, frozen, globals, self_name)
        # line is None means that this field doesn't require
        # initialization (it's a pseudo-field).  Just skip it.
        if line:
            body_lines.append(line)

    # Does this class have a post-init function?
    if has_post_init:
        params_str = ','.join(f.name for f in fields
                              if f._field_type is dataclasses._FIELD_INITVAR)
        body_lines.append(f'{self_name}.{dataclasses._POST_INIT_NAME}({params_str})')

    # If no body lines, use 'pass'.
    if not body_lines:
        body_lines = ['pass']

    locals = {f'_type_{f.name}': f.type for f in fields}
    return dataclasses._create_fn('__init__',
                      [self_name, '*'] + [dataclasses._init_param(f) for f in fields if f.init],
                      body_lines,
                      locals=locals,
                      globals=globals,
                      return_type=None)


def add_init(cls, frozen):
    fields = getattr(cls, dataclasses._FIELDS)

    # Does this class have a post-init function?
    has_post_init = hasattr(cls, dataclasses._POST_INIT_NAME)

    # Include InitVars and regular fields (so, not ClassVars).
    flds = [f for f in fields.values()
            if f._field_type in (dataclasses._FIELD, dataclasses._FIELD_INITVAR)]
    dataclasses._set_new_attribute(cls, '__init__',
                       _init_fn(flds,
                                frozen,
                                has_post_init,
                                # The name to use for the "self"
                                # param in __init__.  Use "self"
                                # if possible.
                                '__dataclass_self__' if 'self' in fields
                                else 'self',
                                ))

    return cls


# a dataclass with a constructor that only takes keyword arguments
def dataclass_keyword_only(_cls=None, *, repr=True, eq=True, order=False,
              unsafe_hash=False, frozen=False):
    def wrap(cls):
        cls = dataclasses.dataclass(
            cls, init=False, repr=repr, eq=eq, order=order, unsafe_hash=unsafe_hash, frozen=frozen)
        return add_init(cls, frozen)

    # See if we're being called as @dataclass or @dataclass().
    if _cls is None:
        # We're called with parens.
        return wrap

    # We're called as @dataclass without parens.
    return wrap(_cls)

(也作为 gist 发布,使用 Python 3.6 向后移植测试)

这需要将 child class 定义为

@dataclass_keyword_only
class Child(Parent):
    school: str
    ugly: bool = True

并会生成 __init__(self, *, name:str, age:int, ugly:bool=True, school:str)(有效 python)。这里唯一的警告是不允许使用位置参数初始化 objects,否则它是一个完全常规的 dataclass,没有丑陋的 hacks。

在发现数据类 may 正在获取允许对字段重新排序的装饰器参数后,我又回到了这个问题。这无疑是一个很有前途的发展,尽管此功能的进展似乎有些停滞。

现在,您可以通过使用 dataclassy 我对数据类的重新实现来克服这样的挫折,从而获得这种行为以及其他一些好处。在原始示例中使用 from dataclassy 代替 from dataclasses 意味着它可以正常运行。

使用inspect 打印Child 的签名可以清楚地知道发生了什么;结果是 (name: str, age: int, school: str, ugly: bool = True)。字段总是重新排序,以便在初始化程序的参数中具有默认值的字段位于没有它们的字段之后。两个列表(没有默认值的字段和有默认值的字段)仍然按定义顺序排列。

面对这个问题是促使我编写数据类替代品的因素之一。此处详述的解决方法虽然有用,但需要将代码扭曲到这样的程度,以至于它们完全否定了数据类的天真方法(字段顺序是微不足道的可预测的)提供的可读性优势。

一个快速而肮脏的解决方案:

from typing import Optional

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

    def __post_init__(self):
        assert self.school is not None

然后回去重构一次(希望)语言被扩展。

补充使用 attrs 的 Martijn Pieters 解决方案:可以在没有默认属性复制的情况下创建继承,其中:

import attr

@attr.s(auto_attribs=True)
class Parent:
    name: str
    age: int
    ugly: bool = attr.ib(default=False, kw_only=True)


@attr.s(auto_attribs=True)
class Child(Parent):
    school: str
    ugly: bool = True

有关 kw_only 参数的更多信息,请参阅 here

如何定义 ugly 字段而不是默认方式?

ugly: bool = field(metadata=dict(required=False, missing=False))

使用Python继承创建数据类时,无法保证all个默认值的字段出现在all[=34=之后] 没有默认值的字段。

一个简单的解决方案是避免使用多重继承来构造“合并”数据类。相反,我们可以通过对父数据类的字段进行过滤和排序来构建合并的数据类。

试试这个 merge_dataclasses() 函数:

import dataclasses
import functools
from typing import Iterable, Type


def merge_dataclasses(
    cls_name: str,
    *,
    merge_from: Iterable[Type],
    **kwargs,
):
    """
    Construct a dataclass by merging the fields
    from an arbitrary number of dataclasses.

    Args:
        cls_name: The name of the constructed dataclass.

        merge_from: An iterable of dataclasses
            whose fields should be merged.

        **kwargs: Keyword arguments are passed to
            :py:func:`dataclasses.make_dataclass`.

    Returns:
        Returns a new dataclass
    """
    # Merge the fields from the dataclasses,
    # with field names from later dataclasses overwriting
    # any conflicting predecessor field names.
    each_base_fields = [d.__dataclass_fields__ for d in merge_from]
    merged_fields = functools.reduce(
        lambda x, y: {**x, **y}, each_base_fields
    )

    # We have to reorder all of the fields from all of the dataclasses
    # so that *all* of the fields without defaults appear
    # in the merged dataclass *before* all of the fields with defaults.
    fields_without_defaults = [
        (f.name, f.type, f)
        for f in merged_fields.values()
        if isinstance(f.default, dataclasses._MISSING_TYPE)
    ]
    fields_with_defaults = [
        (f.name, f.type, f)
        for f in merged_fields.values()
        if not isinstance(f.default, dataclasses._MISSING_TYPE)
    ]
    fields = [*fields_without_defaults, *fields_with_defaults]

    return dataclasses.make_dataclass(
        cls_name=cls_name,
        fields=fields,
        **kwargs,
    )

然后您可以按如下方式合并数据类。请注意,我们可以合并 AB 并且默认字段 bd 被移动到合并数据类的末尾。

@dataclasses.dataclass
class A:
    a: int
    b: int = 0


@dataclasses.dataclass
class B:
    c: int
    d: int = 0


C = merge_dataclasses(
    "C",
    merge_from=[A, B],
)

# Note that 
print(C(a=1, d=1).__dict__)
# {'a': 1, 'd': 1, 'b': 0, 'c': 0}

当然,这个解决方案的缺陷是 C 实际上并没有 AB 继承 ,这意味着您不能使用 isinstance() 或其他类型的断言来验证 C 的出身。

请注意,使用 Python 3.10,现在可以使用 dataclasses 在本地执行此操作。

Dataclasses 3.10 添加了 kw_only 属性(类似于 attrs)。 它允许您指定哪些字段是 keyword_only,因此将在 init 的末尾设置,不会导致继承问题。

直接取自 Eric Smith blog post on the subject,这是人们要求此功能的两个原因:

  • 当数据class 有很多字段时,按位置指定它们会变得不可读。它还要求为了向后兼容,所有新字段都添加到数据的末尾class。这并不总是可取的。
  • 当一个数据class继承自另一个数据class,并且基础class有带默认值的字段,那么派生的所有字段class 也必须有默认值。

接下来是使用这个新参数的最简单方法,但是您可以通过多种方法使用它在父级中使用默认值继承 class:

from dataclasses import dataclass

@dataclass(kw_only=True)
class Parent:
    name: str
    age: int
    ugly: bool = False

@dataclass(kw_only=True)
class Child(Parent):
    school: str

ch = Child(name="Kevin", age=17, school="42")
print(ch.ugly)

查看上面链接的博文以获得对 kw_only 的更详尽的解释。

干杯!

PS:因为它是相当新的,请注意您的 IDE 可能仍会引发错误,但它在运行时有效

一个实验性但有趣的解决方案是使用元classes。下面的解决方案使 Python dataclasses 的使用具有简单的继承,而根本不使用 dataclass 装饰器。此外,它可以继承父基 classes 的字段而无需抱怨位置参数的顺序(non-default 字段)。

from collections import OrderedDict
import typing as ty
import dataclasses
from itertools import takewhile

class DataClassTerm:
    def __new__(cls, *args, **kwargs):
        return super().__new__(cls)

class DataClassMeta(type):
    def __new__(cls, clsname, bases, clsdict):
        fields = {}

        # Get list of base classes including the class to be produced(initialized without its original base classes as those have already become dataclasses)
        bases_and_self = [dataclasses.dataclass(super().__new__(cls, clsname, (DataClassTerm,), clsdict))] + list(bases)

        # Whatever is a subclass of DataClassTerm will become a DataClassTerm. 
        # Following block will iterate and create individual dataclasses and collect their fields
        for base in bases_and_self[::-1]: # Ensure that last fields in last base is prioritized
            if issubclass(base, DataClassTerm):
                to_dc_bases = list(takewhile(lambda c: c is not DataClassTerm, base.__mro__))
                for dc_base in to_dc_bases[::-1]: # Ensure that last fields in last base in MRO is prioritized(same as in dataclasses)
                    if dataclasses.is_dataclass(dc_base):
                        valid_dc = dc_base
                    else:
                        valid_dc = dataclasses.dataclass(dc_base)
                    for field in dataclasses.fields(valid_dc):
                        fields[field.name] = (field.name, field.type, field)
        
        # Following block will reorder the fields so that fields without default values are first in order
        reordered_fields = OrderedDict()
        for n, t, f  in fields.values():
            if f.default is dataclasses.MISSING and f.default_factory is dataclasses.MISSING:
                reordered_fields[n] = (n, t, f)
        for n, t, f  in fields.values():
            if n not in reordered_fields.keys():
                reordered_fields[n] = (n, t, f)
        
        # Create a new dataclass using `dataclasses.make_dataclass`, which ultimately calls type.__new__, which is the same as super().__new__ in our case
        fields = list(reordered_fields.values())
        full_dc = dataclasses.make_dataclass(cls_name=clsname, fields=fields, init=True, bases=(DataClassTerm,))
        
        # Discard the created dataclass class and create new one using super but preserve the dataclass specific namespace.
        return super().__new__(cls, clsname, bases, {**full_dc.__dict__,**clsdict})
    
class DataClassCustom(DataClassTerm, metaclass=DataClassMeta):
    def __new__(cls, *args, **kwargs):
        if len(args)>0:
            raise RuntimeError("Do not use positional arguments for initialization.")
        return super().__new__(cls, *args, **kwargs)

现在让我们创建一个样本数据class,父数据class和样本混合class:

class DataClassCustomA(DataClassCustom):
    field_A_1: int = dataclasses.field()
    field_A_2: ty.AnyStr = dataclasses.field(default=None)

class SomeOtherClass:
    def methodA(self):
        print('print from SomeOtherClass().methodA')

class DataClassCustomB(DataClassCustomA,SomeOtherClass):
    field_B_1: int = dataclasses.field()
    field_B_2: ty.Dict = dataclasses.field(default_factory=dict)

结果是

result_b = DataClassCustomB(field_A_1=1, field_B_1=2)

result_b
# DataClassCustomB(field_A_1=1, field_B_1=2, field_A_2=None, field_B_2={})

result_b.methodA()
# print from SomeOtherClass().methodA

尝试对每个父 class 上的 @dataclass 装饰器执行相同操作会在以下子 class 中引发异常,例如 TypeError(f'non-default argument <field-name) follows default argument')。上面的解决方案可以防止这种情况发生,因为首先对字段进行了重新排序。但是,由于修改了字段的顺序,因此必须防止在 DataClassCustom.__new__ 中使用 *args,因为原始顺序不再有效。

虽然在 Python >=3.10 中引入了 kw_only 特性,本质上使数据中的继承变得更加可靠 classes ,上面的例子仍然可以用作一种方式使不需要使用 @dataclass 装饰器的数据class 可继承。