Debugging y Profiling
Técnicas esenciales para encontrar errores y optimizar el rendimiento de tu código
El debugging y profiling son técnicas que van de la mano con el testing y son cruciales para dominar. Incluso cuando nuestro código es correcto, los errores sucederán. Si no los manejamos adecuadamente, pueden causar que nuestros mejores planes fallen. El debugging nos ayuda a encontrar y corregir errores, mientras que el profiling nos permite identificar cuellos de botella y optimizar el rendimiento.
En este artículo exploraremos las herramientas y técnicas fundamentales: desde técnicas de debugging con print y el módulo pdb, hasta profiling avanzado con cProfile, timeit y análisis de memoria.
Fundamentos del Debugging
El debugging es el proceso de encontrar y resolver errores (bugs) en el código. Python proporciona varias herramientas y técnicas para ayudarnos en esta tarea, desde simples declaraciones print hasta debuggers interactivos completos.
Técnicas Básicas de Debugging
Debugging con Print
La técnica más simple pero a menudo efectiva es usar declaraciones print para inspeccionar valores en puntos específicos del código:
def calculate_average(numbers): print(f"DEBUG: Input numbers = {numbers}") total = sum(numbers) print(f"DEBUG: Total = {total}") count = len(numbers) print(f"DEBUG: Count = {count}") if count == 0: return 0 average = total / count print(f"DEBUG: Average = {average}") return average # Uso result = calculate_average([10, 20, 30, 40]) print(f"Result: {result}")
DEBUG: Total = 100
DEBUG: Count = 4
DEBUG: Average = 25.0
Result: 25.0
Usando el Módulo Logging
Para aplicaciones más complejas, el módulo logging es preferible a print porque permite controlar niveles de detalle y dirigir la salida a diferentes destinos:
import logging # Configurar el logger logging.basicConfig( level=logging.DEBUG, format='%(asctime)s - %(levelname)s - %(message)s' ) logger = logging.getLogger(__name__) def process_data(data): logger.debug(f"Processing data: {data}") try: result = [x * 2 for x in data] logger.info(f"Successfully processed {len(result)} items") return result except TypeError as e: logger.error(f"Error processing data: {e}") raise # Uso process_data([1, 2, 3]) process_data("invalid") # Esto generará un error
logging te permite configurar diferentes niveles de detalle. En producción, puedes establecer el nivel a WARNING para ignorar mensajes DEBUG e INFO, mientras que en desarrollo usas DEBUG para ver todo.
El Debugger PDB
Python incluye un debugger interactivo llamado pdb (Python Debugger). Con pdb puedes pausar la ejecución, inspeccionar variables, ejecutar código paso a paso y establecer breakpoints.
Iniciando el Debugger
Hay varias formas de iniciar pdb. La más moderna (Python 3.7+) es usar la función incorporada breakpoint():
def buggy_function(items): result = [] for i, item in enumerate(items): if i == 2: breakpoint() # El debugger se activa aquí result.append(item * 2) return result # Método alternativo (compatible con versiones anteriores) import pdb def another_function(x, y): pdb.set_trace() # Iniciar debugger aquí return x + y # Ejecutar buggy_function([1, 2, 3, 4, 5])
Comandos Esenciales de PDB
| Comando | Descripción |
|---|---|
| n (next) | Ejecutar la siguiente línea |
| s (step) | Entrar en una función |
| c (continue) | Continuar hasta el siguiente breakpoint |
| p <expr> | Imprimir el valor de una expresión |
| pp <expr> | Pretty-print de una expresión |
| l (list) | Mostrar código alrededor de la línea actual |
| w (where) | Mostrar el stack trace |
| b <line> | Establecer un breakpoint en una línea |
| q (quit) | Salir del debugger |
Sesión Interactiva de PDB
def factorial(n): """Calcular el factorial de n.""" if n < 0: raise ValueError("n debe ser no negativo") if n == 0: return 1 return n * factorial(n - 1) def main(): numbers = [5, 3, -1, 7] results = [] for num in numbers: breakpoint() # Inspeccionar cada iteración try: result = factorial(num) results.append(result) except ValueError as e: print(f"Error: {e}") return results if __name__ == "__main__": main()
-> breakpoint()
(Pdb) p num
5
(Pdb) p numbers
[5, 3, -1, 7]
(Pdb) n
> /path/pdb_session_example.py(16)main()
(Pdb) s
--Call--
> /path/pdb_session_example.py(1)factorial()
(Pdb) c
breakpoint() antes de hacer deploy a producción. Puedes desactivar breakpoints globalmente estableciendo la variable de entorno PYTHONBREAKPOINT=0.
Entendiendo Tracebacks
Cuando ocurre una excepción no manejada, Python imprime un traceback. Aprender a leer tracebacks es fundamental para el debugging efectivo.
Anatomía de un Traceback
def squareroot(number): if number < 0: raise ValueError("No se permiten números negativos") return number ** 0.5 def quadratic(a, b, c): d = b**2 - 4 * a * c return ( (-b - squareroot(d)) / (2 * a), (-b + squareroot(d)) / (2 * a) ) # x² + 1 = 0 (no tiene solución real) quadratic(1, 0, 1)
File "traceback_example.py", line 14, in <module>
quadratic(1, 0, 1)
^^^^^^^^^^^^^^^^^^
File "traceback_example.py", line 9, in quadratic
(-b - squareroot(d)) / (2 * a),
^^^^^^^^^^^^^
File "traceback_example.py", line 3, in squareroot
raise ValueError("No se permiten números negativos")
ValueError: No se permiten números negativos
Mejoras en Python 3.11+
Python 3.11 introdujo mejoras significativas en los tracebacks con indicadores precisos que subrayan exactamente qué parte de la expresión causó el error usando caracteres ^^^^.
Profiling con timeit
El módulo timeit proporciona una forma precisa de medir el tiempo de ejecución de pequeños fragmentos de código Python. Es más confiable que usar time.time() porque ejecuta el código múltiples veces y calcula promedios.
Uso Básico de timeit
import timeit # Comparar list comprehension vs for loop setup = "numbers = range(1000)" # Método 1: List comprehension comprehension = "[x**2 for x in numbers]" # Método 2: For loop forloop = """ result = [] for x in numbers: result.append(x**2) """ # Ejecutar cada uno 10000 veces time_comp = timeit.timeit(comprehension, setup, number=10000) time_loop = timeit.timeit(forloop, setup, number=10000) print(f"List comprehension: {time_comp:.4f}s") print(f"For loop: {time_loop:.4f}s") print(f"Ratio: {time_loop/time_comp:.2f}x más lento")
For loop: 1.2534s
Ratio: 1.60x más lento
Usando timeit desde la Línea de Comandos
# Comparar métodos de concatenación de strings $ python -m timeit -s "s=''" "for i in range(100): s += str(i)" 10000 loops, best of 5: 25.3 usec per loop $ python -m timeit -s "parts=[]" "for i in range(100): parts.append(str(i))" "''.join(parts)" 50000 loops, best of 5: 8.2 usec per loop
Timer con Decorador Personalizado
import functools import time def timer(func): """Decorador para medir tiempo de ejecución.""" @functools.wraps(func) def wrapper(*args, **kwargs): start = time.perf_counter() result = func(*args, **kwargs) end = time.perf_counter() print(f"{func.__name__} ejecutado en {end - start:.4f}s") return result return wrapper @timer def slow_function(): """Simular operación lenta.""" total = 0 for i in range(1000000): total += i ** 2 return total @timer def fast_function(): """Versión optimizada.""" return sum(i ** 2 for i in range(1000000)) # Comparar slow_function() fast_function()
fast_function ejecutado en 0.1456s
Profiling con cProfile
El módulo cProfile es el profiler determinístico estándar de Python. Proporciona estadísticas detalladas sobre el tiempo gastado en cada función, el número de llamadas y más.
Uso Básico de cProfile
import cProfile import pstats def fibonacci(n): """Calcular Fibonacci de forma recursiva.""" if n < 2: return n return fibonacci(n - 1) + fibonacci(n - 2) def process_data(): results = [] for i in range(25): results.append(fibonacci(i)) return results # Perfilar la función if __name__ == "__main__": profiler = cProfile.Profile() profiler.enable() process_data() profiler.disable() stats = pstats.Stats(profiler) stats.sort_stats('cumulative') stats.print_stats(10) # Top 10 funciones
Ordered by: cumulative time
ncalls tottime percall cumtime percall filename:lineno(function)
1 0.000 0.000 0.052 0.052 cprofile_example.py:10(process_data)
242785/25 0.052 0.000 0.052 0.002 cprofile_example.py:4(fibonacci)
25 0.000 0.000 0.000 0.000 {method 'append'}
Desde la Línea de Comandos
# Perfilar cualquier script $ python -m cProfile -s cumulative script.py # Guardar estadísticas en un archivo $ python -m cProfile -o output.prof script.py # Analizar el archivo guardado $ python -c "import pstats; p = pstats.Stats('output.prof'); p.sort_stats('time').print_stats(10)"
Interpretando los Resultados
| Columna | Descripción |
|---|---|
| ncalls | Número de llamadas a la función |
| tottime | Tiempo total en la función (excluyendo subfunciones) |
| percall | tottime dividido por ncalls |
| cumtime | Tiempo acumulativo (incluyendo subfunciones) |
| filename:lineno | Ubicación de la función |
242785/25 en ncalls indica llamadas recursivas: 242785 llamadas totales, pero solo 25 fueron llamadas "primitivas" (no recursivas).
Técnicas de Optimización
Una vez identificados los cuellos de botella con profiling, podemos aplicar técnicas de optimización. La clave es optimizar solo lo que importa y medir siempre antes y después.
Memoización
La memoización almacena resultados de funciones costosas para evitar recálculos:
from functools import lru_cache import timeit # Fibonacci sin memoización def fib_naive(n): if n < 2: return n return fib_naive(n - 1) + fib_naive(n - 2) # Fibonacci con memoización usando lru_cache @lru_cache(maxsize=None) def fib_memo(n): if n < 2: return n return fib_memo(n - 1) + fib_memo(n - 2) # Comparar rendimiento n = 30 time_naive = timeit.timeit( f"fib_naive({n})", globals=globals(), number=1 ) time_memo = timeit.timeit( f"fib_memo({n})", globals=globals(), number=1 ) print(f"Sin memoización: {time_naive:.4f}s") print(f"Con memoización: {time_memo:.6f}s") print(f"Speedup: {time_naive/time_memo:.0f}x")
Con memoización: 0.000012s
Speedup: 23725x
Comparación de Estructuras de Datos
import timeit # Búsqueda en lista vs set setup_list = "data = list(range(10000))" setup_set = "data = set(range(10000))" search = "9999 in data" time_list = timeit.timeit(search, setup_list, number=10000) time_set = timeit.timeit(search, setup_set, number=10000) print("Búsqueda de elemento:") print(f" Lista: {time_list:.4f}s") print(f" Set: {time_set:.4f}s") print(f" Set es {time_list/time_set:.0f}x más rápido") # Concatenación de strings print("\nConcatenación de strings:") concat_plus = """ s = '' for i in range(1000): s += str(i) """ concat_join = """ parts = [] for i in range(1000): parts.append(str(i)) s = ''.join(parts) """ time_plus = timeit.timeit(concat_plus, number=1000) time_join = timeit.timeit(concat_join, number=1000) print(f" += operator: {time_plus:.4f}s") print(f" join(): {time_join:.4f}s") print(f" join() es {time_plus/time_join:.1f}x más rápido")
Lista: 0.8234s
Set: 0.0012s
Set es 686x más rápido
Concatenación de strings:
+= operator: 0.1456s
join(): 0.0523s
join() es 2.8x más rápido
Memory Profiling
Además del tiempo de ejecución, el uso de memoria es crucial para aplicaciones que manejan grandes volúmenes de datos. Python proporciona herramientas para analizar el consumo de memoria.
Usando sys.getsizeof()
import sys # Comparar tamaños de diferentes estructuras data_list = [i for i in range(1000)] data_tuple = tuple(i for i in range(1000)) data_gen = (i for i in range(1000)) print("Tamaño de estructuras con 1000 elementos:") print(f" Lista: {sys.getsizeof(data_list):>8} bytes") print(f" Tupla: {sys.getsizeof(data_tuple):>8} bytes") print(f" Generator: {sys.getsizeof(data_gen):>8} bytes") # Tamaño de diferentes tipos print("\nTamaño de tipos básicos:") print(f" int (0): {sys.getsizeof(0)} bytes") print(f" int (2^30): {sys.getsizeof(2**30)} bytes") print(f" float: {sys.getsizeof(3.14)} bytes") print(f" str vacío: {sys.getsizeof('')} bytes") print(f" str 10 chars: {sys.getsizeof('a'*10)} bytes") print(f" dict vacío: {sys.getsizeof({})} bytes")
Lista: 8856 bytes
Tupla: 8040 bytes
Generator: 200 bytes
Tamaño de tipos básicos:
int (0): 28 bytes
int (2^30): 32 bytes
float: 24 bytes
str vacío: 49 bytes
str 10 chars: 59 bytes
dict vacío: 64 bytes
Generadores vs Listas para Memoria
import sys def process_with_list(n): """Procesar creando lista completa en memoria.""" data = [x ** 2 for x in range(n)] return sum(data) def process_with_generator(n): """Procesar usando generador - memoria constante.""" data = (x ** 2 for x in range(n)) return sum(data) # Ambas funciones dan el mismo resultado n = 1000000 print(f"Con lista: {process_with_list(n)}") print(f"Con generador: {process_with_generator(n)}") # Pero el uso de memoria es muy diferente list_data = [x ** 2 for x in range(n)] gen_data = (x ** 2 for x in range(n)) print(f"\nMemoria lista: {sys.getsizeof(list_data) / 1024 / 1024:.2f} MB") print(f"Memoria generador: {sys.getsizeof(gen_data)} bytes")
Con generador: 333332833333500000
Memoria lista: 8.00 MB
Memoria generador: 200 bytes
generadores son excelentes para procesar grandes conjuntos de datos porque mantienen un uso de memoria constante independientemente del tamaño de los datos.
Resumen de Técnicas
01 // Print & Logging
Técnicas básicas para rastrear valores y estados del programa durante la ejecución.
02 // PDB Debugger
Debugger interactivo para inspección paso a paso, breakpoints y análisis de stack.
03 // Tracebacks
Leer y entender los mensajes de error para localizar rápidamente la fuente de problemas.
04 // timeit
Medición precisa del tiempo de ejecución para comparar diferentes implementaciones.
05 // cProfile
Profiler completo para identificar funciones costosas y cuellos de botella.
06 // Memory Profiling
Análisis del uso de memoria para optimizar el consumo de recursos.