Python Avanzado. En este capítulo nos adentramos en temas más avanzados de Python, aquellos que permiten escribir código más eficiente. Aprenderemos cómo funciona la gestión de memoria en Python, exploraremos el potencial de los decoradores para modificar el comportamiento de funciones y clases, y descubriremos cómo los context managers simplifican la gestión de recursos en nuestros programas. Además, abordaremos los conceptos fundamentales para manejar la concurrencia, desde hilos y procesos hasta el uso de asyncio
para programación asíncrona.
Empecemos…..
Gestión de Memoria en Python
La gestión de memoria en Python es un componente esencial que influye directamente en el rendimiento y en la optimización de los programas. Aunque Python simplifica muchas tareas con su sistema de gestión de memoria automática, comprender los detalles detrás de escena puede ayudarte a detectar y resolver problemas de rendimiento, evitar fugas de memoria y escribir un código más eficiente.
¿Cómo maneja Python la memoria?
Todo es un objeto: En Python, todo es un objeto, desde números enteros hasta funciones. Estos objetos se almacenan en la heap de memoria administrada por el intérprete de Python.
Referencias a objetos: Las variables no contienen directamente los valores; en cambio, actúan como referencias a los objetos almacenados en la memoria.
Por ejemplo:
a = [1, 2, 3] # 'a' es una referencia a un objeto de tipo lista
b = a # 'b' también apunta al mismo objeto
Contador de referencias: Python usa un contador de referencias para cada objeto. Cada vez que una variable apunta a un objeto, el contador aumenta; cuando se elimina una referencia, disminuye. Si el contador llega a cero, Python libera automáticamente la memoria del objeto.
Recolección de basura: Aunque el contador de referencias maneja la mayoría de los casos, puede fallar cuando hay ciclos de referencia (es decir, dos o más objetos se referencian mutuamente). Por eso, Python incluye un algoritmo de recolección de basura que identifica estos ciclos y libera la memoria.
Estructura de la Memoria en Python
La memoria de Python se organiza en tres áreas principales:
Stack (Pila):
Almacena variables locales y llamadas a funciones.
Su tamaño es limitado y es manejado automáticamente.
Heap (Montón):
Donde se almacenan los objetos creados dinámicamente (como listas, diccionarios o instancias de clases).
Es administrada por el recolector de basura de Python.
Memoria Interna de Python:
Incluye estructuras como el free list, una optimización interna para tipos de datos pequeños como enteros y cadenas de texto.
Funciones y Herramientas para la Gestión de Memoria
1. gc
(Garbage Collector)
El módulo gc
permite interactuar directamente con el recolector de basura.
Forzar la recolección de basura:
import gc
gc.collect() # Realiza una recolección manual
Obtener información del estado:
print("Objetos recolectados:", gc.get_count()) # Devuelve la cuenta de objetos
Habilitar/Deshabilitar la recolección automática:
gc.disable() # Desactiva la recolección automática
gc.enable() # Activa nuevamente la recolección
2. Identificación de Ciclos de Referencia
Puedes usar gc.garbage
para listar objetos que no pueden ser recolectados automáticamente.
import gc
gc.collect() # Recolecta ciclos de referencia
print("Ciclos de referencia:", gc.garbage)
3. sys
para análisis de memoria
El módulo sys
permite acceder a detalles sobre la memoria utilizada por objetos individuales.
Obtener el tamaño de un objeto:
import sys
a = [1, 2, 3]
print("Tamaño en bytes:", sys.getsizeof(a))
Buenas Prácticas para Gestionar la Memoria
Evitar ciclos de referencia innecesarios: Los ciclos de referencia son problemáticos y dificultan la liberación de memoria. Usa estructuras simples o weakref
para evitar que se mantengan referencias innecesarias
import weakref
class Nodo:
def __init__(self, valor):
self.valor = valor
self.siguiente = None
a = Nodo(1)
b = Nodo(2)
# Uso de referencias débiles
a.siguiente = weakref.ref(b)
Python, aunque gestiona automáticamente la memoria, permite que los desarrolladores analicen y optimicen el uso de memoria en sus programas mediante herramientas como:
memory_profiler
: Mide el consumo de memoria línea por línea de una función o script.objgraph
: Rastrea los objetos en memoria y ayuda a identificar fugas de memoria.tracemalloc
: Rastrea la asignación de memoria en el programa.
Uso de memory_profiler
El módulo memory_profiler
te permite perfilar el uso de memoria en tus funciones. Se utiliza decorando las funciones con @profile
para rastrear su consumo línea por línea.
Como siempre, primero instalaremos la herramienta con el comando:
pip install memory-profiler
Ejemplo Básico
El siguiente ejemplo muestra cómo monitorear el uso de memoria de una función que crea una lista grande:
from memory_profiler import profile
@profile
def crear_lista():
print("Creando una lista de un millón de elementos...")
a = [i for i in range(1000000)] # Consume memoria para crear la lista
return a
crear_lista()
Ejecutar el Análisis
Para que memory_profiler
funcione correctamente, el script debe ejecutarse desde la terminal con el comando:
python -m profile tu_archivo.py
El resultado mostrará el uso de memoria antes y después de cada línea de la función:
Salida Ejemplo:
Creando una lista de un millón de elementos...
Line # Mem usage Increment Line Contents
================================================
3 18.4 MiB 0.0 MiB @profile
4 18.4 MiB 0.0 MiB def crear_lista():
5 18.4 MiB 0.0 MiB print("Creando una lista de un millón de elementos...")
6 23.8 MiB 5.4 MiB a = [i for i in range(1000000)]
7 23.8 MiB 0.0 MiB return a
Explicación:
Mem usage
: Uso total de memoria en ese momento.Increment
: Incremento de memoria desde la línea anterior.Line Contents
: Contenido del código que se está evaluando.
En este caso, la línea que crea la lista (a = [...]
) consume 5.4 MiB de memoria adicional.
Si el consumo de memoria es demasiado alto, puede intentar:
- Optimizar la función (por ejemplo, usar generadores en lugar de listas).
- Identificar fugas de memoria y eliminarlas.
Generadores para Ahorrar Memoria
Los generadores consumen memoria de manera mucho más eficiente que las listas porque producen elementos sobre la marcha.
Código Modificado:
from memory_profiler import profile
@profile
def crear_generador():
print("Creando un generador para un millón de elementos...")
a = (i for i in range(1000000)) # Generador que consume menos memoria
return a
crear_generador()
Salida del Ejemplo: En este caso verás que no hay incremento de memoria a parte de que la ejecución del programa es mucho más rápida.
Creando un generador para un millón de elementos...
Filename: memoria2.py
Line # Mem usage Increment Occurrences Line Contents
=============================================================
3 23.2 MiB 23.2 MiB 1 @profile
4 def crear_generador():
5 23.2 MiB 0.0 MiB 1 print("Creando un generador para un millón de elementos...")
6 23.2 MiB 0.0 MiB 1 a = (i for i in range(1000000)) # Generador consume menos memoria
7 23.2 MiB 0.0 MiB 1 return a
83204 function calls (81711 primitive calls) in 0.160 seconds
Limitaciones de memory_profiler
- Es más preciso en sistemas operativos basados en Unix (Linux, macOS).
- Puede ser muy lento en programas grandes debido al análisis línea por línea.
- No identifica automáticamente ciclos de referencia; para eso, herramientas como
objgraph
pueden ser útiles.
Complemento: objgraph
Para analizar fugas de memoria más detalladas, puedes usar objgraph
, que muestra la cantidad de objetos en memoria y sus referencias de manera gráfica que es mucho más visual y fácil de interpretar.
Ejemplo Básico con objgraph
:
Como siempre, primero instalamos la librería
pip install objgraph
Código:
import objgraph
a = [1, 2, 3]
b = {"clave": "valor"}
# Muestra los tipos de objetos más comunes en memoria
objgraph.show_most_common_types(limit=10)
# Rastrear las referencias a un objeto específico
objgraph.show_refs([a], filename='refs-graph.png')
Esto genera un archivo llamado refs-grafph.png que te permite analizar visualmente para identificar posibles fugas.

En definitiva, herramientas como memory_profiler
y objgraph
son esenciales para comprender y optimizar el uso de memoria en aplicaciones Python. Combinar estas herramientas con un buen diseño de código asegura un uso eficiente de los recursos del sistema.
Liberar objetos explícitamente: Es recomendable que cuando termines de usar un objeto, lo elimines usando del
para reducir su contador de referencias.
del objeto
Conceptos Avanzados Relacionados con Memoria
Memorización: Técnica para almacenar los resultados de funciones costosas en memoria para su reutilización.
from functools import lru_cache
@lru_cache(maxsize=100)
def fibonacci(n):
if n <= 1:
return n
return fibonacci(n-1) + fibonacci(n-2)
print(fibonacci(30))
Uso de Generadores: Los generadores (yield
) son ideales para manejar grandes conjuntos de datos sin consumir grandes cantidades de memoria.
def generador():
for i in range(1000000):
yield i
for num in generador():
print(num)
Así pues, la gestión de memoria en Python combina automatización con herramientas avanzadas que te permiten optimizar y depurar sus programas. Al comprender cómo funciona la memoria, podrás escribir código más eficiente, evitar problemas comunes como fugas de memoria y mejorar el rendimiento general de tus aplicaciones.
Decoradores Avanzados
Los decoradores son una forma de modificar o extender el comportamiento de funciones o métodos sin cambiar su código. Los decoradores avanzados pueden aceptar argumentos y envolturas, proporcionando una flexibilidad significativa.
Decorador que acepta argumentos:
def decorador_repetir(veces):
def decorador(func):
def envoltura(*args, **kwargs):
for _ in range(veces):
resultado = func(*args, **kwargs)
return resultado
return envoltura
return decorador
@decorador_repetir(3)
def saludar(nombre):
print(f"Hola, {nombre}!")
saludar("Ana")
# Salida:
# Hola, Ana!
# Hola, Ana!
# Hola, Ana!
En este ejemplo, el decorador decorador_repetir
toma un argumento que define cuántas veces se debe llamar a la función original.
Decoradores anidados:
También se pueden aplicar múltiples decoradores a una función.
def decorador_1(func):
def envoltura(*args, **kwargs):
print("Antes de la función")
return func(*args, **kwargs)
return envoltura
def decorador_2(func):
def envoltura(*args, **kwargs):
resultado = func(*args, **kwargs)
print("Después de la función")
return resultado
return envoltura
@decorador_1
@decorador_2
def saludo(nombre):
print(f"Hola, {nombre}!")
saludo("Luis")
# Salida:
# Antes de la función
# Hola, Luis!
# Después de la función
Context Managers (with)
Los administradores de contexto permiten gestionar recursos de forma eficiente. Se utilizan comúnmente para manejar archivos, conexiones a bases de datos y otros recursos que necesitan ser inicializados y liberados.
Uso de with
:
El bloque with
asegura que los recursos se liberan adecuadamente, incluso si ocurre un error dentro del bloque.
Ejemplo de uso con archivos:
with open('archivo.txt', 'w') as archivo:
archivo.write("Hola, mundo!")
# El archivo se cierra automáticamente al salir del bloque 'with'
Creación de un Administrador de Contexto:
Puedes crear tus propios administradores de contexto utilizando la declaración class
y definiendo los métodos __enter__()
y __exit__()
.
Ejemplo de un administrador de contexto personalizado:
class Contador:
def __init__(self):
self.contador = 0
def __enter__(self):
return self
def __exit__(self, exc_type, exc_val, exc_tb):
print("Saliendo del contexto, contador final:", self.contador)
def incrementar(self):
self.contador += 1
print("Contador:", self.contador)
# Uso del administrador de contexto
with Contador() as c:
c.incrementar()
c.incrementar()
# Salida:
# Contador: 1
# Contador: 2
# Saliendo del contexto, contador final: 2
Concurrencia en Python: Conceptos Fundamentales
La concurrencia es la capacidad de un programa para realizar múltiples tareas al mismo tiempo o casi al mismo tiempo. En Python, se puede lograr la concurrencia mediante varios enfoques, incluyendo hilos, procesos y programación asíncrona. Cada enfoque tiene sus propias ventajas, limitaciones y casos de uso específicos.
Hilos (Threads)
Un hilo es la unidad más pequeña de procesamiento que puede ser gestionada por un sistema operativo. Los hilos comparten la misma memoria dentro del proceso, lo que facilita la comunicación entre ellos, pero también puede generar problemas como condiciones de carrera.
Características principales:
- Livianos y comparten memoria.
- Útiles para tareas donde el cuello de botella es la espera de E/S (como operaciones de red).
- Limitados por el Global Interpreter Lock (GIL) en Python, lo que significa que solo un hilo puede ejecutar código de Python puro a la vez.
Ejemplo de uso de hilos:
import threading
import time
def imprimir_mensaje(nombre):
for _ in range(3):
print(f"Hola desde {nombre}")
time.sleep(1)
# Crear hilos
hilo1 = threading.Thread(target=imprimir_mensaje, args=("Hilo 1",))
hilo2 = threading.Thread(target=imprimir_mensaje, args=("Hilo 2",))
# Iniciar hilos
hilo1.start()
hilo2.start()
# Esperar a que terminen
hilo1.join()
hilo2.join()
print("Tareas completadas")
Ventajas:
- Fácil de usar.
- Ideal para tareas de entrada/salida.
Desventajas:
- No mejora el rendimiento en tareas computacionalmente intensivas debido al GIL.
Procesos (Processes)
Un proceso es una instancia independiente de un programa en ejecución. Cada proceso tiene su propia memoria, lo que elimina los problemas del GIL, pero requiere más recursos para la comunicación entre procesos.
Características principales:
- No comparten memoria; usan mecanismos como colas o tuberías para comunicarse.
- Útil para tareas computacionalmente intensivas (como procesamiento de datos o algoritmos matemáticos complejos).
Ejemplo de uso de procesos:
from multiprocessing import Process
def imprimir_mensaje(nombre):
for _ in range(3):
print(f"Hola desde {nombre}")
# Crear procesos
proceso1 = Process(target=imprimir_mensaje, args=("Proceso 1",))
proceso2 = Process(target=imprimir_mensaje, args=("Proceso 2",))
# Iniciar procesos
proceso1.start()
proceso2.start()
# Esperar a que terminen
proceso1.join()
proceso2.join()
print("Tareas completadas")
Ventajas:
- Evita el GIL.
- Ideal para tareas que requieren un uso intensivo del CPU.
Desventajas:
- Mayor consumo de memoria y sobrecarga en la comunicación entre procesos.
Programación Asíncrona (asyncio
)
La programación asíncrona permite manejar múltiples tareas que pueden estar en diferentes etapas de ejecución sin bloquear el programa. Se basa en corutinas, que son funciones que pueden ser pausadas y reanudadas.
Características principales:
- No utiliza hilos ni procesos, sino un solo hilo de ejecución.
- Ideal para tareas que involucran mucha espera (como operaciones de red o acceso a bases de datos).
Ejemplo básico con asyncio
:
import asyncio
async def saludar(nombre):
for _ in range(3):
print(f"Hola desde {nombre}")
await asyncio.sleep(1)
async def main():
tarea1 = asyncio.create_task(saludar("Tarea 1"))
tarea2 = asyncio.create_task(saludar("Tarea 2"))
await tarea1
await tarea2
asyncio.run(main())
Ventajas:
- Muy eficiente para manejar muchas tareas de entrada/salida concurrentemente.
- Uso eficiente de recursos.
Desventajas:
- No mejora el rendimiento en tareas computacionales intensivas.
- Puede ser complejo de entender y depurar.
Comparativa de los Enfoques de Concurrencia
Característica | Hilos | Procesos | Asyncio |
---|---|---|---|
Memoria Compartida | Sí | No | No |
GIL | Limitado por el GIL | No aplica | No aplica |
Tareas CPU-intensivas | Ineficiente | Eficiente | Ineficiente |
Tareas de E/S | Eficiente | Menos eficiente | Muy eficiente |
Facilidad de Uso | Fácil | Moderada | Complejo |
Buenas Prácticas para Manejar la Concurrencia
- Elegir el enfoque adecuado:
- Hilos para tareas de E/S.
- Procesos para tareas computacionales.
- Asyncio para manejar gran cantidad de conexiones o tareas concurrentes.
- Evitar bloqueos innecesarios:
- Usa
Lock
oSemaphore
en hilos para evitar condiciones de carrera.
- Usa
- Utilizar librerías especializadas:
concurrent.futures
: Simplifica el manejo de hilos y procesos.asyncio.Queue
: Para manejar colas en programación asíncrona.
- Probar y depurar cuidadosamente: La concurrencia introduce complejidad. Usa herramientas como
threading
omultiprocessing
para depuración avanzada.
En definitiva, la concurrencia en Python te permite manejar múltiples tareas simultáneamente. Los hilos, procesos y programación asíncrona son herramientas que proporciona Python para este tipo de programación. Cada una diseñada para un caso de uso específico y entender sus características y limitaciones es fundamental para escribir programas más rápidos, eficientes y escalables.
Resumen del capítulo
La programación avanzada en Python proporciona herramientas y conceptos que ayudan a optimizar el rendimiento y mejorar la estructura del código. En este capítulo hemos visto una pequeña introducción, pero comprender la gestión de memoria requiere de mucha práctica y profundización en su comprensión. Los decoradores avanzados amplían la funcionalidad de funciones de forma elegante, mientras que los administradores de contexto aseguran que los recursos se manejen de manera adecuada y se liberen correctamente. Integrar estos conceptos en el desarrollo diario puede llevar a una escritura de código más limpia y mantenible.
Practiquemos!!!!
Ejercicio 1: Gestión de Memoria con el Módulo gc
Crea un programa que genere un ciclo de referencias entre dos objetos (una situación donde dos objetos se referencian entre sí). Luego, utiliza el módulo gc
para forzar la recolección de basura y liberar la memoria ocupada por esos objetos. Muestra cómo se reduce el conteo de objetos gestionados por el recolector de basura después de realizar esta operación.
gc
Crea un programa que genere un ciclo de referencias entre dos objetos (una situación donde dos objetos se referencian entre sí). Luego, utiliza el módulo
gc
para forzar la recolección de basura y liberar la memoria ocupada por esos objetos. Muestra cómo se reduce el conteo de objetos gestionados por el recolector de basura después de realizar esta operación.import gc
class Nodo:
def __init__(self, nombre):
self.nombre = nombre
self.referencia = None
# Crear objetos con ciclo de referencia
nodo1 = Nodo("Nodo 1")
nodo2 = Nodo("Nodo 2")
nodo1.referencia = nodo2
nodo2.referencia = nodo1
# Mostrar conteo inicial de objetos gestionados por gc
print("Conteo inicial de objetos gestionados por gc:", gc.get_count())
# Eliminar las referencias principales
del nodo1
del nodo2
# Forzar recolección de basura
gc.collect()
# Mostrar conteo después de recolección
print("Conteo tras la recolección de basura:", gc.get_count())
Explicación:
- Se crean dos objetos (
nodo1
ynodo2
) que se refieren mutuamente, formando un ciclo de referencias. - Aunque se eliminan las referencias principales (
del nodo1
,del nodo2
), los objetos no se liberan automáticamente debido al ciclo. - Usamos
gc.collect()
para forzar la recolección de basura y eliminar estos objetos. - La diferencia en el conteo de objetos gestionados por el recolector de basura (
gc.get_count()
) demuestra que los objetos han sido recolectados y liberados.
Ejercicio 2: Uso de Hilos para Tareas Concurrentes
Escribe un programa que simule el proceso de descargar tres archivos diferentes utilizando hilos. Cada hilo debe imprimir mensajes indicando el inicio y el final de la descarga del archivo, y simular el tiempo de descarga con time.sleep
.
Escribe un programa que simule el proceso de descargar tres archivos diferentes utilizando hilos. Cada hilo debe imprimir mensajes indicando el inicio y el final de la descarga del archivo, y simular el tiempo de descarga con
time.sleep
.import threading
import time
def descargar_archivo(nombre_archivo, duracion):
print(f"Iniciando descarga de {nombre_archivo}...")
time.sleep(duracion)
print(f"Descarga de {nombre_archivo} completada en {duracion} segundos.")
# Crear hilos para descargar tres archivos
hilo1 = threading.Thread(target=descargar_archivo, args=("Archivo1.txt", 3))
hilo2 = threading.Thread(target=descargar_archivo, args=("Archivo2.txt", 5))
hilo3 = threading.Thread(target=descargar_archivo, args=("Archivo3.txt", 2))
# Iniciar los hilos
hilo1.start()
hilo2.start()
hilo3.start()
# Esperar a que todos los hilos terminen
hilo1.join()
hilo2.join()
hilo3.join()
print("Todas las descargas han sido completadas.")
Explicación:
- Se define una función
descargar_archivo
que simula la descarga de un archivo mediantetime.sleep
. - Se crean tres hilos, cada uno para descargar un archivo diferente, especificando su duración como argumento.
- Los hilos se inician con
start()
y luego se espera a que terminen conjoin()
. - El programa permite que las descargas ocurran concurrentemente, demostrando el uso eficiente de hilos para tareas independientes.
Bibliografía de Python de interés.
- Curso de Programación Python. Autor: Arturo Montejo Ráez y Salud María Jiménez Zafra (Editorial Anaya)
- Aprende Python desde cero hasta avanzado. Autor: Xavier Reyes Ochoa (Editorial Book Shelter GmbH)
- Aprende la Programación Orientada a Objetos con el lenguaje Python. Autor: Vincent Boucheny (Editorial Ediciones ENI)
- 100 Ejercicios Python para praticar. Autor: Laurentine K.Masson (Editorial: Publicación Independiente).