Herencia, polimorfismo y abstracción
Reutilizar comportamiento sin copiarlo: super(), sobrescritura, clases abstractas y el orden de resolución MRO
La herencia permite que una clase reutilice el comportamiento de otra y que objetos distintos respondan al mismo mensaje. Aquí aprendes a usar super(), a sobrescribir métodos, a exigir contratos con clases abstractas y a decidir cuándo heredar es mala idea.
- Declarar una clase derivada y llamar a super().__init__() en el orden correcto
- Sobrescribir un método reutilizando la versión heredada
- Recorrer una lista de objetos distintos con una sola llamada polimórfica
- Definir un contrato con ABC y @abstractmethod
- Leer el MRO con __mro__ y explicar qué versión de un método se ejecuta
- Decidir entre herencia y composición según la relación «es un» o «tiene un»
Antes necesitas: Clases y objetos
En esta lección
- ¿Qué es la herencia?
- ¿Para qué sirve?
- ¿Cómo funciona por dentro?
- Sintaxis
- Ejemplo básico: un perro que hereda de animal
- Ejemplo intermedio: polimorfismo con una lista
- Caso práctico: un contrato para transformadores de datos
- Visualización: crear un objeto y resolver un método
- Errores comunes
- Buenas prácticas
- Ejercicios
- Soluciones explicadas
- Resumen
- Evaluación
En la lección anterior aprendiste a crear una clase con __init__ y métodos: un molde que produce objetos con estado propio. En cuanto tienes dos clases parecidas aparece el problema siguiente: comparten casi todo el código y solo se diferencian en un detalle. Copiar y pegar el método en ambas funciona hasta que el detalle común cambia y hay que corregirlo dos veces.
La herencia resuelve eso declarando que una clase es una versión especializada de otra. Aquí la usas para reutilizar comportamiento, entiendes cómo Python decide qué método ejecutar, cómo exigir un contrato y cuándo la respuesta correcta es no heredar.
¿Qué es la herencia?
La herencia es una relación entre clases: class Perro(Animal) significa «un perro es un animal», y por tanto recibe sus atributos y métodos. Animal es la clase base (o superclase) y Perro la clase derivada (o subclase).
Piensa en una ficha de producto: la ficha genérica tiene referencia, precio y descripción; la ficha de un televisor añade las pulgadas. La ficha del televisor no repite los campos comunes, los hereda y agrega los suyos. Si mañana cambia el formato de la referencia, cambia en un solo sitio y todas las fichas lo reflejan.
Heredar no copia el código dentro de la subclase. La subclase guarda una referencia a su base y, cuando buscas un atributo que no definió, Python lo busca allí. Ese detalle explica por qué un cambio en la base afecta de inmediato a todas las subclases y por qué una jerarquía mal pensada se vuelve frágil.
- Sobrescritura: la subclase define un método con el mismo nombre y reemplaza el comportamiento heredado.
- Extensión: la subclase llama a
super()y añade algo antes o después, sin perder lo anterior. - Herencia simple: una sola base. Es el caso del 95 % del código real y el único que necesitas dominar para empezar.
- Herencia múltiple: varias bases. Existe y funciona, pero introduce ambigüedad; aquí la usas solo para combinar interfaces pequeñas.
¿Para qué sirve?
Estos son los usos que aparecerán una y otra vez en el resto del curso:
- Jerarquías del dominio:
Empleado→Vendedor,Gerente;Figura→Rectangulo,Circulo. - Contratos: una clase abstracta fija qué métodos debe ofrecer cualquier implementación, por ejemplo un transformador de datos.
- Extensiones de bibliotecas:
scikit-learnespera que tu modelo herede deBaseEstimatory cumpla la interfazfit/predict. - Errores propios:
class ErrorDeDato(Exception)hereda deExceptionpara poder lanzarse conraise, como verás en la lección de excepciones. - Polimorfismo: recorrer una lista de objetos distintos y llamar al mismo método, dejando que cada uno responda a su manera.
¿Cómo funciona por dentro?
Cuando escribes perro.hablar(), Python no mira solo en el objeto. Sigue un orden: primero los atributos de la instancia (perro.__dict__), después la clase (Perro) y, si el nombre no aparece, recorre las clases base en el orden que define el MRO (Method Resolution Order). Ese orden se calcula con el algoritmo C3 y puedes consultarlo con Perro.__mro__.
El MRO importa cuando hay herencia múltiple. Con class D(B, C) y ambas heredando de A, el orden es D, B, C, A, object: Python busca en B antes que en C y solo llega a A si ninguna de las dos define el método. object es la base de todo, y por eso cualquier objeto tiene __str__, __eq__ y compañía aunque tú no los escribas.
super() no significa «mi clase padre». Significa «el siguiente en el MRO de esta instancia». En herencia simple coincide con la base, pero en una jerarquía con diamante es lo que garantiza que cada clase inicialice su parte una sola vez. Por eso super().__init__(nombre) es la forma correcta y Animal.__init__(self, nombre) es una bomba de tiempo: fija la clase base a mano y rompe cualquier subclase intermedia.
Sintaxis
class Base:
def __init__(self, nombre):
self.nombre = nombre
def saludar(self):
return f"Hola, {self.nombre}"
class Derivada(Base):
def __init__(self, nombre, turno):
super().__init__(nombre) # primero la parte heredada
self.turno = turno
def saludar(self): # sobrescribe el método
base = super().saludar() # reutiliza lo que ya existía
return f"{base} (turno {self.turno})"
d = Derivada("Ana", "mañana")
print(d.saludar())
print(isinstance(d, Base))| Forma | Qué hace |
|---|---|
class Hija(Base): | Declara que Hija hereda de Base |
super().__init__(...) | Ejecuta el inicializador siguiente en el MRO |
super().metodo() | Reutiliza la versión heredada dentro de una sobrescritura |
isinstance(obj, Base) | True si el objeto es de la clase o de cualquier subclase |
issubclass(Hija, Base) | True si la clase hereda, directa o indirectamente, de otra |
Hija.__mro__ | Tupla con el orden de búsqueda de métodos |
class Animal:
def hablar(self):
return "..."
class Perro(Animal):
def hablar(self):
return "Guau"
print(Perro().hablar())#include <iostream>
#include <string>
#include <utility>
class Animal {
public:
explicit Animal(std::string nombre) : nombre_(std::move(nombre)) {}
virtual ~Animal() = default;
virtual std::string hablar() const { return "..."; }
std::string nombre() const { return nombre_; }
private:
std::string nombre_;
};
class Perro : public Animal {
public:
explicit Perro(std::string nombre) : Animal(std::move(nombre)) {}
std::string hablar() const override { return "Guau"; }
};
int main() {
Perro p{"Toby"};
std::cout << p.hablar() << '\n';
std::cout << p.nombre() << '\n';
return 0;
}En Python el despacho es dinámico por defecto: no hace falta ninguna palabra clave. En C++ necesitas `virtual` para que la llamada se resuelva en ejecución y `override` para que el compilador avise si la firma no coincide.
Ejemplo básico: un perro que hereda de animal
El caso mínimo de una jerarquía: la base guarda el nombre, la subclase añade la raza y sobrescribe hablar.
class Animal:
def __init__(self, nombre):
self.nombre = nombre
def hablar(self):
return "..."
class Perro(Animal):
def __init__(self, nombre, raza):
super().__init__(nombre)
self.raza = raza
def hablar(self):
return "Guau"
p = Perro("Toby", "mestizo")
print(p.hablar())
print(p.nombre, p.raza)
print(isinstance(p, Animal))super().__init__(nombre) asigna self.nombre; sin esa línea, p.nombre lanzaría AttributeError. La sobrescritura de hablar no borra la versión de Animal: solo la tapa en la búsqueda. isinstance(p, Animal) devuelve True porque Perro está en la cadena de Animal, aunque p sea de la clase derivada.
Ejemplo intermedio: polimorfismo con una lista
El polimorfismo es la consecuencia práctica de la sobrescritura: un mismo mensaje, respuestas distintas según el objeto. Fíjate en que el bucle no pregunta de qué clase es cada animal.
class Animal:
def __init__(self, nombre):
self.nombre = nombre
class Perro(Animal):
def hablar(self):
return "Guau"
class Gato(Animal):
def hablar(self):
return "Miau"
animales = [Perro("Toby"), Gato("Luna")]
for animal in animales:
print(f"{animal.nombre}: {animal.hablar()}")La salida es Toby: Guau y Luna: Miau. Si mañana añades class Vaca(Animal) con return "Muu", el bucle sigue igual: no hay if isinstance(...) ni elif. Esa es la señal de un diseño polimórfico sano. La alternativa —una función que inspecciona tipos y crece con cada animal nuevo— obliga a modificar código que ya funcionaba cada vez que aparece una especie.
Caso práctico: un contrato para transformadores de datos
En un proyecto de Machine Learning cada paso de preprocesamiento se ajusta con los datos de entrenamiento y luego transforma datos nuevos. Si cada clase inventa nombres de métodos, el pipeline no puede tratarlas igual. Una clase abstracta fija el contrato: qué métodos existen y con qué nombres.
from abc import ABC, abstractmethod
class Transformador(ABC):
@abstractmethod
def ajustar(self, datos):
...
@abstractmethod
def transformar(self, datos):
...
class EscaladorMinMax(Transformador):
def __init__(self):
self.minimo = None
self.maximo = None
def ajustar(self, datos):
self.minimo = min(datos)
self.maximo = max(datos)
def transformar(self, datos):
rango = self.maximo - self.minimo
return [round((x - self.minimo) / rango, 2) for x in datos]
class Centrador(Transformador):
def __init__(self):
self.media = 0
def ajustar(self, datos):
self.media = sum(datos) / len(datos)
def transformar(self, datos):
return [round(x - self.media, 2) for x in datos]
datos = [10, 20, 30]
for transformador in (EscaladorMinMax(), Centrador()):
transformador.ajustar(datos)
print(type(transformador).__name__, transformador.transformar(datos))La salida es EscaladorMinMax [0.0, 0.5, 1.0] y Centrador [-10.0, 0.0, 10.0]. ABC y @abstractmethod hacen que Transformador no se pueda instanciar y que una subclase incompleta falle al crearse, no al usarse, con TypeError: Can't instantiate abstract class Centrador with abstract method transformar. Detectar el error al construir es mucho más barato que descubrirlo a mitad de un entrenamiento.
Fíjate en lo que no hace el bucle: no comprueba la clase, no usa hasattr, no pregunta por el nombre. Solo llama a los métodos que el contrato garantiza. Si un compañero escribe su propio transformador y hereda de Transformador, el código funciona sin tocarlo. type(transformador).__name__ obtiene el nombre de la clase concreta sin necesidad de guardarlo en un atributo.
Visualización: crear un objeto y resolver un método
Sigue la creación de Perro("Toby", "mestizo") y las tres consultas finales. En cada paso observa el contenido de p y el orden que devuelve Perro.__mro__.
class Animal: def __init__(self, nombre): self.nombre = nombre def hablar(self): return "..."class Perro(Animal): def __init__(self, nombre, raza): super().__init__(nombre) self.raza = raza def hablar(self): return "Guau"p = Perro("Toby", "mestizo")print(p.hablar())print(isinstance(p, Animal))print([c.__name__ for c in Perro.__mro__])Animal. El cuerpo se ejecuta una vez: crea las funciones __init__ y hablar y las guarda como atributos de la clase, no del objeto.—
Avanza paso a paso: observa la línea resaltada, las variables y la salida.
Errores comunes
| Mensaje | Causa habitual | Cómo se arregla |
|---|---|---|
TypeError: __init__() missing 1 required positional argument: 'raza' | Llamaste Perro("Toby") sin el argumento que pide la subclase | Revisa la firma del __init__ de la clase que instancias |
AttributeError: 'Perro' object has no attribute 'nombre' | La subclase no llamó a super().__init__() | Llama a super().__init__(...) antes de usar atributos heredados |
TypeError: Can't instantiate abstract class Transformador with abstract methods ajustar, transformar | Intentaste crear un objeto de una clase abstracta o de una subclase incompleta | Implementa todos los métodos marcados con @abstractmethod |
AttributeError: 'int' object has no attribute 'upper' | Pasaste un tipo inesperado a un método que asumía texto | Valida el argumento antes de usarlo o conviértelo explícitamente |
class Perro(Animal):
def __init__(self, nombre, raza):
self.raza = raza
p = Perro("Toby", "mestizo")
print(p.nombre)class Perro(Animal):
def __init__(self, nombre, raza):
super().__init__(nombre)
self.raza = raza
p = Perro("Toby", "mestizo")
print(p.nombre)El `__init__` de la subclase reemplaza al de la base: si no lo llamas, `self.nombre` nunca se asigna. El error aparece más tarde, al leer el atributo, y el mensaje no menciona `super()` por ningún lado. La regla es simple: inicializa la parte heredada primero.
El último error no es de herencia, pero aparece cuando una subclase especializa: el método heredado asume un contrato que el llamador rompió. Los mensajes se leen de abajo hacia arriba: tipo, motivo y línea.
Buenas prácticas
- Hereda solo cuando puedas decir «X es un Y» sin forzar la frase.
- Llama a
super().__init__(...)en la primera línea del__init__de la subclase. - Sobrescribe para especializar, no para desactivar: una subclase que lanza
NotImplementedErrordonde la base funcionaba suele indicar un mal diseño. - No heredes solo para reutilizar una función: si la relación no es de tipo, usa composición o una función libre.
- Mantén la firma y el significado de los métodos heredados: si
area()devuelve metros cuadrados, ninguna subclase puede devolver otra cosa (principio de sustitución de Liskov). - Define contratos con
ABCy@abstractmethodcuando varias clases deban ofrecer la misma interfaz. - Prefiere
isinstance(obj, Base)atype(obj) is Base: lo primero acepta subclases, lo segundo no.
Ejercicios
Figuras con área obligatoria
Figura con un método abstracto area() y un método concreto describir() que devuelva el nombre de la clase y el área con dos decimales. Implementa Rectangulo(base, altura) y Circulo(radio), y recorre una lista con un rectángulo de 3×4 y un círculo de radio 2. Comprueba que instanciar Figura directamente lanza TypeError.Pista
Importa ABC y abstractmethod desde abc, y pi desde math. Dentro de describir puedes usar type(self).__name__ para obtener el nombre de la clase concreta.
Nómina con sobrescritura y super()
Empleado(nombre, base) con un método sueldo() que devuelva el sueldo base. Crea Vendedor(nombre, base, ventas), que suma un 10 % de las ventas, y Gerente(nombre, base, bono), que suma un bono fijo. Los dos deben reutilizar super().sueldo(). Recorre una planilla con un empleado, un vendedor y un gerente, imprime cada sueldo con dos decimales y el total.Pista
Define sueldo() en la base y sobrescríbelo en las dos subclases llamando primero a super().sueldo(). Para el total usa sum(e.sueldo() for e in planilla).
from abc import ABC, class Forma(): @abstractmethod def area(self): ... class Cuadrado(Forma): def __init__(self, lado): self.lado = lado def area(self): return self.lado ** 2 print(Cuadrado(3).area())
El decorador se importa junto a `ABC`, y la clase abstracta hereda de esa misma clase especial.
Soluciones explicadas
En el ejercicio de figuras, la decisión clave es poner describir en la clase abstracta: depende de area, que aún no existe, y se resuelve en tiempo de ejecución. Es el patrón método plantilla, y explica por qué una clase abstracta puede tener código útil.
En la nómina, super().sueldo() no es un detalle de estilo. Con Empleado.sueldo(self) fijas la base a mano y cualquier clase intermedia queda fuera de la cadena. Con super() cada clase aporta su parte y el MRO decide el orden.
Una alternativa: para sumar totales no necesitas herencia; una lista de diccionarios resuelve lo mismo. La herencia aporta cuando hay comportamiento compartido que evoluciona junto, no cuando solo agrupa datos. La próxima lección cubre el otro extremo: clases que colaboran sin heredar.
Resumen
class Hija(Base)declara una relación «es un» y hereda atributos y métodos.super().__init__(...)inicializa la parte heredada; sin esa llamada faltan atributos.- Sobrescribir es definir un método con el mismo nombre en la subclase;
super().metodo()reutiliza la versión heredada. - El polimorfismo permite recorrer objetos distintos y llamar al mismo método sin preguntar el tipo.
- El MRO fija el orden de búsqueda;
super()avanza al siguiente de ese orden, no a la clase padre literal. ABCy@abstractmethodimpiden instanciar clases incompletas y documentan el contrato.- El duck typing funciona sin herencia: basta con que el objeto tenga el método que usas.
- Si la relación real es «tiene un», la respuesta es composición, no herencia.
Evaluación
¿Qué hace exactamente super().__init__(nombre) dentro del __init__ de Perro?
Con class D(B, C), donde B y C heredan de A, B no define saludar y C sí, ¿qué versión se ejecuta al llamar D().saludar()?
¿En cuál de estos casos NO conviene usar herencia?
¿Ya lo entendiste?
Márcala como completada para llevar tu progreso y desbloquear el siguiente paso de la ruta.