Patrones de Desarrollo Python
Patrones y mejores prácticas pitónicos para construir aplicaciones robustas, eficientes y mantenibles.
Cuándo Activar
- Escribir nuevo código Python
- Revisar código Python
- Refactorizar código Python existente
- Diseñar paquetes/módulos Python
Principios Fundamentales
1. La Legibilidad Cuenta
Python prioriza la legibilidad. El código debe ser obvio y fácil de entender.
# Bueno: Claro y legible
def obtener_usuarios_activos(usuarios: list[Usuario]) -> list[Usuario]:
"""Devuelve solo los usuarios activos de la lista proporcionada."""
return [usuario for usuario in usuarios if usuario.activo]
# Malo: Ingenioso pero confuso
def obtener_usuarios_activos(u):
return [x for x in u if x.a]
2. Explícito es Mejor que Implícito
Evita la magia; sé claro sobre lo que hace tu código.
# Bueno: Configuración explícita
import logging
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
)
# Malo: Efectos secundarios ocultos
import algun_modulo
algun_modulo.configurar() # ¿Qué hace esto?
3. EAFP - Es Más Fácil Pedir Perdón que Permiso
Python prefiere el manejo de excepciones sobre la comprobación de condiciones.
# Bueno: Estilo EAFP
def obtener_valor(diccionario: dict, clave: str, valor_por_defecto: Cualquiera = None) -> Cualquiera:
try:
return diccionario[clave]
except KeyError:
return valor_por_defecto
# Malo: Estilo LBYL (Mira Antes de Saltar)
def obtener_valor(diccionario: dict, clave: str, valor_por_defecto: Cualquiera = None) -> Cualquiera:
if clave in diccionario:
return diccionario[clave]
else:
return valor_por_defecto
Sugerencias de Tipo
Anotaciones de Tipo Básicas
from typing import Optional, List, Dict, Any
def procesar_usuario(
id_usuario: str,
datos: Dict[str, Any],
activo: bool = True
) -> Optional[Usuario]:
"""Procesa un usuario y devuelve el Usuario actualizado o None."""
if not activo:
return None
return Usuario(id_usuario, datos)
Sugerencias de Tipo Modernas (Python 3.9+)
# Python 3.9+ - Usa tipos incorporados
def procesar_elementos(elementos: list[str]) -> dict[str, int]:
return {elemento: len(elemento) for elemento in elementos}
# Python 3.8 y anteriores - Usa el módulo typing
from typing import List, Dict
def procesar_elementos(elementos: List[str]) -> Dict[str, int]:
return {elemento: len(elemento) for elemento in elementos}
Alias de Tipo y TypeVar
from typing import TypeVar, Union
# Alias de tipo para tipos complejos
JSON = Union[dict[str, Any], list[Any], str, int, float, bool, None]
def analizar_json(datos: str) -> JSON:
return json.loads(datos)
# Tipos genéricos
T = TypeVar('T')
def primero(elementos: list[T]) -> T | None:
"""Devuelve el primer elemento o None si la lista está vacía."""
return elementos[0] if elementos else None
Tipado Dinámico Basado en Protocolos
from typing import Protocol
class Renderizable(Protocol):
def renderizar(self) -> str:
"""Renderiza el objeto a una cadena."""
def renderizar_todos(elementos: list[Renderizable]) -> str:
"""Renderiza todos los elementos que implementan el protocolo Renderizable."""
return "\n".join(elemento.renderizar() for elemento in elementos)
Patrones de Manejo de Errores
Manejo Específico de Excepciones
# Bueno: Captura excepciones específicas
def cargar_configuracion(ruta: str) -> Configuracion:
try:
with open(ruta) as f:
return Configuracion.desde_json(f.read())
except FileNotFoundError as e:
raise ErrorConfiguracion(f"Archivo de configuración no encontrado: {ruta}") from e
except json.JSONDecodeError as e:
raise ErrorConfiguracion(f"JSON inválido en configuración: {ruta}") from e
# Malo: Excepto desnudo
def cargar_configuracion(ruta: str) -> Configuracion:
try:
with open(ruta) as f:
return Configuracion.desde_json(f.read())
except:
return None # ¡Fallo silencioso!
Encadenamiento de Excepciones
def procesar_datos(datos: str) -> Resultado:
try:
analizado = json.loads(datos)
except json.JSONDecodeError as e:
# Encadenar excepciones para preservar el rastreo
raise ValueError(f"Error al analizar datos: {datos}") from e
Jerarquía de Excepciones Personalizadas
class ErrorApp(Exception):
"""Excepción base para todos los errores de la aplicación."""
pass
class ErrorValidacion(ErrorApp):
"""Se lanza cuando falla la validación de entrada."""
pass
class ErrorNoEncontrado(ErrorApp):
"""Se lanza cuando no se encuentra un recurso solicitado."""
pass
# Uso
def obtener_usuario(id_usuario: str) -> Usuario:
usuario = db.encontrar_usuario(id_usuario)
if not usuario:
raise ErrorNoEncontrado(f"Usuario no encontrado: {id_usuario}")
return usuario
Administradores de Contexto
Gestión de Recursos
# Bueno: Usando administradores de contexto
def procesar_archivo(ruta: str) -> str:
with open(ruta, 'r') as f:
return f.read()
# Malo: Gestión manual de recursos
def procesar_archivo(ruta: str) -> str:
f = open(ruta, 'r')
try:
return f.read()
finally:
f.close()
Administradores de Contexto Personalizados
from contextlib import contextmanager
@contextmanager
def temporizador(nombre: str):
"""Administrador de contexto para medir el tiempo de un bloque de código."""
inicio = time.perf_counter()
yield
elapsed = time.perf_counter() - inicio
print(f"{nombre} tomó {elapsed:.4f} segundos")
# Uso
with temporizador("procesamiento de datos"):
procesar_conjunto_grande_de_datos()
Clases de Administradores de Contexto
class TransaccionBaseDatos:
def __init__(self, conexion):
self.conexion = conexion
def __enter__(self):
self.conexion.iniciar_transaccion()
return self
def __exit__(self, tipo_exc, val_exc, tb_exc):
if tipo_exc is None:
self.conexion.confirmar()
else:
self.conexion.revertir()
return False # No suprimir excepciones
# Uso
with TransaccionBaseDatos(conn):
usuario = conn.crear_usuario(datos_usuario)
conn.crear_perfil(usuario.id, datos_perfil)
Comprensiones y Generadores
Comprensiones de Lista
# Bueno: Comprensión de lista para transformaciones simples
nombres = [usuario.nombre for usuario in usuarios if usuario.activo]
# Malo: Bucle manual
nombres = []
for usuario in usuarios:
if usuario.activo:
nombres.append(usuario.nombre)
# Las comprensiones complejas deben expandirse
# Malo: Demasiado complejo
resultado = [x * 2 for x in elementos if x > 0 if x % 2 == 0]
# Bueno: Usar una función generadora
def filtrar_y_transformar(elementos: Iterable[int]) -> list[int]:
resultado = []
for x in elementos:
if x > 0 and x % 2 == 0:
resultado.append(x * 2)
return resultado
Expresiones Generadoras
# Bueno: Generador para evaluación perezosa
total = sum(x * x for x in range(1_000_000))
# Malo: Crea una lista intermedia grande
total = sum([x * x for x in range(1_000_000)])
Funciones Generadoras
def leer_archivo_grande(ruta: str) -> Iterator[str]:
"""Lee un archivo grande línea por línea."""
with open(ruta) as f:
for linea in f:
yield linea.strip()
# Uso
for linea in leer_archivo_grande("enorme.txt"):
procesar(linea)
Clases de Datos y Tuplas Nombradas
Clases de Datos
from dataclasses import dataclass, field
from datetime import datetime
@dataclass
class Usuario:
"""Entidad de usuario con __init__, __repr__ y __eq__ automáticos."""
id: str
nombre: str
email: str
creado_en: datetime = field(default_factory=datetime.now)
activo: bool = True
# Uso
usuario = Usuario(
id="123",
nombre="Alicia",
email="[email protected]"
)
Clases de Datos con Validación
@dataclass
class Usuario:
email: str
edad: int
def __post_init__(self):
# Validar formato de email
if "@" not in self.email:
raise ValueError(f"Email inválido: {self.email}")
# Validar rango de edad
if self.edad < 0 or self.edad > 150:
raise ValueError(f"Edad inválida: {self.edad}")
Tuplas Nombradas
from typing import NamedTuple
class Punto(NamedTuple):
"""Punto 2D inmutable."""
x: float
y: float
def distancia(self, otro: 'Punto') -> float:
return ((self.x - otro.x) ** 2 + (self.y - otro.y) ** 2) ** 0.5
# Uso
p1 = Punto(0, 0)
p2 = Punto(3, 4)
print(p1.distancia(p2)) # 5.0
Decoradores
Decoradores de Función
import functools
import time
def temporizador(func: Callable) -> Callable:
"""Decorador para medir el tiempo de ejecución de una función."""
@functools.wraps(func)
def envoltura(*args, **kwargs):
inicio = time.perf_counter()
resultado = func(*args, **kwargs)
elapsed = time.perf_counter() - inicio
print(f"{func.__name__} tomó {elapsed:.4f}s")
return resultado
return envoltura
@temporizador
def funcion_lenta():
time.sleep(1)
# funcion_lenta() imprime: funcion_lenta tomó 1.0012s
Decoradores Parametrizados
def repetir(veces: int):
"""Decorador para repetir una función múltiples veces."""
def decorador(func: Callable) -> Callable:
@functools.wraps(func)
def envoltura(*args, **kwargs):
resultados = []
for _ in range(veces):
resultados.append(func(*args, **kwargs))
return resultados
return envoltura
return decorador
@repetir(veces=3)
def saludar(nombre: str) -> str:
return f"¡Hola, {nombre}!"
# saludar("Alicia") devuelve ["¡Hola, Alicia!", "¡Hola, Alicia!", "¡Hola, Alicia!"]
Decoradores Basados en Clase
class ContadorLlamadas:
"""Decorador que cuenta cuántas veces se llama a una función."""
def __init__(self, func: Callable):
functools.update_wrapper(self, func)
self.func = func
self.contador = 0
def __call__(self, *args, **kwargs):
self.contador += 1
print(f"{self.func.__name__} ha sido llamada {self.contador} veces")
return self.func(*args, **kwargs)
@ContadorLlamadas
def procesar():
pass
# Cada llamada a procesar() imprime el contador de llamadas
Patrones de Concurrencia
Hilos para Tareas con E/S
import concurrent.futures
import threading
def obtener_url(url: str) -> str:
"""Obtiene una URL (operación con E/S)."""
import urllib.request
with urllib.request.urlopen(url) as respuesta:
return respuesta.read().decode()
def obtener_todas_urls(urls: list[str]) -> dict[str, str]:
"""Obtiene múltiples URLs concurrentemente usando hilos."""
with concurrent.futures.ThreadPoolExecutor(max_workers=10) as executor:
futuro_a_url = {executor.submit(obtener_url, url): url for url in urls}
resultados = {}
for futuro in concurrent.futures.as_completed(futuro_a_url):
url = futuro_a_url[futuro]
try:
resultados[url] = futuro.result()
except Exception as e:
resultados[url] = f"Error: {e}"
return resultados
Multiprocesamiento para Tareas con Uso Intensivo de CPU
def procesar_datos(datos: list[int]) -> int:
"""Cómputo intensivo de CPU."""
return sum(x ** 2 for x in datos)
def procesar_todos(conjuntos_datos: list[list[int]]) -> list[int]:
"""Procesa múltiples conjuntos de datos usando múltiples procesos."""
with concurrent.futures.ProcessPoolExecutor() as executor:
resultados = list(executor.map(procesar_datos, conjuntos_datos))
return resultados
Async/Await para E/S Concurrente
import asyncio
async def obtener_async(url: str) -> str:
"""Obtiene una URL asincrónicamente."""
import aiohttp
async with aiohttp.ClientSession() as sesion:
async with sesion.get(url) as respuesta:
return await respuesta.text()
async def obtener_todas(urls: list[str]) -> dict[str, str]:
"""Obtiene múltiples URLs concurrentemente."""
tareas = [obtener_async(url) for url in urls]
resultados = await asyncio.gather(*tareas, return_exceptions=True)
return dict(zip(urls, resultados))
Organización del Paquete
Estructura Estándar del Proyecto
mi_proyecto/
├── src/
│ └── mi_paquete/
│ ├── __init__.py
│ ├── principal.py
│ ├── api/
│ │ ├── __init__.py
│ │ └── rutas.py
│ ├── modelos/
│ │ ├── __init__.py
│ │ └── usuario.py
│ └── utilidades/
│ ├── __init__.py
│ └── ayudantes.py
├── pruebas/
│ ├── __init__.py
│ ├── conftest.py
│ ├── prueba_api.py
│ └── prueba_modelos.py
├── pyproject.toml
├── README.md
└── .gitignore
Convenciones de Importación
# Bueno: Orden de importación - stdlib, terceros, local
import os
import sys
from pathlib import Path
import requests
from fastapi import FastAPI
from mi_paquete.modelos import Usuario
from mi_paquete.utilidades import formatear_nombre
# Bueno: Usa isort para ordenamiento automático de importaciones
# pip install isort
init.py para Exportaciones del Paquete
# mi_paquete/__init__.py
"""mi_paquete - Un paquete Python de ejemplo."""
__version__ = "1.0.0"
# Exportar clases/funciones principales a nivel de paquete
from mi_paquete.modelos import Usuario, Publicacion
from mi_paquete.utilidades import formatear_nombre
__all__ = ["Usuario", "Publicacion", "formatear_nombre"]
Memoria y Rendimiento
Usando slots para Eficiencia de Memoria
# Malo: La clase regular usa __dict__ (más memoria)
class Punto:
def __init__(self, x: float, y: float):
self.x = x
self.y = y
# Bueno: __slots__ reduce el uso de memoria
class Punto:
__slots__ = ['x', 'y']
def __init__(self, x: float, y: float):
self.x = x
self.y = y
Generador para Datos Grandes
# Malo: Devuelve lista completa en memoria
def leer_lineas(ruta: str) -> list[str]:
with open(ruta) as f:
return [linea.strip() for linea in f]
# Bueno: Produce líneas una a la vez
def leer_lineas(ruta: str) -> Iterator[str]:
with open(ruta) as f:
for linea in f:
yield linea.strip()
Evitar la Concatenación de Cadenas en Bucles
# Malo: O(n²) debido a la inmutabilidad de las cadenas
resultado = ""
for elemento in elementos:
resultado += str(elemento)
# Bueno: O(n) usando join
resultado = "".join(str(elemento) for elemento in elementos)
# Bueno: Usando StringIO para construcción
from io import StringIO
buffer = StringIO()
for elemento in elementos:
buffer.write(str(elemento))
resultado = buffer.getvalue()
Integración de Herramientas Python
Comandos Esenciales
# Formateo de código
black .
isort .
# Linting
ruff check .
# Verificación de tipo
mypy .
# Pruebas
pytest .
# Construir documentación
pdoc mi_paquete -o docs/
# Perfilado
python -m cProfile -o perfil.prof mi_script.py
python -m snakeviz perfil.prof
Configuración de pyproject.toml
# pyproject.toml
[build-system]
requires = ["setuptools>=61.0"]
build-backend = "setuptools.build_meta"
[project]
name = "mi_paquete"
version = "1.0.0"
description = "Un proyecto Python de ejemplo"
requires-python = ">=3.9"
dependencies = [
"requests>=2.28.0",
]
optional-dependencies = {
"dev": [
"black==23.1.0",
"isort==5.12.0",
"ruff==0.0.252",
"mypy==1.0.0",
"pytest==7.2.0",
]
}
[tool.black]
line-length = 88
target-version = ["py39"]
[tool.isort]
profile = "black"
line_length = 88
[tool.ruff]
line-length = 88
target-version = "py39"
select = ["E", "F", "I", "N", "W"]
[tool.mypy]
python_version = "3.9"
warn_unused_configs = true
warn_redundant_casts = true
warn_unused_ignores = true
disallow_any_unimported = true
strict_equality = true
[tool.pytest.ini_options]
pythonpath = ["src"]
testpaths = ["pruebas"]
Convenciones de Estilo PEP 8
Nombrado
# Módulos: minúsculas_con_guiones_bajos
import mi_modulo
# Clases: MayúsculaInicial
class MiClase:
pass
# Funciones y métodos: minúsculas_con_guiones_bajos
def mi_funcion():
pass
# Constantes: MAYÚSCULAS_CON_GUIONES_BAJOS
VELOCIDAD_MAXIMA = 120
# Variables privadas: _prefijo_con_guiones_bajos
_dato_interno = None
# Variables de instancia y método: minúsculas_con_guiones_bajos
class Ejemplo:
def __init__(self):
self.mi_atributo = None
Longitud de Línea y Espaciado
# Bueno: Máximo 88 caracteres por línea (estándar Black)
def funcion_con_parametros_largos(
parametro_uno: str,
parametro_dos: int,
parametro_tres: float,
parametro_cuatro: bool,
) -> dict[str, Any]:
pass
# Bueno: Dos líneas en blanco entre definiciones de nivel superior
class MiClase:
pass
# Bueno: Una línea en blanco entre métodos dentro de la clase
class MiClase:
def metodo_uno(self):
pass
def metodo_dos(self):
pass
Docstrings
def funcion_ejemplo(parametro: str) -> bool:
"""
Descripción breve de la función.
Explicación más detallada si es necesario.
Args:
parametro: Descripción del parámetro.
Returns:
Descripción del valor de retorno.
Raises:
ValueError: Descripción de cuándo se lanza.
"""
if not parametro:
raise ValueError("Parámetro no puede estar vacío")
return True
Notas Finales
- Siempre escribe código legible y explícito. La legibilidad del código es primordial en Python.
- Usa sugerencias de tipo para una mejor capacidad de mantenimiento y soporte de herramientas.
- Maneja excepciones de forma específica y explícita en lugar de usar
except:genéricos. - Aprovecha las características incorporadas de Python como comprensiones, generadores, clases de datos y administradores de contexto.
- Elige la herramienta de concurrencia adecuada basada en si tu tarea está limitada por E/S o CPU.
- Organiza tu código en paquetes con importaciones claras y explícitas.
- Usa herramientas de automatización como Black, isort, ruff y mypy para mantener la calidad del código.
- Recuerda: "Simple es mejor que complejo." Siempre que sea posible, prefiere la solución más simple que funcione.