Conceptos Avanzados de POO en Python

Potenciando tus habilidades en Python

En esta sección final, exploraremos conceptos avanzados que te permitirán aprovechar al máximo las capacidades de Python para el desarrollo orientado a objetos:

  • Decoradores: Una poderosa herramienta para modificar o extender el comportamiento de funciones y métodos
  • Manejo de excepciones: Técnicas para gestionar errores y situaciones excepcionales de manera elegante
¿Por qué son importantes?

Estos conceptos no son exclusivos de la POO, pero su dominio te permitirá:

  • Escribir código más limpio y mantenible
  • Implementar patrones de diseño avanzados
  • Crear soluciones robustas que manejen adecuadamente los errores
  • Extender la funcionalidad de clases y métodos sin modificar su código fuente

Decoradores

¿Qué son los Decoradores?

Los decoradores son una característica poderosa de Python que permite modificar el comportamiento de funciones o métodos sin cambiar su código interno. En esencia, un decorador es una función que toma otra función como entrada y devuelve una función modificada.

Principios del Decorador

Los decoradores se basan en tres conceptos clave:

  1. Las funciones son objetos de primera clase en Python (pueden asignarse a variables, pasarse como argumentos, etc.)
  2. Una función puede definir otras funciones (funciones anidadas)
  3. Una función puede devolver otra función

Decoradores Básicos

Veamos un ejemplo sencillo de decorador:

decorador_basico.py
def mi_decorador(funcion):
    """Un decorador simple que imprime mensajes antes y después de llamar a la función."""
    
    def funcion_envolvente(*args, **kwargs):
        print("-" * 30)
        print(f"Ejecutando: {funcion.__name__}")
        
        # Llamar a la función original
        resultado = funcion(*args, **kwargs)
        
        print(f"Finalizada: {funcion.__name__}")
        print("-" * 30)
        
        # Devolver el resultado de la función original
        return resultado
    
    # Devolver la función envolvente
    return funcion_envolvente


# Aplicando el decorador con la sintaxis @
@mi_decorador
def saludar(nombre):
    print(f"¡Hola, {nombre}!")
    return f"Saludo a {nombre} completado"


# La función saludar ahora está decorada
resultado = saludar("María")
print(f"Resultado: {resultado}")

print("\n")

# Esto es equivalente a:
def despedir(nombre):
    print(f"¡Adiós, {nombre}!")
    return f"Despedida a {nombre} completada"

# Decorar la función manualmente
despedir_decorada = mi_decorador(despedir)

# Llamar a la función decorada
resultado2 = despedir_decorada("Carlos")
print(f"Resultado: {resultado2}")
>>> Ejecuta el código para ver la salida

Decoradores con Argumentos

También podemos crear decoradores que acepten argumentos:

decorador_con_argumentos.py
def repetir(veces):
    """
    Decorador que repite la función decorada un número específico de veces.
    Este decorador acepta un argumento: el número de veces a repetir.
    """
    def decorador_real(funcion):
        def funcion_envolvente(*args, **kwargs):
            resultados = []
            for i in range(veces):
                print(f"Ejecución {i+1}/{veces}")
                resultado = funcion(*args, **kwargs)
                resultados.append(resultado)
            return resultados
        return funcion_envolvente
    return decorador_real


@repetir(veces=3)
def saludar(nombre):
    return f"¡Hola, {nombre}!"


@repetir(veces=2)
def sumar(a, b):
    return a + b


# Probar las funciones decoradas
print("\nProbando saludar:")
resultados_saludos = saludar("Ana")
for i, resultado in enumerate(resultados_saludos):
    print(f"  Resultado {i+1}: {resultado}")

print("\nProbando sumar:")
resultados_suma = sumar(5, 3)
for i, resultado in enumerate(resultados_suma):
    print(f"  Resultado {i+1}: {resultado}")
>>> Ejecuta el código para ver la salida

Decoradores de Clases en POO

Los decoradores son especialmente útiles en la Programación Orientada a Objetos para añadir funcionalidades a clases y métodos:

decoradores_poo.py
import time
from functools import wraps


# Decorador para medir el tiempo de ejecución
def medir_tiempo(funcion):
    @wraps(funcion)  # Para preservar los metadatos de la función original
    def wrapper(*args, **kwargs):
        inicio = time.time()
        resultado = funcion(*args, **kwargs)
        fin = time.time()
        print(f"Tiempo de ejecución de {funcion.__name__}: {fin - inicio:.6f} segundos")
        return resultado
    return wrapper


# Decorador para registrar llamadas a métodos
def registrar_llamada(funcion):
    @wraps(funcion)
    def wrapper(*args, **kwargs):
        # args[0] es 'self' en métodos de instancia
        clase = args[0].__class__.__name__ if args else "?"
        print(f"Llamada a {clase}.{funcion.__name__}()")
        return funcion(*args, **kwargs)
    return wrapper


# Ejemplo de una clase con métodos decorados
class Calculadora:
    def __init__(self, nombre):
        self.nombre = nombre
        self.historial = []
    
    @registrar_llamada
    def sumar(self, a, b):
        resultado = a + b
        self.historial.append(f"Suma: {a} + {b} = {resultado}")
        return resultado
    
    @registrar_llamada
    @medir_tiempo
    def operacion_compleja(self, n):
        """Simula una operación que toma tiempo."""
        print(f"Realizando cálculo complejo con n={n}...")
        time.sleep(1)  # Simular proceso que toma tiempo
        resultado = sum(i**2 for i in range(n))
        self.historial.append(f"Operación compleja con n={n}, resultado={resultado}")
        return resultado
    
    @registrar_llamada
    def mostrar_historial(self):
        print(f"Historial de {self.nombre}:")
        for operacion in self.historial:
            print(f"  {operacion}")


# Usamos la clase con métodos decorados
calc = Calculadora("MiCalculadora")

print("Realizando operaciones:")
print(f"2 + 3 = {calc.sumar(2, 3)}")
print(f"4 + 5 = {calc.sumar(4, 5)}")
print(f"Operación compleja: {calc.operacion_compleja(1000)}")

print("\nMostrando historial:")
calc.mostrar_historial()
>>> Ejecuta el código para ver la salida

Decorando Clases Completas

También podemos decorar clases enteras para modificar su comportamiento:

decorador_clase.py
def agregar_representacion(cls):
    """
    Decorador que agrega métodos __str__ y __repr__ a una clase
    si no están ya definidos.
    """
    # Verificar si la clase ya tiene el método __str__
    if '__str__' not in cls.__dict__:
        # Definir un método __str__ automático
        def auto_str(self):
            atributos = [f"{key}={value!r}" for key, value in self.__dict__.items()]
            return f"{cls.__name__}({', '.join(atributos)})"
        
        # Agregar el método a la clase
        cls.__str__ = auto_str
    
    # Verificar si la clase ya tiene el método __repr__
    if '__repr__' not in cls.__dict__:
        # Definir un método __repr__ automático
        def auto_repr(self):
            atributos = [f"{key}={value!r}" for key, value in self.__dict__.items()]
            return f"{cls.__name__}({', '.join(atributos)})"
        
        # Agregar el método a la clase
        cls.__repr__ = auto_repr
    
    # Devolver la clase modificada
    return cls


def singleton(cls):
    """
    Decorador que convierte una clase en un singleton.
    Un singleton es una clase que solo puede tener una instancia.
    """
    # Almacenar la única instancia
    instancias = {}
    
    # Reemplazar el método __new__ para controlar la creación de instancias
    original_new = cls.__new__
    
    def __new__(cls, *args, **kwargs):
        # Si la clase no tiene una instancia, créala
        if cls not in instancias:
            # Llamar al método __new__ original
            instancias[cls] = original_new(cls)
        # Devolver la instancia existente
        return instancias[cls]
    
    # Reemplazar el método __new__ de la clase
    cls.__new__ = __new__
    
    # Devolver la clase modificada
    return cls


# Usar los decoradores en clases

@agregar_representacion
class Persona:
    def __init__(self, nombre, edad):
        self.nombre = nombre
        self.edad = edad


@singleton
@agregar_representacion
class ConfiguracionApp:
    def __init__(self):
        self.tema = "Claro"
        self.idioma = "Español"
        self.version = "1.0.0"
    
    def cambiar_tema(self, nuevo_tema):
        self.tema = nuevo_tema


# Probar las clases decoradas
print("=== Clase con representación automática ===")
p1 = Persona("Ana", 30)
p2 = Persona("Carlos", 25)

print(f"p1: {p1}")
print(f"p2: {p2}")
print(f"repr(p1): {repr(p1)}")

print("\n=== Clase Singleton ===")
config1 = ConfiguracionApp()
config1.cambiar_tema("Oscuro")
print(f"config1: {config1}")

# Intentar crear otra instancia (debería devolver la misma)
config2 = ConfiguracionApp()
print(f"config2: {config2}")

# Demostrar que son el mismo objeto
print("Cambiando idioma en config2...")
config2.idioma = "Inglés"
print(f"config1 después del cambio: {config1}")
print(f"¿Son el mismo objeto? {config1 is config2}")
>>> Ejecuta el código para ver la salida
Usos Comunes de los Decoradores

Los decoradores son ampliamente utilizados para implementar:

  • Registro (logging): Para rastrear llamadas a funciones y sus resultados
  • Memoización: Para almacenar en caché resultados de funciones
  • Validación de entrada: Para verificar argumentos antes de ejecutar una función
  • Medición de rendimiento: Para medir cuánto tiempo tarda una función
  • Control de acceso: Para implementar autenticación y autorización
  • Patrones de diseño: Para implementar patrones como Singleton, Factory, etc.

Manejo de Excepciones

Introducción al Manejo de Excepciones

El manejo de excepciones es una técnica para manejar errores y situaciones inusuales en tiempo de ejecución, permitiendo que tu programa continúe funcionando incluso cuando se encuentren problemas.

Estructura básica del manejo de excepciones
try:
    # Código que podría generar una excepción
    pass
except TipoDeExcepcion:
    # Código que se ejecuta si ocurre una excepción del tipo especificado
    pass
except OtroTipoDeExcepcion as error:
    # Puedes capturar el objeto de excepción para obtener más información
    pass
else:
    # Código que se ejecuta si no ocurre ninguna excepción
    pass
finally:
    # Código que siempre se ejecuta, haya ocurrido una excepción o no
    pass
excepciones_basicas.py
def dividir(a, b):
    try:
        resultado = a / b
        return resultado
    except ZeroDivisionError:
        print("¡Error! No se puede dividir por cero.")
        return None
    except TypeError as e:
        print(f"Error de tipo: {e}")
        return None
    finally:
        print("División finalizada.")


def acceder_elemento(lista, indice):
    try:
        elemento = lista[indice]
        print(f"Elemento encontrado: {elemento}")
        return elemento
    except IndexError:
        print(f"¡Error! Índice {indice} fuera de rango. La lista tiene {len(lista)} elementos.")
        return None
    except TypeError:
        print("¡Error! El tipo de dato no admite indexación.")
        return None


# Probar la función dividir
print("=== Pruebas de división ===")
print(f"10 / 2 = {dividir(10, 2)}")
print(f"10 / 0 = {dividir(10, 0)}")
print(f"10 / 'a' = {dividir(10, 'a')}")

# Probar la función acceder_elemento
print("\n=== Pruebas de acceso a elementos ===")
mi_lista = [10, 20, 30, 40, 50]
acceder_elemento(mi_lista, 2)
acceder_elemento(mi_lista, 10)
acceder_elemento(123, 0)  # No se puede indexar un entero
>>> Ejecuta el código para ver la salida

Excepciones Personalizadas

Podemos definir nuestras propias clases de excepción para manejar errores específicos de nuestra aplicación:

excepciones_personalizadas.py
class ErrorCuentaBancaria(Exception):
    """Clase base para excepciones relacionadas con cuentas bancarias."""
    pass


class SaldoInsuficiente(ErrorCuentaBancaria):
    """Se lanza cuando se intenta retirar más dinero del disponible."""
    def __init__(self, saldo_actual, cantidad):
        self.saldo_actual = saldo_actual
        self.cantidad = cantidad
        self.deficit = cantidad - saldo_actual
        mensaje = f"Saldo insuficiente. Tienes {saldo_actual}€, intentas retirar {cantidad}€. Faltan {self.deficit}€."
        super().__init__(mensaje)


class CuentaBloqueada(ErrorCuentaBancaria):
    """Se lanza cuando se intenta operar con una cuenta bloqueada."""
    def __init__(self, razon="No especificada"):
        self.razon = razon
        mensaje = f"La cuenta está bloqueada. Razón: {razon}"
        super().__init__(mensaje)


class CuentaBancaria:
    def __init__(self, titular, saldo_inicial=0):
        self.titular = titular
        self._saldo = saldo_inicial
        self._bloqueada = False
        self._razon_bloqueo = ""
    
    def depositar(self, cantidad):
        try:
            if self._bloqueada:
                raise CuentaBloqueada(self._razon_bloqueo)
            
            if cantidad <= 0:
                raise ValueError("La cantidad a depositar debe ser positiva")
            
            self._saldo += cantidad
            print(f"Depósito de {cantidad}€ realizado. Nuevo saldo: {self._saldo}€")
            return True
        except Exception as e:
            print(f"Error al depositar: {e}")
            return False
    
    def retirar(self, cantidad):
        try:
            if self._bloqueada:
                raise CuentaBloqueada(self._razon_bloqueo)
            
            if cantidad <= 0:
                raise ValueError("La cantidad a retirar debe ser positiva")
            
            if cantidad > self._saldo:
                raise SaldoInsuficiente(self._saldo, cantidad)
            
            self._saldo -= cantidad
            print(f"Retiro de {cantidad}€ realizado. Nuevo saldo: {self._saldo}€")
            return True
        except Exception as e:
            print(f"Error al retirar: {e}")
            return False
    
    def bloquear(self, razon="Cuenta bloqueada por seguridad"):
        self._bloqueada = True
        self._razon_bloqueo = razon
        print(f"Cuenta de {self.titular} bloqueada. Razón: {razon}")
    
    def desbloquear(self):
        self._bloqueada = False
        self._razon_bloqueo = ""
        print(f"Cuenta de {self.titular} desbloqueada")
    
    @property
    def saldo(self):
        return self._saldo


# Probar la clase con manejo de excepciones
print("=== Simulación de Operaciones Bancarias ===\n")

# Crear una cuenta
cuenta = CuentaBancaria("Juan Pérez", 1000)
print(f"Cuenta creada para {cuenta.titular} con saldo inicial de {cuenta.saldo}€\n")

# Realizar operaciones
cuenta.depositar(500)
cuenta.retirar(300)

# Intentar retirar más dinero del disponible
cuenta.retirar(2000)

# Bloquear la cuenta
cuenta.bloquear("Actividad sospechosa detectada")

# Intentar operar con la cuenta bloqueada
cuenta.depositar(100)
cuenta.retirar(50)

# Desbloquear la cuenta
cuenta.desbloquear()

# Operación exitosa después de desbloquear
cuenta.depositar(200)
>>> Ejecuta el código para ver la salida

Combinar Decoradores y Manejo de Excepciones

Podemos crear decoradores que manejen excepciones para proporcionar un manejo de errores consistente:

decorador_excepciones.py
from functools import wraps
import logging

# Configurar el sistema de logging
logging.basicConfig(level=logging.INFO,
                   format='%(asctime)s - %(name)s - %(levelname)s - %(message)s')
logger = logging.getLogger("AppDemo")


def manejar_excepciones(func=None, reintento=0, valor_por_defecto=None, log_level=logging.ERROR):
    """
    Decorador que maneja las excepciones de una función.
    Args:
        func: La función a decorar
        reintento: Número de veces que se reintenta ejecutar la función en caso de error
        valor_por_defecto: Valor a devolver si la función falla después de todos los reintentos
        log_level: Nivel de logging para los errores
    """
    # Permitir que el decorador se use con o sin parámetros
    if func is None:
        return lambda f: manejar_excepciones(f, reintento, valor_por_defecto, log_level)
    
    @wraps(func)
    def wrapper(*args, **kwargs):
        intentos = 0
        max_intentos = reintento + 1  # +1 para el intento inicial
        
        while intentos < max_intentos:
            try:
                return func(*args, **kwargs)
            except Exception as e:
                intentos += 1
                
                # Registrar el error
                if intentos < max_intentos:
                    logger.warning(f"Error en {func.__name__}: {e}. Reintentando ({intentos}/{reintento})...")
                else:
                    logger.log(log_level, f"Error en {func.__name__}: {e}. No hay más reintentos.")
                
                # Si es el último intento, devolver el valor por defecto
                if intentos >= max_intentos:
                    return valor_por_defecto
    
    return wrapper


class BaseDeDatos:
    def __init__(self):
        self.conectado = False
        self.datos = {"usuario1": {"nombre": "Ana", "edad": 30},
                     "usuario2": {"nombre": "Carlos", "edad": 25}}
    
    def conectar(self):
        # Simulamos una conexión a base de datos
        print("Conectando a la base de datos...")
        self.conectado = True
        print("Conexión establecida")
    
    def desconectar(self):
        # Simulamos cerrar la conexión
        if self.conectado:
            print("Cerrando conexión...")
            self.conectado = False
            print("Conexión cerrada")
    
    @manejar_excepciones(reintento=2, valor_por_defecto=None)
    def obtener_usuario(self, id_usuario):
        """
        Busca un usuario en la base de datos.
        Reintenta hasta 2 veces en caso de error.
        """
        if not self.conectado:
            raise ConnectionError("No hay conexión a la base de datos")
        
        if id_usuario not in self.datos:
            raise KeyError(f"Usuario con ID '{id_usuario}' no encontrado")
        
        return self.datos[id_usuario]
    
    @manejar_excepciones(log_level=logging.CRITICAL)
    def operacion_critica(self):
        """
        Operación que no debe fallar, los errores se registran como críticos.
        """
        if not self.conectado:
            raise ConnectionError("No hay conexión para operación crítica")
        
        # Simular una operación importante
        print("Ejecutando operación crítica en la base de datos...")
        return "Operación completada con éxito"


# Probar el decorador de manejo de excepciones
print("=== Sistema de Base de Datos con Manejo de Excepciones ===\n")

db = BaseDeDatos()

# Intentar operaciones sin conectar
print("Intentando operaciones sin conectar:")
usuario = db.obtener_usuario("usuario1")
print(f"Resultado: {usuario}")

operacion = db.operacion_critica()
print(f"Resultado: {operacion}")

# Conectar y volver a intentar
print("\nConectando a la base de datos y reintentando:")
db.conectar()

usuario = db.obtener_usuario("usuario1")
print(f"Usuario encontrado: {usuario}")

usuario_inexistente = db.obtener_usuario("usuario999")
print(f"Búsqueda de usuario inexistente: {usuario_inexistente}")

operacion = db.operacion_critica()
print(f"Operación crítica: {operacion}")

# Cerrar conexión
db.desconectar()
>>> Ejecuta el código para ver la salida
Buenas Prácticas en el Manejo de Excepciones
  • Ser específico: Captura solo las excepciones que puedes manejar de forma adecuada
  • No silenciar errores: Evita bloques try-except vacíos que ocultan problemas
  • Usar el bloque finally: Para liberar recursos independientemente de lo que ocurra
  • Crear jerarquías de excepciones: Para organizar los diferentes tipos de errores
  • Documentar las excepciones: Indica qué excepciones puede lanzar cada función
  • Lanzar excepciones apropiadas: Usa el tipo de excepción más adecuado al problema

Ejercicio Práctico

Sistema de Validación

Implementa un sistema de validación para una clase Producto usando decoradores y manejo de excepciones:

  • Crea un decorador @validar_datos que verifique si los datos del producto son válidos
  • Define excepciones personalizadas para diferentes errores (precio negativo, nombre vacío, etc.)
  • Implementa la clase Producto con métodos para establecer sus atributos
ejercicio_validador.py
# Define aquí tus excepciones personalizadas

# Implementa el decorador validar_datos

# Implementa la clase Producto

# Crea productos y prueba tu sistema

Resumen y Conclusión

En esta sección, hemos explorado conceptos avanzados que complementan tus habilidades de Programación Orientada a Objetos en Python:

  • Decoradores: Te permiten extender el comportamiento de funciones y clases de manera elegante, siguiendo el principio de composición sobre herencia.
  • Manejo de excepciones: Te ayuda a crear código más robusto que puede manejar errores de manera controlada y recuperarse de situaciones inesperadas.

Estos conceptos son esenciales para el desarrollo de software profesional en Python y te permitirán crear aplicaciones más robustas, mantenibles y flexibles.

Próximos pasos en tu aprendizaje

Para seguir mejorando tus habilidades en Programación Orientada a Objetos con Python, te recomendamos explorar:

  • Metaprogramación y metaclases
  • Patrones de diseño en Python
  • Testing de aplicaciones orientadas a objetos
  • Programación concurrente y paralela con objetos