Cómo usar la función super() eficientemente

Cómo usar la función super() eficientemente

Traducido del inglés de Python’s super() considered super!.

Si no estás impresionado por la función incorporada super(), probablemente no sepas de lo que es realmente capaz o cómo usarla efectivamente.

Mucho se ha escrito acerca de super() y la mayoría ha sido un fracaso. Este artículo busca mejorar la situación de la siguiente manera.

  • Proveyendo casos de uso prácticos,
  • otorgando un modelo mental claro acerca de cómo funciona,
  • mostrando la técnica para hacerla funcionar siempre,
  • priorizando ejemplos reales por sobre los puramente teóricos.

Empecemos por un caso de uso básico, una subclase para extender un método desde una de las clases incorporadas [los diccionarios].

class LoggingDict(dict):
    def __setitem__(self, key, value):
        logging.info('Setting to %r' % (key, value))
        super().__setitem__(key, value)

Esta clase tiene la misma funcionalidad que su padre, dict, pero extiende el método __setitem__ para hacer un registro cuando una clave es actualizada. Hecho esto, el método emplea super() para delegar el trabajo de actualizar el diccionario con el par clave/valor.

Antes que super() fuese introducida, hubiésemos tenido que cablear la llamada vía dict.__setitem__(self, key, value). No obstante, super() es mejor porque es una referencia indirecta calculada.

Uno de los beneficios de la indirección es que no tenemos que especificar la clase padre por su nombre. Si editas el código fuente para cambiar la clase padre a alguna otra, la referencia de super() la va a seguir automáticamente. Tienes una única fuente de verdad:

class LoggingDict(SomeOtherMapping):            # nueva clase padre
    def __setitem__(self, key, value):
        logging.info('Settingto %r' % (key, value))
        super().__setitem__(key, value)         # sin cambios necesarios

Además de aislar los cambios, hay otro benificio importante de la indirección calculada, uno que acaso no será familiar para aquellos provenientes de lenguajes estáticos. Dado que la indirección es calculada en tiempo de ejecución, tenemos la libertad de influenciar dicho cálculo para que la indirección apunte a alguna otra clase.

El cálculo depende tanto de la clase desde donde super() es llamada como del árbol de ancestros de la instancia. El primer componente, la clase desde donde es llamada, es determinada por el código fuente para esa clase. En nuestro ejemplo, super() es llamada en el método LoggingDict.__setitem__. Ese componente es fijo. El segundo y más interesante componente es variable (podemos crear nuevas subclases con un rico árbol de ancestros).

Usemos esto en provecho nuestro para construir un diccionario ordenado con capacidad de registro sin modificar nuestras clases existentes:

class LoggingOD(LoggingDict, collections.OrderedDict):
    pass

El árbol de ancestros para nuestra nueva clase es: LoggingOD, LoggingDict, OrderedDict, dict, object. Para nuestros propósitos, ¡el resultado importante es que OrderedDict fue insertado después que LoggingDict y antes que dict! Esto significa que la llamada a super() en LoggingDict.__setitem__ ahora despacha el par clave/valor a OrderedDict en lugar de dict.

Piensa en eso por un momento. No alteramos el código fuente de LoggingDict. En su lugar creamos una subclase cuya única logica es la de unir dos clases existentes y controlar su orden de resolución.

Orden de resolución

Lo que he estado llamando orden de resolución o árbol de ancestros es oficialmente conocido como Method Resolution Order o MRO. Es fácil de verlo imprimiento el atributo __mro__.

>>> pprint(LoggingOD.__mro__)
(<class '__main__.LoggingOD'>,
 <class '__main__.LoggingDict'>,
 <class 'collections.OrderedDict'>,
 <class 'dict'>,
 <class 'object'>)

Si nuestro objetivo es crear una subclase con un MRO a nuestro gusto, tenemos que saber cómo es calculado. Los principios son simples. La secuencia incluye la clase, sus padres, los padres de estos padres y así sucesivamente hasta alcanzar object que es la clase troncal de todas las clases. La secuencia está ordenada de modo que una clase aparezca siempre antes que sus padres, y si hay múltiples padres, mantienen el mismo orden que la tupla de clases padres [__bases__].

El MRO mostrado anteriormente es el orden que sigue esas reglas:

  • LoggingOD precede a sus padres, LoggingDict y OrderedDict.
  • LoggingDict precede a OrderedDict porque LoggingOD.__bases__ es (LoggingDict, OrderedDict).
  • LoggingDict precede a su padre que es dict.
  • OrderedDict precede a su padre que es dict.
  • dict precede a su padre que es object.

El proceso de resolver esas restricciones es conocido como linealización. Hay un buen número de artículos escritos sobre la materia, pero para crear subclases con un MRO a nuestro gusto, solo tenemos que conocer las dos reglas: hijos preceden a sus padres y el orden en que aparecen en __bases__ debe ser respetado.

Consejo práctico

super() está en el negocio de delegar llamadas a métodos a alguna clase en el árbol de ancestros de la instancia. Para que las llamadas a métodos reordenables funcionen, las clases tienen que diseñarse cooperativamente. Esto presenta tres dificultades prácticas fácilmente solucionables:

  • el método llamado por super() tiene que existir
  • el que llama y el que es llamado tienen que tener la misma estructura de argumentos
  • toda ocurrencia del método tiene que usar super()

1) Veamos primero las estrategias para que los argumentos desde donde es llamada super() tengan la misma estructura de argumentos que el método invocado. Esto es un poco más complejo que la solución tradicional en donde se sabe de antemano el método que se va a llamar. Con super(), este último no es conocido al momento en que la clase es escrita (porque una subclase escrita después podría introducir nuevas clases en el MRO).

Una solución es mantenerse con una estructura fija de argumentos posicionales. Esto funciona bien con métodos como __setitem__ que tiene una estructura fija de dos argumentos, una clave y un valor. Esta técnica se muestra en el ejemplo de LoggingDict donde __setitem__ tiene la misma estructura en ambos LoggingDict y dict.

Otra solución más flexible es tener a cada método en el árbol de ancestros diseñado cooperativamente para aceptar argumentos posicionales y un diccionario de argumentos por nombre, para obtener los argumentos que necesite, y luego redireccionar los restantes usando **kwds [véase Argumentos en funciones (*args y **kwargs)], eventualmente dejando el diccionario vacío para la última llamada de la cadena.

Cada nivel quita los argumentos por nombre que necesita para que al final se envíe un diccionario vacío a un método que no espera ningún argumento (por ejemplo, object.__init__).

class Shape:
    def __init__(self, shapename, **kwds):
        self.shapename = shapename
        super().__init__(**kwds)        

class ColoredShape(Shape):
    def __init__(self, color, **kwds):
        self.color = color
        super().__init__(**kwds)

cs = ColoredShape(color='red', shapename='circle')

2) Habiendo visto las estrategias para que las estructuras de los argumentos coincidan entre el método que llama y el que es llamado, veamos ahora cómo asegurarnos que el método que debemos llamar exista.

El ejemplo anterior muestra el caso más simple. Sabemos que object tiene un método __init__ y que object es siempre la última clase en la cadena de resolución de métodos (MRO), por lo que toda secuencia de llamadas a super().__init__ está garantizada a terminar en una llamada al método object.__init__. En otras palabras, estamos seguros que el objetivo de la llamada a super() existe y por ende no fallará con un AttributeError.

Para los casos en los que object no tiene un método de interés (por ejemplo, un método draw()), tenemos que escribir una clase troncal [root] que sí o sí será llamada antes que object. La responsabilidad de la clase troncal es simplemente la de «comerse» la llamada al método sin hacer una redirección usando super().

Root.draw también puede emplear programación defensiva haciendo una comprobación para asegurarse que no está encubriendo algún otro método draw() que venga luego en la cadena. Esto podría pasar si una subclase incorpora erróneamente una clase que tiene el método draw() pero que no hereda de Root:

class Root:
    def draw(self):
        # la cadena de delegación se detiene aquí
        assert not hasattr(super(), 'draw')

class Shape(Root):
    def __init__(self, shapename, **kwds):
        self.shapename = shapename
        super().__init__(**kwds)
    def draw(self):
        print('Drawing.  Setting shape to:', self.shapename)
        super().draw()

class ColoredShape(Shape):
    def __init__(self, color, **kwds):
        self.color = color
        super().__init__(**kwds)
    def draw(self):
        print('Drawing.  Setting color to:', self.color)
        super().draw()

cs = ColoredShape(color='blue', shapename='square')
cs.draw()

Si las subclases quieren inyectar otras clases en el MRO, esas otras clases también tienen que heredar de Root para que ningún camino para llamar a draw() pueda alcanzar object sin haber sido detenido por Root.draw. Esto tendría que estar claramente documentado de modo que alguien que esté escribiendo nuevas clases cooperativas sepa que tiene que heredar de Root. Esta restricción no es muy diferente del requerimiento de Python de que toda nueva excepción debe heredar de BaseException.

3) Las técnicas mostradas arriba aseguran que super() llame a un método que se sabe que existe y que la estructura será correcta; no obstante, todavía dependemos de que super() sea llamada en cada paso para que la cadena de delegación no se rompa. Esto es fácil de conseguir si estamos diseñando las clases cooperativamente – simplemente añade una llamada a super() a cada método en la cadena.

Las tres técnicas listadas proveen los medios para diseñar clases cooperativas que pueden ser compuestas u reordenadas por subclases.

Cómo incorporar una clase no cooperativa

Ocasionalmente, una clase querrá usar técnicas cooperativas de herencia múltiple con una clase de tercero que no fue diseñada para ello (tal vez su método de interés no emplea super() o bien la clase no hereda de la clase troncal). Esta situación es fácilmente remediada creando una clase adaptador que sigue las reglas.

Por ejemplo, la siguiente clase Moveable no hace llamadas a super(), su método __init__() tiene una estructura que es incompatible con object.__init__, y no hereda de Root.

class Moveable:
    def __init__(self, x, y):
        self.x = x
        self.y = y
    def draw(self):
        print('Drawing at position:', self.x, self.y)

Si queremos usar esta clase con nuestra ColoredShape, que fue diseñada cooperativamente, necesitamos crear un adaptador con las llamadas a super() requeridas.

class MoveableAdapter(Root):
    def __init__(self, x, y, **kwds):
        self.movable = Moveable(x, y)
        super().__init__(**kwds)
    def draw(self):
        self.movable.draw()
        super().draw()

class MovableColoredShape(ColoredShape, MoveableAdapter):
    pass

MovableColoredShape(color='red', shapename='triangle',
                    x=10, y=20).draw()

Notas y referencias

* Al hacer una subclase de una clase incorporada como dict(), generalmente es necesario reemplazar o extender múltiples métodos al mismo tiempo. En los ejemplos anteriores, la extensión __setitem__ es usada por otros métodos como dict.update, por lo que sería necesario extender este último también. Este requerimiento no es propio de super(); más bien, aparece siempre que se crea una subclase de una clase incorporada.

* Si una clase depende de una clase padre precediendo otra (por ejemplo, LoggingOD depende de LoggingDict viniendo antes que OrderedDict, que, a su vez, viene antes que dict), es fácil introducir comprobaciones para validar y documentar el orden de resolución de métodos esperado:

position = LoggingOD.__mro__.index
assert position(LoggingDict) < position(OrderedDict)
assert position(OrderedDict) < position(dict)

* Buenos escritos sobre algoritmos de linealización pueden ser encontrados en la documentación de Python sobre MRO y en la entrada de Wikipedia para Linealización C3 [en inglés].

* El lenguaje de programación Dylan tiene un método que opera de forma similar a la función super() de Python. Véase la documentación sobre las clases de Dylan para un pequeño escrito sobre cómo funciona.

* En este artículo se usa la versión de Python 3 de super(). El código fuente completo puede encontrarse en: Recipe 577720. La sintaxis de Python 2 difiere en que los argumentos type y object de super() son explícitos en lugar de implícitos. También, la versión de Python 2 de super() solo funciona con clases de nuevo estilo [new-style classes] (aquellas que explícitamente heredan de object o algún otro tipo incorporado). El código fuente completo usando la sintaxis de Python 2 está en: Recipe 577721.

Agradecimientos

Varios pythonistas hicieron una revisión preliminar de este artículo. Sus comentarios ayudaron a mejorarlo un poco.

Ellos son: Laura Creighton, Alex Gaynor, Philip Jenvey, Brian Curtin, David Beazley, Chris Angelico, Jim Baker, Ethan Furman, y Michael Foord. Gracias a cada uno de ellos.

Curso online 👨‍💻

¡Ya lanzamos el curso oficial de Recursos Python en Udemy! Un curso moderno para aprender Python desde cero con programación orientada a objetos, SQL y tkinter en 2024.

Consultoría 💡

Ofrecemos servicios profesionales de desarrollo y capacitación en Python a personas y empresas. Consultanos por tu proyecto.

Deja una respuesta