Subclases: ¿Es posible anular una propiedad con un atributo convencional?

14

Supongamos que queremos crear una familia de clases que sean implementaciones o especializaciones diferentes de un concepto global. Supongamos que hay una implementación predeterminada plausible para algunas propiedades derivadas. Nos gustaría poner esto en una clase base

class Math_Set_Base:
    @property
    def size(self):
        return len(self.elements)

Por lo tanto, una subclase podrá contar automáticamente sus elementos en este ejemplo bastante tonto

class Concrete_Math_Set(Math_Set_Base):
    def __init__(self,*elements):
        self.elements = elements

Concrete_Math_Set(1,2,3).size
# 3

Pero, ¿qué pasa si una subclase no quiere usar este valor predeterminado? Esto no funciona:

import math

class Square_Integers_Below(Math_Set_Base):
    def __init__(self,cap):
        self.size = int(math.sqrt(cap))

Square_Integers_Below(7)
# Traceback (most recent call last):
#   File "<stdin>", line 1, in <module>
#   File "<stdin>", line 3, in __init__
# AttributeError: can't set attribute

Me doy cuenta de que hay formas de anular una propiedad con una propiedad, pero me gustaría evitar eso. Debido a que el propósito de la clase base es hacer la vida lo más fácil posible para su usuario, no agregar hinchazón imponiendo un método de acceso complicado y superfluo (desde el punto de vista estrecho de la subclase).

Se puede hacer? Si no, ¿cuál es la siguiente mejor solución?

Paul Panzer
fuente
Esto probablemente indica un problema con la propia jerarquía de clases. La mejor solución es reelaborar esa jerarquía. Usted menciona que "hay una implementación predeterminada plausible para algunas propiedades derivadas" con el ejemplo de conjuntos matemáticos y len(self.elements)como implementación. Esta implementación muy específica impone un contrato en todas las instancias de la clase, a saber, que proporcionan elementscuál es un contenedor de tamaño . Sin embargo, su Square_Integers_Belowclase no parece comportarse de esa manera (tal vez está generando sus miembros dinámicamente), por lo que debe definir su propio comportamiento.
a_guest hace
La anulación solo es necesaria porque en primer lugar heredó el comportamiento (incorrecto). len(self.elements)no es una implementación predeterminada adecuada para conjuntos matemáticos ya que existen "muchos" conjuntos que ni siquiera tienen cardinalidad finita. En general, si una subclase no quiere usar el comportamiento de sus clases base, debe anularlo a nivel de clase. Los atributos de instancia, como su nombre indica, funcionan en el nivel de instancia y, por lo tanto, se rigen por el comportamiento de la clase.
a_guest hace

Respuestas:

6

Una propiedad es un descriptor de datos que tiene prioridad sobre un atributo de instancia con el mismo nombre. Puede definir un descriptor sin datos con un __get__()método único : un atributo de instancia tiene prioridad sobre el descriptor sin datos con el mismo nombre, consulte los documentos . El problema aquí es que lo que se non_data_propertydefine a continuación es solo para fines de cálculo (no puede definir un establecedor o un eliminador), pero parece ser el caso en su ejemplo.

import math

class non_data_property:
    def __init__(self, fget):
        self.__doc__ = fget.__doc__
        self.fget = fget

    def __get__(self, obj, cls):
        if obj is None:
            return self
        return self.fget(obj)

class Math_Set_Base:
    @non_data_property
    def size(self, *elements):
        return len(self.elements)

class Concrete_Math_Set(Math_Set_Base):
    def __init__(self, *elements):
        self.elements = elements


class Square_Integers_Below(Math_Set_Base):
    def __init__(self, cap):
        self.size = int(math.sqrt(cap))

print(Concrete_Math_Set(1, 2, 3).size) # 3
print(Square_Integers_Below(1).size) # 1
print(Square_Integers_Below(4).size) # 2
print(Square_Integers_Below(9).size) # 3

Sin embargo, esto supone que tiene acceso a la clase base para realizar estos cambios.

Arkelis
fuente
Esto está bastante cerca de ser perfecto. Entonces, ¿esto significa que una propiedad, incluso si solo define el getter, agrega implícitamente un setter que no hace nada más que bloquear la asignación? Interesante. Probablemente acepte esta respuesta.
Paul Panzer
Sí, de acuerdo con los documentos, una propiedad siempre define los tres métodos de descriptor ( __get__(), __set__()y __delete__()) y AttributeErrorgeneran un si no proporciona ninguna función para ellos. Ver Python equivalente a la implementación de la propiedad .
Arkelis
1
Mientras no le interese la propiedad settero __set__la propiedad, esto funcionará. Sin embargo, le advierto que esto también significa que puede sobrescribir fácilmente la propiedad no solo a nivel de clase ( self.size = ...) sino incluso a nivel de instancia (por ejemplo Concrete_Math_Set(1, 2, 3).size = 10, sería igualmente válido). Alimento para los pensamientos :)
r.ook
Y Square_Integers_Below(9).size = 1también es válido ya que es un atributo simple. En este caso de uso particular de "tamaño", esto puede parecer incómodo (use una propiedad en su lugar), pero en el caso general "anular fácilmente un accesorio calculado en la subclase", podría ser bueno en algunos casos. También puede controlar el acceso a los atributos con, __setattr__()pero podría ser abrumador.
Arkelis
1
No estoy en desacuerdo con que podría haber un caso de uso válido para estos, solo quería mencionar la advertencia, ya que puede complicar los esfuerzos de depuración en el futuro. Mientras tanto usted como OP sean conscientes de las implicaciones, todo estará bien.
r.ook
10

Esta será una respuesta larga y sin aliento que solo podría ser complementaria ... pero su pregunta me llevó a dar una vuelta por la madriguera del conejo, así que también me gustaría compartir mis hallazgos (y dolor).

En última instancia, puede encontrar esta respuesta no útil para su problema real. De hecho, mi conclusión es que no haría esto en absoluto. Dicho esto, los antecedentes de esta conclusión pueden entretenerlo un poco, ya que está buscando más detalles.


Abordar algunos conceptos erróneos

La primera respuesta, aunque correcta en la mayoría de los casos, no siempre es el caso. Por ejemplo, considere esta clase:

class Foo:
    def __init__(self):
        self.name = 'Foo!'
        @property
        def inst_prop():
            return f'Retrieving {self.name}'
        self.inst_prop = inst_prop

inst_prop, aunque es un property, es irrevocablemente un atributo de instancia:

>>> Foo.inst_prop
Traceback (most recent call last):
  File "<pyshell#60>", line 1, in <module>
    Foo.inst_prop
AttributeError: type object 'Foo' has no attribute 'inst_prop'
>>> Foo().inst_prop
<property object at 0x032B93F0>
>>> Foo().inst_prop.fget()
'Retrieving Foo!'

Todo depende donde sus propertyse define en el primer lugar. Si su @propertyse define dentro de la clase "ámbito" (o realmente, el namespace), se convierte en un atributo de clase. En mi ejemplo, la clase en sí misma no conoce ninguna inst_prophasta que se instancia. Por supuesto, no es muy útil como propiedad aquí.


Pero primero, abordemos su comentario sobre la resolución de herencia ...

Entonces, ¿cómo influye exactamente la herencia en este problema? El siguiente artículo se sumerge un poco en el tema, y ​​el Orden de resolución de métodos está algo relacionado, aunque analiza principalmente la amplitud de la herencia en lugar de la profundidad.

En combinación con nuestro hallazgo, dada la siguiente configuración:

@property
def some_prop(self):
    return "Family property"

class Grandparent:
    culture = some_prop
    world_view = some_prop

class Parent(Grandparent):
    world_view = "Parent's new world_view"

class Child(Parent):
    def __init__(self):
        try:
            self.world_view = "Child's new world_view"
            self.culture = "Child's new culture"
        except AttributeError as exc:
            print(exc)
            self.__dict__['culture'] = "Child's desired new culture"

Imagine lo que sucede cuando se ejecutan estas líneas:

print("Instantiating Child class...")
c = Child()
print(f'c.__dict__ is: {c.__dict__}')
print(f'Child.__dict__ is: {Child.__dict__}')
print(f'c.world_view is: {c.world_view}')
print(f'Child.world_view is: {Child.world_view}')
print(f'c.culture is: {c.culture}')
print(f'Child.culture is: {Child.culture}')

El resultado es así:

Instantiating Child class...
can't set attribute
c.__dict__ is: {'world_view': "Child's new world_view", 'culture': "Child's desired new culture"}
Child.__dict__ is: {'__module__': '__main__', '__init__': <function Child.__init__ at 0x0068ECD8>, '__doc__': None}
c.world_view is: Child's new world_view
Child.world_view is: Parent's new world_view
c.culture is: Family property
Child.culture is: <property object at 0x00694C00>

Date cuenta cómo:

  1. self.world_viewse pudo aplicar, aunque self.culturefalló
  2. cultureno existe en Child.__dict__(el mappingproxyde la clase, no debe confundirse con la instancia __dict__)
  3. Aunque cultureexiste en c.__dict__, no se hace referencia a él.

Es posible que pueda adivinar por qué: la clase la world_viewsobrescribió Parentcomo no propiedad, por Childlo que también pude sobrescribirla. Mientras tanto, como culturese hereda, solo existe dentro mappingproxydeGrandparent :

Grandparent.__dict__ is: {
    '__module__': '__main__', 
    'culture': <property object at 0x00694C00>, 
    'world_view': <property object at 0x00694C00>, 
    ...
}

De hecho, si intentas eliminar Parent.culture:

>>> del Parent.culture
Traceback (most recent call last):
  File "<pyshell#67>", line 1, in <module>
    del Parent.culture
AttributeError: culture

Notarás que ni siquiera existe para Parent. Porque el objeto se está refiriendo directamente a Grandparent.culture.


Entonces, ¿qué pasa con la orden de resolución?

Por lo tanto, estamos interesados ​​en observar el orden de resolución real, intentemos eliminarlo Parent.world_view:

del Parent.world_view
print(f'c.world_view is: {c.world_view}')
print(f'Child.world_view is: {Child.world_view}')

¿Se pregunta cuál es el resultado?

c.world_view is: Family property
Child.world_view is: <property object at 0x00694C00>

¡Volvió a los abuelos world_view property, a pesar de que habíamos logrado asignar el self.world_viewantes! Pero, ¿qué pasa si cambiamos con fuerza world_viewa nivel de clase, como la otra respuesta? ¿Qué pasa si lo eliminamos? ¿Qué sucede si asignamos el atributo de clase actual como una propiedad?

Child.world_view = "Child's independent world_view"
print(f'c.world_view is: {c.world_view}')
print(f'Child.world_view is: {Child.world_view}')

del c.world_view
print(f'c.world_view is: {c.world_view}')
print(f'Child.world_view is: {Child.world_view}')

Child.world_view = property(lambda self: "Child's own property")
print(f'c.world_view is: {c.world_view}')
print(f'Child.world_view is: {Child.world_view}')

El resultado es:

# Creating Child's own world view
c.world_view is: Child's new world_view
Child.world_view is: Child's independent world_view

# Deleting Child instance's world view
c.world_view is: Child's independent world_view
Child.world_view is: Child's independent world_view

# Changing Child's world view to the property
c.world_view is: Child's own property
Child.world_view is: <property object at 0x020071B0>

Esto es interesante porque c.world_viewse restaura a su atributo de instancia, mientras que Child.world_viewes el que le asignamos. Después de eliminar el atributo de instancia, vuelve al atributo de clase. Y después de reasignar Child.world_viewa la propiedad, perdemos instantáneamente el acceso al atributo de instancia.

Por lo tanto, podemos suponer el siguiente orden de resolución :

  1. Si existe un atributo de clase y es a property, recupere su valor mediante gettero fget(más sobre esto más adelante). La clase actual primero a la clase base última.
  2. De lo contrario, si existe un atributo de instancia, recupere el valor del atributo de instancia.
  3. De lo contrario, recupere el propertyatributo que no es de clase. La clase actual primero a la clase base última.

En ese caso, eliminemos la raíz property:

del Grandparent.culture
print(f'c.culture is: {c.culture}')
print(f'Child.culture is: {Child.culture}')

Lo que da:

c.culture is: Child's desired new culture
Traceback (most recent call last):
  File "<pyshell#74>", line 1, in <module>
    print(f'Child.culture is: {Child.culture}')
AttributeError: type object 'Child' has no attribute 'culture'

Ta-dah! Childahora tiene el suyo culturebasado en la inserción forzada en c.__dict__. Child.cultureno existe, por supuesto, ya que nunca se definió en Parento Childatributo de clase, y Grandparentse eliminó.


¿Es esta la causa raíz de mi problema?

En realidad no . El error que está recibiendo, que todavía estamos observando al asignar self.culture, es totalmente diferente . Pero el orden de herencia establece el telón de fondo para la respuesta, que es la propertypropia.

Además del gettermétodo mencionado anteriormente , propertytambién tiene algunos buenos trucos bajo la manga. Lo más relevante en este caso es el método settero fset, que se activa por self.culture = ...línea. Como propertyno implementó ninguna función settero fgetfunción, python no sabe qué hacer, y en su AttributeErrorlugar arroja un (es decir can't set attribute).

Sin embargo, si implementó un settermétodo:

@property
def some_prop(self):
    return "Family property"

@some_prop.setter
def some_prop(self, val):
    print(f"property setter is called!")
    # do something else...

Al crear una instancia de la Childclase, obtendrá:

Instantiating Child class...
property setter is called!

En lugar de recibir un AttributeError, ahora estás llamando al some_prop.settermétodo. Lo que le da más control sobre su objeto ... con nuestros hallazgos anteriores, sabemos que necesitamos tener un atributo de clase sobrescrito antes de que llegue a la propiedad. Esto podría implementarse dentro de la clase base como desencadenante. Aquí hay un nuevo ejemplo:

class Grandparent:
    @property
    def culture(self):
        return "Family property"

    # add a setter method
    @culture.setter
    def culture(self, val):
        print('Fine, have your own culture')
        # overwrite the child class attribute
        type(self).culture = None
        self.culture = val

class Parent(Grandparent):
    pass

class Child(Parent):
    def __init__(self):
        self.culture = "I'm a millennial!"

c = Child()
print(c.culture)

Lo que resulta en:

Fine, have your own culture
I'm a millennial!

TA-DAH! ¡Ahora puede sobrescribir su propio atributo de instancia sobre una propiedad heredada!


Entonces, ¿problema resuelto?

... Realmente no. El problema con este enfoque es que ahora no puede tener un settermétodo adecuado . Hay casos en los que desea establecer valores en su property. Pero ahora cada vez que se establece self.culture = ...que será siempre sobrescribir cualquier función que ha definido en el getter(que en este caso, realmente es sólo la @propertyparte envuelta. Usted puede añadir en medidas más matizadas, pero de una manera u otra siempre va a implicar más que simplemente self.culture = .... p.ej:

class Grandparent:
    # ...
    @culture.setter
    def culture(self, val):
        if isinstance(val, tuple):
            if val[1]:
                print('Fine, have your own culture')
                type(self).culture = None
                self.culture = val[0]
        else:
            raise AttributeError("Oh no you don't")

# ...

class Child(Parent):
    def __init__(self):
        try:
            # Usual setter
            self.culture = "I'm a Gen X!"
        except AttributeError:
            # Trigger the overwrite condition
            self.culture = "I'm a Boomer!", True

Es muuuucho más complicado que la otra respuesta, size = Nonea nivel de clase.

También podría considerar escribir su propio descriptor en vez de manejar la __get__e __set__, o métodos adicionales. Pero al final del día, cuando self.culturese hace referencia, __get__siempre se activará primero, y cuando self.culture = ...se haga referencia, __set__siempre se activará primero. No hay forma de evitarlo por lo que he intentado.


El quid de la cuestión, la OMI

El problema que veo aquí es que no puedes tener tu pastel y comértelo también. propertyse entiende como un descriptor con acceso conveniente desde métodos como getattro setattr. Si también desea que estos métodos logren un propósito diferente, solo está buscando problemas. Quizás debería repensar el enfoque:

  1. ¿Realmente necesito un propertypara esto?
  2. ¿Podría un método servirme de manera diferente?
  3. Si necesito un property, ¿hay alguna razón por la que deba sobrescribirlo?
  4. ¿La subclase realmente pertenece a la misma familia si propertyno se aplica?
  5. Si necesito sobrescribir alguno / todos los propertys, ¿un método separado me serviría mejor que simplemente reasignar, ya que la reasignación puede anular accidentalmente el propertys?

Para el punto 5, mi enfoque sería tener un overwrite_prop()método en la clase base que sobrescriba el atributo de clase actual para que propertyya no se active:

class Grandparent:
    # ...
    def overwrite_props(self):
        # reassign class attributes
        type(self).size = None
        type(self).len = None
        # other properties, if necessary

# ...

# Usage
class Child(Parent):
    def __init__(self):
        self.overwrite_props()
        self.size = 5
        self.len = 10

Como puede ver, aunque todavía es un poco artificial, es al menos más explícito que un críptico size = None. Dicho esto, en última instancia, no sobrescribiría la propiedad en absoluto, y reconsideraría mi diseño desde la raíz.

Si has llegado hasta aquí, gracias por caminar este viaje conmigo. Fue un pequeño ejercicio divertido.

torre
fuente
2
Wow, muchas gracias! Me tomará un poco de tiempo digerir esto, pero supongo que lo pedí.
Paul Panzer
6

A @propertyse define a nivel de clase. La documentación entra en detalles exhaustivos sobre cómo funciona, pero es suficiente para decir que establecer u obtener la propiedad resuelve llamar a un método en particular. Sin embargo, el propertyobjeto que gestiona este proceso se define con la propia definición de la clase. Es decir, se define como una variable de clase pero se comporta como una variable de instancia.

Una consecuencia de esto es que puedes reasignarlo libremente a nivel de clase :

print(Math_Set_Base.size)
# <property object at 0x10776d6d0>

Math_Set_Base.size = 4
print(Math_Set_Base.size)
# 4

Y al igual que cualquier otro nombre de nivel de clase (por ejemplo, métodos), puede anularlo en una subclase definiéndolo de manera explícita de manera diferente:

class Square_Integers_Below(Math_Set_Base):
    # explicitly define size at the class level to be literally anything other than a @property
    size = None

    def __init__(self,cap):
        self.size = int(math.sqrt(cap))

print(Square_Integers_Below(4).size)  # 2
print(Square_Integers_Below.size)     # None

Cuando creamos una instancia real, la variable de instancia simplemente sombrea la variable de clase del mismo nombre. El propertyobjeto normalmente usa algunas travesuras para manipular este proceso (es decir, aplicar getters y setters), pero cuando el nombre de nivel de clase no se define como una propiedad, no sucede nada especial, por lo que actúa como cabría esperar de cualquier otra variable.

Chico de capa verde
fuente
Gracias, eso es refrescantemente simple. ¿Significa que incluso si nunca hubo propiedades en algún lugar de mi clase y sus antepasados, todavía buscará primero en todo el árbol de herencia un nombre de nivel de clase antes de siquiera molestarse en mirar el __dict__? Además, ¿hay alguna forma de automatizar esto? Sí, es solo una línea, pero es el tipo de cosa que es muy críptico de leer si no está familiarizado con los detalles sangrientos de las propiedades, etc.
Paul Panzer
@PaulPanzer No he investigado los procedimientos detrás de esto, por lo que no pude darle una respuesta satisfactoria. Puede intentar descifrarlo del código fuente de cpython si lo desea. En cuanto a la automatización del proceso, no creo que haya una buena manera de hacerlo que no sea convertirlo en una propiedad en primer lugar, o simplemente agregar un comentario / cadena de documentación para que los que leen su código sepan lo que está haciendo. . Algo así como# declare size to not be a @property
Green Cloak Guy
4

No necesita asignación (a size) en absoluto. sizees una propiedad en la clase base, por lo que puede anular esa propiedad en la clase secundaria:

class Math_Set_Base:
    @property
    def size(self):
        return len(self.elements)

    # size = property(lambda self: self.elements)


class Square_Integers_Below(Math_Set_Base):

    def __init__(self, cap):
        self._cap = cap

    @property
    def size(self):
        return int(math.sqrt(self._cap))

    # size = property(lambda self: int(math.sqrt(self._cap)))

Puede (micro) optimizar esto precalculando la raíz cuadrada:

class Square_Integers_Below(Math_Set_Base):

    def __init__(self, cap):
        self._size = int(math.sqrt(self._cap))

    @property
    def size(self):
        return self._size
chepner
fuente
Ese es un gran punto, ¡simplemente anule una propiedad principal con una propiedad secundaria! +1
r.ook
Esto es, de alguna manera, lo más sencillo, pero estaba interesado y solicité específicamente formas para permitir una anulación que no sea de propiedad para no imponer el inconveniente de tener que escribir una propiedad de memorización donde la asignación simple haría trabajo.
Paul Panzer
No estoy seguro de por qué querrías complicar tu código así. Por el pequeño precio de reconocer que sizees una propiedad y no un atributo de instancia, no tiene que hacer nada elegante.
chepner
Mi caso de uso son muchas clases secundarias con muchos atributos; no tener que escribir 5 líneas en lugar de una para todas esas justifica una cierta cantidad de fantasía, en mi opinión.
Paul Panzer
2

Parece que quieres definir sizeen tu clase:

class Square_Integers_Below(Math_Set_Base):
    size = None

    def __init__(self, cap):
        self.size = int(math.sqrt(cap))

Otra opción es almacenar capen su clase y calcularlo con sizedefinido como una propiedad (que anula la propiedad de la clase base size).

Uri
fuente
2

Sugiero agregar un setter así:

class Math_Set_Base:
    @property
    def size(self):
        try:
            return self._size
        except:
            return len(self.elements)

    @size.setter
    def size(self, value):
        self._size = value

De esta manera, puede anular la .sizepropiedad predeterminada de la siguiente manera:

class Concrete_Math_Set(Math_Set_Base):
    def __init__(self,*elements):
        self.elements = elements

class Square_Integers_Below(Math_Set_Base):
    def __init__(self,cap):
        self.size = int(math.sqrt(cap))

print(Concrete_Math_Set(1,2,3).size) # 3
print(Square_Integers_Below(7).size) # 2
Amir
fuente
1

También puedes hacer lo siguiente

class Math_Set_Base:
    _size = None

    def _size_call(self):
       return len(self.elements)

    @property
    def size(self):
        return  self._size if self._size is not None else self._size_call()

class Concrete_Math_Set(Math_Set_Base):
    def __init__(self, *elements):
        self.elements = elements


class Square_Integers_Below(Math_Set_Base):
    def __init__(self, cap):
        self._size = int(math.sqrt(cap))
Ryabchenko Alexander
fuente
Eso está bien, pero realmente no necesitas _sizeni estar _size_callallí. Podría haber incorporado la llamada a la función sizecomo una condición, y usar try... except... para probar en _sizelugar de tomar una referencia de clase adicional que no se utilizará. Sin embargo, creo que esto es aún más críptico que la simple size = Nonesobrescritura en primer lugar.
r.ook
0

Creo que es posible establecer una propiedad de una clase base en otra propiedad de una clase derivada, dentro de la clase derivada y luego usar la propiedad de la clase base con un nuevo valor.

En el código inicial hay una especie de conflicto entre el nombre sizede la clase base y el atributo self.sizede la clase derivada. Esto puede ser visible si reemplazamos el nombre self.sizede la clase derivada con self.length. Esto generará:

3
<__main__.Square_Integers_Below object at 0x000001BCD56B6080>

Entonces, si sustituimos el nombre del método sizecon lengthen todas las ocurrencias de todo el programa, esto dará lugar a la misma excepción:

Traceback (most recent call last):
  File "C:/Users/Maria/Downloads/so_1.2.py", line 24, in <module>
    Square_Integers_Below(7)
  File "C:/Users/Maria/Downloads/so_1.2.py", line 21, in __init__
    self.length = int(math.sqrt(cap))
AttributeError: can't set attribute

El código fijo, o de todos modos, una versión que de alguna manera funciona, es mantener el código exactamente igual, con la excepción de la clase Square_Integers_Below, que establecerá el método sizede la clase base en otro valor.

class Square_Integers_Below(Math_Set_Base):

    def __init__(self,cap):
        #Math_Set_Base.__init__(self)
        self.length = int(math.sqrt(cap))
        Math_Set_Base.size = self.length

    def __repr__(self):
        return str(self.size)

Y luego, cuando ejecutamos todo el programa, la salida es:

3
2

Espero que esto haya sido útil de una forma u otra.

Sofia190
fuente