Dziedziczenie klas w klasach danych Python 3.7

90

Obecnie próbuję swoich sił w nowych konstrukcjach klas danych wprowadzonych w Pythonie 3.7. Obecnie utknąłem na próbach dziedziczenia klasy nadrzędnej. Wygląda na to, że kolejność argumentów została zepsuta przez moje obecne podejście, tak że parametr bool w klasie potomnej jest przekazywany przed innymi parametrami. Powoduje to błąd typu.

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

Po uruchomieniu tego kodu otrzymuję to TypeError:

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

Jak to naprawić?

Mysterio
źródło
ugly: bool = True= rekt :)
four43

Odpowiedzi:

138

Sposób, w jaki klasy danych łączy atrybuty, uniemożliwia użycie atrybutów z wartościami domyślnymi w klasie bazowej, a następnie użycie atrybutów bez wartości domyślnych (atrybuty pozycyjne) w podklasie.

Dzieje się tak, ponieważ atrybuty są łączone, zaczynając od dołu MRO i tworząc uporządkowaną listę atrybutów w kolejności od pierwszego zobaczenia; nadpisania są zachowywane w ich pierwotnej lokalizacji. ParentZaczyna się więc od ['name', 'age', 'ugly'], gdzie uglyma wartość domyślną, a następnie Childdodaje ['school']na końcu tej listy (z uglyjuż na liście). Oznacza to, że kończysz z, ['name', 'age', 'ugly', 'school']a ponieważ schoolnie ma wartości domyślnej, skutkuje to nieprawidłowym wykazem argumentów dla __init__.

Jest to udokumentowane w klasach danych PEP-557 , w ramach dziedziczenia :

Gdy klasa danych jest tworzona przez @dataclassdekorator, przegląda wszystkie klasy bazowe klasy w odwrotnym MRO (czyli zaczynając od object) i dla każdej znalezionej klasy danych dodaje pola z tej klasy bazowej do uporządkowanej mapowanie pól. Po dodaniu wszystkich pól klasy bazowej dodaje własne pola do uporządkowanego mapowania. Wszystkie wygenerowane metody wykorzystają to połączone, obliczone uporządkowane mapowanie pól. Ponieważ pola są w kolejności wstawiania, klasy pochodne zastępują klasy podstawowe.

i pod specyfikacją :

TypeErrorzostanie podniesiony, jeśli pole bez wartości domyślnej następuje po polu z wartością domyślną. Dzieje się tak, gdy dzieje się to w jednej klasie lub w wyniku dziedziczenia klas.

Masz tutaj kilka opcji, aby uniknąć tego problemu.

Pierwszą opcją jest użycie oddzielnych klas bazowych, aby wymusić umieszczenie pól z wartościami domyślnymi na późniejszej pozycji w kolejności MRO. Za wszelką cenę unikaj ustawiania pól bezpośrednio w klasach, które mają być używane jako klasy bazowe, takich jak Parent.

Działa następująca hierarchia klas:

# 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

Wyciągając pola do oddzielnych klas bazowych z polami bez wartości domyślnych i polami z wartościami domyślnymi oraz starannie dobraną kolejnością dziedziczenia, można utworzyć MRO, które umieszcza wszystkie pola bez wartości domyślnych przed polami z wartościami domyślnymi. Odwrócona MRO (ignorująca object) dla Childto:

_ParentBase
_ChildBase
_ParentDefaultsBase
_ChildDefaultsBase
Parent

Zwróć uwagę, że Parentnie ustawia to żadnych nowych pól, więc nie ma znaczenia, że ​​kończy się jako ostatnie w kolejności wyświetlania pól. Klasy z polami bez wartości domyślnych ( _ParentBasei _ChildBase) poprzedzają klasy z polami z wartościami domyślnymi ( _ParentDefaultsBasei _ChildDefaultsBase).

Rezultatem jest Parenti Childklasy z rozsądnym polem starsze, podczas gdy Childnadal jest podklasą 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

więc możesz tworzyć instancje obu klas:

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

Inną opcją jest używanie tylko pól z wartościami domyślnymi; nadal możesz popełnić błąd, aby nie podać schoolwartości, podnosząc ją w __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'")

ale nie zmieniają kolejność pól; schoolkończy się po ugly:

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

a narzędzie do sprawdzania podpowiedzi typu będzie narzekać, że _no_defaultnie jest ciągiem znaków.

Możesz także skorzystać z attrsprojektu , który był projektem, który zainspirował dataclasses. Używa innej strategii łączenia dziedziczenia; ciągnie pola przesłonięte w podklasie na końcu listy pól, więc ['name', 'age', 'ugly']w Parentklasie staje się ['name', 'age', 'school', 'ugly']w Childklasie; nadpisując pole wartością domyślną, attrspozwala na nadpisanie bez konieczności wykonywania tańca MRO.

attrsobsługuje definiowanie pól bez podpowiedzi typu, ale pozwala trzymać się obsługiwanego trybu podpowiedzi typu poprzez ustawienie 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
Martijn Pieters
źródło
1
Wielkie dzięki za szczegółową odpowiedź
Mysterio
To jest bardzo pomocne. Jednak jestem zdezorientowany co do mro. Uruchamiam print (Child.mro ()) Otrzymuję: [<class ' main .Child'>, <class ' main .Parent'>, <class ' main ._ChildDefaultsBase'>, <class ' main ._ParentDefaultsBase'>, < class ' main ._ChildBase'>, <class ' main ._ParentBase'>, <class 'object'>] Czy więc domyślne bazy nie poprzedzają klas bazowych?
Ollie
1
@Ollie to właściwa kolejność; zauważ, że wymieniłem to w mojej odpowiedzi. Jeśli masz wiele klas bazowych, potrzebujesz sposobu na zlinearyzowanie zaangażowanych klas, aby zdecydować, które klasy są przed innymi podczas dziedziczenia. Python używa metody linearyzacji C3, a moja odpowiedź wykorzystuje sposób, w jaki to działa, aby zapewnić, że atrybuty z wartościami domyślnymi zawsze pojawią się po wszystkich atrybutach bez wartości domyślnych.
Martijn Pieters
Właściwie atry mogą działać, ale musisz ich użyć attr.ib(kw_only=True), zobacz github.com/python-attrs/attrs/issues/38
laike9m.
8

Widzisz ten błąd, ponieważ argument bez wartości domyślnej jest dodawany po argumencie z wartością domyślną. Kolejność wstawiania dziedziczonych pól do klasy danych jest odwrotnością kolejności rozwiązywania metod , co oznacza, że Parentpola zajmują pierwsze miejsce, nawet jeśli zostaną później nadpisane przez ich dzieci.

Przykład z PEP-557 - Klasy danych :

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

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

Ostateczna lista pól to w kolejności x, y, z. Ostatnim typem xjest int, jak określono w klasie C.

Niestety nie sądzę, aby można było to obejść. Rozumiem, że jeśli klasa nadrzędna ma argument domyślny, żadna klasa podrzędna nie może mieć argumentów innych niż domyślne.

Patrick Haugh
źródło
Rozumiem, że argument inny niż domyślny musi występować przed argumentem domyślnym, ale jak to zrobić, gdy argumenty nadrzędne są inicjalizowane przed dodaniem argumentów podrzędnych?
Mysterio
3
Niestety nie sądzę, aby można było to obejść. Rozumiem, że jeśli klasa nadrzędna ma argument domyślny, żadna klasa podrzędna nie może mieć argumentów innych niż domyślne.
Patrick Haugh
1
Czy możesz dodać te informacje do odpowiedzi, zanim ją zaznaczę? Pomoże komuś kiedyś. Szkoda, że ​​ograniczenie klas danych. Renderuje to dyskusję na temat mojego obecnego projektu w Pythonie. Miło jest jednak widzieć takie realizacje
Mysterio
6

Możesz używać atrybutów z wartościami domyślnymi w klasach nadrzędnych, jeśli wykluczysz je z funkcji init. Jeśli potrzebujesz możliwości nadpisania wartości domyślnej w init, rozszerz kod o odpowiedź 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
SimonMarcin
źródło
Myślę, że ta odpowiedź powinna być bardziej rozpoznawalna. Rozwiązano problem posiadania domyślnego pola w klasie nadrzędnej, usuwając w ten sposób błąd TypeError.
Nils Bengtsson
5

w oparciu o rozwiązanie Martijn Pieters wykonałem następujące czynności:

1) Utwórz miksowanie implementujące 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) Następnie na zajęciach z problemem dziedziczenia:

from src.utils import no_default, NoDefaultAttributesChild

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

EDYTOWAĆ:

Po jakimś czasie znajduję również problemy z tym rozwiązaniem z myPy, poniższy kod rozwiązuje problem.

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
Daniel Albarral
źródło
Czy zamierzałeś napisać „klasę MyDataclass (DataclassWithDefaults, NoDefaultAttributesPostInitMixin)” powyżej w 2)?
Scott P.
5

Poniższe podejście rozwiązuje ten problem podczas korzystania z czystego języka Python dataclassesi bez dużej ilości kodu standardowego.

Do ugly_init: dataclasses.InitVar[bool]służy jako pseudo-pole tylko pomóc nam zrobić inicjalizacji i zostaną utracone po utworzeniu instancji. While ugly: bool = field(init=False)to element członkowski instancji, który nie zostanie zainicjowany __init__metodą, ale może być alternatywnie zainicjowany przy użyciu __post_init__metody (więcej można znaleźć tutaj ).

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

Jeśli chcesz użyć wzorca, w którym ugly_initjest opcjonalny, możesz zdefiniować metodę klasy w obiekcie nadrzędnym, która zawiera ugly_initjako opcjonalny parametr:

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

Teraz możesz użyć createmetody class jako metody fabrycznej do tworzenia klas nadrzędnych / podrzędnych z domyślną wartością dla ugly_init. Zauważ, że musisz użyć nazwanych parametrów, aby to podejście działało.

Praveen Kulkarni
źródło
parametr ugly_init jest teraz wymaganym parametrem bez wartości domyślnych
Vadym Tyemirov
2

Wróciłem do tego pytania po odkryciu, że klasy danych mogą uzyskiwać parametr dekoratora, który umożliwia zmianę kolejności pól. Jest to z pewnością obiecująca zmiana, chociaż wydaje się, że rozwój tej funkcji nieco się zatrzymał.

W tej chwili możesz uzyskać to zachowanie i kilka innych subtelności, używając klasy danych , mojej ponownej implementacji klas danych, która przezwycięża takie frustracje. Użycie from dataclassyzamiast from dataclassesw oryginalnym przykładzie oznacza, że ​​działa on bez błędów.

Używanie funkcji inspect do drukowania podpisu Childsprawia, że ​​wszystko jest jasne; wynik jest (name: str, age: int, school: str, ugly: bool = True). Kolejność pól jest zawsze zmieniana, tak aby pola z wartościami domyślnymi występowały po polach bez nich w parametrach inicjatora. Obie listy (pola bez wartości domyślnych i te z nimi) są nadal uporządkowane w kolejności definicji.

Stanie twarzą w twarz z tym problemem było jednym z czynników, które skłoniły mnie do napisania zamiennika klas danych. Opisane tutaj obejścia, choć pomocne, wymagają, aby kod był zniekształcony do takiego stopnia, że ​​całkowicie neguje naiwne podejście klas danych, które zapewnia przewagę czytelności (gdzie porządkowanie pól jest trywialnie przewidywalne).

biqqles
źródło
1

Możliwym obejściem jest użycie małpiego poprawiania w celu dołączenia pól nadrzędnych

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)

Możliwe jest również dodawanie pól innych niż domyślne, przez zaznaczenie if f.default is dc.MISSING, ale prawdopodobnie jest to zbyt brudne.

Chociaż małpie łatanie nie ma niektórych cech dziedziczenia, nadal można je stosować do dodawania metod do wszystkich klas pseudo-potomnych.

Aby uzyskać bardziej szczegółową kontrolę, ustaw wartości domyślne za pomocą dc.field(compare=False, repr=True, ...)

Patrick
źródło
1

Możesz użyć zmodyfikowanej wersji klas danych, która wygeneruje __init__metodę opartą tylko na słowach kluczowych :

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)

(również opublikowane jako sedno , przetestowane z backportem Python 3.6)

Będzie to wymagało zdefiniowania klasy podrzędnej jako

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

I wygeneruje __init__(self, *, name:str, age:int, ugly:bool=True, school:str)(co jest prawidłowym Pythonem). Jedynym zastrzeżeniem jest to, że nie pozwala na inicjalizację obiektów za pomocą argumentów pozycyjnych, ale poza tym jest to całkowicie normalne dataclassbez brzydkich hacków.

noamk
źródło