Pradhvan

FluentPython

Decorators 101

  • A decorator may perform some processing with the decorated function, and returns it or replaces it with another function or callable object.

  • Inspection reveals that target is a now a reference to inner

@deco
def target():
    print("Running target()")

target # <function deco.<locals>.inner at 0x10063b598>
  • Decorators have the power to replace the decorated function with a different one.
  • Decorators are executed immediately when a module is loaded.
# Program to check how decorators are executed

registry = []


def register(func):
    print(f"running register({func})")
    registry.append(func)
    return func

@register
def f1():
    print("running f1()")

@register
def f2():
    print("running f2()")

def f3():
    print(f"running f3()")

def main():
    print("running main()")
    print("registry ->", registry)
    f1()
    f2()
    f3()

if __name__ == '__main__':
    main()
"""
OUTPUT
------

running register(<function f1 at 0x107a25c10>)
running register(<function f2 at 0x1079b0280>)
running main()
registry -> [<function f1 at 0x107a25c10>, <function f2 at 0x1079b0280>]
running f1()
running f2()
running f3()

"""
  • Register runs (twice) before any other function in the module.

  • When register is called, it receives as an argument the function object being decorated—for example, <function f1 at 0x100631bf8>.

    • After the module is loaded, the registry list holds references to the two decorated functions: f1 and f2.
  • A real decorator is usually defined in one module and applied to functions in other modules.Unlike the example above.

Variable Scoping

b = 6

def smart_function(a):
    print(a)
    print(b)
    b = 9 

smart_function(3)

"""
OUTPUT
------

      3 def smart_function(a):
      4     print(a)
----> 5     print(b)
      6     b = 9
      7 

UnboundLocalError: local variable 'b' referenced before assignment
"""
  • Python does not require you to declare variables, but assumes that a variable assigned in the body of a function is local.

  • To make the above program work with b=6 you would need to use the keyword global.

Closure

  • A closure is a function with an extended scope that encompasses nonglobal variables referenced in the body of the function but not defined there.

  • It does not matter whether the function is anonymous or not; what matters is that it can access nonglobal variables that are defined outside of its body.

# Closure Example

def make_averager():
    series = [] # Free Variable
    
    def averager(new_value):
        series.append(new_value)
        total = sum(series)
        return total/sum(series)
    return averager # Returns a function object

avg = make_averager()

avg(10) # 10.0

avg(11) # 10.5 

avg(12) # 1.0
  • series is a local variable of make_averager because the assignment series = [] happens in the body of that function. But when avg(10) is called, make_averager has already returned, and its local scope is long gone.

  • Within averager(), series is a free variable i.e a variable that is not bound in the local scope.

  • __code__ attribute keeps the names of the local and free variables.

  • The value for series is kept in the __closure__ attribute of the returned function avg.

avg.__code__.co_varnames # ('new_value', 'total')

avg.__code__.co_freevars # ('series',)

avg.__closure__ # (<cell at 0x107a44f78: list object at 0x107a91a48>,) 

avg.__closure__[0].cell_contents # [10, 11, 12]

Formal Definition

A closure is a function that retains the bindings of the free variables that exist when the function is defined, so that they can be used later when the function is invoked and the defining scope is no longer available.

Refactoring averager

def make_averager():
    count = 0
    total = 0
    
    def averager(new_value):
        count += 1
        total += new_value
        return total/count
    return averager

avg = make_averager()
avg(10)

"""
OUTPUT
------
UnboundLocalError: local variable 'count' referenced before assignment
"""

Why did count not behave like series i.e free variable ?

  • For series we took advantage of the fact that lists are mutable and we never assigned to the series name. We only called series.append and invoked sum and len on it.
  • count is an immutable types and all you can do is read, never update. If you try to rebind them, as in count = count + 1, then you are implicitly creating a local variable count. It is no longer a free variable, and therefore it is not saved in the closure.

Refactoring averager with nonlocal

def make_averager():
    count = 0
    total = 0
    
    def average(new_value):
        nonlocal count, total
        count += 1 
        total += new_value
        return total / count 
    return averager
  • nonlocal lets you declare a variable as a free variable even when it is assigned within the function.
  • If a new value is assigned to a nonlocal variable, the binding stored in the closure is changed.

    # Code example here!
    

Understanding a simple decorator

# clockdeco.py
import time 

def clock(func):
    def clocked(*args):
        t0 = time.perf_counter()
        # closure for clocked encompasses the func free variable
        result = func(*args)
        elapsed = time.perf_counter()
        name = func.__name__
        arg_str = ','.join(repr(args) for arg in args)
        print(f'[{elapsed:0.8f}s] {name}({arg_str}) -> {result!r}')
        return result
    return clocked
# SimpleDeco.py

import time 

from clockdeco import clock

@clock
def snooze(seconds):
    time.sleep(seconds)

@clock
def factorial(n):
    return 1 if n < 2 else n*factorial(n-1)


if __name__ == '__main__':
    print('*' * 40, 'Calling snooze(.123)')
    snooze(.123)
    print('*' * 40, 'Calling factorial(6)')
    print('6! =', factorial(6))
    
"""
OUTPUT
------

**************************************** Calling snooze(.123)
[14549.25258126s] snooze((0.123,)) -> None
**************************************** Calling factorial(6)
[14549.25291446s] factorial((1,)) -> 1
[14549.25296426s] factorial((2,)) -> 2
[14549.25301028s] factorial((3,)) -> 6
[14549.25305792s] factorial((4,)) -> 24
[14549.25310024s] factorial((5,)) -> 120
[14549.25314435s] factorial((6,)) -> 720
6! = 720
"""    
  • @clock on factorial(n) is just the syntatic sugar for factorial = clock(factorial)
  • clock(func) gets the factorial function as its func argument.
  • It then creates and returns the clocked function, which the Python interpreter assigns to factorial behind the scenes.
  • The __name__ of factorial(n) would give you clocked
import clockdeco_demo
clockdeco_demo.factorial.__name__
"""
Output
------
'clocked'
"""
  • Each time factorial(n) is called, clocked(n) gets executed.

Common Decorators in the standard library through functools

  • @wraps
  • @lru_cache
  • @singledispatch

#notes #Python #FluentPython #Decorators #Closure