Herencia de clases en clases de datos de Python 3.7

84

Actualmente estoy probando las nuevas construcciones de clases de datos introducidas en Python 3.7. Actualmente estoy atascado tratando de hacer algo de herencia de una clase principal. Parece que el orden de los argumentos está arruinado por mi enfoque actual, de modo que el parámetro bool en la clase secundaria se pasa antes que los otros parámetros. Esto está provocando un error de tipo.

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

Cuando ejecuto este código, obtengo esto TypeError:

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

¿Cómo puedo solucionar esto?

Mysterio
fuente

Respuestas:

125

La forma en que las clases de datos combinan atributos le impide poder usar atributos con valores predeterminados en una clase base y luego usar atributos sin un valor predeterminado (atributos posicionales) en una subclase.

Esto se debe a que los atributos se combinan comenzando desde la parte inferior del MRO y creando una lista ordenada de los atributos en el orden en que se ven por primera vez; las anulaciones se mantienen en su ubicación original. Entonces Parentcomienza con ['name', 'age', 'ugly'], where uglytiene un valor predeterminado, y luego se Childagrega ['school']al final de esa lista (con uglyya en la lista). Esto significa que termina con ['name', 'age', 'ugly', 'school']y debido a schoolque no tiene un valor predeterminado, esto da como resultado una lista de argumentos no válidos para __init__.

Esto está documentado en PEP-557 Dataclasses , bajo herencia :

Cuando el @dataclassdecorador crea la clase de datos , examina todas las clases base de la clase en MRO inverso (es decir, comenzando en object) y, para cada clase de datos que encuentra, agrega los campos de esa clase base a un orden mapeo de campos. Después de agregar todos los campos de la clase base, agrega sus propios campos al mapeo ordenado. Todos los métodos generados utilizarán este mapeo ordenado calculado combinado de campos. Como los campos están en orden de inserción, las clases derivadas anulan las clases base.

y bajo Especificación :

TypeErrorse generará si un campo sin un valor predeterminado sigue a un campo con un valor predeterminado. Esto es cierto cuando esto ocurre en una sola clase o como resultado de la herencia de clases.

Tiene algunas opciones aquí para evitar este problema.

La primera opción es usar clases base separadas para forzar los campos con valores predeterminados a una posición posterior en la orden MRO. A toda costa, evite establecer campos directamente en clases que se utilizarán como clases base, como Parent.

La siguiente jerarquía de clases funciona:

# 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

Al extraer campos en clases base separadas con campos sin valores predeterminados y campos con valores predeterminados, y un orden de herencia cuidadosamente seleccionado, puede producir un MRO que coloca todos los campos sin valores predeterminados antes que aquellos con valores predeterminados. El MRO invertido (ignorando object) para Childes:

_ParentBase
_ChildBase
_ParentDefaultsBase
_ChildDefaultsBase
Parent

Tenga en cuenta que Parentno establece ningún campo nuevo, por lo que no importa aquí que termine como 'último' en el orden de lista de campos. Las clases con campos sin valores predeterminados ( _ParentBasey _ChildBase) preceden a las clases con campos con valores predeterminados ( _ParentDefaultsBasey _ChildDefaultsBase).

El resultado es Parenty Childclases con un campo sano más antiguo, mientras que Childsigue siendo una subclase de 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

y así puedes crear instancias de ambas clases:

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

Otra opción es usar solo campos con valores predeterminados; aún puede cometer un error al no proporcionar un schoolvalor, aumentando uno en __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'")

pero esto hace alterar el orden de los campos; schooltermina después de ugly:

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

y un verificador de sugerencias de tipo se quejará de que _no_defaultno es una cadena.

También puede utilizar el attrsproyecto , que fue el proyecto que inspiró dataclasses. Utiliza una estrategia de fusión de herencia diferente; tira de los campos anulados en una subclase al final de la lista de campos, por lo que ['name', 'age', 'ugly']en la Parentclase se convierte ['name', 'age', 'school', 'ugly']en la Childclase; anulando el campo con un valor predeterminado, attrspermite la anulación sin necesidad de hacer un baile MRO.

attrsadmite la definición de campos sin sugerencias de tipo, pero sigamos con el modo de sugerencia de tipo admitido configurando 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
fuente
1
Muchas gracias por la respuesta detallada
Mysterio
Esto es muy útil. Sin embargo, estoy confundido acerca del mro. Al ejecutar print (Child.mro ()) obtengo: [<class ' main .Child'>, <class ' main .Parent'>, <class ' main ._ChildDefaultsBase'>, <class ' main ._ParentDefaultsBase'>, < class ' main ._ChildBase'>, <class ' main ._ParentBase'>, <class 'object'>] Entonces, ¿las bases predeterminadas no preceden a las clases base?
Ollie
1
@Ollie ese es el orden correcto; tenga en cuenta que lo enumeré en mi respuesta. Cuando tiene varias clases base, necesita una forma de linealizar las clases involucradas para decidir qué clases vienen antes que otras al heredar. Python usa el método de linealización C3 y mi respuesta aprovecha cómo funciona esto para garantizar que los atributos con valores predeterminados siempre vengan después de todos los atributos sin valores predeterminados.
Martijn Pieters
En realidad, los attrs pueden funcionar, pero debes usarlos attr.ib(kw_only=True), consulta github.com/python-attrs/attrs/issues/38
laike9m
8

Está viendo este error porque se agrega un argumento sin un valor predeterminado después de un argumento con un valor predeterminado. El orden de inserción de los campos heredados en la clase de datos es el inverso del Orden de resolución del método , lo que significa que los Parentcampos vienen primero, incluso si sus hijos los sobrescriben más tarde.

Un ejemplo de PEP-557 - Clases de datos :

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

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

La lista final de campos es, en orden x, y, z,. El tipo final de xes int, como se especifica en la clase C.

Desafortunadamente, no creo que haya ninguna forma de evitar esto. Tengo entendido que si la clase principal tiene un argumento predeterminado, ninguna clase secundaria puede tener argumentos no predeterminados.

Patrick Haugh
fuente
Entiendo que el argumento no predeterminado debe ir antes que el predeterminado, pero ¿cómo puede hacerlo cuando los argumentos principales se inicializan antes de agregar los argumentos secundarios?
Mysterio
3
Desafortunadamente, no creo que haya ninguna forma de evitarlo. Tengo entendido que si la clase principal tiene un argumento predeterminado, ninguna clase secundaria puede tener argumentos no predeterminados.
Patrick Haugh
1
¿Puedes agregar esa información a la respuesta antes de que la marque? Ayudará a alguien algún día. Es bastante lamentable esa limitación de las clases de datos. Hace que sea discutible mi proyecto actual de Python. Es bueno ver tales implementaciones aunque
Mysterio
5

Puede usar atributos con valores predeterminados en las clases principales si los excluye de la función init. Si necesita la posibilidad de anular el valor predeterminado en init, amplíe el código con la respuesta de 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
fuente
Creo que esta respuesta debería ser más reconocida. Resolvió el problema de tener un campo predeterminado en la clase principal, por lo que elimina el TypeError.
Nils Bengtsson
5

basado en la solución de Martijn Pieters hice lo siguiente:

1) Crea una mezcla implementando 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) Luego en las clases con el problema de herencia:

from src.utils import no_default, NoDefaultAttributesChild

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

EDITAR:

Después de un tiempo también encuentro problemas con esta solución con mypy, el siguiente código soluciona el problema.

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
fuente
¿Tenía la intención de escribir "clase MyDataclass (DataclassWithDefaults, NoDefaultAttributesPostInitMixin)" arriba en 2)?
Scott P.
4

El siguiente enfoque trata este problema mientras se usa Python puro dataclassesy sin mucho código repetitivo.

Los ugly_init: dataclasses.InitVar[bool]sirve como un pseudo-campo sólo para ayudar a hacer la inicialización y se perderán una vez que se crea la instancia. Mientras ugly: bool = field(init=False)es un miembro de instancia que no se inicializará mediante el __init__método, pero se puede inicializar alternativamente mediante el __post_init__método (puede encontrar más aquí ).

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()
Praveen Kulkarni
fuente
ugly_init ahora es un parámetro obligatorio sin valor predeterminado
Vadym Tyemirov
2

Volví a esta pregunta después de descubrir que las clases de datos pueden estar obteniendo un parámetro de decorador que permite reordenar los campos. Este es sin duda un desarrollo prometedor, aunque el desarrollo de esta característica parece haberse estancado un poco.

En este momento, puede obtener este comportamiento, además de algunas otras sutilezas, utilizando dataclassy , mi reimplementación de clases de datos que supera frustraciones como esta. Usar from dataclassyen lugar de from dataclassesen el ejemplo original significa que se ejecuta sin errores.

El uso de inspeccionar para imprimir la firma de Childaclara lo que está sucediendo; el resultado es (name: str, age: int, school: str, ugly: bool = True). Los campos siempre se reordenan para que los campos con valores predeterminados vengan después de los campos sin ellos en los parámetros del inicializador. Ambas listas (los campos sin valores predeterminados y los que los tienen) todavía están ordenados por orden de definición.

Enfrentarme cara a cara con este problema fue uno de los factores que me impulsó a escribir un reemplazo para las clases de datos. Las soluciones que se detallan aquí, aunque son útiles, requieren que el código se contorsione hasta tal punto que nieguen por completo la ventaja de legibilidad que ofrece el enfoque ingenuo de las clases de datos (por el que el orden de los campos es trivialmente predecible).

biqqles
fuente
1

Una posible solución es utilizar el parche de mono para agregar los campos principales

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)

También es posible anteponer campos no predeterminados, marcando if f.default is dc.MISSING, pero esto probablemente sea demasiado sucio.

Si bien el parche de mono carece de algunas características de herencia, aún se puede usar para agregar métodos a todas las clases pseudo-secundarias.

Para un control más detallado, configure los valores predeterminados usando dc.field(compare=False, repr=True, ...)

Patricio
fuente
1

Puede utilizar una versión modificada de clases de datos, que generará un __init__método de palabra clave únicamente :

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)

(también publicado como esencia , probado con el backport de Python 3.6)

Esto requerirá definir la clase secundaria como

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

Y generaría __init__(self, *, name:str, age:int, ugly:bool=True, school:str)(que es python válido). La única advertencia aquí es no permitir inicializar objetos con argumentos posicionales, pero por lo demás es un proceso completamente regular dataclasssin trucos desagradables.

noamk
fuente