Пользователь
11,7
рейтинг
23 июля 2013 в 19:28

Разработка → Python: декорируем декораторы. Снова tutorial

В прошлом году на Хабре уже была очень развёрнутая статья в двух частях о декораторах. Цель этой новой статьи — cut to the chase и сразу заняться интересными, осмысленными примерами, чтобы успеть затем разобраться в примерах ещё более мудрёных, чем в предыдущих статьях.
Целевая аудитория — программисты, уже знакомые (например по C#) с функциями высшего порядка и с замыканиями, но привыкшие, что аннотации у функций — это «метаинформация», проявляющаяся только при рефлексии. Особенность Питона, сразу же бросающаяся в глаза таким программистам — то, что присутствие декоратора перед объявлением функции позволяет изменить поведение этой функции:



Как это работает? Ничего хитрого: декоратор — это просто функция, принимающая аргументом декорируемую функцию, и возвращающая «исправленную»:

def timed(fn):
    def decorated(*x):
        start = time()
        result = fn(*x)
        print "Executing %s took %d ms" % (fn.__name__, (time()-start)*1000)
        return result
    return decorated

@timed
def cpuload():
    load = psutil.cpu_percent()
    print "cpuload() returns %d" % load
    return load

print "cpuload.__name__==" + cpuload.__name__
print "CPU load is %d%%" % cpuload()
(Исходник целиком)
cpuload.__name__==decorated
cpuload() returns 16
Executing cpuload took 105 ms
CPU load is 16%
Объявление @timed def cpuload(): ... разворачивается в def cpuload(): ...; cpuload=timed(cpuload), так что в результате глобальное имя cpuload связывается с функцией decorated внутри timed, замкнутой на исходную функцию cpuload через переменную fn. В результате мы и видим cpuload.__name__==decorated

В качестве декоратора может использоваться любое выражение, значение которого — функция, принимающая функцию и возвращающая функцию. Таким образом возможно создавать «декораторы с параметрами» (фактически, фабрики декораторов):

def repeat(times):
    """ повторить вызов times раз, и вернуть среднее значение """
    def decorator(fn):
        def decorated2(*x):
            total = 0
            for i in range(times):
                total += fn(*x)
            return total / times
        return decorated2
    return decorator

@repeat(5)
def cpuload():
    """ внутри функции cpuload ничего не изменилось """

print "cpuload.__name__==" + cpuload.__name__
print "CPU load is %d%%" % cpuload()
(Исходник целиком)
cpuload.__name__==decorated2
cpuload() returns 7
cpuload() returns 16
cpuload() returns 0
cpuload() returns 0
cpuload() returns 33
CPU load is 11%
Значение выражения repeat(5) — функция decorator, замкнутая на times=5. Это значение и используется в качестве декоратора; фактически имеем def cpuload(): ...; cpuload=repeat(5)(cpuload)

Можно сочетать несколько декораторов на одной функции, тогда они применяются в естественном порядке — справа налево. Если два предыдущих примера объединить в @timed @repeat(5) def cpuload(): — то на выходе получим
cpuload.__name__==decorated
cpuload() returns 28
cpuload() returns 16
cpuload() returns 0
cpuload() returns 0
cpuload() returns 0
Executing decorated2 took 503 ms
CPU load is 9%
А если поменять порядок декораторов — @repeat(5) @timed def cpuload(): — то получим
cpuload.__name__==decorated2
cpuload() returns 16
Executing cpuload took 100 ms
cpuload() returns 14
Executing cpuload took 109 ms
cpuload() returns 0
Executing cpuload took 101 ms
cpuload() returns 0
Executing cpuload took 100 ms
cpuload() returns 0
Executing cpuload took 99 ms
CPU load is 6%
В первом случае объявление развернулось в cpuload=timed(repeat(5)(cpuload)), во втором случае — в cpuload=repeat(5)(timed(cpuload)). Обратите внимание и на печатаемые имена функций: по ним можно проследить цепочку вызовов в обоих случаях.

Предельный случай параметрической декорации — декоратор, принимающий параметром декоратор:
def toggle(decorator):
    """ позволить "подключать" и "отключать" декоратор """
    def new_decorator(fn):
        decorated = decorator(fn)

        def new_decorated(*x):
            if decorator.enabled:
                return decorated(*x)
            else:
                return fn(*x)

        return new_decorated

    decorator.enabled = True
    return new_decorator

@toggle(timed)
def cpuload():
    """ внутри функции cpuload ничего не изменилось """

print "cpuload.__name__==" + cpuload.__name__
print "CPU load is %d%%" % cpuload()
timed.enabled = False
print "CPU load is %d%%" % cpuload()
(Исходник целиком)
cpuload.__name__==new_decorated
cpuload() returns 28
Executing cpuload took 101 ms
CPU load is 28%
cpuload() returns 0
CPU load is 0%
Значение, управляющее подключением/отключением декоратора, сохраняется в атрибуте enabled декорированной функции: Питон позволяет «налепить» на любую функцию произвольные атрибуты.

Получившуюся функцию toggle можно использовать и в качестве декоратора для декораторов:

@toggle
def timed(fn):
    """ внутри декоратора timed ничего не изменилось """

@toggle
def repeat(times):
    """ внутри декоратора repeat ничего не изменилось """

@timed
@repeat(5)
def cpuload():
    """ внутри функции cpuload ничего не изменилось """

print "cpuload.__name__==" + cpuload.__name__
print "CPU load is %d%%" % cpuload()
timed.enabled = False
print "CPU load is %d%%" % cpuload()
(Исходник целиком)
cpuload.__name__==new_decorated
cpuload() returns 28
cpuload() returns 0
cpuload() returns 0
cpuload() returns 0
cpuload() returns 0
Executing decorated2 took 501 ms
CPU load is 5%
cpuload() returns 0
cpuload() returns 16
cpuload() returns 14
cpuload() returns 16
cpuload() returns 0
Executing decorated2 took 500 ms
CPU load is 9%
Гм… нет, не сработало! Но почему?
Почему декоратор timed не отключился при втором вызове cpuload?

Вспомним, что глобальное имя timed у нас связано с декорированным декоратором, т.е. с функцией new_decorated; значит, строчка timed.enabled = False изменяет на самом деле атрибут функции new_decorated — общей «обёртки» обоих декораторов. Можно было бы внутри new_decorated вместо if decorator.enabled: проверять if new_decorator.enabled:, но тогда строчка timed.enabled = False будет отключать сразу оба декоратора.

Исправим этот баг: чтобы пользоваться атрибутом enabled на «внутреннем» декораторе, как и прежде — налепим на функцию new_decorated пару методов:

def toggle(decorator):
    """ позволить "подключать" и "отключать" декоратор """
    def new_decorator(fn):
        decorated = decorator(fn)

        def new_decorated(*x): # без изменений
            if decorator.enabled:
                return decorated(*x)
            else:
                return fn(*x)

        return new_decorated

    def enable():
        decorator.enabled = True
    def disable():
        decorator.enabled = False
    new_decorator.enable = enable
    new_decorator.disable = disable
    enable()
    return new_decorator

print "cpuload.__name__==" + cpuload.__name__
print "CPU load is %d%%" % cpuload()
timed.disable()
print "CPU load is %d%%" % cpuload()
(Исходник целиком)
Желаемый результат достигнут — timed отключился, но repeat продолжил работать:
cpuload.__name__==new_decorated
cpuload() returns 14
cpuload() returns 16
cpuload() returns 0
cpuload() returns 0
cpuload() returns 0
Executing decorated2 took 503 ms
CPU load is 6%
cpuload() returns 0
cpuload() returns 0
cpuload() returns 7
cpuload() returns 0
cpuload() returns 0
CPU load is 1%
Это одна из очаровательнейших возможностей Питона — к функциям можно добавлять не только атрибуты, но и произвольные функции-методы. Функции на функциях сидят и функциями погоняют.
@tyomitch
карма
306,7
рейтинг 11,7
Реклама помогает поддерживать и развивать наши сервисы

Подробнее
Реклама

Самое читаемое Разработка

Комментарии (16)

  • +7
    Уточнение:
    Лучше всё таки передавать в декорируемую функцию не только позиционные, но и именованные аргументы.
    То есть, вместо
    decorated(*x)
        ...
        fn(*x)
    
    Писать
    decorated(*x, **y)
        ...
        fn(*x, **y)
    
    Иначе функции после декорирования могут «вести себя неоднозначно».
    • +5
      Дополню: де-факто используют имена *args, **kwargs.
  • +14
    Думаю, что стоит в примерах использовать функцию functools.wraps. Это хорошая практика, и стоит к ней приучать.
  • 0
    Я вот хоть убейте не пойму, нафиг это надо. Какой в этом смысл, если декорируется _объявление_ функции? Т.е. я не могу, например, импортнуть функцию из другого файла и задекорировать ее. Также я не могу декорировать разными декораторами вызов одной и той же функции. Что мне мешает нужное поведение реализовать в самом телефункции? В чем радость от «внутри самой функции ничего не изменилось»?

    Более того, если ты уже объявил задекорированную функцию, ты уже несможешь ее «передекорировать»… Темный лес :-((
    • +4
      > Т.е. я не могу, например, импортнуть функцию из другого файла и задекорировать ее.
      Верно, потому что декоратором называется оборачивание функции во время объявления. Вы можете обернуть функцию в любое другое время:
      from module import func
      func = decorator(func)
      

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

      > Что мне мешает нужное поведение реализовать в самом телефункции?
      Например то, что это поведение слабо связано с логикой работы функции, или это поведение вам нужно для большого числа функций. Или даже для неизвестного числа функций (как non_atomic_requests на иллюстрации).
      • 0
        В принципе понятно, но, чувствую, пока не столкнешься с развесистым проектом, до конца не прочувствуешь. Просто вот это один из самых «длинных» моих скриптов: github.com/dccharacter/AHRS/blob/master/myVisualisation.py. Т.е. я так понимаю я просто не дорос до декораторов всяких.
      • 0
        Это опять же следует из определения. Раз декоратор является частью объявления функции, вы его не можете отменить. Точно так же как вы не можете например сделать так, чтобы одна функция возвращала разные значения. Но вы опять же можете обернут любым количеством разных функция.
        Не совсем. Я здесь где‐то видел пример вытягивания оригинальной функции из пачки вложенных декораторов с помощью какого‐то хака. И хак, насколько я помню, предполагал, что все декораторы реализованы на замыканиях. А ведь есть ещё встроенные функции (написанные на C) и классы в качестве декораторов. Так что так лучше не делать.

        Но есть причины, по которым оригинальную функцию доставать может быть нужно. Например, мне в powerline, понадобились для документации и автоматической проверки настроек значения по‐умолчанию оригинальной функции и названия аргументов. Пришлось сочинять свой аналог functools.wraps, который применяет сам functools.wraps, после чего сохраняет оригинальную функцию как значение атрибута powerline_origin и своё расширение для Sphinx, которое умеет с этим делом работать. Впрочем, ни первое, ни последнее практически не представляет проблемы: вместо велосипедостроения в последнем случае я просто использую своего наследника одного из классов autodoc и вызываю его же функцию setup. Желания найти статью и взять оттуда хак у меня не возникло.
        • +2
          Видимо не только у Вас появлялась подобная необходимость.
          Начиная с 3.2 wraps сохраняет исходную функцию в аттрибуте __wrapped__.
        • 0
          > Я здесь где‐то видел пример вытягивания оригинальной функции из пачки вложенных
          > декораторов с помощью какого‐то хака.
          Мы так можем дойти и до того, что результат возвращаемой функции можно поменять, изменив байт-код.
          • +1
            Можно. В Python со всем, что написано на Python, можно делать что угодно, зачастую даже не изменяя байт‐код. Я как‐то обходил попытку IPython (до версии 0.11, после они такой фигнёй не страдали) запретить использовать юникод в rewrite prompt (это стрелочка вида ---->, показываемая если вы включили автовызов функций и набрали в строке, к примеру str: без скобок) с помощью своего класса со своими методами __str__ и __add__.

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

            Кстати, что именно значит «поменять результат возвращаемой функции» и «одна функция, возвращающая разные значения»? Я так понял, что имеется ввиду возможность заставить функцию выдать результат, неожиданный для её автора, противоречащий тому, что функция должна делать. Ничего невозможного тут нет.

            Декоратор, хоть и выглядит частью определения функции, ею не является и отменить его можно. В Python вообще можно сделать практически всё, что никогда не придёт в голову разработчику на более строгом языке. Это не нужно учитывать, если вы пишете библиотеку декораторов, но это нужно помнить, столкнувшись с недостатками чужого кода. А также делая заявления о невозможности чего‐либо. Если в C вы легко натолкнётесь на очередной системе на пользователя с hardened ядром и запретом изменять исполняемый код в памяти без специального разрешения, таким образом обосновав невозможность залезть в чужую функцию, то в Python такого нет и написать хак можно всегда, пока не задействованы C расширения.
            • 0
              Python вообще удивителен в этом плане: возможностей для создания хаков гораздо больше, чем в любом другом известном мне языке, а отношение к ним более терпимое. Конечно, такие трюки — последнее, что вам следует использовать, но в примере выше между «ставьте пропатченую версию IPython», «мы не поддерживаем версии ≤0.11» и «у нас есть хак для IPython <0.11» я уверенно буду выбирать последнее (пока разработчики Gentoo не объявят что‐то из >¹0.11 стабильной версией). Соответственно и не буду давать забывать о таких возможностях другим: иногда они очень полезны.

              ¹ Знаки ≤, < (без =) и > (тоже без =) не случайны: сама 0.11 является переходной версией и потому не поддерживается.
    • +4
      Отличные примеры — @login_required или @render_to в Django. Если есть много функций с различным поведением, а к тому же, со временем возможно потребуется это поведение еще и изменять, то намного проще добавить/удалить декоратор, чем править логику функции каждый раз.
    • 0
      Понял, вопросы по теме статьи задавать нельзя. Надо картинки из футурамы постить.
      Кстати, почему не пришли оповещения от хабры о камментах?
      • +1
        и комменты глючат :-(
    • 0
      Понял, вопросы по теме статьи задавать нельзя. Надо картинки из футурамы постить.
      Кстати, почему не пришли оповещения от хабры о камментах?
  • +1
    Кажется, впервые в жизни промахнулся с ответом.

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