Как реализовать кэширование (мемоизацию) для функций в Python

Ответ

Мемоизация — это техника оптимизации, при которой результаты выполнения "дорогих" функций сохраняются (кэшируются), чтобы избежать повторных вычислений с теми же входными аргументами.

В Python самый простой способ реализовать мемоизацию — использовать декоратор functools.lru_cache.

Использование lru_cache

Декоратор lru_cache (Least Recently Used) кэширует результаты вызовов функции. Если функция вызывается повторно с теми же аргументами, результат возвращается из кэша, а не вычисляется заново.

Пример:

import time
from functools import lru_cache

# maxsize - максимальное количество хранимых результатов
# Если maxsize=None, кэш будет неограниченным
@lru_cache(maxsize=128)
def fibonacci(n):
    print(f"Вычисляем fibonacci({n})...")
    if n < 2:
        return n
    return fibonacci(n-1) + fibonacci(n-2)

# Первый вызов - все значения будут вычислены
print("Первый вызов:")
start = time.time()
result = fibonacci(10)
print(f"Результат: {result}, время: {time.time() - start:.6f} сек")

# Второй вызов - результат будет взят из кэша мгновенно
print("nВторой вызов:")
start = time.time()
result = fibonacci(10)
print(f"Результат: {result}, время: {time.time() - start:.6f} сек")

Важные аспекты и ограничения:

  • Хешируемые аргументы: Все аргументы функции должны быть хешируемыми (числа, строки, кортежи, frozenset). Списки и словари использовать нельзя.
  • Чистые функции: lru_cache лучше всего подходит для "чистых" функций, результат которых зависит только от входных аргументов и не имеет побочных эффектов.
  • Потокобезопасность: Кэш является потокобезопасным.

Когда lru_cache недостаточно?

lru_cache реализует in-memory кэш в рамках одного процесса. Для более сложных сценариев требуются другие решения:

  • Распределенное кэширование: Если приложение работает в нескольких процессах или на нескольких серверах, необходим внешний кэш, такой как Redis или Memcached. Это позволяет всем экземплярам приложения использовать общий кэш.
  • Персистентное кэширование: Если нужно, чтобы кэш сохранялся между перезапусками приложения, данные следует сохранять на диск, например, с помощью joblib.Memory или в базу данных.

Ответ 18+ 🔞

Давай разберём эту тему про мемоизацию, а то я вижу, у некоторых глаза уже стекленеют, как у пьяного ежа. Суть проще пареной репы, но с подвохами, конечно.

Представь, ты каждый день идёшь в булочную за одним и тем же батоном. Сначала ты спрашиваешь у продавщицы: «Мария Ивановна, почём батон?». Она тебе: «Тридцать рублей». На следующий день ты опять: «Мария Ивановна, почём батон?». Она, блядь, уже смотрит на тебя как на идиота: «Тридцать, Петрович, как вчера». А ты на третий день опять: «Мария Ивановна...». Вот чтобы не быть этим Петровичем, который каждый раз заново вычисляет цену батона, и придумали мемоизацию. Запомнил ответ на вопрос один раз — и всё, в следующий раз просто достал из кармана памяти.

В Питоне для этого есть готовый инструмент — functools.lru_cache. Это такой декоратор, который вешает на функцию волшебную табличку. Функция отработала с какими-то аргументами — результат записал в табличку. Позвали её с теми же аргументами снова — она даже не шелохнётся, а сразу плюнет тебе готовый ответ из этой таблицы.

Смотри, как это выглядит в деле. Возьмём классику — числа Фибоначчи. Без кэша это пиздец какой рекурсивный ад.

import time
from functools import lru_cache

# maxsize — это сколько последних результатов помнить.
# Поставил None — значит, помнить всё, как слон, до самого конца работы программы.
@lru_cache(maxsize=128)
def fibonacci(n):
    print(f"Вычисляем fibonacci({n})...") # Это чтобы видеть, когда реально считаем
    if n < 2:
        return n
    return fibonacci(n-1) + fibonacci(n-2)

# Первый заход. Тут будет дикая возня, функция сама себя будет вызывать до посинения.
print("Первый вызов:")
start = time.time()
result = fibonacci(10)
print(f"Результат: {result}, время: {time.time() - start:.6f} сек")

# Второй заход. А тут уже — красота! Все значения для 10 и меньше уже в кэше лежат.
print("nВторой вызов:")
start = time.time()
result = fibonacci(10)
print(f"Результат: {result}, время: {time.time() - start:.6f} сек")

Запустишь — увидишь, что в первый раз print начнёт строчить, как сумасшедший, а во второй раз — тишина и мгновенный результат. Вот она, магия, ёпта!

Но не всё так гладко, есть подводные ебалы

  1. Аргументы должны быть хешируемыми. Это, блядь, самое важное! Кэш — это словарь, ключами в котором являются аргументы твоей функции. А ключ в словаре должен быть неизменяемым и хешируемым. Цифры, строки, кортежи — пожалуйста. А вот списки, словари или твой самописный объект без метода __hash__ — нихуя не получится. «TypeError: unhashable type: 'list'» — и сиди, расшифровывай.
  2. Функция должна быть чистой. То есть её результат должен зависеть ТОЛЬКО от того, что ты ей скормил. Если внутри она читает текущее время, случайное число или состояние вселенной — кэшировать это бесполезно, а то и опасно. Получишь вчерашний результат на сегодняшний вопрос.
  3. Потокобезопасность. Тут lru_cache молодцом — его можно в потоках юзать, не боясь, что кэш развалится в самый ответственный момент.

А когда lru_cache уже не катит?

Вот представь, твоё приложение размножилось и бегает на пяти серверах. На одном сервере ты посчитал сложную хуйню и закэшировал. А на другом сервере другой процесс про тот же самый результат понятия не имеет и начинает считать всё заново. Получается, ты экономишь ресурсы одного ядра, но в масштабах кластера нихуя не экономишь. Это как если бы в каждой квартире подъезда жил свой Петрович, и все они по отдельности ходили спрашивать про цену батона.

Для таких распиздяйских масштабов нужен внешний кэш: Redis или Memcached. Это такая отдельная, блядь, служба, к которой все твои серверы ходят как к единой Марии Ивановне за запомненными ответами.

Ну и если ты хочешь, чтобы кэш пережил перезагрузку твоего приложения (чтобы не вычислять заново то, что считалось три дня), то lru_cache тоже не помощник — он живёт только в оперативке. Тут надо смотреть в сторону персистентных штук вроде joblib.Memory или просто писать результаты в БД. Но это уже, как говорится, совсем другая история, и костылей там — овердохуища.