AulaML Python · C++
Python intermedio · 44 min · Programación orientada a objetos

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.

Lección 2 de 4 del módulo
Al terminar esta lección podrás
  • 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
  1. ¿Qué es la herencia?
  2. ¿Para qué sirve?
  3. ¿Cómo funciona por dentro?
  4. Sintaxis
  5. Ejemplo básico: un perro que hereda de animal
  6. Ejemplo intermedio: polimorfismo con una lista
  7. Caso práctico: un contrato para transformadores de datos
  8. Visualización: crear un objeto y resolver un método
  9. Errores comunes
  10. Buenas prácticas
  11. Ejercicios
  12. Soluciones explicadas
  13. Resumen
  14. 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-learn espera que tu modelo herede de BaseEstimator y cumpla la interfaz fit/predict.
  • Errores propios: class ErrorDeDato(Exception) hereda de Exception para poder lanzarse con raise, 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

PYTHON El esqueleto de una jerarquía
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))
FormaQué 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
Sobrescribir un método en los dos lenguajes
Python
PYTHON herencia.py
class Animal:
    def hablar(self):
        return "..."

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

print(Perro().hablar())
C++
CPP herencia.cpp
#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.

PYTHON animales.py
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.

PYTHON polimorfismo.py
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.

PYTHON transformadores.py
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__.

Ejecución visual ·Del `class` al método que realmente se ejecuta
1 / 6
1 class Animal:
2 def __init__(self, nombre):
3 self.nombre = nombre
4
5 def hablar(self):
6 return "..."
7
8 class Perro(Animal):
9 def __init__(self, nombre, raza):
10 super().__init__(nombre)
11 self.raza = raza
12
13 def hablar(self):
14 return "Guau"
15
16 p = Perro("Toby", "mestizo")
17 print(p.hablar())
18 print(isinstance(p, Animal))
19 print([c.__name__ for c in Perro.__mro__])
Se define la clase Animal. El cuerpo se ejecuta una vez: crea las funciones __init__ y hablar y las guarda como atributos de la clase, no del objeto.
Variables
Animal = <class 'Animal'>
Salida
—

Avanza paso a paso: observa la línea resaltada, las variables y la salida.

Errores comunes

MensajeCausa habitualCómo se arregla
TypeError: __init__() missing 1 required positional argument: 'raza'Llamaste Perro("Toby") sin el argumento que pide la subclaseRevisa 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, transformarIntentaste crear un objeto de una clase abstracta o de una subclase incompletaImplementa 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 textoValida el argumento antes de usarlo o conviértelo explícitamente
Olvidar super().__init__ no da error hasta que falta el atributo
Incorrecto
PYTHON Falla al leer nombre
class Perro(Animal):
    def __init__(self, nombre, raza):
        self.raza = raza

p = Perro("Toby", "mestizo")
print(p.nombre)
Correcto
PYTHON Inicializa la parte heredada
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 NotImplementedError donde 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 ABC y @abstractmethod cuando varias clases deban ofrecer la misma interfaz.
  • Prefiere isinstance(obj, Base) a type(obj) is Base: lo primero acepta subclases, lo segundo no.

Ejercicios

Ejercicio 1

Figuras con área obligatoria

Crea una clase abstracta 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.

Ejercicio 2

Nómina con sobrescritura y super()

Modela 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).

Completar código Completa el contrato abstracto
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.
  • ABC y @abstractmethod impiden 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

Evaluación 1

¿Qué hace exactamente super().__init__(nombre) dentro del __init__ de Perro?

Evaluación 2

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()?

Evaluación 3

¿En cuál de estos casos NO conviene usar herencia?

Tus notas

¿Ya lo entendiste?

Márcala como completada para llevar tu progreso y desbloquear el siguiente paso de la ruta.