Curso
En este artículo, aprenderemos sobre el almacenamiento en caché en Python. Vamos a entender qué es y cómo utilizarlo de manera eficaz.
El almacenamiento en caché es una técnica que se utiliza para mejorar el rendimiento de las aplicaciones mediante el almacenamiento temporal de los resultados obtenidos por el programa para reutilizarlos si es necesario más adelante.
En este tutorial, aprenderemos diferentes técnicas para el almacenamiento en caché en Python, incluidos los decoradores @lru_cache
y @cache
del módulo functools
.
Para aquellos que tengáis prisa, comencemos con una implementación muy breve del almacenamiento en caché y luego continuaremos con más detalles.
Respuesta breve: Implementación del almacenamiento en caché en Python
Para crear una caché en Python, podemos usar el decorador @cache
del módulo functools
. En el código siguiente, observa que la función print()
solo se ejecuta una vez:
import functools
@functools.cache
def square(n):
print(f"Calculating square of {n}")
return n * n
# Testing the cached function
print(square(4))
print(square(4))
# Calculating square of 4
# 16
# 16
¿Qué es el almacenamiento en caché en Python?
Supongamos que necesitamos resolver un problema matemático y tardamos una hora en obtener la respuesta correcta. Si tuvierais que resolver el mismo problema al día siguiente, sería útil reutilizar vuestro trabajo anterior en lugar de empezar de cero.
El almacenamiento en caché en Python sigue un principio similar: almacena valores cuando se calculan dentro de llamadas a funciones para reutilizarlos cuando sea necesario. Este tipo de almacenamiento en caché también se conoce como memoización.
Veamos un breve ejemplo que calcula dos veces la suma de un amplio rango de números:
output = sum(range(100_000_001))
print(output)
output = sum(range(100_000_001))
print(output)
# 5000000050000000
# 5000000050000000
El programa tiene que calcular la suma cada vez. Podemos confirmarlo cronometrando las dos llamadas:
import timeit
print(
timeit.timeit(
"sum(range(100_000_001))",
globals=globals(),
number=1,
)
)
print(
timeit.timeit(
"sum(range(100_000_001))",
globals=globals(),
number=1,
)
)
El resultado muestra que ambas llamadas tardan aproximadamente el mismo tiempo (dependiendo de nuestra configuración, podemos obtener tiempos de ejecución más rápidos o más lentos).
Sin embargo, podemos usar una caché para evitar calcular el mismo valor más de una vez. Podemos redefinir el nombre sum
utilizando la función cache()
del módulo integrado functools
:
import functools
import timeit
sum = functools.cache(sum)
print(
timeit.timeit(
"sum(range(100_000_001))",
globals=globals(),
number=1,
)
)
print(
timeit.timeit(
"sum(range(100_000_001))",
globals=globals(),
number=1,
)
)
La segunda llamada ahora tarda un par de microsegundos en lugar de más de un segundo, porque el resultado de la suma de los números del 0 al 100 000 000 ya se ha calculado y almacenado en la caché: la segunda llamada utiliza el valor que se calculó y almacenó anteriormente.
Arriba, utilizamos el decorador functools.cache()
para incluir una caché en la función integrada sum()
. Como nota al margen, un decorador en Python es una función que modifica el comportamiento de otra función sin cambiar permanentemente su código. Puedes obtener más información sobre los decoradores en este tutorial sobre decoradores de Python.
El decorador functools.cache()
se añadió a Python en la versión 3.9, pero podemos usar functools.lru_cache()
para versiones anteriores. En la siguiente sección, exploraremos estas dos formas de crear una caché, incluyendo el uso de la notación decoradora más frecuente, como @cache
.
Almacenamiento en caché de Python: Diferentes métodos
El módulo functools
de Python tiene dos decoradores para aplicar el almacenamiento en caché a las funciones. Exploremos functools.lru_cache()
y functools.cache()
con un ejemplo.
Escribamos una función sum_digits()
que tome una secuencia de números y devuelva la suma de los dígitos de esos números. Por ejemplo, si usamos la tupla (23, 43, 8)
como entrada, entonces:
- La suma de los dígitos de
23
es cinco. - La suma de los dígitos de
43
es siete. - La suma de los dígitos de
8
es ocho. - Por lo tanto, la suma total es 20.
Esta es una forma en la que podemos escribir nuestra función sum_digits()
:
def sum_digits(numbers):
return sum(
int(digit) for number in numbers for digit in str(number)
)
numbers = 23, 43, 8
print(sum_digits(numbers))
# 20
Utilicemos esta función para explorar diferentes formas de crear una caché.
Almacenamiento en caché manual en Python
Primero, creemos la caché manualmente. Aunque también podríamos automatizar esto fácilmente, crear una caché manualmente nos ayuda a comprender el proceso.
Creemos un diccionario y añadamos pares clave-valor cada vez que llamemos a la función con un nuevo valor para almacenar los resultados. Si llamamos a la función con un valor que ya está almacenado en este diccionario, la función devolverá el valor almacenado sin volver a calcularlo:
import random
import timeit
def sum_digits(numbers):
if numbers not in sum_digits.my_cache:
sum_digits.my_cache[numbers] = sum(
int(digit) for number in numbers for digit in str(number)
)
return sum_digits.my_cache[numbers]
sum_digits.my_cache = {}
numbers = tuple(random.randint(1, 1000) for _ in range(1_000_000))
print(
timeit.timeit(
"sum_digits(numbers)",
globals=globals(),
number=1
)
)
print(
timeit.timeit(
"sum_digits(numbers)",
globals=globals(),
number=1
)
)
La segunda llamada a sum_digits(numbers)
es mucho más rápida que la primera porque utiliza el valor almacenado en caché.
Ahora vamos a explicar el código anterior con más detalle. En primer lugar, observa que creamos el diccionario sum_digits.my_cache
después de definir la función, aunque lo usamos en la definición de la función.
La función sum_digits()
comprueba si el argumento pasado a la función ya es una de las claves del diccionario sum_digits.my_cache
. La suma de todos los dígitos solo se evalúa si el argumento no se encuentra ya en la caché.
Dado que el argumento que utilizamos al llamar a la función sirve como clave en el diccionario, debe ser un tipo de datos hashable. Una lista no es hashable, por lo que no podemos utilizarla como clave en un diccionario. Por ejemplo, probemos a sustituir numbers
por una lista en lugar de una tupla; esto generará un error TypeError
:
# ...
numbers = [random.randint(1, 1000) for _ in range(1_000_000)]
# ...
Traceback (most recent call last):
...
TypeError: unhashable type: 'list'
Crear una caché manualmente es ideal para aprender, pero ahora vamos a explorar formas más rápidas de hacerlo.
Almacenamiento en caché en Python con functools.lru_cache()
Python cuenta con el decorador lru_cache()
desde la versión 3.2. Las siglas «lru» al principio del nombre de la función significan «menos utilizado recientemente». Podemos pensar en la caché como una caja para almacenar cosas que se utilizan con frecuencia: cuando se llena, la estrategia LRU elimina el elemento que no hemos utilizado en más tiempo para dejar espacio para algo nuevo.
Decoremos nuestra función sum_digits()
con @functools.lru_cache
:
import functools
import random
import timeit
@functools.lru_cache
def sum_digits(numbers):
return sum(
int(digit) for number in numbers for digit in str(number)
)
numbers = tuple(random.randint(1, 1000) for _ in range(1_000_000))
print(
timeit.timeit(
"sum_digits(numbers)",
globals=globals(),
number=1
)
)
print(
timeit.timeit(
"sum_digits(numbers)",
globals=globals(),
number=1
)
)
Gracias al almacenamiento en caché, la segunda llamada tarda mucho menos tiempo en ejecutarse.
De forma predeterminada, la caché almacena los primeros 128 valores calculados. Una vez que se llenan los 128 lugares, el algoritmo elimina el valor menos utilizado recientemente (LRU) para hacer espacio para los nuevos valores.
Podemos establecer un tamaño máximo de caché diferente cuando decoramos la función utilizando el parámetro maxsize
:
import functools
import random
import timeit
@functools.lru_cache(maxsize=5)
def sum_digits(numbers):
return sum(
int(digit) for number in numbers for digit in str(number)
)
# ...
En este caso, la caché solo almacena cinco valores. También podemos establecer el argumento maxsize
en None
si no deseamos limitar el tamaño de la caché.
Almacenamiento en caché de Python con functools.cache()
Python 3.9 incluye un decorador de almacenamiento en caché más sencillo y rápido: functools.cache()
. Este decorador tiene dos características principales:
- No tiene un tamaño máximo, es similar a llamar a
functools.lru_cache(maxsize=None)
. - Almacena todas las llamadas a funciones y sus resultados (no utiliza la estrategia LRU). Esto es adecuado para funciones con salidas relativamente pequeñas o cuando no hay que preocuparse por las limitaciones del tamaño de la caché.
Usemos el decorador @functools.cache
en la función sum_digits()
:
import functools
import random
import timeit
@functools.cache
def sum_digits(numbers):
return sum(
int(digit) for number in numbers for digit in str(number)
)
numbers = tuple(random.randint(1, 1000) for _ in range(1_000_000))
print(
timeit.timeit(
"sum_digits(numbers)",
globals=globals(),
number=1
)
)
print(
timeit.timeit(
"sum_digits(numbers)",
globals=globals(),
number=1
)
)
Decorar sum_digits()
con @functools.cache
equivale a asignar sum_digits
a functools.cache()
:
# ...
def sum_digits(numbers):
return sum(
int(digit) for number in numbers for digit in str(number)
)
sum_digits = functools.cache(sum_digits)
Ten en cuenta que también podemos utilizar un estilo de importación diferente:
from functools import cache
De esta manera, podemos decorar nuestras funciones utilizando solo @cache
.
Otras estrategias de almacenamiento en caché
Las herramientas propias de Python implementan la estrategia de almacenamiento en caché LRU, en la que se eliminan las entradas menos utilizadas recientemente para dejar espacio a los nuevos valores.
Veamos otras estrategias de almacenamiento en caché:
- Primero en entrar, primero en salir (FIFO): Cuando la caché está llena, el primer elemento añadido se elimina para dejar espacio a los nuevos valores. La diferencia entre LRU y FIFO es que LRU mantiene los elementos utilizados recientemente en la caché, mientras que FIFO descarta el elemento más antiguo independientemente de su uso.
- Último en entrar, primero en salir (LIFO): El elemento añadido más recientemente se elimina cuando la caché está llena. Imagina una pila de platos en una cafetería. La placa que colocamos en la pila más recientemente (la última en entrar) es la que sacaremos primero (la primera en salir).
- Últimos utilizados (MRU): El valor que se ha utilizado más recientemente se descarta cuando se necesita espacio en la caché.
- Sustitución aleatoria (RR): Esta estrategia descarta aleatoriamente un elemento para hacer espacio para uno nuevo.
Estas estrategias también pueden combinarse con medidas de la vida útil válida, es decir, el tiempo durante el cual un dato almacenado en la caché se considera válido o relevante. Imagina un artículo de noticias en una caché. Puede que se acceda a ella con frecuencia (LRU la mantendría), pero al cabo de una semana, la noticia podría estar desactualizada.
Almacenamiento en caché de Python: Casos de uso comunes
Hasta ahora, hemos utilizado ejemplos simplificados con fines didácticos. Sin embargo, el almacenamiento en caché tiene muchas aplicaciones en el mundo real.
En la ciencia de datos, a menudo ejecutamos operaciones repetidas en grandes conjuntos de datos. El uso de resultados almacenados en caché reduce el tiempo y el coste asociados a la realización repetida de los mismos cálculos con los mismos conjuntos de datos.
También podemos utilizar el almacenamiento en caché para guardar recursos externos, como páginas web o bases de datos. Veamos un ejemplo y almacenemos en caché un artículo de DataCamp. Pero primero tendremos que instalar el módulo de terceros requests
ejecutando la siguiente línea en la terminal:
$ python -m pip install requests
Una vez instalado requests
, podemos probar el siguiente código, que intenta recuperar el mismo artículo de DataCamp dos veces mientras utiliza el decorador @lru_cache
:
import requests
from functools import lru_cache
@lru_cache(maxsize=10)
def get_article(url):
print(f"Fetching article from {url}")
response = requests.get(url)
return response.text
print(get_article("https://www.datacamp.com/tutorial/decorators-python"))
print(get_article("https://www.datacamp.com/tutorial/decorators-python"))
Como nota al margen, hemos truncado el resultado porque es muy largo. Ten en cuenta, sin embargo, que solo la primera llamada a get_article()
imprime la frase Fetching article from {url}
.
Esto se debe a que solo se accede a la página web la primera vez que se realiza la llamada. El resultado se almacena en la caché de la función. Cuando solicitamos la misma página web por segunda vez, se devuelven los datos almacenados en la caché.
El almacenamiento en caché garantiza que no haya retrasos innecesarios al recuperar los mismos datos repetidamente. Las API externas también suelen tener límites de velocidad y costes asociados a la obtención de datos. El almacenamiento en caché reduce los costes de las API y la probabilidad de alcanzar los límites de frecuencia.
Otro caso de uso habitual es en aplicaciones de machine learning, donde es necesario repetir varios cálculos costosos. Por ejemplo, si necesitamos tokenizar y vectorizar un texto antes de utilizarlo en un modelo de machine learning, podemos almacenar el resultado en una caché. De esta manera, no será necesario repetir las operaciones que requieren un gran esfuerzo computacional.
Retos comunes al utilizar el almacenamiento en caché en Python
Hemos aprendido las ventajas del almacenamiento en caché en Python. También hay algunos retos e inconvenientes que hay que tener en cuenta a la hora de implementar una caché:
- Invalidación y consistencia de la caché: Los datos pueden cambiar con el tiempo. Por lo tanto, es posible que también sea necesario actualizar o eliminar los valores almacenados en la caché.
- Gestión de la memoria: Almacenar grandes cantidades de datos en una caché requiere memoria, lo que puede causar problemas de rendimiento si la caché crece indefinidamente.
- Complejidad: Añadir cachés introduce complejidad en el sistema a la hora de crear y mantener la caché. A menudo, los beneficios superan estos costes, pero esta mayor complejidad podría dar lugar a errores difíciles de encontrar y corregir.
Conclusión
Podemos utilizar el almacenamiento en caché para optimizar el rendimiento cuando se repiten operaciones que requieren un gran esfuerzo computacional con los mismos datos.
Python tiene dos decoradores para crear una caché al llamar a funciones: @lru_cache
y @cache
en el módulo functools
.
Sin embargo, debemos asegurarnos de mantener la caché actualizada y gestionar adecuadamente la memoria.
Si deseas aprender más sobre el almacenamiento en caché y Python, echa un vistazo a este programa de seis lecciones sobre programación en Python.
Estudié Física y Matemáticas a nivel UG en la Universidad de Malta. Después me trasladé a Londres y me doctoré en Física en el Imperial College. Trabajé en novedosas técnicas ópticas para obtener imágenes de la retina humana. Ahora, me centro en escribir sobre Python, comunicar sobre Python y enseñar Python.