В python есть великолепный модуль functools, в котором находится, пожалуй, моя самая любимая штука во всем python - декораторы lru_cache и cached_property.

  • lru_cache позволяет кешировать (иногда еще говорят мемоизировать) результаты работы декорируемых функций - это чертовски ускоряет повторное (или рекурсивное) исполение CPU bound функций, как впрочем и для IO bound функций он бывает полезен.
  • cached_property позволяет кешировать исполнение метода класса, но только если у него дополнительных аргументов, кроме self.

Вместе они позволяют покрыть большинство хотелок по кешированию чего-либо прямо в memory питоньего процесса.

Однако, есть у них как неочивидные фишки, так и некоторые тонкости, о которых сейчас и поговорим.

Начнем с декоратора cached_property

Использовать его очень просто:

In [4]: from functools import cached_property

In [17]: class A:
    ...:     @cached_property
    ...:     def area(self):
    ...:         print('actually running...')
    ...:         return 15
    ...: 

In [18]: a = A()

In [19]: a.__dict__
Out[19]: {}

In [20]: a.area
actually running...
Out[20]: 15

In [21]: a.area
Out[21]: 15

In [22]: a.__dict__
Out[22]: {'area': 15}

Строчка ‘actually running…’ напечаталась только раз. Результат сохранили и переиспользовали. Сохранили именно внутри объекта. То есть:

Умрет объект - умрет и закешированное значение. Это важно. Мы еще к этому вернемся, когда будем говорить о lru_cache.

Забавная фишка, про которую мало кто знает: можно удалить атрибут - и кэш сбросится. Следующий вызов будет честным вызовом функции, результат снова закешируется.

In [23]: del a.area

In [24]: a.__dict__
Out[24]: {}

In [25]: a.area
actually running...
Out[25]: 15

In [26]: a.area
Out[26]: 15

Вторая забавная фишка, про которую мало кто знает: вы сами можете записывать в атрибут все, что пожелаете, и ничего не сломается.

In [27]: a.area = 25

In [28]: a.area
Out[28]: 25

In [29]: a.__dict__
Out[29]: {'area': 25}

Вывод: cached_property - это не какая-то магия, а очень простая штука: “Если в объекте нет атрибута - то cached_property позовет функцию и запишет ее вывод в атрибут.”

Прикольно, но что если нам все таки нужно звать функцию/метод с аргументом?

Напрямую воспользоваться cached_property не получится. По идее, для этого и есть lru_cache, но он заслуживает отдельного поста. Пока рассмотрим менее очевидный способ применения cached_property.

Для примера я взял фрагмент production кода, который я недавно написал. Тут и type hinting, и type alias, а еще тут используется модный pydantic. Вы ведь про него слышали, да?

from pydantic import BaseModel
from functools import lru_cache

type _gid = str
type _mid = int

class SomeState(BaseModel):
    groups:  dict[_gid, GroupState]

    def get_group(self, gid: _gid) -> GroupState | None:
        return self.groups.get(gid)

    def find_gid(self, mid: _mid) -> _gid | None:
        for gid, group in self.groups.items():
            for m in group.iterate_mids():
                if m == mid:
                    return gid

Итак - есть объект SomeState, представляющий текущее состояние неких GroupState, которые просто себе лежат в словарике groups по ключу gid. Мы хотим искать группы в стейте не только по gid (что легко и быстро - обращение по индексу в словаре), но и по mid - а mid лежат внутри объектов GroupState неким образом. Но перебирать на каждый запрос SomeState.find_gid(mid) все группы и все mid в них не очень быстро, хотелось бы это как-то закешировать…

Задекорировать метод find_gid с помощью cached_property нельзя из-за аргумента mid. По идее, мы можем просто задекорировать его с помощью lru_cache .. но пока не будем;)

А будем мы делать вот так:

from pydantic import BaseModel
from functools import cached_property

type _gid = str
type _mid = int

class SomeState(BaseModel):
    groups:  dict[_gid, GroupState]

    @cached_property
    def _mid_index(self) -> dict[ _mid, _gid ]:
        return { mid: gid for gid, group in self.groups.items() for mid in group.iterate_mids() }

    def find_gid(self, mid: _mid) -> _gid | None:
        return self._mid_index.get(mid)

Мы оставляем метод SomeState.find_gid не закешированным, но все, что он делает - это lookup в SomeState._mid_index, а это уже закешированный дикт, в котором лежат все известные нам mid и соответствующие им gid.

Если стейты меняются не часто (каждый новый стейт - это новый объект, с пустым кешем), а запросы к SomeState.find_gid происходят часто - то кеш будет очень даже эффективен. Тем более, мы не делаем O(n) поиск по всему дикту на каждый запрос, а один раз тратим O(n) на весь стейт сразу.

Смущены загадочным термином O(n)? Вам сюда.

Если бы мы просто задекорировали метод find_gid с помощью lru_cache - то да, мы получили бы ускорение для повторных запросов с тем же mid, но каждое построение кэша SomeState.find_gid(mid) стоило бы нам O(n) - ведь в худшем случае нам нужно пробежать по всем группам, чтобы найти mid. И так для каждого запрашиваемого mid. А еще появилась бы необходимость сделать SomeState хешируемым объектом (wtf?!), но об этом в следующий раз.

Ну а cached_property обойдется нам только один раз в O(n) и закеширует все mid сразу, без регистрации и смс.

Круто? Очень круто.

Получается, в каком-то смысле, cached_property может кешировать методы с аргументом. Но только в определенных ситуациях.

Думайте об этом подходе, как об индексе в БД. Только перестраивается он не при записи, а при первой попытке чтения. Lazy индекс, получается. Ну и мы тоже немного lazy - добились желаемого, не изучая lru_cache.

Что еще почитать?

Доки cached_property

Заинтересовались type hinting’ами и type alias’ами?

Модный pydantic

Загадочный O(n), он жe Big O или Big O notation - по быстрому тут, но вообще Вам нужна книга “Грокаем алгоритмы”, глава 1.

Какие такие индексы в БД?