Herencia en Python — ejercicios para dominar jerarquías de clases

La herencia en Python ejercicios con solución cierran este bloque. Ya viste la teoría y practicaste con jerarquías reales. Ahora toca resolver por tu cuenta. Dos ejercicios en tres niveles de dificultad con especial atención al error más común en FP2 olvidar llamar a super().__init__() en la subclase y quedarse con atributos de la superclase sin inicializar. Corrige tu código con la ayuda de pythontutor.com.


Herencia en Python ejercicios — Nivel Básico

Ejercicio 1 — Sistema de animales con comportamiento específico

Diseña una jerarquía de clases para representar animales. La clase base Animal debe ser abstracta con estos métodos abstractos: sonido() y moverse(). Las subclases son Perro, Gato y Pajaro.

Requisitos de la clase base Animal:

  • Atributos privados: nombre y edad
  • Método no abstracto describir() que usa sonido() y moverse()
  • __str__ y __repr__

Requisitos de cada subclase:

  • Perro(nombre, edad, raza) — sonido: ladra, movimiento: corre
  • Gato(nombre, edad, indoor) — sonido: maúlla, movimiento: camina sigilosamente
  • Pajaro(nombre, edad, puede_volar) — sonido: canta, movimiento: vuela o camina según puede_volar

La salida debe ser:

Rex (Labrador, 3 años)
  Sonido: ¡Guau guau!
  Movimiento: corre moviendo la cola
  Descripción: Rex ladra y corre moviendo la cola

Luna (Gato indoor, 5 años)
  Sonido: Miau...
  Movimiento: camina sigilosamente
  Descripción: Luna maúlla y camina sigilosamente

Pío (Canario, 2 años)
  Sonido: ♪ Pío pío ♪
  Movimiento: vuela libremente
  Descripción: Pío canta y vuela libremente

Kiwi (Pingüino, 4 años)
  Sonido: ♪ Pío pío ♪
  Movimiento: camina torpemente
  Descripción: Kiwi canta y camina torpemente

💡 Pista — el error de olvidar super().init():

# MAL — atributos de Animal sin inicializar
class Perro(Animal):
    def __init__(self, nombre, edad, raza):
        self.__raza = raza    # Animal.__init__ nunca se llamó
        # self.nombre → AttributeError al intentar usarlo

# BIEN — siempre llamar a super().__init__() primero
class Perro(Animal):
    def __init__(self, nombre, edad, raza):
        super().__init__(nombre, edad)    # inicializa nombre y edad
        self.__raza = raza                # luego los propios

Sin super().__init__() los atributos de Animal no existen en el objeto, cualquier método heredado que los use lanzará AttributeError.


Herencia en Python ejercicios — Nivel Intermedio y Desafío

Ejercicio 2 — Jerarquía de cuentas bancarias

Diseña una jerarquía completa de cuentas bancarias. La clase base Cuenta debe ser abstracta.

Clase base Cuenta (abstracta):

  • Atributos privados: titular, saldo, lista de movimientos
  • Métodos abstractos: tipo(), interes_anual(), puede_retirar(cantidad)
  • Métodos no abstractos: depositar(cantidad), retirar(cantidad), aplicar_interes(), extracto()
  • retirar llama a puede_retirar antes de ejecutar — cada subclase decide si se permite

Subclases:

CuentaCorriente(titular, saldo_inicial, descubierto_max):

  • Sin interés anual (0%)
  • Puede retirar hasta saldo + descubierto_max — permite saldo negativo hasta el límite
  • Comisión de 15€ si el saldo baja de 0

CuentaAhorro(titular, saldo_inicial, interes):

  • Interés anual configurable
  • Solo puede retirar si el saldo resultante es >= 0
  • Mínimo de 3 meses entre retiradas (usa un contador simplificado de operaciones)

CuentaInversion(titular, saldo_inicial, perfil_riesgo):

  • Perfil de riesgo: conservador (4%), moderado (7%), agresivo (12%)
  • Solo puede retirar si el saldo resultante es >= 1000 (mínimo de inversión)
  • Penalización del 2% sobre la cantidad retirada

La salida debe ser:

=== CUENTA CORRIENTE ===
CuentaCorriente — Sergio | Saldo: 500.00€
Depositando 200€... Saldo: 700.00€
Retirando 900€... Saldo: -200.00€ (en descubierto)
Comisión descubierto: -15.00€ | Saldo: -215.00€

=== CUENTA AHORRO ===
CuentaAhorro — María | Saldo: 2000.00€
Aplicando interés 3.5%... Saldo: 2070.00€
Retirando 500€... Saldo: 1570.00€
Error: debes esperar más operaciones antes de retirar

=== CUENTA INVERSIÓN ===
CuentaInversion — Juan | Saldo: 5000.00€
Perfil: agresivo (12% anual)
Aplicando interés... Saldo: 5600.00€
Retirando 2000€ (penalización 2%)... Saldo: 3560.00€
Error: saldo mínimo de inversión: 1000€

💡 Pistas:

  • retirar(cantidad) está en Cuenta y llama a self.puede_retirar(cantidad) — método abstracto que cada subclase implementa
  • aplicar_interes() está en Cuenta y usa self.interes_anual() — abstracto
  • El error de super().__init__() es crítico aquí — si lo olvidas en CuentaAhorro, el atributo __saldo de Cuenta no existe y todos los métodos heredados fallan
  • Para la penalización de CuentaInversion descuenta el 2% directamente del saldo después de retirar
  • extracto() muestra todos los movimientos — guárdalos como strings en la lista

Soluciones Comentadas

Solución Ejercicio 1:

from abc import ABC, abstractmethod

class Animal(ABC):
    def __init__(self, nombre, edad):
        if not nombre or not isinstance(nombre, str):
            raise ValueError('Nombre no válido')
        if not isinstance(edad, int) or edad < 0:
            raise ValueError('Edad no válida')
        self.__nombre = nombre
        self.__edad = edad

    @property
    def nombre(self):
        return self.__nombre

    @property
    def edad(self):
        return self.__edad

    @abstractmethod
    def sonido(self):
        pass

    @abstractmethod
    def moverse(self):
        pass

    def describir(self):
        verbo = self.sonido().split()[0].lower()
        return f'{self.__nombre} {verbo} y {self.moverse()}'

    def __str__(self):
        return (f'{self.__nombre} ({self._info()}, {self.__edad} años)\n'
                f'  Sonido: {self.sonido()}\n'
                f'  Movimiento: {self.moverse()}\n'
                f'  Descripción: {self.describir()}')

    @abstractmethod
    def _info(self):
        pass

    def __repr__(self):
        return f'{self.__class__.__name__}("{self.__nombre}", {self.__edad})'


class Perro(Animal):
    def __init__(self, nombre, edad, raza):
        super().__init__(nombre, edad)    # SIEMPRE primero
        self.__raza = raza

    def sonido(self):
        return '¡Guau guau!'

    def moverse(self):
        return 'corre moviendo la cola'

    def _info(self):
        return self.__raza


class Gato(Animal):
    def __init__(self, nombre, edad, indoor=True):
        super().__init__(nombre, edad)
        self.__indoor = indoor

    def sonido(self):
        return 'Miau...'

    def moverse(self):
        return 'camina sigilosamente'

    def _info(self):
        return 'Gato indoor' if self.__indoor else 'Gato outdoor'


class Pajaro(Animal):
    def __init__(self, nombre, edad, puede_volar, especie='Pájaro'):
        super().__init__(nombre, edad)
        self.__puede_volar = puede_volar
        self.__especie = especie

    def sonido(self):
        return '♪ Pío pío ♪'

    def moverse(self):
        return 'vuela libremente' if self.__puede_volar else 'camina torpemente'

    def _info(self):
        return self.__especie


# Uso
animales = [
    Perro('Rex', 3, 'Labrador'),
    Gato('Luna', 5, indoor=True),
    Pajaro('Pío', 2, True, 'Canario'),
    Pajaro('Kiwi', 4, False, 'Pingüino')
]

for animal in animales:
    print(animal)
    print()

# No se puede instanciar Animal directamente
try:
    a = Animal('Test', 1)
except TypeError as err:
    print(f'Error esperado: {err}')

Solución Ejercicio 2:

from abc import ABC, abstractmethod

class ErrorCuenta(Exception):
    pass

class Cuenta(ABC):
    def __init__(self, titular, saldo_inicial):
        if not titular or not isinstance(titular, str):
            raise ValueError('Titular no válido')
        if not isinstance(saldo_inicial, (int, float)) or saldo_inicial < 0:
            raise ValueError('Saldo inicial no válido')
        self.__titular = titular
        self.__saldo = saldo_inicial
        self.__movimientos = []
        self._registrar(f'Apertura: +{saldo_inicial:.2f}€')

    @property
    def titular(self):
        return self.__titular

    @property
    def saldo(self):
        return self.__saldo

    @abstractmethod
    def tipo(self):
        pass

    @abstractmethod
    def interes_anual(self):
        pass

    @abstractmethod
    def puede_retirar(self, cantidad):
        pass

    def _registrar(self, movimiento):
        self.__movimientos.append(movimiento)

    def _modificar_saldo(self, cantidad):
        self.__saldo += cantidad

    def depositar(self, cantidad):
        if not isinstance(cantidad, (int, float)) or cantidad <= 0:
            raise ErrorCuenta('La cantidad debe ser positiva')
        self.__saldo += cantidad
        self._registrar(f'Depósito: +{cantidad:.2f}€ | Saldo: {self.__saldo:.2f}€')

    def retirar(self, cantidad):
        if not isinstance(cantidad, (int, float)) or cantidad <= 0:
            raise ErrorCuenta('La cantidad debe ser positiva')
        self.puede_retirar(cantidad)    # cada subclase decide
        self.__saldo -= cantidad
        self._registrar(f'Retirada: -{cantidad:.2f}€ | Saldo: {self.__saldo:.2f}€')
        self._post_retirada(cantidad)   # hook para efectos secundarios

    def _post_retirada(self, cantidad):
        pass    # las subclases pueden sobreescribir

    def aplicar_interes(self):
        interes = self.__saldo * self.interes_anual()
        self.__saldo += interes
        self._registrar(
            f'Interés {self.interes_anual()*100:.1f}%: '
            f'+{interes:.2f}€ | Saldo: {self.__saldo:.2f}€')

    def extracto(self):
        lineas = [f'=== Extracto {self.tipo()} — {self.__titular} ===']
        for mov in self.__movimientos:
            lineas.append(f'  {mov}')
        lineas.append(f'  Saldo actual: {self.__saldo:.2f}€')
        return '\n'.join(lineas)

    def __str__(self):
        return f'{self.tipo()} — {self.__titular} | Saldo: {self.__saldo:.2f}€'

    def __repr__(self):
        return f'{self.__class__.__name__}("{self.__titular}", {self.__saldo})'


class CuentaCorriente(Cuenta):
    def __init__(self, titular, saldo_inicial, descubierto_max=500):
        super().__init__(titular, saldo_inicial)    # SIEMPRE primero
        self.__descubierto_max = descubierto_max

    def tipo(self):
        return 'CuentaCorriente'

    def interes_anual(self):
        return 0.0

    def puede_retirar(self, cantidad):
        if cantidad > self.saldo + self.__descubierto_max:
            raise ErrorCuenta(
                f'Límite de descubierto: {self.__descubierto_max:.2f}€')

    def _post_retirada(self, cantidad):
        if self.saldo < 0:
            self._modificar_saldo(-15)
            self._registrar(
                f'Comisión descubierto: -15.00€ | Saldo: {self.saldo:.2f}€')


class CuentaAhorro(Cuenta):
    def __init__(self, titular, saldo_inicial, interes=0.02):
        super().__init__(titular, saldo_inicial)
        if not (0 <= interes <= 1):
            raise ValueError('Interés no válido')
        self.__interes = interes
        self.__operaciones_desde_retirada = 0

    def tipo(self):
        return 'CuentaAhorro'

    def interes_anual(self):
        return self.__interes

    def puede_retirar(self, cantidad):
        if self.__operaciones_desde_retirada < 3:
            raise ErrorCuenta(
                'Debes esperar más operaciones antes de retirar')
        if self.saldo - cantidad < 0:
            raise ErrorCuenta('Saldo insuficiente')

    def depositar(self, cantidad):
        super().depositar(cantidad)
        self.__operaciones_desde_retirada += 1

    def _post_retirada(self, cantidad):
        self.__operaciones_desde_retirada = 0


class CuentaInversion(Cuenta):
    PERFILES = {'conservador': 0.04, 'moderado': 0.07, 'agresivo': 0.12}
    SALDO_MINIMO = 1000

    def __init__(self, titular, saldo_inicial, perfil_riesgo):
        if perfil_riesgo not in self.PERFILES:
            raise ValueError(f'Perfil no válido: {perfil_riesgo}')
        if saldo_inicial < self.SALDO_MINIMO:
            raise ValueError(f'Mínimo inicial: {self.SALDO_MINIMO}€')
        super().__init__(titular, saldo_inicial)
        self.__perfil = perfil_riesgo

    def tipo(self):
        return f'CuentaInversion ({self.__perfil})'

    def interes_anual(self):
        return self.PERFILES[self.__perfil]

    def puede_retirar(self, cantidad):
        penalizacion = cantidad * 0.02
        if self.saldo - cantidad - penalizacion < self.SALDO_MINIMO:
            raise ErrorCuenta(
                f'Saldo mínimo de inversión: {self.SALDO_MINIMO}€')

    def _post_retirada(self, cantidad):
        penalizacion = cantidad * 0.02
        self._modificar_saldo(-penalizacion)
        self._registrar(
            f'Penalización 2%: -{penalizacion:.2f}€ | Saldo: {self.saldo:.2f}€')


# Uso
print('=== CUENTA CORRIENTE ===')
cc = CuentaCorriente('Sergio', 500, descubierto_max=300)
cc.depositar(200)
cc.retirar(900)
print(cc.extracto())

print('\n=== CUENTA AHORRO ===')
ca = CuentaAhorro('María', 2000, interes=0.035)
ca.aplicar_interes()
ca.depositar(100)
ca.depositar(100)
ca.depositar(100)
ca.retirar(500)
try:
    ca.retirar(200)
except ErrorCuenta as err:
    print(f'Error: {err}')
print(ca.extracto())

print('\n=== CUENTA INVERSIÓN ===')
ci = CuentaInversion('Juan', 5000, 'agresivo')
ci.aplicar_interes()
ci.retirar(2000)
try:
    ci.retirar(4000)
except ErrorCuenta as err:
    print(f'Error: {err}')
print(ci.extracto())

Chuletario — Herencia y polimorfismo en Python

# ============================================
# CHULETARIO — Herencia y polimorfismo
# Sergio Learns · sergiolearns.com
# ============================================

# HERENCIA BÁSICA
class SubClase(SuperClase):
    def __init__(self, param_propio, *params_super):
        super().__init__(*params_super)    # SIEMPRE primero
        self.propio = param_propio         # luego los propios

# OVERRIDE — redefinir método heredado
class SubClase(SuperClase):
    def metodo(self):                      # sustituye al de SuperClase
        return 'versión SubClase'

# OVERRIDE EXTENDIDO — añadir al heredado
class SubClase(SuperClase):
    def metodo(self):
        base = super().metodo()            # llama al de SuperClase
        return f'{base} + extra'

# CLASES ABSTRACTAS
from abc import ABC, abstractmethod

class MiAbstracta(ABC):
    def __init__(self, param):
        self.__param = param               # puede tener __init__

    @abstractmethod
    def metodo_obligatorio(self):          # subclases DEBEN implementar
        pass

    def metodo_normal(self):              # subclases heredan
        return self.metodo_obligatorio()   # puede llamar a abstractos

# No instanciable directamente → TypeError
# Subclase sin implementar @abstractmethod → también abstracta → TypeError

# isinstance vs type
isinstance(obj, Clase)    # True para Clase Y sus subclases
isinstance(obj, (A, B))   # True si es A o B o subclase de alguna
type(obj) == Clase        # True solo para esa clase exacta
type(obj).__name__        # nombre de la clase como string

# JERARQUÍA EN DOS NIVELES
class A:
    pass
class B(A):
    def __init__(self):
        super().__init__()
class C(B):               # C hereda de B que hereda de A
    def __init__(self):
        super().__init__()    # llama a B.__init__ que llama a A.__init__

# POLIMORFISMO CON LISTAS MIXTAS
objetos = [SubClase1(), SubClase2(), SubClase3()]
for obj in objetos:
    obj.metodo()          # cada uno ejecuta su versión

# ATRIBUTOS PRIVADOS Y HERENCIA
class SuperClase:
    def __init__(self):
        self.__privado = 0     # NO accesible desde subclases
        self._protegido = 0    # accesible pero convención privado

class SubClase(SuperClase):
    def metodo(self):
        self.__privado        # AttributeError
        self._protegido       # OK — convención

# HOOK PATTERN — método vacío que subclases pueden sobreescribir
class Base:
    def operacion(self):
        self._pre_operacion()     # hook
        # lógica principal
        self._post_operacion()    # hook

    def _pre_operacion(self): pass     # subclases sobreescriben si quieren
    def _post_operacion(self): pass

# ERRORES TÍPICOS
# 1. Olvidar super().__init__() → atributos de superclase no inicializados
# 2. Instanciar clase abstracta → TypeError
# 3. Subclase sin implementar @abstractmethod → TypeError al instanciar
# 4. type() en vez de isinstance() → no respeta jerarquía
# 5. Llamar super() fuera de un método → RuntimeError
# 6. Hardcodear nombre de clase en vez de super() → frágil

# PATRÓN COMPLETO
from abc import ABC, abstractmethod

class Base(ABC):
    def __init__(self, param):
        self.__param = param

    @property
    def param(self): return self.__param

    @abstractmethod
    def comportamiento(self): pass      # obligatorio

    def describir(self):                # usa el abstracto
        return f'{self.__param}: {self.comportamiento()}'

    def __str__(self): return self.describir()
    def __repr__(self): return f'{self.__class__.__name__}("{self.__param}")'

class Concreta(Base):
    def __init__(self, param, extra):
        super().__init__(param)         # SIEMPRE primero
        self.__extra = extra

    def comportamiento(self):           # obligatorio — implementado
        return f'comportamiento con {self.__extra}'
Sigue aprendiendoTodos los artículos de Python

¿Te ha servido este artículo? Así puedes apoyar SergioLearns →

Sigue aprendiendo

Un comentario

Deja una respuesta

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *