En esta página

Herencia y polimorfismo

14 min lectura TextoCap. 4 — OOP y archivos

Herencia en Python

La herencia permite crear nuevas clases basadas en clases existentes, reutilizando y extendiendo su comportamiento. La clase hija hereda todos los atributos y métodos de la clase padre:

class Animal:
    """Clase base para todos los animales."""

    def __init__(self, nombre: str, especie: str) -> None:
        self.nombre = nombre
        self.especie = especie
        self._energia = 100

    def comer(self, alimento: str) -> None:
        self._energia += 20
        print(f"{self.nombre} come {alimento} (energía: {self._energia})")

    def dormir(self) -> None:
        self._energia += 30
        print(f"{self.nombre} duerme plácidamente")

    def hacer_sonido(self) -> str:
        return "..."

    def __repr__(self) -> str:
        return f"{type(self).__name__}(nombre={self.nombre!r})"


class Perro(Animal):
    """Subclase de Animal — representa un perro."""

    def __init__(self, nombre: str, raza: str) -> None:
        super().__init__(nombre, especie="Canis lupus familiaris")
        self.raza = raza
        self.trucos: list[str] = []

    def hacer_sonido(self) -> str:
        return "¡Guau!"

    def aprender_truco(self, truco: str) -> None:
        self.trucos.append(truco)
        print(f"{self.nombre} aprendió: {truco}")

    def mostrar_trucos(self) -> None:
        if self.trucos:
            print(f"Trucos de {self.nombre}: {', '.join(self.trucos)}")
        else:
            print(f"{self.nombre} no sabe ningún truco")


class Gato(Animal):
    """Subclase de Animal — representa un gato."""

    def __init__(self, nombre: str, indoor: bool = True) -> None:
        super().__init__(nombre, especie="Felis catus")
        self.indoor = indoor

    def hacer_sonido(self) -> str:
        return "¡Miau!"

    def ronronear(self) -> None:
        print(f"{self.nombre} ronronea... 🐱")


# Usar las subclases
rex = Perro("Rex", "Pastor Alemán")
michi = Gato("Michi")

rex.comer("croquetas")      # Método heredado de Animal
rex.aprender_truco("sentarse")
rex.aprender_truco("dar la pata")
rex.mostrar_trucos()

michi.comer("atún")
michi.ronronear()

print(rex.hacer_sonido())   # ¡Guau!
print(michi.hacer_sonido()) # ¡Miau!

super() — Acceder a la clase padre

super() es la función que permite llamar a métodos de la clase padre desde una subclase. Es especialmente importante en __init__ y cuando sobreescribes métodos:

class Empleado(Persona):
    def __init__(self, nombre: str, año_nac: int, empresa: str, salario: float) -> None:
        super().__init__(nombre, año_nac)  # Llama a Persona.__init__
        self.empresa = empresa
        self.salario = salario

    def __repr__(self) -> str:
        base = super().__repr__()  # Llama a Persona.__repr__
        return f"{base}, Empleado en {self.empresa}"


class Gerente(Empleado):
    def __init__(
        self,
        nombre: str,
        año_nac: int,
        empresa: str,
        salario: float,
        equipo: list[str]
    ) -> None:
        super().__init__(nombre, año_nac, empresa, salario)
        self.equipo = equipo

    def agregar_al_equipo(self, miembro: str) -> None:
        self.equipo.append(miembro)
        print(f"{miembro} ahora reporta a {self.nombre}")

MRO — Orden de resolución de métodos

Cuando Python busca un método o atributo, sigue el MRO (Method Resolution Order) calculado con el algoritmo C3 linearization:

class A:
    def metodo(self) -> str:
        return "A"

class B(A):
    def metodo(self) -> str:
        return f"B → {super().metodo()}"

class C(A):
    def metodo(self) -> str:
        return f"C → {super().metodo()}"

class D(B, C):
    def metodo(self) -> str:
        return f"D → {super().metodo()}"

# Ver el MRO
print(D.__mro__)
# (<class 'D'>, <class 'B'>, <class 'C'>, <class 'A'>, <class 'object'>)

d = D()
print(d.metodo())  # D → B → C → A

Herencia múltiple y Mixins

Python soporta herencia múltiple. El patrón de Mixin es una forma segura y común de compartir comportamiento sin crear jerarquías complejas:

class JSONMixin:
    """Mixin que agrega serialización JSON a cualquier clase."""
    import json as _json

    def to_json(self) -> str:
        import json
        return json.dumps(
            {k: v for k, v in self.__dict__.items() if not k.startswith("_")},
            default=str,
            ensure_ascii=False
        )

    @classmethod
    def from_json(cls, json_str: str) -> "JSONMixin":
        import json
        datos = json.loads(json_str)
        return cls(**datos)


class LogMixin:
    """Mixin que agrega logging básico a métodos."""
    def log(self, mensaje: str, nivel: str = "INFO") -> None:
        from datetime import datetime
        marca = datetime.now().strftime("%H:%M:%S")
        print(f"[{marca}][{nivel}][{type(self).__name__}] {mensaje}")


class ValidacionMixin:
    """Mixin para validación de datos."""
    def validar(self) -> list[str]:
        """Devuelve lista de errores de validación."""
        return []

    def es_valido(self) -> bool:
        return len(self.validar()) == 0


class UsuarioAPI(JSONMixin, LogMixin, ValidacionMixin):
    def __init__(self, nombre: str, email: str) -> None:
        self.nombre = nombre
        self.email = email

    def validar(self) -> list[str]:
        errores = []
        if not self.nombre:
            errores.append("El nombre es requerido")
        if "@" not in self.email:
            errores.append("El email es inválido")
        return errores

    def guardar(self) -> None:
        if not self.es_valido():
            errores = self.validar()
            raise ValueError(f"Validación fallida: {errores}")
        self.log(f"Guardando usuario {self.nombre}")
        # Aquí iría la lógica de persistencia


usuario = UsuarioAPI("Ana García", "[email protected]")
print(usuario.to_json())     # {"nombre": "Ana García", "email": "[email protected]"}
print(usuario.es_valido())   # True
usuario.guardar()

ABC — Clases base abstractas

ABC (Abstract Base Class) fuerza a las subclases a implementar ciertos métodos. Si no implementan todos los métodos abstractos, Python lanzará un TypeError al intentar instanciarlas:

from abc import ABC, abstractmethod
import math

class Forma(ABC):
    """Clase base abstracta para formas geométricas."""

    @abstractmethod
    def area(self) -> float:
        """Calcula el área de la forma."""
        ...

    @abstractmethod
    def perimetro(self) -> float:
        """Calcula el perímetro de la forma."""
        ...

    @property
    @abstractmethod
    def nombre(self) -> str:
        """Nombre de la forma."""
        ...

    # Método concreto que usan todas las subclases
    def es_mayor_que(self, otra: "Forma") -> bool:
        return self.area() > otra.area()

    def __str__(self) -> str:
        return f"{self.nombre}: área={self.area():.2f}m², perímetro={self.perimetro():.2f}m"


class Circulo(Forma):
    def __init__(self, radio: float) -> None:
        if radio <= 0:
            raise ValueError("El radio debe ser positivo")
        self.radio = radio

    @property
    def nombre(self) -> str:
        return "Círculo"

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

    def perimetro(self) -> float:
        return 2 * math.pi * self.radio


class Rectangulo(Forma):
    def __init__(self, ancho: float, alto: float) -> None:
        self.ancho = ancho
        self.alto = alto

    @property
    def nombre(self) -> str:
        return "Rectángulo"

    def area(self) -> float:
        return self.ancho * self.alto

    def perimetro(self) -> float:
        return 2 * (self.ancho + self.alto)


class Triangulo(Forma):
    def __init__(self, a: float, b: float, c: float) -> None:
        if not (a + b > c and b + c > a and a + c > b):
            raise ValueError("Los lados no forman un triángulo válido")
        self.a, self.b, self.c = a, b, c

    @property
    def nombre(self) -> str:
        return "Triángulo"

    def area(self) -> float:
        s = (self.a + self.b + self.c) / 2  # Semiperímetro
        return math.sqrt(s * (s-self.a) * (s-self.b) * (s-self.c))  # Heron

    def perimetro(self) -> float:
        return self.a + self.b + self.c


# No se puede instanciar Forma directamente
# forma = Forma()  # TypeError: Can't instantiate abstract class

circulo = Circulo(5)
rectangulo = Rectangulo(4, 6)
triangulo = Triangulo(3, 4, 5)

formas: list[Forma] = [circulo, rectangulo, triangulo]
for forma in formas:
    print(forma)

print(f"\nMayor forma: {max(formas, key=lambda f: f.area())}")

Protocol — Tipado estructural (duck typing estático)

Los Protocol de typing permiten el tipado estructural: no necesitas heredar de una clase, solo implementar los métodos requeridos:

from typing import Protocol, runtime_checkable

@runtime_checkable
class Serializable(Protocol):
    def to_dict(self) -> dict: ...
    def to_json(self) -> str: ...


class ConfiguracionApp:
    """No hereda de Serializable, pero implementa sus métodos."""
    def __init__(self, host: str, puerto: int) -> None:
        self.host = host
        self.puerto = puerto

    def to_dict(self) -> dict:
        return {"host": self.host, "puerto": self.puerto}

    def to_json(self) -> str:
        import json
        return json.dumps(self.to_dict())


def guardar_configuración(obj: Serializable, archivo: str) -> None:
    """Acepta cualquier objeto que implemente Serializable."""
    datos = obj.to_json()
    print(f"Guardando en {archivo}: {datos}")


config = ConfiguracionApp("localhost", 8080)
guardar_configuración(config, "config.json")

# runtime_checkable permite usar isinstance()
print(isinstance(config, Serializable))  # True

Polimorfismo en la práctica

El polimorfismo permite tratar objetos de distintas clases de la misma manera, siempre que compartan la misma interfaz:

from typing import Sequence

def calcular_area_total(formas: Sequence[Forma]) -> float:
    """Calcula el área total sin importar qué tipo de forma sea."""
    return sum(forma.area() for forma in formas)

def describir_coleccion(formas: Sequence[Forma]) -> None:
    for i, forma in enumerate(formas, 1):
        print(f"  {i}. {forma}")

coleccion: list[Forma] = [
    Circulo(3),
    Rectangulo(5, 8),
    Triangulo(6, 8, 10),
    Circulo(1),
]

print("=== Formas en la colección ===")
describir_coleccion(coleccion)
print(f"\nÁrea total: {calcular_area_total(coleccion):.2f} m²")

Resumen

La herencia permite reutilizar y especializar código. super() asegura que las cadenas de herencia funcionen correctamente. El MRO resuelve el orden de búsqueda en herencia múltiple. Las clases abstractas con ABC garantizan que las subclases implementen los métodos necesarios, mientras que Protocol ofrece tipado estructural más flexible. En la próxima lección aprenderemos a trabajar con archivos y datos JSON.

ABC vs Protocol — tipado nominal vs estructural
ABC usa herencia nominal (debes heredar explícitamente). Protocol usa tipado estructural (duck typing estático): si una clase implementa los métodos necesarios, satisface el Protocol sin importarlo. Protocol es más flexible para código que no controlas.
Siempre llama super().__init__() en subclases
Cuando una subclase define __init__, debe llamar a super().__init__() para asegurarse de que el constructor de la clase padre se ejecute correctamente, especialmente en herencia múltiple donde el MRO determina el orden.
La herencia múltiple puede ser compleja — úsala con cuidado
Aunque Python soporta herencia múltiple, puede generar problemas difíciles de depurar (el problema del diamante). Prefiere composición sobre herencia cuando sea posible, y usa Mixins para compartir funcionalidad horizontal.