Saltar al contenido
Portada » Lenguajes » 13. Python Avanzado

13. Python Avanzado

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:

  1. memory_profiler: Mide el consumo de memoria línea por línea de una función o script.
  2. objgraph: Rastrea los objetos en memoria y ayuda a identificar fugas de memoria.
  3. 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:

  1. Optimizar la función (por ejemplo, usar generadores en lugar de listas).
  2. 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

  1. Es más preciso en sistemas operativos basados en Unix (Linux, macOS).
  2. Puede ser muy lento en programas grandes debido al análisis línea por línea.
  3. 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ísticaHilosProcesosAsyncio
Memoria CompartidaNoNo
GILLimitado por el GILNo aplicaNo aplica
Tareas CPU-intensivasIneficienteEficienteIneficiente
Tareas de E/SEficienteMenos eficienteMuy eficiente
Facilidad de UsoFácilModeradaComplejo

Buenas Prácticas para Manejar la Concurrencia

  1. Elegir el enfoque adecuado:
    • Hilos para tareas de E/S.
    • Procesos para tareas computacionales.
    • Asyncio para manejar gran cantidad de conexiones o tareas concurrentes.
  2. Evitar bloqueos innecesarios:
    • Usa Lock o Semaphore en hilos para evitar condiciones de carrera.
  3. Utilizar librerías especializadas:
    • concurrent.futures: Simplifica el manejo de hilos y procesos.
    • asyncio.Queue: Para manejar colas en programación asíncrona.
  4. Probar y depurar cuidadosamente: La concurrencia introduce complejidad. Usa herramientas como threading o multiprocessing 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.
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:

  1. Se crean dos objetos (nodo1 y nodo2) que se refieren mutuamente, formando un ciclo de referencias.
  2. Aunque se eliminan las referencias principales (del nodo1, del nodo2), los objetos no se liberan automáticamente debido al ciclo.
  3. Usamos gc.collect() para forzar la recolección de basura y eliminar estos objetos.
  4. 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.
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:

  1. Se define una función descargar_archivo que simula la descarga de un archivo mediante time.sleep.
  2. Se crean tres hilos, cada uno para descargar un archivo diferente, especificando su duración como argumento.
  3. Los hilos se inician con start() y luego se espera a que terminen con join().
  4. El programa permite que las descargas ocurran concurrentemente, demostrando el uso eficiente de hilos para tareas independientes.

Bibliografía de Python de interés.

Logo Python. Python Avanzado

Deja una respuesta

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *