Saltar al contenido
herencia.py · devschool

Herencia en Python

Lección 17 de 19 · 10 min de lectura · Actualizado el

En esta lección
  1. Qué es la herencia
  2. super(): aprovechar el código del padre
  3. isinstance e issubclass
  4. Herencia múltiple y MRO
  5. Polimorfismo y duck typing
  6. Clases abstractas con abc
  7. Métodos especiales
  8. Composición frente a herencia
  9. Errores frecuentes
  10. Resumen

En la lección de clases y objetos aprendiste a crear tus propias clases. Ahora darás dos pasos más. Con la herencia, una clase nueva aprovecha todo lo que ya hace otra y solo añade o cambia lo que necesita. Con los métodos especiales, tus objetos se comportan como los tipos de Python: se pueden sumar con +, comparar con <, medir con len() o recorrer con for.

Qué es la herencia

Imagina una empresa con empleados. Todos tienen nombre y sueldo, pero los programadores además tienen un lenguaje favorito y los becarios cobran de otra forma. En vez de copiar el mismo código en tres clases, creas una clase base (o padre) con lo común y clases hijas que heredan de ella.

La relación que debe cumplirse es “es un”: un programador es un empleado. Si la frase no tiene sentido, la herencia no es la herramienta adecuada.

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

    def presentarse(self):
        return f"Soy {self.nombre}"

    def sueldo_anual(self):
        return self.sueldo * 14

class Becario(Empleado):
    def sueldo_anual(self):
        return self.sueldo * 12

leo = Becario("Leo", 800)
print(leo.presentarse())    # Soy Leo
print(leo.sueldo_anual())   # 9600

class Becario(Empleado) significa “Becario hereda de Empleado”. Becario no define __init__ ni presentarse, así que usa los del padre. En cambio, sobrescribe sueldo_anual con su propia versión: cuando Python busca un método, mira primero en la clase del objeto y, si no lo encuentra, sube a la clase padre.

Consejo: todas las clases de Python heredan en último término de object, aunque no lo escribas. De ahí salen los __str__ y __eq__ por defecto que tienen todos los objetos.

super(): aprovechar el código del padre

Ampliar __init__

Si la clase hija necesita atributos nuevos, define su propio __init__. Pero no repitas lo que ya hace el padre: llámalo con super().__init__(...).

class Programador(Empleado):
    def __init__(self, nombre, sueldo, lenguaje):
        super().__init__(nombre, sueldo)   # el padre guarda nombre y sueldo
        self.lenguaje = lenguaje           # la hija añade lo suyo

    def presentarse(self):
        base = super().presentarse()
        return f"{base} y programo en {self.lenguaje}"

eva = Programador("Eva", 2000, "Python")
print(eva.presentarse())    # Soy Eva y programo en Python
print(eva.sueldo_anual())   # 28000

super() devuelve un objeto que da acceso a los métodos de la clase padre. En presentarse lo usamos para ampliar el método original en vez de reescribirlo entero.

Si olvidas llamar a super().__init__

Cuando la hija define __init__, el del padre ya no se ejecuta solo. Si no lo llamas, los atributos del padre no existen:

class Jefe(Empleado):
    def __init__(self, nombre, sueldo, equipo):
        self.equipo = equipo   # ¡falta super().__init__(nombre, sueldo)!

jefa = Jefe("Sara", 3000, "Web")
jefa.presentarse()   # AttributeError: 'Jefe' object has no attribute 'nombre'

isinstance e issubclass

isinstance(objeto, Clase) comprueba si un objeto es de esa clase o de una de sus hijas. issubclass(Hija, Padre) hace lo mismo con clases.

print(isinstance(eva, Programador))    # True
print(isinstance(eva, Empleado))       # True: un programador es un empleado
print(isinstance(eva, Becario))        # False
print(issubclass(Becario, Empleado))   # True
print(issubclass(Empleado, Becario))   # False
print(type(eva) == Empleado)           # False: type no tiene en cuenta la herencia
print(isinstance(3, (int, float)))     # True: acepta una tupla de clases

Por eso se prefiere isinstance a comparar con type(): respeta la herencia. Ya lo usaste sin saberlo al capturar excepciones: except ErrorTienda atrapa también todas sus clases hijas.

Herencia múltiple y MRO

Python permite heredar de varias clases a la vez:

class Volador:
    def moverse(self):
        return "vuela"
    def describir(self):
        return f"Puedo {self.moverse()}"

class Nadador:
    def moverse(self):
        return "nada"
    def bucear(self):
        return "bucea"

class Pato(Volador, Nadador):
    pass

pato = Pato()
print(pato.moverse())   # vuela
print(pato.bucear())    # bucea
print([c.__name__ for c in Pato.__mro__])
# ['Pato', 'Volador', 'Nadador', 'object']

Si dos padres tienen un método con el mismo nombre, ¿cuál gana? Python sigue el MRO (Method Resolution Order): un orden fijo de búsqueda que puedes consultar en Clase.__mro__. Como Volador aparece antes en class Pato(Volador, Nadador), su moverse tiene prioridad.

Cuidado: la herencia múltiple se complica enseguida. Úsala con moderación, normalmente para añadir pequeñas capacidades sueltas (lo que se llama mixins), no para jerarquías enormes.

Polimorfismo y duck typing

Polimorfismo significa que objetos de clases distintas responden al mismo método, cada uno a su manera. El código que los usa no necesita saber de qué clase son:

plantilla = [eva, leo, Empleado("Sara", 1500)]
for persona in plantilla:
    print(persona.nombre, persona.sueldo_anual())
# Eva 28000
# Leo 9600
# Sara 21000

Python va aún más lejos con el duck typing: “si camina como un pato y hace cuac como un pato, es un pato”. No importa de qué clase sea un objeto, solo que tenga el método que necesitas. Ni siquiera hace falta herencia:

class Perro:
    def hablar(self):
        return "Guau"

class Robot:
    def hablar(self):
        return "Bip bip"

for cosa in [Perro(), Robot()]:
    print(cosa.hablar())
# Guau
# Bip bip

Si un objeto no tiene el método, el error llega al llamarlo: AttributeError: 'Piedra' object has no attribute 'hablar'. Así funciona todo Python: len() sirve para cadenas, listas y diccionarios porque todos saben responder a esa petición.

Clases abstractas con abc

A veces quieres una clase base que no se pueda usar directamente y que obligue a sus hijas a implementar ciertos métodos. Para eso está el módulo abc (Abstract Base Classes):

from abc import ABC, abstractmethod
import math

class Figura(ABC):
    @abstractmethod
    def area(self):
        ...

    def describir(self):
        return f"{type(self).__name__} de área {self.area():.2f}"

class Rectangulo(Figura):
    def __init__(self, ancho, alto):
        self.ancho = ancho
        self.alto = alto

    def area(self):
        return self.ancho * self.alto

class Circulo(Figura):
    def __init__(self, radio):
        self.radio = radio

    def area(self):
        return math.pi * self.radio ** 2

for figura in [Rectangulo(3, 4), Circulo(1)]:
    print(figura.describir())
# Rectangulo de área 12.00
# Circulo de área 3.14

Figura()
# TypeError: Can't instantiate abstract class Figura without an implementation for abstract method 'area'

Si una hija olvida implementar area, el error salta al crear el objeto, no más tarde en mitad del programa. Es un contrato: “toda figura debe saber calcular su área”. Fíjate además en que describir está en el padre y funciona para cualquier figura gracias al polimorfismo.

Métodos especiales

Los métodos con doble guion bajo al principio y al final (dunder methods) son los que Python llama por dentro cuando usas operadores y funciones integradas. Ya conoces __init__, __str__ y __repr__.

Comparar y operar: __eq__, __lt__, __add__

class Dinero:
    def __init__(self, euros):
        self.euros = round(euros, 2)

    def __repr__(self):
        return f"Dinero({self.euros})"

    def __str__(self):
        return f"{self.euros:.2f} €"

    def __eq__(self, otro):
        if not isinstance(otro, Dinero):
            return NotImplemented
        return self.euros == otro.euros

    def __lt__(self, otro):
        return self.euros < otro.euros

    def __add__(self, otro):
        return Dinero(self.euros + otro.euros)

a = Dinero(10.5)
b = Dinero(4.25)
print(a + b)                        # 14.75 €
print(a == Dinero(10.5))            # True
print(b < a, a > b)                 # True True
print(sorted([a, b, Dinero(7)]))    # [Dinero(4.25), Dinero(7), Dinero(10.5)]
print(max([a, b]))                  # 10.50 €
  • a + b llama a a.__add__(b). Devolvemos un objeto nuevo en vez de modificar a.
  • a == b llama a __eq__. Devolver NotImplemented con otros tipos hace que a == 10.5 dé False en lugar de fallar.
  • Con __lt__, sorted, min y max ya saben ordenar. Python deduce a > b probando b < a.
  • Pero a <= b da TypeError. Si quieres todas las comparaciones, añade el decorador @functools.total_ordering a la clase: completa las que faltan a partir de __eq__ y __lt__.

Cuidado: al definir __eq__, Python quita el __hash__ por defecto y tus objetos ya no pueden ir en un conjunto ni ser claves de diccionario (TypeError: unhashable type). Si los necesitas ahí, usa @dataclass(frozen=True), que genera ambos.

Contenedores: __len__, __iter__, __contains__

class Playlist:
    def __init__(self, nombre):
        self.nombre = nombre
        self._canciones = []

    def agregar(self, cancion):
        self._canciones.append(cancion)

    def __len__(self):
        return len(self._canciones)

    def __iter__(self):
        return iter(self._canciones)

    def __contains__(self, cancion):
        return cancion in self._canciones

lista = Playlist("Estudio")
lista.agregar("Clair de lune")
lista.agregar("Nocturno op. 9")
print(len(lista))                   # 2
print("Nocturno op. 9" in lista)    # True
for cancion in lista:
    print("-", cancion)
# - Clair de lune
# - Nocturno op. 9

__iter__ debe devolver un iterador; lo más sencillo es delegar en el de la lista interna con iter(...). Un detalle curioso: con __len__ definido, una playlist vacía cuenta como False en un if.

MétodoSe activa con
__str__ / __repr__print(x) / repr(x)
__eq__, __lt__, __le__…==, <, <=…
__add__, __sub__, __mul__+, -, *
__len__len(x)
__iter__for e in x
__contains__e in x
__getitem__x[i]

Composición frente a herencia

La herencia no es la única forma de reutilizar código. En la composición, un objeto contiene a otro y le delega el trabajo. La relación es “tiene un”: un coche tiene un motor, pero no es un motor.

class Motor:
    def __init__(self, cv):
        self.cv = cv

    def arrancar(self):
        return f"Motor de {self.cv} CV en marcha"

class Coche:
    def __init__(self, modelo, cv):
        self.modelo = modelo
        self.motor = Motor(cv)

    def arrancar(self):
        return f"{self.modelo}: {self.motor.arrancar()}"

print(Coche("Ibiza", 95).arrancar())   # Ibiza: Motor de 95 CV en marcha

La composición es más flexible: puedes cambiar el motor por otro sin tocar la clase Coche. Por eso muchos programadores siguen la regla “prefiere la composición a la herencia” y reservan la herencia para relaciones “es un” claras y jerarquías poco profundas.

Errores frecuentes

  • Olvidar super().__init__() en el __init__ de la hija: los atributos del padre no existen.
  • Heredar por comodidad: si la relación no es “es un”, usa composición.
  • Comparar tipos con type(x) == Clase: ignora la herencia. Usa isinstance.
  • Modificar el objeto en __add__: a + b no debería cambiar a. Devuelve un objeto nuevo.
  • Olvidar @abstractmethod: sin él, la clase base se puede crear y el método “obligatorio” deja de serlo.
  • Jerarquías demasiado profundas: más de dos o tres niveles suelen ser difíciles de seguir.

Resumen

ConceptoSintaxis
Heredarclass Hija(Padre):
Llamar al padresuper().__init__(...), super().metodo()
Comprobar tipoisinstance(obj, Clase), issubclass(Hija, Padre)
Orden de búsquedaClase.__mro__
Clase abstractaclass Base(ABC): + @abstractmethod
Operadores propios__eq__, __lt__, __add__
Contenedores propios__len__, __iter__, __contains__
Composiciónself.motor = Motor(...)

Con esto ya dominas lo esencial de la orientación a objetos en Python. Uno de los módulos de la biblioteca estándar que más partido le saca es datetime, que verás en fechas con datetime.

Pon a prueba lo que has aprendido

[Python] ¿Qué imprime este código?
class A:
    def saludo(self):
        return "A"

class B(A):
    def saludo(self):
        return "B" + super().saludo()

class C(B):
    pass

print(C().saludo())

[Python] ¿Qué imprime este código?
class Caja:
    def __init__(self, n):
        self.n = n

print(Caja(5) == Caja(5))

[Python] Una clase Figura hereda de ABC y tiene area marcado con @abstractmethod. Triangulo hereda de Figura pero no define area. ¿Qué pasa al ejecutar Triangulo(3, 2)?

[Python] Tienes las clases Coche y Motor. ¿Qué diseño es más adecuado?

¿Te ha quedado claro? Márcala y verás tu progreso en el explorador.