Наследование классов в классах данных Python 3.7

89

В настоящее время я пробую новые конструкции классов данных, представленные в 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

Как это исправить?

Мистерио
источник

Ответы:

138

То, как классы данных объединяют атрибуты, не позволяет вам использовать атрибуты со значениями по умолчанию в базовом классе, а затем использовать атрибуты без значений по умолчанию (позиционные атрибуты) в подклассе.

Это связано с тем, что атрибуты объединяются, начиная с нижней части MRO и создавая упорядоченный список атрибутов в порядке их появления; переопределения сохраняются в исходном месте. Итак, Parentначинается с ['name', 'age', 'ugly'], где uglyесть значение по умолчанию, а затем Childдобавляется ['school']в конец этого списка ( uglyуже в списке). Это означает, что вы в конечном итоге получаете, ['name', 'age', 'ugly', 'school']а поскольку schoolзначения по умолчанию нет, это приводит к недопустимому списку аргументов для __init__.

Это отражено в ППК-557 Dataclasses , при наследовании :

Когда класс данных создается @dataclassдекоратором, он просматривает все базовые классы класса в обратном MRO (то есть начиная с object) и для каждого найденного класса данных добавляет поля из этого базового класса в упорядоченный отображение полей. После добавления всех полей базового класса он добавляет свои собственные поля к упорядоченному отображению. Все сгенерированные методы будут использовать это комбинированное вычисляемое упорядоченное сопоставление полей. Поскольку поля расположены в порядке вставки, производные классы переопределяют базовые классы.

и в соответствии со спецификацией :

TypeErrorбудет поднят, если поле без значения по умолчанию следует за полем со значением по умолчанию. Это верно либо когда это происходит в одном классе, либо в результате наследования классов.

У вас есть несколько вариантов, чтобы избежать этой проблемы.

Первый вариант - использовать отдельные базовые классы для принудительного переноса полей со значениями по умолчанию на более позднюю позицию в порядке ТОиР. Любой ценой избегайте установки полей непосредственно в классах, которые будут использоваться в качестве базовых классов, например 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, который помещает все поля без значений по умолчанию перед полями со значениями по умолчанию. Обратный MRO (игнорирование object) для Child:

_ParentBase
_ChildBase
_ParentDefaultsBase
_ChildDefaultsBase
Parent

Обратите внимание, что Parentэто не устанавливает никаких новых полей, поэтому здесь не имеет значения, что он заканчивается «последним» в порядке перечисления полей. Классы с полями без значений по умолчанию ( _ParentBaseи _ChildBase) предшествуют классам с полями со значениями по умолчанию ( _ParentDefaultsBaseи _ChildDefaultsBase).

В результате Parentи Childклассы с полем здравомыслящего старшего, в то время как 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)

Другой вариант - использовать только поля со значениями по умолчанию; вы все равно можете сделать ошибку, чтобы не указать schoolзначение, подняв его в __post_init__:

_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'")

но это действительно изменяет порядок полей; schoolзаканчивается после ugly:

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

и средство проверки подсказок будет жаловаться на _no_defaultто, что это не строка.

Вы также можете использовать attrsпроект , который вдохновил вас dataclasses. Он использует другую стратегию слияния наследования; он вытягивает переопределенные поля в подклассе в конец списка полей, так что ['name', 'age', 'ugly']в Parentклассе становится ['name', 'age', 'school', 'ugly']в Childклассе; заменив поле значением по умолчанию,attrs позволяет переопределение без необходимости выполнять танец MRO.

attrsподдерживает определение полей без подсказок типа, но позволяет придерживаться режима подсказки поддерживаемого типа , установив auto_attribs=True:

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
Мартейн Питерс
источник
1
Большое спасибо за подробный ответ
Мистерио
Это очень полезно. Хотя я запутался в mro. Запустив print (Child.mro ()), я получаю: [<class ' main .Child'>, <class ' main .Parent'>, <class ' main ._ChildDefaultsBase'>, <class » main ._ParentDefaultsBase '>, < class ' main ._ChildBase'>, <class ' main ._ParentBase'>, <class 'object'>] Так разве базы по умолчанию не предшествуют базовым классам?
Олли
1
@Ollie, это правильный порядок; обратите внимание, что я перечислил это в своем ответе. Когда у вас есть несколько базовых классов, вам нужен способ линеаризации участвующих классов, чтобы решить, какие классы идут раньше других при наследовании. Python использует метод линеаризации C3, и в моем ответе используется то, как это работает, чтобы атрибуты со значениями по умолчанию всегда приходили после всех атрибутов без значений по умолчанию.
Мартейн Питерс
На самом деле, ATTRS может работать , но вы должны использовать attr.ib(kw_only=True), см github.com/python-attrs/attrs/issues/38
laike9m
8

Вы видите эту ошибку, потому что аргумент без значения по умолчанию добавляется после аргумента со значением по умолчанию. Порядок вставки унаследованных полей в класс данных является обратным порядку разрешения методов , что означает, что Parentполя идут первыми, даже если их потомки перезаписывают позже.

Пример из PEP-557 - Классы данных :

@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, как указано в class C.

К сожалению, я не думаю, что это можно обойти. Я понимаю, что если родительский класс имеет аргумент по умолчанию, то ни один дочерний класс не может иметь аргументы, отличные от аргументов по умолчанию.

Патрик Хо
источник
Я понимаю, что аргумент не по умолчанию должен предшествовать аргументу по умолчанию, но как это может быть, когда родительские аргументы инициализируются перед добавлением дочерних аргументов?
Мистерио
3
К сожалению, я не думаю, что есть какой-то способ обойти это. Я понимаю, что если родительский класс имеет аргумент по умолчанию, то ни один дочерний класс не может иметь аргументы, отличные от аргументов по умолчанию.
Патрик Хо
1
Можете ли вы добавить эту информацию к ответу, прежде чем я его отмечу? Однажды это поможет кому-то. Очень жаль, что ограничение классов данных. Делает это спорным с моим текущим проектом на Python. Приятно видеть такие реализации, хотя
Мистерио
6

Вы можете использовать атрибуты со значениями по умолчанию в родительских классах, если исключите их из функции init. Если вам нужна возможность переопределить значение по умолчанию в init, дополните код ответом Правина Кулкарни.

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
SimonMarcin
источник
Я думаю, что этот ответ заслуживает большего признания. Это решило проблему наличия поля по умолчанию в родительском классе, тем самым удалив TypeError.
Нильс Бенгтссон
5

на основе решения 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
Даниэль Альбаррал
источник
Вы намеревались написать «класс MyDataclass (DataclassWithDefaults, NoDefaultAttributesPostInitMixin)» выше в 2)?
Скотт П.
5

Приведенный ниже подход решает эту проблему при использовании чистого Python. dataclasses и без особого шаблонного кода.

Он ugly_init: dataclasses.InitVar[bool]служит псевдополем, чтобы помочь нам выполнить инициализацию, и будет утерян после создания экземпляра. Пока ugly: bool = field(init=False)- это член экземпляра, который не будет инициализирован __init__методом, но может быть инициализирован с помощью __post_init__метода (подробнее вы можете найти здесь ).

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это необязательно, вы можете определить метод класса в родительском элементе, который включает 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 теперь является обязательным параметром без значения по умолчанию
Вадим Тимиров
2

Я вернулся к этому вопросу после того, как обнаружил, что классы данных могут получать параметр декоратора, который позволяет полей. Это, безусловно, многообещающая разработка, хотя разработка этой функции, похоже, несколько застопорилась.

Прямо сейчас вы можете получить это поведение, а также некоторые другие тонкости, используя dataclassy , мою повторную реализацию классов данных, которая преодолевает подобные разочарования. Использование from dataclassyвместо from dataclassesв исходном примере означает, что он работает без ошибок.

Использование inspect для печати подписи Childпроясняет происходящее; результат есть (name: str, age: int, school: str, ugly: bool = True). Поля всегда переупорядочиваются, так что поля со значениями по умолчанию идут после полей без них в параметрах инициализатора. Оба списка (поля без значений по умолчанию и поля с ними) по-прежнему упорядочены в порядке определения.

Столкновение лицом к лицу с этой проблемой было одним из факторов, которые побудили меня написать замену для классов данных. Обходные пути, описанные здесь, хотя и полезны, требуют, чтобы код был искажен до такой степени, что они полностью сводят на нет наивный подход классов данных преимущества удобочитаемости (при котором упорядочение полей тривиально предсказуемо).

biqqles
источник
1

Возможный обходной путь - использовать исправление обезьяны для добавления родительских полей

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, ...)

Патрик
источник
1

Вы можете использовать модифицированную версию классов данных, которая будет генерировать __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)

(также опубликовано как суть , протестировано с помощью бэкпорта 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)(что является допустимым питоном). Единственное предостережение здесь - не разрешать инициализировать объекты позиционными аргументами, но в остальном это совершенно обычный dataclassметод без уродливых хаков.

Ноамк
источник