DEV Community

Rohith PR
Rohith PR

Posted on

Python Decorators Simplified

This blog post aims to demystify Python Decorators for beginners with silly, made up requirements.

Concept: Higher Order Functions

Wikipedia describes Higher Order Functions as follows:

In mathematics and computer science, a higher-order function is a function that does at least one of the following:

  1. takes one or more functions as arguments
  2. returns a function as its result.

map is a simple example of a higher order function that meets the first condition laid out above. map takes a function as its first argument, and an iterable as its second argument. The function that we pass as the first argument is called with each element of the original iterable to give us a new iterable with a transformation applied. Eg:

# You can pass a lambda function as an argument
>>> list(map(lambda x: x * 2, [1, 2, 3, 4, 5]))
[2, 4, 6, 8, 10]

# ...or even a named function
>>> def say_hello(name):
        return f'Hello, {name}'

>>> list(map(say_hello, ['John', 'Jane']))
['Hello, John', 'Hello, Jane']

Now, let’s get on to some requirements for our program where we can start using higher order functions.

Requirement: Create functions to add and multiply two decimal integers

Seems straightforward, lets wrap up our 1 point story.

Note: For the sake of simplicity, I’m adding some rudimentary tests in the same file. You can paste entire snippets in a file and run it to verify that no assertions fail.
def add(first, second):
    return first + second

def multiply(first, second):
    return first * second

if __name__ == '__main__':
    assert add(3, 4) == 7
    assert multiply(3, 4) == 12

New Requirement: For both methods, if any of the inputs is less than or equal to 0, return a 0

Yes, it doesn’t make much sense but that’s what’s required!

def is_not_natural_number(number):
    if number <= 0:
        return True
    return False

def custom_add(first, second):
    if any(map(is_not_natural_number, {first, second})):
        return 0
    return first + second

def custom_multiply(first, second):
    if any(map(is_not_natural_number, {first, second})):
        return 0
    return first * second

if __name__ == '__main__':
    assert custom_add(3, 4) == 7
    assert custom_multiply(3, 4) == 12
    assert custom_add(0, 3) == 0
    assert custom_multiply(-1, 3) == 0

    assert is_not_natural_number(0) == True
    assert is_not_natural_number(-1) == True
    assert is_not_natural_number(1) == False

All our tests pass and we know that the functions work as expected. However, there’s repetition. Things could get messy if we’re asked to add more guard conditions like this or if we’re asked to implement more custom mathematics like subtract, divide etc.

We can solve this by creating a higher order function that does all the common tasks, and then calls our target function if required. Let’s start refactoring the code.

Our own Higher Order Function: custom_math_guard

Let us create a function called custom_math_guard that takes a function func as its first argument, and then the arguments to be passed to this function. The guard function validates input to check if it meets our requirements, and calls func only if everything looks good.

def is_not_natural_number(number):
    if number <= 0:
        return True
    return False

def add(first, second):
    return first + second

def multiply(first, second):
    return first * second

def custom_math_guard(func, first, second):
    if any(map(is_not_natural_number, {first, second})):
        return 0
    return func(first, second)

if __name__ == '__main__':
    # Some tests have not been shown for the sake of brevity

    assert custom_math_guard(add, 3, 4) == 7
    assert custom_math_guard(multiply, 3, 4) == 12

    assert custom_math_guard(add, 0, 3) == 0
    assert custom_math_guard(multiply, -1, 3) == 0

We were able to extract our guard condition into a separate method. We can now add all common checks in custom_math_guard and ensure that our logic is not polluted by random checks.

However, this is a bit too verbose. We don’t want the codebase littered with custom_math_guard(foo, baz, bar) if we can avoid it.

Returning Functions from a Function

So far we have only spoken about higher order functions that accept a function as an argument. However, they can also return another function.

We can change custom_math_guard to accept a function func as an argument, and return another function returned_from_custom_math_guard. returned_from_custom_math_guard has access to the func that was originally passed as an argument to custom_math_guard and call it when required. It can also perform our common checks for non-natural numbers.

def is_not_natural_number(number):
    if number <= 0:
        return True
    return False

def add(first, second):
    return first + second

def multiply(first, second):
    return first * second

def custom_math_guard(func):
    def returned_from_custom_math_guard(first, second):
        if any(map(is_not_natural_number, {first, second})):
            return 0
        return func(first, second)

    return returned_from_custom_math_guard

custom_add = custom_math_guard(add)
custom_multiply = custom_math_guard(multiply)

if __name__ == '__main__':
    assert custom_add(3, 4) == 7
    assert custom_multiply(3, 4) == 12
    assert custom_add(0, 3) == 0
    assert custom_multiply(-1, 3) == 0

Much cleaner, right? custom_add is just returned_from_custom_math_guard which knows that it should call the add function if input validation passes.

Where's the decorator?

You may be wondering why the title of the blog post says “Python Decorators Simplified” but I’ve spoken about everything but decorators. It’s because, decorators are nothing more than syntactic sugar for the line custom_add = custom_math_guard(add) in the code snippet above. Rather than defining a function and then assigning the result of a transformation to a variable, we can just use decorator to let Python know that we want the function to be transformed.

def is_not_natural_number(number):
    if number <= 0:
        return True
    return False

def custom_math_guard(func):
    def returned_from_custom_math_guard(first, second):
        if any(map(is_not_natural_number, {first, second})):
            return 0
        return func(first, second)
    return returned_from_custom_math_guard

@custom_math_guard
def custom_add(first, second):
    return first + second

@custom_math_guard
def custom_multiply(first, second):
    return first * second

if __name__ == '__main__':
    assert custom_add(3, 4) == 7
    assert custom_multiply(3, 4) == 12
    assert custom_add(0, 3) == 0
    assert custom_multiply(-1, 3) == 0

The test cases have remained unchanged, and they all pass. So we know that our code does the exact same thing it did before. The few changes that have been made are:

  1. Define custom_math_guard before it is used: The Python interpreter goes through our script line by line from top to bottom, and it throws an error if it finds a decorator referencing a function that it hasn’t seen yet. So it is important to order methods in the correct sequence. This isn’t usually an issue as we are likely to import decorators from a different file/package. Since imports are done at the top of a file, we can freely use them anywhere in the rest of the file without worrying about the correct order of function definitions.
  2. We no longer have to define a function add and then assign the result of custom_math_guard(add) to custom_add. We don’t have to worry about someone inadvertently calling add instead of custom_add either! We have just one function which does what we want it to do.

Closing notes

Decorators are powerful and can drastically alter the flow of execution not only based on input parameters, but also based on who is calling a method etc. They can be used to enforce access control mechanisms to protect certain endpoints from being called by non-admin users, for instance. Hope you found this blog post useful, thanks for reading!

Latest comments (0)