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:
Estos conceptos no son exclusivos de la POO, pero su dominio te permitirá:
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.
Los decoradores se basan en tres conceptos clave:
Veamos un ejemplo sencillo de decorador:
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}")
También podemos crear decoradores que acepten argumentos:
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}")
Los decoradores son especialmente útiles en la Programación Orientada a Objetos para añadir funcionalidades a clases y métodos:
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()
También podemos decorar clases enteras para modificar su comportamiento:
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}")
Los decoradores son ampliamente utilizados para implementar:
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.
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
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
Podemos definir nuestras propias clases de excepción para manejar errores específicos de nuestra aplicación:
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)
Podemos crear decoradores que manejen excepciones para proporcionar un manejo de errores consistente:
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()
Implementa un sistema de validación para una clase Producto
usando decoradores y manejo de excepciones:
@validar_datos
que verifique si los datos del producto son válidosProducto
con métodos para establecer sus atributos# Define aquí tus excepciones personalizadas
# Implementa el decorador validar_datos
# Implementa la clase Producto
# Crea productos y prueba tu sistema
En esta sección, hemos explorado conceptos avanzados que complementan tus habilidades de Programación Orientada a Objetos en Python:
Estos conceptos son esenciales para el desarrollo de software profesional en Python y te permitirán crear aplicaciones más robustas, mantenibles y flexibles.
Para seguir mejorando tus habilidades en Programación Orientada a Objetos con Python, te recomendamos explorar: