Kodomo

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

Декоратор

Теория

С давних времён в питоне иногда возникала конструкция такого вида:

   1 def f(...):
   2     ...
   3 f = g(f)

(Нужно это было в первую очередь для функций staticmethod и classmethod, о которых я расскажу далее).

Для такой конструкции придумали сокращение:

   1 @g
   2 def f(...):
   3     ...

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

На этом теория про декораторы заканчивается и начинаются примеры.

Пример 1.1. Логи.

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

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

Мы можем для этого сочинить такой декоратор:

   1 def logging(f):
   2     print("Declared %s" % f)
   3     def replacement(*args, **kw):
   4         print("Begin %s" % f)
   5         result = f(*args, **kw)
   6         print("End %s" % f)
   7         return result
   8     return replacement

Имея его, мы можем написать, например:

   1 >>> @logging
   2 ... def hello(world):
   3 ...     print("Hello, %s!" % world)
   4 Declared <function 'hello' at ...>
   5 >>> hello("world")
   6 Begin <function 'hello' at ...>
   7 Hello, world!
   8 End <function 'hello' at ...>

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

   1 def hello(world):
   2     print("Hello, %s!" % world)
   3 hello = logging(hello)

Поэтому во-первых, функция logging сама по себе вызывается единственный раз, сразу после определения функции hello.

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

Т.е., когда мы говорим hello("world"), почти можно сказать, что мы говорим replacement("world") в контексте:

   1 def _original_hello(world):
   2     print("Hello, %s!" % world)
   3 
   4 def replacement(*args, **kw):
   5     print("Begin %s" % _original_hello)
   6     result = _original_hello(*args, **kw)
   7     print("End %s" % _original_hello)
   8     return result

Пример 1.1. functools.wraps

Вернёмся к изначальной записи нашего примера:

   1 def logging(f):
   2     print("Declared %s" % f)
   3     def replacement(*args, **kw):
   4         print("Begin %s" % f)
   5         result = f(*args, **kw)
   6         print("End %s" % f)
   7         return result
   8     return replacement
   9 
  10 >>> @logging
  11 ... def hello(world):
  12 ...     '''Welcome someone named 'world'.'''
  13 ...     print("Hello, %s!" % world)
  14 Declared <function 'hello' at ...>

Что мы увидим, если в этом контексте мы попросим питон дать нам хелпы на hello?

   1 >>> help(hello)
   2 Help on function replacement in module __main__:
   3 
   4 replacement(*args, **kw)

Декоратор в питоне делает строго то, что написано в его определении и ничего больше. То есть вместо функции hello у нас на самом деле оказывается замыкание replacement именно так, как мы его описали.

Но, конечно, не годится, чтобы добавляя полезных свойств функции мы заодно убили её хелпы. Хелпы к функции и её имя в питоне хранятся внутри самого объекта функции, поэтому на самом деле нам достаточно просто перетащить немного данных из функции f в функцию replacement. Это достаточно важная задача для декораторов, чтобы готовая удобная реализация этой задачи в питоне уже была:

   1 import functools
   2 def logging(f):
   3     print("Declared %s" % f)
   4     @functools.wraps(f)
   5     def replacement(*args, **kw):
   6         print("Begin %s" % f)
   7         result = f(*args, **kw)
   8         print("End %s" % f)
   9         return result
  10     return replacement
  11 
  12 >>> @logging
  13 ... def hello(world):
  14 ...     '''Welcome someone named 'world'.'''
  15 ...     print("Hello, %s!" % world)
  16 Declared <function 'hello' at ...>
  17 >>> help(hello)
  18 Help on function hello in module __main__:
  19 
  20 hello(*args, **kw)
  21     Welcome someone named 'world'

Увы, список аргументов help получает не из документации, а выковыривает из кода функции, и тут мы его обмануть не смогли, но зато у нашей функции теперь правильное имя и правильнас строка подсказки.

Пример 1.3.

Ещё одну вещь можно сделать в примере с логами лучше: хотелось бы видеть не питонское описание объекта функции (<function 'hello' at ...>), а более человеческое с перечислением, какие именно аргументы нам дали: hello("world"). Это легко сделать!

Имя функции (и вообще, имя любой сущности) в питоне хранится в поле __name___. А аргументы у нас все уже есть, их нужно только вывести в более удобном виде:

   1 import functools
   2 def logging(f):
   3     print("Declared %s" % f.__name__)
   4     @functools.wraps(f)
   5     def replacement(*args, **kw):
   6         args_repr = map(str, args)
   7         args_repr += ["%s = %s" % (k, v) for k, v in kw.items()]
   8         call = "%s(%s)" % (f.__name__, ", ".join(args_repr))
   9         print("Begin %s" % call)
  10         result = f(*args, **kw)
  11         print("End %s" % call)
  12         return result
  13     return replacement
  14 
  15 >>> @logging
  16 ... def hello(world):
  17 ...     '''Welcome someone named 'world'.'''
  18 ...     print("Hello, %s!" % world)
  19 Declared hello
  20 >>> hello("world")
  21 Begin hello(world)
  22 Hello, world!
  23 End hello(world)

Всё равно не совсем то. hello(world) выглядит несколько иначе, чем hello("world"), но если мы просто добавим в строку кавычек, то у нас всё равно будет не совсем то, что нужно (например, если мы передадим функции число). С этим легко побороться: в питоне есть на самом деле не один, а два стандартных способа превращать объект в список: str и repr. Идея разделения состоит в том, что str возвращает содержимое объекта виде строки, а repr возвращает (если это возможно) строку, которую если исполнить питоном, мы получим тот же объект. Т.е.:

   1 >>> print(str(1))
   2 1
   3 >>> print(str("hello"))
   4 hello
   5 >>> print(repr(1))
   6 1
   7 >>> print(repr("hello"))
   8 'hello'

А это и есть ровно то, что нам нужно для логов! Дабы поправлять в описании функции было совсем мало чего, добавлю ещё одно: у форматирования строк через % кроме %s (который на самом деле вызывает str) есть ещё и формат %r (который вызывает repr). Поэтому окончательная версия нашей функции выглядит так:

   1 import functools
   2 def logging(f):
   3     print("Declared %s" % f.__name__)
   4     @functools.wraps(f)
   5     def replacement(*args, **kw):
   6         args_repr = map(repr, args)
   7         args_repr += ["%s = %r" % (k, v) for k, v in kw.items()]
   8         call = "%s(%s)" % (f.__name__, ", ".join(args_repr))
   9         print("Begin %s" % call)
  10         result = f(*args, **kw)
  11         print("End %s" % call)
  12         return result
  13     return replacement
  14 
  15 >>> @logging
  16 ... def hello(world):
  17 ...     '''Welcome someone named 'world'.'''
  18 ...     print("Hello, %s!" % world)
  19 Declared hello
  20 >>> hello("world")
  21 Begin hello('world')
  22 Hello, world!
  23 End hello('world')

Пример 2. Встроенные декораторы

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

Первый пример вы уже видели: functools.wraps(f) – копирует в декорированную функцию хелпы функции f.

Второй пример: staticmethod(f) – получает метод f и делает из него функцию, которой питон не будет передавать self. То есть фактически, у нас в классе (и в любом объекте класса) будет лежать обычная функция, как бы не имеющая никакого отношения к классу.

Типичный пример, когда это нужно – если мы хотим сделать у нашего класса несколько конструкторов. Например, если мы хотим описать класс Sequence, который умеет читаться из файлов разных форматов. У нас есть несколько некрасивых подходов, как это сделать: сделать, чтобы у __init__ было много аргументов со значением по умолчанию def __init___(self, fasta_file=None, msf_file=None, ...) – и тогда __init__ фактически будет заниматься только тем, что определять, какие из аргументов нам пришли и вызывать анализатор соответствующего формата; ещё мы можем сделать, чтобы последовательность всегда создавалась пустой, а потом мы могли дочитать в неё из файла – такой подход способен сильно запутать пользователя. Хороший классический подход в этом случае был бы такой: на каждый формат файла завести по статическому методу, который создаёт объект Sequence и разбирает в него содержимое файла:

   1 class Sequence(object):
   2 
   3     @staticmethod
   4     def from_fasta(file):
   5         result = Sequence()
   6         ...
   7         return result
   8     
   9     @staticmethod
  10     def from_msf(file):
  11         result = Sequence()
  12         ...
  13         return result
  14 
  15 >>> seq = Sequence.from_fasta(open("t.fasta"))

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

Третий пример встроенного декоратора в питоне аналогичен: classmethod(f) делает из метода f штуку, которая в качестве первого аргумента получает не объект, на котором его вызвали, а сам класс. Так как у меня не было практических случаев, когда бы я пользовался classmethod, я не буду о нём подробнее рассказывать.

Четвёртый пример: property.

Предположим, вы описали класс для векторов в декартовых координатах:

   1 class Vector(object):
   2 
   3     def __init__(self, x, y):
   4         self.x = x
   5         self.y = y

После чего вы написали большую программу, которая пользуется этим вектором и поняли, что вам везде было бы гораздо удобнее хранить вектор в полярных координатах, а декартовы координаты вы используете только изредка. Кода уже написано много, переписывать его не хочется. Как поступить? Можно попытаться при каждом изменении полярных координат пытаться синхронизовать их с декартовыми – но как отслеживать изменение? (Это можно сделать, но я не буду в этой лекции рассказывать, как, ибо это средства уже только для совсем отчаянных). Хорошее решение такое: в питоне можно сделать так, чтобы обращение к аргументу было на самом деле вызовом функции.

   1 class Vector(object):
   2 
   3     @staticmethod
   4     def from_r_phi(self, r, phi)
   5         self.r = r
   6         self.phi = phi
   7 
   8     @property
   9     def x(self):
  10         return self.r * math.cos(self.phi)
  11 
  12     @property
  13     def y(self):
  14         return self.r * math.sin(self.phi)

Теперь мы можем сказать:

   1 >>> v = Vector.from_r_phi(1, math.pi)
   2 >>> v.x
   3 -1

Правда, записать в v.x нам питон не разрешит. На самом деле, у property есть три аргумента: функция чтения, функция записи, функция удаления. Начиная с питона версии 2.6 (это пока что считается довольно новым питоном и он есть далеко не везде), эти функции тоже можно назначать через декораторы, вот так:

   1 class F(object):
   2     @property
   3     def x(self):
   4         return ...
   5 
   6     @x.setter
   7     def x(self, value)
   8         ...
   9 
  10     @x.deleter
  11     def x(self):
  12         ...

Пример 3. Автоматизация __init__

Посмотрим снова на определение какого-нибудь игрушечного класса в питоне:

   1 class F(object):
   2     def __init__(self, x, y, l, speed=100):
   3         self.x = x
   4         self.y = y
   5         self.l = l
   6         self.speed = speed

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

   1 class F(object):
   2     def __init__(self, **kw):
   3         vars(self).update(kw)

(Напоминаю, что vars(объект) возвращает словарь, которым представлены поля объекта).

Только у этого подхода есть сразу три недостатка: во-первых, мы заставляем пользователя всегда передавать аргументы по имени (объект такого класса не получится создать вызовом F(1, 2, 3)), во-вторых, мы заставляем пользователя передавать все аргументы (либо нам нужно делать ещё некоторое количество действий для определения параметров по умолчанию, и в-третьих, из этого фрагмента кода совершенно непонятно, какие аргуметы тут вообще говоря нужны. Мы лишаем код самодокументируемости. А это очень плохо.

Но со всем этим оказывается вовсе не трудно побороться: внутри объекта функции (и объекта метода класса тоже) лежит список имён аргументов, а значит мы можем создать декоратор, который будет автоматизировать заполнение полей класса!

Для изучения свойств объектов, модулей, классов, и функций2 в питоне есть модуль inspect. В нём есть функция getargspec, которая возвращает четвёрку: имена аргументов, имя аргумента со звёздочкой или None, имя аргумента с двумя звёздочками или None, значения по умолчанию для последних нескольких аргументов или None. getargspec хорошо работает для функций, описанных пользователем и почти не работает для встроенных функций (увы, такую кривизну Гвидо в питоне допускает направо и налево). К счастью, на интересует сейчас метод __init__, который будем описывать мы сами, значит нам эта функция годится.

Вооружившись этой функцией мы можем написать декоратор:

   1 import functools
   2 import inspect
   3 
   4 def autoinit(f):
   5     print f.__doc__
   6     @functools.wraps(f)
   7     def replacement(self, *args, **kw):
   8         arg_names, _, _, _ = inspect.getargspec(f)
   9         for name, value in zip(arg_names[1:], args):
  10             vars(self)[name] = value
  11         return f(self, *args, **kw)
  12     print replacement.__doc__
  13     return replacement

Первое, на что здесь нужно обратить внимание – это работа с self. Разумеется, как и сам метод __init__, наш replacement получит self в качестве первого аргумента. Но и чтобы вызвать потом исходный метод __init__ мы можем обратиться к нему как к обычной функции, которой мы первым аргументом передаём self.

Возможно, я демонстрировал вам такой пример: если x – объект класса X, то эти два вызова полностью эквивалентны:

   1 >>> x.f(y)
   2 >>> X.f(x, y)

Разница в том, что в первом случае мы создаём замыкание на объекте и вызываем его, а во втором случае, мы вынимаем из класса метод, как если бы это была обычная функция, и передаём ей все аргументы, которые у неё описаны, включая self.

Вторая деталь, на которую нужно обратить внимание – раз мы явно указали у replacement аргумент self, то в args у нас будет список значений аргуметов, кроме self, а в arg_names у нас будет список всех имён аргументов __init__, включая self. Поэтому первый элемент списка arg_names нам не интересен.

Третье – раз мы делаем этот декоратор ради самодокументируемости кода, то уж сохранить встроенную документацию мы просто обязаны, поэтому мы применяем functools.wraps. (В немного более продвинутой верскии autoinit мы могли бы компенсировать недоработки functools.wraps и приписать в хелпы нашего возвращаемого __init__ список имён его аргументов и их значений по умолчанию).

Теперь, с этим декоратором у нас будет работать такой пример:

   1 class F(object):
   2     @autoinit
   3     def __init__(self, x, y, l, speed=100):
   4         pass
   5 
   6 >>> o = F(1, 2, 3, 4)
   7 >>> o.x
   8 1
   9 >>> o.speed
  10 4

Но у нас ещё есть ошибки:

   1 >>> o = F(1, 2, 3)
   2 >>> o.speed
   3 Traceback (most recent call last):
   4   ...
   5 AttributeError: object has no attribute 'speed'

Действительно, мы забыли про значения по умолчанию. Давайте, исправим:

   1 import functools
   2 import inspect
   3 
   4 def autoinit(f):
   5     print f.__doc__
   6     @functools.wraps(f)
   7     def replacement(self, *args, **kw):
   8         arg_names, _, _, defaults = inspect.getargspec(f)
   9         arg_values = args
  10         if defaults is not None:
  11             arg_values = args + defaults[-(len(arg_names) - len(args) - 1):]
  12         for name, value in zip(arg_names[1:], arg_names):
  13             vars(self)[name] = value
  14         return f(self, *args, **kw)
  15     print replacement.__doc__
  16     return replacement

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

arg_names содержит имена всех аргументов функции (у которых действительно есть имена); args содержит значения первых скольких-то аргументов, которые нам передали – кроме self; defaults содержит значения последних скольких-то аргументов, у которых есть значения по умолчанию. То есть картинка получается примерно такая, как ниже. В ней плюсиками я пометил те значения, которые мы считаем полученными внутри функции, то есть, которые нам нужно присвоить в self.

args:            [++++++++]
names:    [self, ..................]
defaults:             [....++++++++]

Это значит, что мы берём из defaults последние n значений, где n – это количество имён аргументов минус количество переданных аргументов, и ещё минус единичка для self. Ровно это и написано в примере реализации autoinit выше.

С этими определениями:

   1 >>> o = F(1, 2, 3)
   2 >>> o.speed
   3 100

Но всё ещё есть недоработка:

   1 >>> o = F(l=10, x=1, y=2)
   2 >>> o.x
   3 Traceback (most recent call last):
   4   ...
   5 AttributeError: object has no attribute 'x'

Почему это случилось? Потому, что мы не учли значений параметров, переданных по имени! Учтём, благо это совсем просто. Нам неважно, присваиваем мы значения, переданные по имени до или после остальных значений, так как питон всё равно следит, чтобы каждый параметр не передавался двумя способами.

   1 import functools
   2 import inspect
   3 
   4 def autoinit(f):
   5     print f.__doc__
   6     @functools.wraps(f)
   7     def replacement(self, *args, **kw):
   8         arg_names, _, _, defaults = inspect.getargspec(f)
   9         arg_values = args
  10         if defaults is not None:
  11             arg_values = args + defaults[-(len(arg_names) - len(args) - 1):]
  12         for name, value in zip(arg_names[1:], arg_names):
  13             vars(self)[name] = value
  14         for name in kw:
  15             vars(self)[name] = kw[name]
  16         return f(self, *args, **kw)
  17     print replacement.__doc__
  18     return replacement

Теперь и последний пример будет работать! На удивление, теперь будут работать и примеры, в которых у init есть аргумент со звёздочкой или с двумя звёздочками. (Правда, значения, свалившиеся в аргумент со звёздочкой, мы не будем автоматически запихивать в self потому, что мы и не знаем, куда бы их там положить).

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

Пример 4. Решение уравнений

Напоследок маленький совсем игрушечный совсем простой пример. Определение декоратора @x; def f... именно таково, что def f...; f = x(f). Отсюда следует два вывода:

  1. Декоратор не обязан возвращать функцию
  2. Декоратор не обязан сам быть просто именем функции, а может быть любым выражением, которое возвращает функцию.

Покажу несколько искусственный пример на эту тему:

   1 def solve(left, right, delta=0.001):
   2     def decorator(f):
   3         l, m, r = left, (left + right) / 2.0, right
   4         while abs(f(m)) > delta:
   5             m = (l + r) / 2.0
   6             a, b, c = f(l), f(m), f(r)
   7             if a == 0:
   8                 return l
   9             elif b == 0:
  10                 return m
  11             elif c == 0:
  12                 return r
  13             elif a * b < 0:
  14                 r = m
  15             elif b * c < 0:
  16                 l = m
  17             else:
  18                 raise Exception("Function may not have solution within [%s,%s]" % (l, r))
  19         return m
  20     return decorator
  21 
  22 import math
  23 @solve(3, 4)
  24 def pi(x):
  25     return math.sin(x)

Функция decorator(f) – это простейший пример функции, которая находит приблизительное решение уравнения f(x) == 0 в диапазоне [left, right], с точностью delta. Делает она это следующим образом: мы требуем, чтобы на концах интервала знаки функции f(x) были разные; на каждой итерации мы делим интервал пополам и для следующей итерации выбираем ту из половин, в которой знаки функции f(x) снова будут разные. Большая цепочка if, elif нужна для того, чтобы заранее отловить точно найденное решение (можно назвать это оптимизацией и сократить эту цепочку вдвое). Этот алгоритм, хотя и очень примитивен, очень быстро находит хорошее решение. (Впрочем, почему-то теоретики численных методов в математике считают этот алгоритм очень медленным и предлагают заменять его другими, тяжело постижимыми).

Единственная тонкость в функции decorator – это то, что переменные left и right для неё являются глобальными, а одна переменная не может в функции быть и глобальной, и локальной, поэтому мы не можем в них присваивать. Поэтому нам пришлось завести переменные l и r с теми же начальными значениями.

Функция solve(left, right, delta) получает на вход диапазон, в котором мы хотим решить уравнение и возвращает замыкание decorator, которое умеет решать уравнения в этом диапазоне.

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

  1. А сама функция replacement является замыканием на стеке с f = hello. (1)

  2. Возможность изучать свойства частей языка из программ на этом языке называется интроспективностью. В питоне её много! (2)