Functools в Python: lru_cache, partial и wraps без сюрпризов
Три инструмента из functools экономят строки кода, но каждый бьёт по рукам в неожиданном месте: lru_cache течёт памятью, partial ломает интроспекцию, wraps молча теряет часть метаданных. Разбираем на конкретных задачах.

Три функции из стандартного модуля functools встречаются почти в каждом проекте: lru_cache ускоряет тяжёлые вычисления, partial фиксирует аргументы, wraps чинит декораторы. Проблема не в том, как их вызвать — это одна строка. Проблема в цене: кеш на методе объекта не даёт сборщику мусора освободить экземпляр, partial-объект не проходит проверки, которые ждут обычную функцию, а забытый wraps ломает автодокументацию и отладку. Разберём на задачах, где каждый инструмент даёт выигрыш, и где за него приходится платить. Всё проверено на CPython 3.11 и 3.12.
lru_cache: кеш, который вы не видите
lru_cache оборачивает функцию и хранит результаты в словаре по ключу из аргументов. Повторный вызов с теми же аргументами возвращает готовый результат, не запуская тело функции. Классический пример — рекурсия с перекрывающимися подзадачами.
from functools import lru_cache
@lru_cache(maxsize=None)
def fib(n: int) -> int:
if n < 2:
return n
return fib(n - 1) + fib(n - 2)
# Без кеша fib(35) делает ~29 млн вызовов.
# С кешем — 36 уникальных, остальное берётся из словаря.
print(fib(100)) # мгновенно, хотя без кеша это часы
Первый подводный камень — аргументы должны быть хешируемыми. Список или словарь в аргументах поднимут TypeError: unhashable type. Кортеж и frozenset — можно.
Второй, куда опаснее — кеш на методе экземпляра. Декоратор держит ссылку на self внутри ключа, и объект не будет удалён сборщиком, пока жив кеш:
class Report:
def __init__(self, data):
self.data = data
@lru_cache(maxsize=None) # утечка: self попадает в ключ кеша
def total(self):
return sum(self.data)
Пока класс Report существует в памяти, кеш на нём держит все экземпляры, для которых вызывали total. Тысяча отчётов — тысяча живых объектов, которые не соберутся. Для методов в Python 3.8+ есть отдельный инструмент — functools.cached_property для свойств без аргументов, а результат кешируется на самом экземпляре и умирает вместе с ним.
maxsize: не забывайте про потолок
maxsize=None означает безлимитный кеш — он растёт, пока не кончится память. Для функции с ограниченным набором аргументов это нормально. Для функции, которую зовут с уникальными аргументами (например, ID пользователей), это медленно текущая утечка. Ставьте число:
@lru_cache(maxsize=1024)
def get_user_profile(user_id: int) -> dict:
# тяжёлый запрос к БД
...
# Диагностика: сколько попаданий и промахов
print(get_user_profile.cache_info())
# CacheInfo(hits=812, misses=214, maxsize=1024, currsize=214)
# Сбросить кеш вручную (например, после изменения данных)
get_user_profile.cache_clear()
Кеш без стратегии инвалидации — это не оптимизация, а отложенный баг. Если данные заlru_cacheменяются, вы должны знать, кто и когда вызываетcache_clear. Иначе пользователь увидит старый профиль после обновления.
Инвалидация по событию — отдельная тема; lru_cache её не решает, он умеет только полный сброс.
partial: фиксируем аргументы, теряем интроспекцию
partial создаёт новый вызываемый объект с частью аргументов, зафиксированных заранее. Удобно, когда одну функцию нужно передать в API, который ждёт callable без аргументов или с меньшим их числом.
from functools import partial
def connect(host: str, port: int, timeout: float) -> None:
...
# Фиксируем host и port, оставляем timeout вызывающему коду
connect_local = partial(connect, "127.0.0.1", 5432)
connect_local(timeout=2.0) # host и port уже подставлены
Частый реальный случай — коллбэки в GUI и обработчики, где нужно передать функцию с уже подставленным контекстом, не заводя лямбду с замыканием.
Цена: partial-объект — это не функция. У него нет __name__, а интроспекция ломается:
import inspect
print(connect_local.__name__)
# AttributeError: 'functools.partial' object has no attribute '__name__'
# inspect.signature работает, но показывает урезанную сигнатуру
print(inspect.signature(connect_local))
# (timeout: float) -> None
Если ваш код где-то полагается на func.__name__ (логирование, регистрация обработчиков, роутинг), partial его сломает без явной ошибки — просто в лог попадёт functools.partial вместо осмысленного имени. Достучаться до исходной функции можно через атрибуты:
print(connect_local.func) # <function connect at 0x...>
print(connect_local.args) # ('127.0.0.1', 5432)
print(connect_local.keywords) # {}
Ещё одна ловушка — порядок. Позиционные аргументы в partial подставляются слева. Если вам нужно зафиксировать не первый, а, скажем, третий аргумент, придётся фиксировать по имени как keyword — или переписать сигнатуру исходной функции.
wraps: без него декоратор врёт о себе
Декоратор без wraps подменяет метаданные обёрнутой функции. Имя, docstring, аннотации — всё это заменяется данными внутренней обёртки. Пример декоратора-таймера, написанного неправильно:
import time
def timed(func):
def wrapper(*args, **kwargs):
start = time.perf_counter()
result = func(*args, **kwargs)
print(f"{func.__name__}: {time.perf_counter() - start:.4f}s")
return result
return wrapper
@timed
def load_data(path: str) -> list:
"""Читает данные из файла."""
...
print(load_data.__name__) # 'wrapper' — не то, что мы ждали
print(load_data.__doc__) # None — docstring потерян
Это ломает не только читаемость. Sphinx сгенерирует документацию для несуществующего wrapper, отладчик покажет wrapper в трейсбеке, а любой код, который матчит функции по имени, промахнётся. Лечится одной строкой:
from functools import wraps
def timed(func):
@wraps(func) # копирует __name__, __doc__, __wrapped__ и другие
def wrapper(*args, **kwargs):
start = time.perf_counter()
result = func(*args, **kwargs)
print(f"{func.__name__}: {time.perf_counter() - start:.4f}s")
return result
return wrapper
wraps сохраняет __name__, __qualname__, __doc__, __module__, __dict__ и, что важно для интроспекции, ставит атрибут __wrapped__ — ссылку на исходную функцию. Благодаря ему inspect.signature по умолчанию раскрывает настоящую сигнатуру, а не (*args, **kwargs) обёртки.
Чего wraps не делает: он не копирует поведение и не защищает от порчи аннотаций, если вы их меняете вручную. И если обёртка меняет сигнатуру (добавляет аргумент), __wrapped__ будет показывать старую — тут интроспекция начнёт вводить в заблуждение.
Когда что брать
| Инструмент | Задача | Главная цена |
|---|---|---|
lru_cache | Кешировать результат чистой функции по хешируемым аргументам | Память; утечка на методах; полный сброс вместо точечной инвалидации |
cached_property | Кешировать вычисляемое свойство на экземпляре | Живёт с объектом, но не пересчитывается при изменении данных |
partial | Зафиксировать аргументы для передачи callable дальше | Нет __name__; ломает код, полагающийся на интроспекцию функции |
wraps | Сохранить метаданные при написании декоратора | Почти нет; не забывать ставить, вот и вся сложность |
Практическое правило: lru_cache — только на функциях без побочных эффектов и с ограниченным maxsize, если аргументы не из конечного набора. partial — там, где принимающая сторона зовёт объект как obj(...) и не лезет в его имя. wraps — всегда, когда пишете декоратор, без исключений.
Что проверить после
- Пройдитесь по всем
@lru_cacheна методах — замените наcached_propertyили вынесите функцию за пределы класса, если ловите рост памяти. - Найдите
lru_cache(maxsize=None)на функциях с разнообразными аргументами и поставьте разумный потолок. - Добавьте
cache_info()в метрики: низкий процент попаданий означает, что кеш только тратит память, а не ускоряет. - Проверьте свои декораторы на наличие
@wraps— быстрый способ найти пропуски — вызватьfunc.__name__у задекорированных функций в тесте. - Читайте официальную документацию модуля — там же описаны
reduce,singledispatchиtotal_ordering, которые закрывают смежные задачи.
Задачи: математика, физика, химия: Разбор задачи по шагам с проверкой и похожей задачей
Спросить за 10 ₽Источники: functools — Higher-order functions and operations on callable objects, Python docs
Частые вопросы
У меня Python 3.7, там нет cached_property. Что делать с кешем на методе?
lru_cache потокобезопасен?
Почему после @wraps inspect.signature всё равно показывает (*args, **kwargs)?
Можно ли применять lru_cache к async-функции?
Не сработало: partial выдаёт TypeError про повторный аргумент. В чём дело?
Как измерить, помогает ли кеш вообще?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.