Programming #14: Decorators

This will be the first post in a series of posts on some advanced Python features. Future topics will include:

  • Generators
  • TypeHints
  • Collections
  • Itertools
  • Functools
  • Packing/Unpacking
  • Context Managers

Explaining Decorators

If we look up the term “decorator” in Python’s glossary we find:

A function returning another function, usually applied as a function transformation using the @wrapper syntax.

A function that returns another function. Why would we even want to do that? We already know how to define a function that does whatever we want, so it doesn’t really make sense. Let’s look up the reason decorators were added to Python in the first place:

The current method for transforming functions and methods (for instance, declaring them as a class or static method) is awkward and can lead to code that is difficult to understand. Ideally, these transformations should be made at the same point in the code where the declaration itself is made. This PEP introduces new syntax for transformations of a function or method declaration.

In other words, decorators are useful when we want to change or perform a task on another function/method while keeping everything pretty. We take in an old function and we return a new “decorated” function with a single line of code.

Some popular examples of decorators can be found here and here. Let’s explore six use cases to get a senes of how useful decorators can be:

  • Decorator that adds variables.
  • Decorator that adds preconditions.
  • Decorator that stores output.
  • Decorator that helps with debugging.
  • Decorator that tracks runtime.
  • Decorator that applies any number of functions to a function.

Code: Decorator that adds variables

For an intuitive example of a decorator, imagine that we want to model the price of a house. We can create a function get_house_price that takes in sqft as an argument and returns the price of the house (let’s assume 200/sqft).

def get_house_price(sqft):
        return sqft * 200

get_house_price(1000)

>>>
# 200000

Now, say we want to add decorations to our house and charge for each one. For example, the same house with a refrigerator would cost an extra 1000, with a couch an extra 500, and with a desk an extra 300. We can create a decorator that adds these prices to our base model. It literally adds decorations to our model.

def decorations(*addons):
    prices = {'refrigerator': 1000, 'couch': 500, 'desk': 300}
    def wrapper(old_function):
        def add_decorations(arg):
            return old_function(arg) + sum([prices[i] for i in addons])
        return add_decorations
    return wrapper

@decorations('refrigerator', 'couch')
def get_house_price(sqft):
        return sqft * 200

get_house_price(1000)

>>>
# 201500

Code: Decorator that adds preconditions

We can use a decorator to add a precondition to a function that needs to be met before running. For example, say we want to check that the right type is being passed as an argument. If the type is correct, we run the function. If not, we print “Bad Type Inputted”.

def argument_test(type):
    def wrapper(old_function):
        def check_type(arg):
            if isinstance(arg, type):
                return old_function(arg)
            else:
                print("Bad Type Inputted")
        return check_type
    return wrapper

@argument_test(int)
def test1(n):
    return n + n

@argument_test(str)
def test2(s):
    return s + ' .'

print(test1(1))
test1("a")
print(test2("a"))
test2(1)

>>>
# 2
# Bad Type Inputted
# a .
# Bad Type Inputted

Code: Decorator that stores output

We can use a decorator to store an output every time a function is called. If the output has already been stored, the function won’t need to be called again and it will save us some time. For example, we can store the numbers in the Fibonacci sequence.

def cache_fib(old_function):
    stored_values = {}
    def wrapper(*args):
        if args not in stored_values:
            stored_values[args] = old_function(*args)
        return stored_values[args]
    return wrapper

@cache_fib
def fibonacci(n):
    if n < 2:
        return n
    return fibonacci(n-1) + fibonacci(n-2)

def fibonacci_no_cache(n):
    if n < 2:
        return n
    return fibonacci(n-1) + fibonacci(n-2)

%timeit [fibonacci(n) for n in range(1, 20)]
%timeit [fibonacci_no_cache(n) for n in range(1, 20)]

>>>
# 4.87 µs per loop (mean ± std. dev. of 7 runs, 100000 loops each)
# 12.3 µs per loop (mean ± std. dev. of 7 runs, 100000 loops each)

Code: Decorator that helps with debugging

We can use a decorator to add a print statement to our code that helps with debugging. For example, say we want to see what is going on with our inputs and outputs for each function. Our decorator will allow us to do this with a single line of code.

def debug(old_function):
    def wrapper(*args, **kwargs):
        result = old_function(*args, *kwargs)
        print("{}{} -> {}".format(old_function.__name__, args, result))
        return result
    return wrapper

@debug
def test1(x, y):
    return x + y

@debug
def test2(x, y, z):
    return x + y + z

@debug
def test3(x):
    return x + 1

test1(1, 2)
test2(1, 2, 3)
test3(1)

>>>
# test1(1, 2) -> 3
# test2(1, 2, 3) -> 6
# test3(1,) -> 2

Code: Decorator that tracks runtime

We can use a decorator to track the runtime of our functions.

import time
def timer(old_function):
    def wrapper(*args, **kwargs):
        t0 = time.time()
        old_function(*args, **kwargs)
        t1 = time.time()
        print('{} ran in: {} seconds'.format(old_function.__name__, t1 - t0))
    return wrapper

@timer
def test(x, y):
    for i in range(1, x):
        for ii in range(1, y):
            [x for x in range(i * ii)]

test(100, 100)

>>>
# test ran in: 0.9135763645172119 seconds

Code: Decorator that applies any number of functions to a function

We can use a decorator run a function on a function on a function. Inception style.

def inception(*functions):
    def wrapper(old_function):
        def apply_functions(*args, **kwargs):
            start = functions[0](old_function(*args, **kwargs))
            for func in functions[1:]:
                start = func(start)
            return start
        return apply_functions
    return wrapper
    
def function_1(x):
    return x**2      # 4
    
def function_2(x):
    return x + x     # 8
    
def function_3(x):
    return x**2      # 64
    
def function_4(x):
    return x-10      # 54

@inception(function_1, function_2, function_3, function_4)
def function_0(x, y):
    return x + y     # 2
    
function_0(1, 1)

>>>
# 54
Advertisements

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out /  Change )

Google photo

You are commenting using your Google account. Log Out /  Change )

Twitter picture

You are commenting using your Twitter account. Log Out /  Change )

Facebook photo

You are commenting using your Facebook account. Log Out /  Change )

Connecting to %s