Curso
Neste artigo, vamos aprender sobre cache em Python. Vamos entender o que é e como usá-lo de forma eficaz.
O cache é uma técnica usada para melhorar o desempenho dos aplicativos, armazenando temporariamente os resultados obtidos pelo programa para reutilizá-los se necessário posteriormente.
Neste tutorial, vamos aprender diferentes técnicas para cache em Python, incluindo os decoradores @lru_cache
e @cache
no módulo functools
.
Para quem está com pressa, vamos começar com uma implementação de cache bem curta e depois continuar com mais detalhes.
Resposta curta: Implementação de cache em Python
Pra criar um cache em Python, a gente pode usar o decorador @cache
do módulo functools
. No código abaixo, repara que a função print()
só é executada uma 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
O que é cache em Python?
Digamos que precisamos resolver um problema matemático e levamos uma hora pra chegar na resposta certa. Se tivéssemos que resolver o mesmo problema no dia seguinte, seria útil reutilizar nosso trabalho anterior, em vez de começar tudo de novo.
O cache em Python funciona de um jeito parecido — ele guarda valores quando eles são calculados dentro de chamadas de função pra usar de novo quando precisar. Esse tipo de cache também é conhecido como memoização.
Vamos ver um pequeno exemplo que calcula a soma de um grande intervalo de números duas vezes:
output = sum(range(100_000_001))
print(output)
output = sum(range(100_000_001))
print(output)
# 5000000050000000
# 5000000050000000
O programa precisa calcular a soma toda vez. A gente pode confirmar isso cronometrando as duas chamadas:
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,
)
)
A saída mostra que as duas chamadas levam mais ou menos o mesmo tempo (dependendo da nossa configuração, podemos ter tempos de execução mais rápidos ou mais lentos).
Mas dá pra usar um cache pra não ter que calcular o mesmo valor várias vezes. A gente pode redefinir o nome sum
usando a função cache()
no módulo functools
que já vem instalado:
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,
)
)
A segunda chamada agora leva alguns microssegundos em vez de mais de um segundo, porque o resultado da soma dos números de 0 a 100.000.000 já foi calculado e armazenado em cache — a segunda chamada usa o valor que foi calculado e armazenado anteriormente.
Acima, usamos o decorador functools.cache()
para incluir um cache na função embutida sum()
. Só pra constar, um decorador em Python é uma função que mexe no jeito que outra função funciona, sem mexer no código dela de verdade. Você pode aprender mais sobre decoradores neste Tutorial de Decoradores Python.
O decorador functools.cache()
foi adicionado ao Python na versão 3.9, mas a gente pode usar functools.lru_cache()
para versões mais antigas. Na próxima seção, vamos ver essas duas maneiras de criar um cache, incluindo o uso da notação decoradora mais comum, como @cache
.
Cache em Python: Diferentes métodos
O módulo functools
do Python tem dois decoradores para aplicar o cache às funções. Vamos dar uma olhada em functools.lru_cache()
e functools.cache()
com um exemplo.
Vamos escrever uma função sum_digits()
que recebe uma sequência de números e devolve a soma dos dígitos desses números. Por exemplo, se usarmos a tupla (23, 43, 8)
como entrada, então:
- A soma dos dígitos de
23
é cinco. - A soma dos dígitos de
43
é sete. - A soma dos dígitos de
8
é oito. - Então, o total é 20.
Essa é uma maneira de escrevermos nossa função 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
Vamos usar essa função para ver diferentes maneiras de criar um cache.
Cache manual em Python
Primeiro, vamos criar o cache manualmente. Embora também pudéssemos automatizar isso facilmente, criar um cache manualmente nos ajuda a entender o processo.
Vamos criar um dicionário e adicionar pares de chave-valor cada vez que chamarmos a função com um novo valor para guardar os resultados. Se chamarmos a função com um valor que já está guardado nesse dicionário, a função vai devolver o valor guardado sem precisar calcular de novo:
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
)
)
A segunda chamada para sum_digits(numbers)
é bem mais rápida que a primeira porque usa o valor armazenado em cache.
Vamos agora explicar o código acima com mais detalhes. Primeiro, repara que criamos o dicionário sum_digits.my_cache
depois de definir a função, mesmo que a usemos na definição da função.
A função sum_digits()
verifica se o argumento passado para a função já é uma das chaves no dicionário sum_digits.my_cache
. A soma de todos os dígitos só é calculada se o argumento ainda não estiver no cache.
Como o argumento que usamos ao chamar a função serve como chave no dicionário, ele precisa ser um tipo de dados hashável. Uma lista não é hashable, então não dá pra usar como chave num dicionário. Por exemplo, vamos tentar substituir numbers
por uma lista em vez de uma tupla — isso vai gerar um erro de tipo TypeError
:
# ...
numbers = [random.randint(1, 1000) for _ in range(1_000_000)]
# ...
Traceback (most recent call last):
...
TypeError: unhashable type: 'list'
Criar um cache manualmente é ótimo para fins de aprendizagem, mas agora vamos explorar maneiras mais rápidas de fazer isso.
Cache Python com functools.lru_cache()
O Python tem o decorador lru_cache()
desde a versão 3.2. O “lru” no começo do nome da função significa “menos usado recentemente”. A gente pode pensar no cache como uma caixa pra guardar coisas que a gente usa bastante — quando ela fica cheia, a estratégia LRU joga fora o item que a gente não usa há mais tempo pra abrir espaço pra algo novo.
Vamos decorar nossa função sum_digits()
com @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
)
)
Graças ao cache, a segunda chamada leva bem menos tempo pra rodar.
Por padrão, o cache guarda os primeiros 128 valores calculados. Quando todos os 128 lugares estiverem ocupados, o algoritmo apaga o valor menos usado recentemente (LRU) para abrir espaço para novos valores.
A gente pode definir um tamanho máximo diferente para o cache quando decoramos a função usando o 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)
)
# ...
Nesse caso, o cache só guarda cinco valores. Também podemos definir o argumento maxsize
como None
se não quisermos limitar o tamanho do cache.
Cache Python com functools.cache()
O Python 3.9 tem um decorador de cache mais simples e rápido —functools.cache()
. Esse decorador tem duas características principais:
- Não tem um tamanho máximo — é tipo chamar
functools.lru_cache(maxsize=None)
. - Ele guarda todas as chamadas de função e seus resultados (não usa a estratégia LRU). Isso é legal para funções com saídas relativamente pequenas ou quando a gente não precisa se preocupar com as limitações do tamanho do cache.
Vamos usar o decorador @functools.cache
na função 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()
com @functools.cache
é o mesmo que colocar sum_digits
em 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)
Observe que também podemos usar um estilo de importação diferente:
from functools import cache
Assim, podemos decorar nossas funções usando só um @cache
.
Outras estratégias de armazenamento em cache
As próprias ferramentas do Python usam a estratégia de cache LRU, onde as entradas menos usadas são apagadas pra dar espaço pra novos valores.
Vamos dar uma olhada em algumas outras estratégias de cache:
- Primeiro a entrar, primeiro a sair (FIFO): Quando o cache fica cheio, o primeiro item adicionado é removido para abrir espaço para novos valores. A diferença entre LRU e FIFO é que o LRU mantém os itens usados recentemente no cache, enquanto o FIFO descarta o item mais antigo, independentemente do uso.
- Último a entrar, primeiro a sair (LIFO): O item adicionado mais recentemente é removido quando o cache fica cheio. Imagina uma pilha de pratos numa cafeteria. A placa que colocamos na pilha mais recentemente (última a entrar) é a que vamos tirar primeiro (primeira a sair).
- Mais recentemente usado (MRU): O valor que foi usado mais recentemente é descartado quando é preciso espaço no cache.
- Substituição aleatória (RR): Essa estratégia descarta um item aleatoriamente para abrir espaço para um novo.
Essas estratégias também podem ser combinadas com medidas de tempo de vida válido — isso se refere ao tempo durante o qual um dado no cache é considerado válido ou relevante. Imagina uma notícia em um cache. Pode ser acessado com frequência (o LRU o manteria), mas depois de uma semana, a notícia pode estar desatualizada.
Cache em Python: Casos de uso comuns
Até agora, usamos exemplos simples pra facilitar o aprendizado. Mas o cache tem várias aplicações na vida real.
Na ciência de dados, a gente costuma fazer operações repetidas em grandes conjuntos de dados. Usar resultados em cache reduz o tempo e o custo de fazer os mesmos cálculos várias vezes nos mesmos conjuntos de dados.
Também podemos usar o cache para salvar recursos externos, como páginas da web ou bancos de dados. Vamos ver um exemplo e armazenar um artigo do DataCamp no cache. Mas primeiro precisamos instalar o módulo de terceiros requests
executando a seguinte linha no terminal:
$ python -m pip install requests
Depois de instalar o requests
, podemos tentar o seguinte código, que tenta buscar o mesmo artigo do DataCamp duas vezes usando o 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"))
Só pra constar, a gente cortou a saída porque era muito longa. Mas, repara que só a primeira chamada para get_article()
mostra a frase Fetching article from {url}
.
Isso porque a página da web só é acessada na primeira vez que a chamada é feita. O resultado fica guardado no cache da função. Quando a gente pede a mesma página pela segunda vez, os dados que estão guardados no cache são mostrados.
O cache garante que não haja atrasos desnecessários ao buscar os mesmos dados várias vezes. As APIs externas também costumam ter limites de taxa e custos associados à obtenção de dados. O cache reduz os custos das APIs e a chance de atingir os limites de taxa.
Outro caso de uso comum é em aplicativos de machine learning, onde vários cálculos caros precisam ser repetidos. Por exemplo, se precisarmos tokenizar e vetorizar um texto antes de usá-lo em um modelo de machine learning, podemos guardar o resultado em um cache. Assim, não precisaremos repetir as operações que exigem muito processamento.
Desafios comuns ao usar cache em Python
A gente aprendeu sobre as vantagens do cache no Python. Também tem alguns desafios e desvantagens que você precisa ter em mente ao implementar um cache:
- Invalidação e consistência do cache: Os dados podem mudar com o tempo. Então, os valores guardados no cache também podem precisar ser atualizados ou removidos.
- Gerenciamento de memória: Armazenar grandes quantidades de dados em um cache requer memória, e isso pode causar problemas de desempenho se o cache crescer indefinidamente.
- Complexidade: Adicionar caches deixa o sistema mais complicado na hora de criar e manter o cache. Muitas vezes, os benefícios superam esses custos, mas essa complexidade maior pode causar erros difíceis de encontrar e corrigir.
Conclusão
Podemos usar o cache para otimizar o desempenho quando operações computacionalmente intensivas são repetidas nos mesmos dados.
Python tem dois decoradores pra criar um cache quando chamar funções: @lru_cache
e @cache
no módulo functools
.
Precisamos garantir, no entanto, que a cache esteja sempre atualizada e que a memória seja gerenciada corretamente.
Se você quiser saber mais sobre cache e Python, dá uma olhada neste programa de seis módulos sobre programação em Python.
Estudei Física e Matemática em nível superior na Universidade de Malta. Depois, mudei-me para Londres e fiz meu doutorado em Física no Imperial College. Trabalhei em novas técnicas ópticas para obter imagens da retina humana. Agora, meu foco é escrever sobre Python, comunicar sobre Python e ensinar Python.