【问题标题】:Class inheritance in Python 3.7 dataclassesPython 3.7 数据类中的类继承
【发布时间】:2019-01-05 15:20:56
【问题描述】:

我目前正在尝试 Python 3.7 中引入的新数据类结构。我目前坚持尝试对父类进行一些继承。看起来参数的顺序被我当前的方法搞砸了,因此子类中的 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

我该如何解决这个问题?

【问题讨论】:

  • 我认为值得注意的是,在 attrs / dataclass 类型的 Python 范式中,组合通常比继承更受欢迎。像这样扩展您的子类的__init__ 模糊地违反了LSP,因为您的各种子类不能互换。需要明确的是,我认为这种方式通常很实用,但如果你没有考虑使用组合:创建一个不继承的 Child 数据类,然后有一个 child 属性也可能是有意义的Parent 类。

标签: python python-3.x python-3.7 python-dataclasses


【解决方案1】:

请注意,使用 Python 3.10,现在可以使用数据类在本机上执行此操作。

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

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

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

以下是使用此新参数的最简单方法,但您可以使用多种方法在父类中使用默认值继承:

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 可能仍会引发可能的错误,但它在运行时有效

【讨论】:

    【解决方案2】:

    如果将属性从 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
    

    【讨论】:

    • 我认为这个答案应该得到更多的认可。解决了父类有默认字段的问题,去掉了TypeError。
    • @SimonMarcin,这是一个很好的答案!
    • 这是正确的答案。除非您支持新的和闪亮的(> = 3.10),否则这可以解决问题! +1
    • 您可以添加__post_init__,默认值为:def __post_init__(self): self.ugly = True
    【解决方案3】:

    使用Python继承创建数据类时,不能保证所有有默认值的字段会出现在所有无默认值的字段之后。

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

    试试这个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 的出身。

    【讨论】:

      【解决方案4】:

      像这样定义ugly字段怎么样,而不是默认方式?

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

      【讨论】:

        【解决方案5】:

        补充使用 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

        【讨论】:

          【解决方案6】:

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

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

          使用inspect 打印Child 的签名让事情变得一目了然;结果是(name: str, age: int, school: str, ugly: bool = True)。字段总是被重新排序,以便在初始化器的参数中具有默认值的字段在没有它们的字段之后。两个列表(没有默认值的字段和有默认值的字段)仍然按定义顺序排列。

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

          【讨论】:

            【解决方案7】:

            快速而肮脏的解决方案:

            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
            

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

            【讨论】:

              【解决方案8】:

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

              ugly_init: dataclasses.InitVar[bool] 充当pseudo-field 只是为了帮助我们进行初始化,一旦创建实例就会丢失。而ugly: bool = field(init=False) 是一个实例成员,它不会被__init__ 方法初始化,但可以使用__post_init__ 方法初始化(您可以找到更多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 上定义一个包含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 类方法作为工厂方法来创建默认值为ugly_init 的父/子类。请注意,您必须使用命名参数才能使这种方法起作用。

              【讨论】:

              • ugly_init 现在是必需参数,没有默认值
              【解决方案9】:

              你可以使用修改版的数据类,它会生成一个只包含关键字的__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 反向端口测试)

              这需要将子类定义为

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

              并且会生成__init__(self, *, name:str, age:int, ugly:bool=True, school:str)(这是有效的python)。这里唯一需要注意的是不允许使用位置参数初始化对象,否则它是一个完全常规的dataclass,没有丑陋的黑客攻击。

              【讨论】:

              • 谢谢,超级好的解决方案,我看到的唯一问题是这与mypy不兼容,我正在尝试修复它。
              【解决方案10】:

              基于 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
              

              【讨论】:

              • 你是不是打算在2)上面写“class MyDataclass(DataclassWithDefaults, NoDefaultAttributesPostInitMixin)”?
              【解决方案11】:

              一种可能的解决方法是使用猴子补丁来追加父字段

              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, 但这可能太脏了。

              虽然猴子补丁缺少一些继承特性, 它仍然可以用于向所有伪子类添加方法。

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

              【讨论】:

                【解决方案12】:

                数据类组合属性的方式使您无法在基类中使用具有默认值的属性,然后在子类中使用没有默认值的属性(位置属性)。

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

                这记录在PEP-557 Dataclasses 中,在inheritance 下:

                @dataclass 装饰器创建数据类时,它会在反向 MRO 中查看该类的所有基类(即从 object 开始),并且对于它找到的每个数据类,添加该基类中的字段到字段的有序映射。添加完所有基类字段后,它会将自己的字段添加到有序映射中。所有生成的方法都将使用这种组合的、计算的有序字段映射。因为字段是按插入顺序排列的,所以派生类会覆盖基类。

                Specification以下:

                TypeError 如果没有默认值的字段跟在有默认值的字段后面,则会引发。当这发生在单个类中或作为类继承的结果时,这是正确的。

                您确实有几个选项可以避免此问题。

                第一个选项是使用单独的基类将具有默认值的字段强制置于 MRO 顺序中的后面位置。不惜一切代价避免在要用作基类的类上直接设置字段,例如Parent

                以下类层次结构有效:

                # 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
                

                通过将字段提取到具有不带默认值的字段和带默认值的字段的单独的基类中,以及精心选择的继承顺序,您可以生成一个 MRO,它将所有不带默认值的字段放在有默认值的字段之前。 Child 的反向 MRO(忽略 object)是:

                _ParentBase
                _ChildBase
                _ParentDefaultsBase
                _ChildDefaultsBase
                Parent
                

                请注意,Parent 没有设置任何新字段,因此它在字段列表顺序中以“最后一个”结束并不重要。具有无默认字段的类(_ParentBase_ChildBase)位于具有默认字段的类(_ParentDefaultsBase_ChildDefaultsBase)之前。

                结果是ParentChild 类具有较旧的健全字段,而Child 仍然是Parent 的子类:

                >>> 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
                

                因此您可以创建两个类的实例:

                >>> 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 的项目。它使用不同的继承合并策略;它将子类中被覆盖的字段拉到字段列表的末尾,因此Parent 类中的['name', 'age', 'ugly'] 变为Child 类中的['name', 'age', 'school', 'ugly'];通过使用默认值覆盖该字段,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
                

                【讨论】:

                • 非常感谢您的详细解答
                • 这很有帮助。我对mro感到困惑。运行 print(Child.mro()) 我得到: [main.Child'>, main.Parent'>, main._ChildDefaultsBase'>, main._ParentDefaultsBase'>, main._ChildBase'>, main ._ParentBase'>, ] 那么默认基类不是在基类之前吗?
                • @Ollie 这是正确的顺序;请注意,我在答案中列出了它。当您有多个基类时,您需要一种方法来线性化所涉及的类,以决定在继承时哪些类在其他类之前。 Python 使用C3 linearisation method,我的回答利用了它的工作原理,以确保具有默认值的属性始终位于所有没有默认值的属性之后。
                • 其实 attrs 可以工作,但需要使用attr.ib(kw_only=True),见github.com/python-attrs/attrs/issues/38
                【解决方案13】:

                您看到此错误是因为在具有默认值的参数之后添加了没有默认值的参数。继承的字段插入数据类的顺序与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
                

                最终的字段列表按顺序为x, y, zx 的最终类型是 int,在类 C 中指定。

                不幸的是,我认为没有办法解决这个问题。我的理解是,如果父类有默认参数,那么子类不能有非默认参数。

                【讨论】:

                • 我知道非默认参数必须在默认参数之前,但是当父参数在添加子参数之前初始化时怎么办?
                • 不幸的是,我认为没有办法解决它。我的理解是,如果父类有默认参数,那么子类不能有非默认参数。
                • 您能否在我标记之前将该信息添加到答案中?有一天它会帮助某人。不幸的是,数据类的限制。渲染它对我当前的 python 项目没有意义。很高兴看到这样的实现
                猜你喜欢
                • 2023-03-27
                • 2021-04-10
                • 1970-01-01
                • 2016-06-28
                • 2015-09-04
                • 1970-01-01
                相关资源
                最近更新 更多