POO: Herencia en Python

Publicado por Andrea Navarro el

En este artículo exploraremos la herencia en Python, tanto herencia simple como múltiple. Veremos cómo Python utiliza su MRO para encadenar correctamente las clases y como evita el problema del diamante. También veremos el uso de clases bases para crear herencias colaborativas y qué es un Mixin.

Herencia en Python

La herencia es un mecanismo que permite que una clase llamada hija o derivada extienda o modifique el comportamiento de otra clase llamada padre o base. Esta clase hija puede acceder a los atributos y métodos definidos en la clase padre, sobreescribirlos y añadir propios. Esto permite organizar el código en estructuras jerárquicas ordenando atributos y métodos desde lo general a lo específico.

Además de mejorar la claridad de conceptos del código por su jerarquía la herencia, también permite la reutilización de código evitando duplicar lógica. También hace mas sencillo agregar nuevos comportamientos o variantes a las clases existentes facilitando la extensión del código.

Herencia simple

La herencia simple se da cuando una clase hija hereda de una única clase padre. En el siguiente ejemplo se ha definido la clase padre, Empleado, que tiene un iniciador con los atributos nombre y dni, un método tipo_empleado que retorna un mensaje, y el método información que retorna una cadena que concatena un atributo con el resultado del método tipo_empleado.

class Empleado:
    def __init__(self, nombre, dni):
        self.nombre = nombre
        self.dni = dni    

    def tipo_empleado(self):
        return "Empleado fijo"

    def informacion(self):        
        return f"{self.nombre} | Tipo: {self.tipo_empleado()}"

Si ahora se quiere agregar al sistema un empleado temporal que comparte atributos y comportamientos con el empleado fijo, pero que tiene algunas diferencias, esto puede lograrse creando una nueva clase que extienda, o herede, de Empleado.

Para indicar esta extensión se coloca el nombre de la clase padre en la definición de la clase hija .

EmpleadoTemporal cuenta con un iniciador, al igual que Empleado, pero inicializa más atributos, además de nombre y dni se agregan los atributos horas_trabajadas y valor_hora.

Para que todos los los atributos sean inicializados se llama, dentro del __init__() de EmpleadoTemporal, al __init__() de Empleado, haciendo referencia al nombre de la clase padre.

Este es un caso donde el hijo extiende la funcionalidad del padre agregando código nuevo pero manteniendo el comportamiento del padre.

Un caso similar es el método resumen de EmpleadoTemporal, que usa para generar su respuesta la función información de Empleado, agregándole el resultado de su propio método calcular_pago().

Se puede observar que ambas clases tienen el método tipo_empleado. Al definirse e implementarse en el hijo un método existente en el padre, se está sobreescribiendo su comportamiento.

Esto significa que, si se ejecuta el método desde el hijo, solo se ejecutará la implementación del hijo, mientras que la del padre será ignorada.

class EmpleadoTemporal(Empleado):
    def __init__(self, nombre, dni, horas_trabajadas, valor_hora):
        Empleado.__init__(self,nombre, dni)
        self.horas_trabajadas = horas_trabajadas
        self.valor_hora = valor_hora
    
    def tipo_empleado(self):
        return "Empleado por hora"

    def calcular_pago(self):
        return self.horas_trabajadas * self.valor_hora

    def resumen(self):
        info = Empleado.informacion(self)               
        pago = self.calcular_pago()                
        return f"{info} | Pago calculado: ${pago}"

Es posible crear un objeto Empleado y acceder a sus atributos y métodos:

emp = Empleado("Pablo", "33.456.389")

print(emp.tipo_empleado()) 
print(emp.informacion())         
Empleado fijo
Pablo | Tipo: Empleado fijo

Si se crea un objeto EmpleadoTemporal se podrán acceder a los atributos y métodos tanto de EmpleadoTemporal y de Empleado.

emp = EmpleadoTemporal("Marta", "32.456.789", 40, 2500)

print(emp.tipo_empleado())
print(emp.calcular_pago())   
print(emp.resumen())         
Marta
32.456.789
Empleado por hora
100000
Marta | Tipo: Empleado por hora | Pago calculado: $100000

Super

Para trabajar con herencia suele utilizarse super() para llamar a atributos y métodos de la clase padre o, más específicamente, a la siguiente clase del MRO, como veremos más adelante.

Esta sentencia permite hacer referencia a la clase padre sin nombrarla explícitamente. Esto es útil en el caso de que se cambie el nombre de la clase, o se quiera modificar por otra clase con atributos y métodos similares, lo que lleva a un código más limpio y fácil de mantener.

Cuándo se utiliza super() en lugar del nombre de la clase padre no se pasa por argumento self ya que este es pasado por defecto.

class EmpleadoTemporal(Empleado):
    def __init__(self, nombre, dni, horas_trabajadas, valor_hora):
        print(nombre)
        print(dni)        
        super().__init__(nombre, dni)
        self.horas_trabajadas = horas_trabajadas
        self.valor_hora = valor_hora   

    def resumen(self):
        info = super().informacion()               
        pago = self.calcular_pago()                
        return f"{info} | Pago calculado: ${pago}"

Herencia múltiple

La herencia múltiple permite que una clase extienda o herede de más de una clase padre. Este tipo de herencia es útil cuando se quiere combinar comportamientos de varias clases dentro de una nueva clase.

En lugar de definir clases grandes con muchas funcionalidades e implementaciones, se crean clases más pequeñas que aportan funcionalidades específicas. Esto evite la repetición de código y la reutilización de lógica de comportamientos que pueden ser comunes a muchos objetos del sistema.

En el siguiente ejemplo se tienen dos clases, llamadas NotificacionEmail y NotificacionSMS, cada una contiene un método diferente y uno que es común a ambas llamado, enviar_notificación(). La clase AlertaSistema hereda de ambas clases separando sus nombres por coma.

class NotificacionEmail:   

    def enviar_notificacion(self, mensaje):        
        print(f"[Email] | Mensaje: {mensaje}") 

    def mostrar_mails(self):
        print("Mostrando lista de mails...")       
     
class NotificacionSMS:
    
    def enviar_notificacion(self, mensaje):
       print(f"[SMS] | Mensaje: {mensaje}") 

    def mostrar_sms(self):
        print("Mostrando listanúmeros SMS...")

class AlertaSistema(NotificacionEmail, NotificacionSMS):
    def __init__(self, sistema):
        self.sistema = sistema 
    
    def enviar_notificacion(self, mensaje):
        print(f"[Sistema {self.sistema}] Enviando alerta...")
        super().enviar_notificacion(mensaje)

alerta = AlertaSistema(sistema="Control de Temperatura")

alerta.mostrar_mails()
alerta.mostrar_sms()
alerta.enviar_notificacion("Temperatura fuera de rango")    
    

La clase AlertaSistema puede acceder a los métodos de ambas clases padre.

Mostrando lista de mails...
Mostrando listanúmeros SMS...
[Sistema Control de Temperatura] Enviando alerta...
[Email] | Mensaje: Temperatura fuera de rango

Al ejecutar el método enviar_notificacion() de la clase AlertaSistema, puede observarse que se ejecuta el código propio del método de la clase, y al tener definido super(), se está ejecutando también el método con el mismo nombre de la clase NotificaciónEmail.

Se puede observar también que no se está ejecutando el método del mismo nombre, enviar_notificacion(), de NotificacionSMS, aunque también hereda de esa clase.

Esto se debe al MRO.

MRO

El MRO o Method Resolution Order es el orden en el que Python busca atributos y métodos cuando se llama a super(), o cuando se invoca un método en una clase con herencia múltiple.

Python usa el algoritmo C3 linearization para resolver este orden, lo que permite la consistencia, predictibilidad y la eliminación de ambigüedades.

Se puede obtener el MRO de una clase ejecutando el método mro() .

print(AlertaSistema.mro())
[<class '__main__.AlertaSistema'>, <class '__main__.NotificacionEmail'>, <class '__main__.NotificacionSMS'>, <class 'object'>]

El orden de las clases estará dada por:

  • La clase actual
  • Las clases padre en el orden en el que fueron definidas
  • Las superclases o clases padres de las clases anteriores (en el caso que las tengan) en el orden que fueron definidas. Si se termina heredando de la misma clase por caminos distintas estas no se repiten.
  • object

En el ejemplo de AlertaSistema al ejecutar el método enviar_notificacion() se busca primero el método en la clase propia, al encontrarlo lo ejecuta. Cómo la implementación del método contiene super(), se busca en la primera clase padre definida, que es NotificaciónEmail, al encontralo lo ejecuta. Como no contiene super(), la cadena se corta y no se buscan más implementaciones y por lo tanto nunca se ejecuta el método de NotificacionSMS.

Para permitir que se ejecuten ambos métodos es necesario crear una clase base.

Clase base

Cuando se trabaja con herencia múltiple donde se quiere que las clases trabajen de forma colaborativa ejecutando todos sus métodos, todos estos deben ejecutar super(), y se debe crear una clase base.

Esta clase marca el final de la cadena, evitando que surjan errores al llamar al último super.

La nueva clase base NotificacionBase estará vacía, y tanto NotificaciónEmail y NotificacionSMS heredarán de ella.

La implementación de enviar_notificacion en ambas clases hará una llamada a super().

Finalmente NotificacionBase tendrá definido el método, pero no hará nada, no tendrá implementación. De esta manera se cortará la cadena de ejecuciones habiendo pasado por todas las clases.

herencia en python
herencia multiple
class NotificacionBase:
    def enviar_notificacion(self, mensaje):
        pass

class NotificacionEmail(NotificacionBase):
    
    def enviar_notificacion(self, mensaje):
        #print(f"[Email] De: {self.email_remitente} | Mensaje: {mensaje}") 
        print(f"[Email] | Mensaje: {mensaje}") 
        super().enviar_notificacion(mensaje)


class NotificacionSMS(NotificacionBase):

    def enviar_notificacion(self, mensaje):
        #print(f"[SMS] Desde el número: {self.numero_remitente} | Mensaje: {mensaje}") 
        print(f"[SMS] | Mensaje: {mensaje}") 
        super().enviar_notificacion(mensaje)   


class AlertaSistema(NotificacionEmail, NotificacionSMS):
    def __init__(self, sistema):
        self.sistema = sistema       

    def enviar_notificacion(self, mensaje):
        print(f"[Sistema {self.sistema}] Enviando alerta...")
        super().enviar_notificacion(mensaje)
    
    
alerta = AlertaSistema(sistema="Control de Temperatura")
alerta.enviar_notificacion("Temperatura fuera de rango")
print(AlertaSistema.mro())

Si se observa el MRO ahora tiene el orden AlertaSistema -> NotificacionEmail -> NotificacionSMS -> NotificacionBase

Sistema Control de Temperatura] Enviando alerta...
[Email] | Mensaje: Temperatura fuera de rango
[SMS] | Mensaje: Temperatura fuera de rango
[<class '__main__.AlertaSistema'>, <class '__main__.NotificacionEmail'>, <class '__main__.NotificacionSMS'>, <class '__main__.NotificacionBase'>, <class 'object'>]

Init() en clases colaborativas

Para que lo anterior funcione cuando se cuenta con atributos y el método especial __init__(), se deben cumplir ciertas condiciones para que no ocurra un error al crear los objetos.

Cada __init__() debe llamar a super().__init__(), de esta manera se ejecutarán los __init__() correspondiente de toda la cadena de herencia.

Además, como las diferentes clases pueden requerir diferentes atributos en su __init__() estos deben solicitar los que necesitan explícitamente pero aceptar cualquier cantidad de otros parámetros con el uso de **kwargs.

Siguiendo con el ejemplo anterior, la clase NotificacionBase contiene un __init__() que acepta cualquier cantidad de parámetros, y no tiene ninguna implementación. De la misma manera que con el método enviar_notificacion(), está creado para detener la cadena de ejecución creada por los super() de otras clases.

class NotificacionBase:
    def __init__(self, **kwargs):
        pass

    def enviar_notificacion(self, mensaje):
        pass

Tanto NotificacionEmail() y NotificacionSMS() tienen __init__() con el mismo formato:

  • Contienen un asterisco * para especificar que el resto de los atributos tendrán el formato clave-valor.
  • Luego obtienen los parámetros requeridos para sus clases.
  • Y finalmente aceptan el resto de los parámetros con **kwargs.

Dentro de la función __init__() se inicializan sus atributos y llaman a super(), enviando el resto de los parámetros. Esto permite que la cadena continúe sin errores, independientemente del orden en el que se herede.

class NotificacionEmail(NotificacionBase):
    def __init__(self, *, email_remitente=None, **kwargs):
        self.email_remitente = email_remitente
        super().__init__(**kwargs)

    def enviar_notificacion(self, mensaje):
        print(f"[Email] | Mensaje: {mensaje}")
        super().enviar_notificacion(mensaje)

class NotificacionSMS(NotificacionBase):
    def __init__(self, *, numero_remitente=None, **kwargs):
        self.numero_remitente = numero_remitente
        super().__init__(**kwargs)

    def enviar_notificacion(self, mensaje):
        print(f"[SMS] | Mensaje: {mensaje}")
        super().enviar_notificacion(mensaje)

Finalmente el __init__() de AlertaSistema obtiene los atributos de ambas clases de las que hereda y los envía dentro de su super.

class AlertaSistema(NotificacionEmail, NotificacionSMS):
    def __init__(self, *, sistema, email_remitente=None, numero_remitente=None):
        self.sistema = sistema
        super().__init__(
            email_remitente=email_remitente,
            numero_remitente=numero_remitente
        )

    def enviar_notificacion(self, mensaje):
        print(f"[Sistema {self.sistema}] Enviando alerta...")
        super().enviar_notificacion(mensaje)

Al crear el objeto se le pasan todos los parámetros requeridos por las clases de la estructura de herencia.

 alerta = AlertaSistema(
    sistema="Control de Temperatura",
    email_remitente="sistema@example.com",
    numero_remitente="+54 9 261 555 0000"
)

Esta estructura de herencia de clases, que tiene forma de diamante, puede generar problemas si no es implementada correctamente, o si el lenguaje no sabe manejarla.

Esto es conocido como el problema del diamante.

El problema del diamante

El problema del diamante ocurre cuando una clase hereda indirectamente de la misma clase base por dos caminos distintos formando una estructura de diamante.

Aunque en Python este problema es resuelto por el MRO, suele generar dificultades en otros lenguajes, ya que puede generar ambigüedades y ejecutar código por duplicado.

herencia en python
problema del diamante

El siguiente ejemplo tenemos cuatro clases. La clase D hereda de B y de C, pero tanto B como C heredan de A.

El problema surge cuando un lenguaje no sabe si al ejecutar un método que se encuentra en todas las clases, el método en A se ejecutará una o dos veces (una por la herencia de B y otra por la herencia de C) ni en qué orden.

class A:
    def metodo(self):
        print("A")

class B(A):
    def metodo(self):
        print("B")
        super().metodo()

class C(A):
    def metodo(self):
        print("C")
        super().metodo()

class D(B, C):
    def metodo(self):
        print("D")
        super().metodo()

print(D.mro())
d = D()
d.metodo()

Como ya expliqué, el algoritmo del MRO de Python evita este problema, ya que las clases no se repiten en el orden.

[<class '__main__.D'>, <class '__main__.B'>, <class '__main__.C'>, <class '__main__.A'>, <class 'object'>]
D
B
C
A

Mixins

Utilizando librerías de terceros es común encontrar clases cuyo nombre termine en Mixin, como LoggableMixin, SerializableMixin,JsonConvertibleMixin, etc .

Estas clases están diseñadas para ser heredadas junto con otras clases, y de esta forma aportar métodos adicionales, y en ocasiones, atributos.

A diferencia de otras clases, las Mixins no representan entidades o conceptos específicos, sino que encapsulan funcionalidades concretas que pueden ser útiles a diferentes partes de un programa.

La palabra Mixin proviene del ingles, y significa lo que se mezcla o lo que es mezclado, y tiene su origen en otros lenguajes orientados a objetos. Se utiliza este término ya que este tipo de clases funcionan como ingredientes que se mezclan en una clase que hereda de ellas para extender su comportamiento.

El término es, sin embargo, una convención para indicar la funcionalidad de una clase, y es posible encontrar clases que conceptualmente sean Mixins sin que su nombre lo indique.

import json

class SerializableMixin:
    def to_dict(self):        
        return {
            clave: valor
            for clave, valor in self.__dict__.items()
            if not clave.startswith("_")
        }

class JsonConvertibleMixin:
    def to_json(self):       
        if hasattr(self, "to_dict"):
            return json.dumps(self.to_dict(), ensure_ascii=False, indent=2)
        else:
            raise NotImplementedError(
                "La clase hija debe implementar to_dict() para usar to_json()."
            )

class Producto(SerializableMixin, JsonConvertibleMixin):
    def __init__(self, nombre, precio):
        self.nombre = nombre
        self.precio = precio

p = Producto("Teclado Mecánico", 250.0)

print(p.to_dict())
print(p.to_json())

En este ejemplo se tienen dos clases Mixin encargadas de las funcionalidades de serializar y convertir a json respectivamente. La clase Producto hereda de ambas pudiendo acceder a estas funcionalidades, manteniendo su código más sencillo y legible.

{'nombre': 'Teclado Mecánico', 'precio': 250.0}
{
  "nombre": "Teclado Mecánico",
  "precio": 250.0
}

En este artículo hemos visto como funciona la herencia en Python, hemos explorado qué es y cómo funciona la herencia múltiple.

También exploramos la importancia del MRO y el problema del diamante, y finalmente el concepto de Mixin en la programación, muy utilizado varias librerías de Python.

Espero que les sirva! Cualquier duda me comentan!


¿Querés aprender más? 📚

👉 Visitá nuestros cursos!
💬 Y si tenés dudas, o querés dejarnos tus comentarios sumate a la Comunidad JuncoTIC en Telegram!
¡Te esperamos!

Categorías: Programación

Andrea Navarro

- Ingeniera en Informática - Docente universitaria - Investigadora