Cache de função no Python

Utilizar um "cache de função" significa armazenar os resultados das chamadas de uma função, evitando reprocessamentos demorados para os mesmos argumentos.

A ideia é armazenar os argumentos recebidos pela função e o resultado do processamento retornado pela função.

Assim, quando a função for executada, recebendo os mesmos argumentos que em uma execução antarior, ao invés de reprocessar para encontrar o resultado, apenas retorna o resultado previamente processado e armazenado.

Função exemplo

Vamos criar uma função para exemplificar e melhorar o entendimento.

Abaixo temos uma função que calcula a multiplicação de um número inteiro, recebido por argumento, pelo seu sucessor:

def vezes_proximo(numero):
    print('número =', numero)
    proximo = numero + 1
    print('próximo =', proximo)
    resultado = numero * proximo
    print('resultado =', resultado)
    return resultado

Obs.: Criei essa função com várias chamadas à função print para acompanharmos o processamento. Mais exetamente, para verificarmos que realmente a função está sendo executada. Ficando claro que essa não é uma prática que seria utilizada "na vida real".

Ao executarmos essa função, imprimindo seu retorno, desta forma:

print(vezes_proximo(2))

Obteremos as seguintes informações:

número = 2
próximo = 3
resultado = 6
6

Sempre que executarmos novamente exatamente o mesmo comando, obteremos exatamente o mesmo resultado. Como esperado!

print(vezes_proximo(2))
número = 2
próximo = 3
resultado = 6
6

O que fazer caso esse reprocessamento não seja desejado?

Se for o esse o caso, utilizamos um "cache de função".

Implementação cache de função

Vou mostrar uma implementação de cache da nossa função exemplo. Bem simples, com intuito meramente didático.

Podemos criar uma variável e uma função para nos ajudar a atingir o objetivo.

  • Uma variável, do tipo dicionário, para armazenar os resultados já calculados;
  • Uma função que executa a função que queremos fazer cache, no caso a "vezes_proximo", apenas quando necessário.
resultados_anteriores = {}

def cached_vezes_proximo(numero):
    if numero not in resultados_anteriores:
        resultados_anteriores[numero] = vezes_proximo(numero)
    return resultados_anteriores[numero]

Passo a passo do que acontece ao ser chamada a função cached_vezes_proximo.

  • A função verifica se o argumento numero já está guardado como chave do dicionário resultados_anteriores;
    • Se não estiver guardado, executa a função vezes_proximo e guarda o resultado no dicionário resultados_anteriores;
  • Finalmente, retorna o resultado guardado em resultados_anteriores, independentemente de ter sido calculado na hora ou anteriormente.

Assim, ao executarmos:

print(cached_vezes_proximo(2))

Obteremos as seguintes informações:

número = 2
próximo = 3
resultado = 6
6

Porém, ao executarmos novamente obteremos apenas a seguinte informação:

print(cached_vezes_proximo(2))
6

Ou seja, nesta segunda execução da função cached_vezes_proximo esta não chamou à função vezes_proximo, apenas retornou o resultado que já tinha sido calculado e armazenado durante a primeira execução.

Quando utilizar

O cache de função é muito útil para economizar tempo de execução e poupar recursos computacionais custosos. Deve ser utilizado quando uma função:

  • Retorna sempre o mesmo resultado quando chamada com os mesmo argumentos;
  • Tem execução custosa, seja por processar muito internamente ou por utilizar recursos externos demorados;
  • É chamada muitas vezes com o mesmo argumento em um espaço de tempo relativamente curto.

Exemplo simples:

  • Sistema de Sugestões de Produtos Baseadas em Recomendações
    • Cenário: Um sistema de vendas com uma função que recebe o código do cliente e sugere produtos com base em algoritmos de recomendação.
    • Uso de Cache: Como os cálculos de recomendação podem ser complexos, os resultados podem ser armazenados em cache por um tempo para cada cliente, proporcionando sugestões rápidas sempre que o cliente recarregar uma página do sistema.

Nessa descrição de um exemplo, você pode ter percebido um detalhe não abordado nosso código: "resultados podem ser armazenados... por um tempo". Esse "detalhe" é muito importante! Falei em armazenar, mas, armazenar quanta informação e por quanto tempo? Isso é uma discussão longa que posso tratar em outro artigo. Neste artigo, o assunto será apenas brevemente tratado mais para o final.

Quando não utilizar

O cache de função não deve ser utilizado quando uma função:

  • Não retorna sempre o mesmo valor para os mesmos argumentos;
  • Não é chamada muitas vezes com os mesmos argumentos;
  • É "muito simples", sendo mais leve reexecutar o cálculo do que controlar o cache.

Obs.: Na verdade a função que estamos utilizando neste artigo para demonstrar o que é um cache de função, é um exemplo de função "muito simples". Calcular uma soma e uma multiplicação é algo tão simples, que seria mais rápido fazer esses cálculos do que se preocupar com armazenamento e recuperação de dado armazenado. Qual seria o limite do "muito simples"? Isso eu abordarei em outro artigo. Por ora seguiremos com nosso exemplo.

Na prática

A solução de cache desenvolvida como exemplo neste artigo é trabalhosa e tem poucos recursos. Não cuida nem do tamanho do cache de armazenamento, nem do ciclo de vida do dado armazenado.

Existem várias formas de implementar um cache em uma função no Python. Porém, abaixo abordarei apenas uma das mais comuns: utilizar o decorador lru_cache, do módulo functools, que está disponível por padrão no Python.

Ao invés de criarmos uma variável e uma função auxiliares, podemos simplesmente importar e utilizar o lru_cache, como demonstrado nas duas primeiras linhas com código abaixo. Depois dessas linhas consta nossa função original, vezes_proximo, exatamente como apresentada no início do artigo.

from functools import lru_cache

@lru_cache(maxsize=128)
def vezes_proximo(numero):
    print('número =', numero)
    proximo = numero + 1
    print('próximo =', proximo)
    resultado = numero * proximo
    print('resultado =', resultado)
    return resultado

Isso será o suficiente para termos o seguinte funcionamento:

print(vezes_proximo(2))
número = 2
próximo = 3
resultado = 6
6
print(vezes_proximo(2))
6

Mesmo sendo muito simples de implementar, essa solução é muito mais poderosa do que a desenvolvida inicialmente neste artigo, pois cuida do tamanho e do ciclo de vida dos dados armazenados no cache.

No caso, o argumento maxsize, passado para o decorador lru_cache, define o número máximo de resultados que o cache pode armazenar. Ao atingir a quantidade limite, o lru_cache descarta os resultados "menos recentemente usados" ("Least Recently Used"), daí o prefixo lru_ no nome do decorador.

Pronto! Com um import e o uso de um decorador, você pode fazer cache de qualquer função que você tenha criado.

Links

bacana seu artigo parabens, lru cache é muito poderoso quando usado no lugar certo.

Obrigado, Jonatas! Acabei de entrar no tabnews e quis publicar logo alguma coisa. Ainda não sei bem qual deve ser a pegada dos artigos. Não sei se está muito básico. Obrigado pelo retorno! Um abraço, Anselmo
tem de tudo, tem a galera que tras devlog, tem a galera do sass, tem a galera que escreve artigos, tem a galera que pede ajuda em uma frase. Aqui é igual coração de mãe.