Herencia en Python
En esta lección
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 + bllama aa.__add__(b). Devolvemos un objeto nuevo en vez de modificara.a == bllama a__eq__. DevolverNotImplementedcon otros tipos hace quea == 10.5déFalseen lugar de fallar.- Con
__lt__,sorted,minymaxya saben ordenar. Python deducea > bprobandob < a. - Pero
a <= bdaTypeError. Si quieres todas las comparaciones, añade el decorador@functools.total_orderinga 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étodo | Se 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. Usaisinstance. - Modificar el objeto en
__add__:a + bno debería cambiara. 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
| Concepto | Sintaxis |
|---|---|
| Heredar | class Hija(Padre): |
| Llamar al padre | super().__init__(...), super().metodo() |
| Comprobar tipo | isinstance(obj, Clase), issubclass(Hija, Padre) |
| Orden de búsqueda | Clase.__mro__ |
| Clase abstracta | class Base(ABC): + @abstractmethod |
| Operadores propios | __eq__, __lt__, __add__ |
| Contenedores propios | __len__, __iter__, __contains__ |
| Composición | self.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
¿Te ha quedado claro? Márcala y verás tu progreso en el explorador.