Pull to refresh

Python: декорируем декораторы. Снова

Reading time 5 min
Views 34K
В прошлом году на Хабре уже была очень развёрнутая статья в двух частях о декораторах. Цель этой новой статьи — 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%
Это одна из очаровательнейших возможностей Питона — к функциям можно добавлять не только атрибуты, но и произвольные функции-методы. Функции на функциях сидят и функциями погоняют.
Tags:
Hubs:
+23
Comments 16
Comments Comments 16

Articles