Kodomo

Пользователь

Декораторы, часть 2

Вводные слова

Во всех примерах применения декораторов из жизни, которые я видел, декораторы применялись для довольно нетривиальных вещей, таких, что вступление к каждому из примеров само по себе могло бы занять слишком много времени.

Например, из последних примеров, которые я видел, был декораторы для оборачивания функции в транзакцию. Транзакция – это набор действий, которые либо совершаются успешно все вместе, либо не совершается ни одно из них. (Например, если все действия совершаются последовательно и самое последнее из них не смогло выполниться, то мы должны отменить исполнение всех предыдущих в транзакции). О транзакциях не имеет смысла говорить, пока мы не договоримся достаточно строго о том, что мы называем действиями и пока у нас не будет какого-нибудь механизма для воплощения транзакций в жизнь.

Дабы не тратить время фактически на разбор чужих программ, мы будем рассматривать несколько более искусственные примеры.

alias

Пример первый: допустим, мы хотим при определении функции сразу давать ей ещё одно имя (например, потому, что в прошлой версии наша функция называлась по-другому и мы хотим, чтобы те, кто пользуется предыдущей версией нашего модуля, могли продолжать пользоваться и новой).

Такой декоратор описать очень просто:

   1 def alias(name):
   2     def decorator(f):
   3         globals()[name] = f
   4         return f
   5     return decorator

Единственная хоть чуть-чуть сложная строка в этом декораторе: globals()[name] = f. Функция globals() возвращает словарь глобального пространства имён модуля (то пространство имён, в котором обитает всё, что определено вне функций и классов: функции, классы, глобальные переменные). Как функция vars(object) возвращает содержимое объекта в виде словаря, который можно менять, и эти изменения окажутся в объекте, так же и globals() возвращет словарь, который можно менять, тем самым создавая, меняя и удаляя глобальные переменные.

Что мы и делаем.

С этим декоратором мы можем написать:

   1 @alias("hello")
   2 @alias("h")
   3 def greet(who):
   4     print("Hello, %s!" % who)
   5 
   6 >> hello("world")
   7 Hello, world!
   8 >>> h("everybody")
   9 Hello, everybody!

Цепочки декораторов

На этом примере мы видим про декораторы последнюю тонкость, которой мы не наблюдали раньше: декорировать можно не только функцию, но и функцию с декоратором.

Если внимательно вчитаться в это определение, то из него следует, что цепочка из нескольких декораторов раскрывается таким образом:

   1 @a
   2 @b
   3 def f(...):
   4     ...

Эквивалентно:

   1 def f(...):
   2     ...
   3 f = b(f)
   4 f = a(f)

Или, в точности синонимичное:

   1 def f(...):
   2     ...
   3 f = a(b(f))

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

Насколько порядок применения декораторов влияет на результат посмотрим на следующем примере.

retry

В качестве второго примера декоратора возьмём декоратор retry.

Идея такая: у нас есть какая-нибудь функция, которая работает довольно ненадёжно – например, она выкачивает информацию из сети с и без нас перегруженного сайта. Мы хотим сделать из неё функцию, которая с большей вероятностью вернёт нужный результат: пусть она будет повторять то же действие несколько раз, и если ей удалось получить резульат в какой-нибудь из попыток, то мы его и вернём.

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

Тогда постановка задачи выглядит уже довольно прямолинейно, и мы можем описать декоратор:

   1 import functools
   2 
   3 def retry(n):
   4     def decorator(f):
   5         @functools.wraps(f)
   6         def decorated(*args, **kw):
   7             for i in range(n):
   8                 try:
   9                     return f(*args, **kw)
  10                 except Exception:
  11                     pass
  12         return decorated
  13     return decorator

Функция decorated делает в точности то, как мы её описали: она n раз пытается выполнить функцию f и вернуть её результат, а если случается исключение, отлавливает его и переходит на следующую итерацию цикла.

Правда, у этой функции в этой реализации есть недостаток: если мы за n раз не смогли получить результат, то мы вернём None, а казалось бы разумным выбросить то же исключение, которое выбросила функция f. То есть не отлавливать его на последней попытке. То есть вместо n итераций цикла делать n-1, а после цикла возвращать результат функции f уже без изменений, будь он успешным или исключением:

   1 import functools
   2 
   3 def retry(n):
   4     def decorator(f):
   5         @functools.wraps(f)
   6         def decorated(*args, **kw):
   7             for i in range(n - 1):
   8                 try:
   9                     return f(*args, **kw)
  10                 except Exception:
  11                     pass
  12             return f(*args, **kw)
  13         return decorated
  14     return decorator

Теперь сравним два использования наших декораторов:

   1 @alias("get")
   2 @retry(3)
   3 def download(...):
   4     ...

Мы определели функцию download, применили к ней декоратор retry, то есть сделали её более надёжной (условно говоря), и затем уже для новой версии функции сделали дополнительное имя: get.

   1 @retry(3)
   2 @alias("get")
   3 def download(...):
   4     ...

Здесь мы определили функцию download, сделали для неё новое имя get, а потом применили к ней retry – то есть у нас получилась вообще каша: в get лежит дословно то, что описывалось в теле download, а в download лежит несколько другая функция (которая делает несколько попыток).

Отсюда правило: если вы пишете цепочки декораторов, задумывайтесь о том, в какой последовательности вы их пишете и чего вы хотите, вообще говоря, получить.

Мемоизатор

В качестве последнего примера приведём несколько нетривиальный, но изредка полезный декоратор.

Идея такая: у нас есть функция, результат которой зависит только от её аргументов; и эта функция иногда работает очень долго; мы хотим при повторных вызовах этой функции с теми же аргументами, что и в первый раз, не вызывать её снова, вернуть тот же результат.

Первая идея такая: заведём словарь, в котором ключами будут аргументы функции, а значениями – результат выполнения функции и будем перед вызовом функции смотреть, есть ли данный нам набор аргументов у нас в словаре, или нету. Если есть, то возвращаем значение из словаря, если нету, то вызываем функцию, получаем результат, записываем его в словарь, и возвращаем его же.

Тут возникает одна проблема: а что если нашей функции дали в качестве аргумента список или словарь? В качестве ключей у словаря могут быть только неизменяемые типы (либо пользовательские объекты). Словари и списки к ним не относятся. Значит нам нужно как-то преобразовать наши аргументы в неизменяемый вид прежде, чем складывать их в словарь (это и по здравому смыслу выглядит разумно: если нам дали в качестве аргумента что-то, что могут потом изменить и дать нам в качестве аргумента снова, то нам нужно смотреть на его значение а не на то, где оно лежит).

Какие у нас есть способы бороться с этой проблемой?

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

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

Вариант 3. Написать функцию, которая будет честным образом обходить аргументы и превращать их в неизменяемые типы. Это довольно сложный вариант, который на мой вкус отдаёт чем-то велосипедостроительным. Наверняка кто-то такое уже делал.

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

В общем-то, все четыре варианта имеют свои преимущества и недостатки и однозначно лучшего из них нету. Мы выберем вариант 2, как почти самый простой, и при этом почти универсальный. Когда мы используем что-то, что работает _почти_ всегда – это обязательно нужно задокументировать. Иначе кому-нибудь это будет стоить многих часов очень тяжёлой отладки.

Итак, мы договорились хранить аргументы и результаты функции в словаре, а где мы будем хранить сам словарь? Нам нужно, чтобы мы могли независимо мемоизировать разные функции, значит словарь должен для каждой функции быть свой – давайте собственно в функции его и хранить! Функция – это тоже объект, и (в отличие от всяких противных int, str и иже с ними) в него можно добавлять новые поля.

Все основные идеи есть, теперь можно это реализовать:

   1 import functools
   2 
   3 def memoizing(f):
   4     """Decorator to make function f memoizing.
   5 
   6     We store function results in a dict with keys being created
   7     by str() of arguments. Beware the cases when this yields the
   8     same results for different arguments!
   9     """
  10     @functools.wraps(f)
  11     def decorated(*args, **kw):
  12         key = str((args, kw))
  13         try:
  14             return decorated.store[key]
  15         except Exception:
  16             result = f(*args, **kw)
  17             decorated.store[key] = result
  18             return result
  19     decorated.store = {}
  20     return decorated

Postscriptum. Мультиметоды и карринг.

На лекции я предлагал вам на выбор три варианта, какой декоратор привести последним примером: мемоизатор, мультиметоды или карринг. В итоге мы выбрали мемоизатор, поэтому для мультиметодов и карринга я просто дам определение здесь. Их реализации есть на питонской вики. (И, собственно, ровно оттуда и пришла половина примеров, которую я вам показывал).

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

Типичные примеры есть в java и c++, где можно написать примерно такое:

class X {
    f(int x);
    f(int x, int y);
}

В питоне возможно описать декоратор, который будет выглядеть так:

   1 @multimethod(int)
   2 def f(x):
   3     ...
   4 
   5 @multimethod(int, int)
   6 def f(x, y):
   7     ...

Если интересно, читайте по ссылке в начале раздела.

Карринг – это понятие, которое пришло из функционального программирования, от имени математика Haskell Brooks Curry (в честь него же назван и язык программирования Haskell, и язык программирования Curry).

Карринг – это способ исполнения функций, при котором если функции дали недостаточно аргументов, то она запоминает аргументы, которые ей уже дали, и возвращает функцию, которая будет получать оставшиеся аргументы. Тело функции будет вызвано только тогда, когда функция получит все аргумены, которые ей были необходимы.

Например, если у нас уже есть декоратор curried, который делает функцию карринговой, то мы можем написать:

   1 @curried
   2 def f(x, y, z):
   3     print("x = %s, y = %s, z = %s" % (x, y, z))
   4 
   5 >>> a = f(1)
   6 >>> b = a(2)
   7 >>> c = b(3)
   8 x = 1, y = 2, z = 3
   9 >>> f(1, 2, 3)
  10 x = 1, y = 2, z = 3