Содержание
Декораторы, часть 2
Вводные слова
Во всех примерах применения декораторов из жизни, которые я видел, декораторы применялись для довольно нетривиальных вещей, таких, что вступление к каждому из примеров само по себе могло бы занять слишком много времени.
Например, из последних примеров, которые я видел, был декораторы для оборачивания функции в транзакцию. Транзакция – это набор действий, которые либо совершаются успешно все вместе, либо не совершается ни одно из них. (Например, если все действия совершаются последовательно и самое последнее из них не смогло выполниться, то мы должны отменить исполнение всех предыдущих в транзакции). О транзакциях не имеет смысла говорить, пока мы не договоримся достаточно строго о том, что мы называем действиями и пока у нас не будет какого-нибудь механизма для воплощения транзакций в жизнь.
Дабы не тратить время фактически на разбор чужих программ, мы будем рассматривать несколько более искусственные примеры.
alias
Пример первый: допустим, мы хотим при определении функции сразу давать ей ещё одно имя (например, потому, что в прошлой версии наша функция называлась по-другому и мы хотим, чтобы те, кто пользуется предыдущей версией нашего модуля, могли продолжать пользоваться и новой).
Такой декоратор описать очень просто:
Единственная хоть чуть-чуть сложная строка в этом декораторе: globals()[name] = f. Функция globals() возвращает словарь глобального пространства имён модуля (то пространство имён, в котором обитает всё, что определено вне функций и классов: функции, классы, глобальные переменные). Как функция vars(object) возвращает содержимое объекта в виде словаря, который можно менять, и эти изменения окажутся в объекте, так же и globals() возвращет словарь, который можно менять, тем самым создавая, меняя и удаляя глобальные переменные.
Что мы и делаем.
С этим декоратором мы можем написать:
Цепочки декораторов
На этом примере мы видим про декораторы последнюю тонкость, которой мы не наблюдали раньше: декорировать можно не только функцию, но и функцию с декоратором.
Если внимательно вчитаться в это определение, то из него следует, что цепочка из нескольких декораторов раскрывается таким образом:
Эквивалентно:
Или, в точности синонимичное:
Или ещё раз это же определение можно переформулировать так: раньше применяется тот декоратор, который ближе к описанию функции.
Насколько порядок применения декораторов влияет на результат посмотрим на следующем примере.
retry
В качестве второго примера декоратора возьмём декоратор retry.
Идея такая: у нас есть какая-нибудь функция, которая работает довольно ненадёжно – например, она выкачивает информацию из сети с и без нас перегруженного сайта. Мы хотим сделать из неё функцию, которая с большей вероятностью вернёт нужный результат: пусть она будет повторять то же действие несколько раз, и если ей удалось получить резульат в какой-нибудь из попыток, то мы его и вернём.
Будем считать, что если функция выполнилась успешно, то она возвращает какое-нибудь значение, а если неуспешно, то выбрасывает исключение.
Тогда постановка задачи выглядит уже довольно прямолинейно, и мы можем описать декоратор:
Функция decorated делает в точности то, как мы её описали: она n раз пытается выполнить функцию f и вернуть её результат, а если случается исключение, отлавливает его и переходит на следующую итерацию цикла.
Правда, у этой функции в этой реализации есть недостаток: если мы за n раз не смогли получить результат, то мы вернём None, а казалось бы разумным выбросить то же исключение, которое выбросила функция f. То есть не отлавливать его на последней попытке. То есть вместо n итераций цикла делать n-1, а после цикла возвращать результат функции f уже без изменений, будь он успешным или исключением:
Теперь сравним два использования наших декораторов:
Мы определели функцию download, применили к ней декоратор retry, то есть сделали её более надёжной (условно говоря), и затем уже для новой версии функции сделали дополнительное имя: get.
Здесь мы определили функцию 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); }
В питоне возможно описать декоратор, который будет выглядеть так:
Если интересно, читайте по ссылке в начале раздела.
Карринг – это понятие, которое пришло из функционального программирования, от имени математика Haskell Brooks Curry (в честь него же назван и язык программирования Haskell, и язык программирования Curry).
Карринг – это способ исполнения функций, при котором если функции дали недостаточно аргументов, то она запоминает аргументы, которые ей уже дали, и возвращает функцию, которая будет получать оставшиеся аргументы. Тело функции будет вызвано только тогда, когда функция получит все аргумены, которые ей были необходимы.
Например, если у нас уже есть декоратор curried, который делает функцию карринговой, то мы можем написать: