DEV Community

Israel Ayanwola
Israel Ayanwola

Posted on • Originally published at thecodeway.hashnode.dev on

Learn Python Decorators from Basic to Pro in 10 mins

Let's learn about python decorators. This is one of the patterns I love to use in python and It is widely used by senior programmers out there.

What are decorators 🤔

A decorator in python is a design pattern that allows us to extend the functionality of a function without modifying the function itself. We can do this by wrapping the function we want to extend functionality to with another function. It is not complex at all, it's a very simple pattern. On the low level, we are just passing a function as an argument to another function which will return a function we can call to call the function passed as an argument in the first place.

Patterns behind a decorator

In python, we can nest functions. Inner functions can access the outer scope of the enclosing function. Meaning that if we define a variable in the enclosed function, the inner function can have access to it. This pattern is called Closure. This concept is very important, it is the core concept of python decorators. This concept is also used in other languages out there, including JavaScript, Golang, and more.

Coding Decorators

Let's dive into some python codes which will help us understand all those theories and jargon above.

Functional decorators

Assuming we have a function calculate_number that returns a number 5, but then some requirements came up that we needed to increase the output of that function by 1, now we can create another function increase_number that can help us do that. This is just an example we will cover complex use cases towards the end of the tutorial.

We have our function that returns the number

def calculate_number():
    return 5

Enter fullscreen mode Exit fullscreen mode

We have another function here, let's pay close attention to this function

# Decorator to increase function output by one
def increase_number(func):

    def wrapper():
        """
        Calls the function passed as an argument in the outer
        scope and adds one to the output
        """
        return func() + 1

    return wrapper

Enter fullscreen mode Exit fullscreen mode

So let's break this down;

  • Take a look at the increase_number function,

  • Inside the nested function wrapper above, we called the function passed as an argument to the enclosing function and add 1 to it and return the answer,

  def outer_function(func):

      print('Before defining inner function')

      def inner_function():

          print('Do something before calling function')

          func() # Call function passed as argument in outer function

          print('Do something after calling function')

      print('Do something before returning the inner function')

      return inner_function

  def sample():
      print('I was decorated')

  outer_function(sample)()

Enter fullscreen mode Exit fullscreen mode

Output

Before defining inner function
Do something before returning the inner function
Do something before calling function
I was decorated
Do something after calling function

Enter fullscreen mode Exit fullscreen mode

Full code: Increasing the number decorator

def increase_number(func):

    def wrapper():
        return func() + 1

    return wrapper

def calculate_number():
    return 5

var_a = increase_number(calculate_number)
inner_function_response = var_a()
print(inner_function_response)

Enter fullscreen mode Exit fullscreen mode

Running the above code should output 6.

To summarize what is happening again;

  • the function calculate_number returns 5,
  • the function increase_number is a decorator, it takes in a function and wraps it with another function, then returns the wrapper, allowing us to manipulate the return value of the function passed as an argument which is going to be function calculate_number.
  • the nested function, wrapper, inside the function increase_number calls the function passed to function increase_number and adds 1 to the return value, then returns the answer.
  • the nested function, wrapper, is returned by the function increase_number, so that we can do something with it, most of the time we end up just calling it like a normal function as we did here with var_a. I said most of the time because we could also pass the returned function in variable var_a as an argument to another decorator, function.

Pythonic syntax of using decorators

Decorators are used a lot in python, there are even built-in decorators like staticmethod, classmethod, property, and more. So there is a pythonic syntax you can use to decorate a function. Imagine we wanted to apply multiple decorators to the function calculate_number in the code above using the current way, it would be too complex for you or another person to understand the code when there is a bug in the code or just reading through the code.

Pythonic Code: Increasing the number

def increase_number(func):

    def wrapper():
        return func() + 1

    return wrapper

@increase_number
def calculate_number():
    return 5

print(calculate_number())

Enter fullscreen mode Exit fullscreen mode

This is beautiful, I love this syntax. What happened here is simple, we decorated the function calculate_number with function increase_number by placing it just above the function calculate_number with the @ symbol before it. This tells python that this is a decorator, so when this function calculate_number is called, it automatically does the passing of the function to the decorator and calls the wrapper function. So now we can just call the function itself and it will get decorated. Now we can easily add multiple decorators to a function without making it too complex to understand.

Multiple decorators

Multiple decorators can be applied to a single function, by just stacking the decorators on top of each other using the decorator syntax @decorator_function.

def split_string(func):
    def inner():
        return func().split()
    return inner

def uppercase_string(func):
    def inner():
        return func().upper()
    return inner

@split_string
@uppercase_string
def speak_python():
    return "I love speaking Python"

print(speak_python())

Enter fullscreen mode Exit fullscreen mode

Output

['I', 'LOVE', 'SPEAKING', 'PYTHON']

Enter fullscreen mode Exit fullscreen mode

The first question that might come to your mind is, what is the order of how this decorator is applied to this function when it is called? The answer is it is from bottom to top, uppercase_string then split_string. So from the output above you can tell that the string was first converted to uppercase then it was split returning a list of uppercased strings.

You can also test it out, try switching the positions of the decorators, and place @uppercase_string on top of @split_string.

Code

@uppercase_string
@split_string
def speak_python():
    return "I love speaking Python"

Enter fullscreen mode Exit fullscreen mode

Output

...
AttributeError: 'list' object has no attribute 'upper'

Enter fullscreen mode Exit fullscreen mode

From the output above, there was an error because the @uppercase_string decorator was trying to call upper method on a list. This happened because the @split_string decorator split the string first returning a list of strings, then the @uppercase_string decorator tried to apply the upper method on that list which is impossible as python lists do not have an upper method built-in.

Decorating functions that accept arguments

If our functions accept arguments, no need to worry, we just have to update our decorator to accept the arguments and pass them to the decorated function when calling it. Let's modify our speak_python function above to accept a language as an argument, format it with the string, and return it.

Bad Code:

@split_string
@uppercase_string
def speak_language(language):
    return f"I love speaking {language}"

print(speak_language("Python"))

Enter fullscreen mode Exit fullscreen mode

Output:

...
TypeError: inner() takes 0 positional arguments but 1 was given

Enter fullscreen mode Exit fullscreen mode

We got the error because the argument Python is being passed to the inner functions of our decorators, but those inner functions don't take any arguments, positional or keyword arguments. So we have to update the decorators to make sure the inner functions take in arguments.

Good code:

def split_string(func):
    def inner(a):
        return func(a).split()
    return inner

def uppercase_string(func):
    def inner(a):
        return func(a).upper()
    return inner

@split_string
@uppercase_string
def speak_language(language):
    return f"I love speaking {language}"

print(speak_language("Python"))

Enter fullscreen mode Exit fullscreen mode

Output:

['I', 'LOVE', 'SPEAKING', 'PYTHON']

Enter fullscreen mode Exit fullscreen mode

Now it's working, what happened;

  • the inner functions now accept an argument,
  • then the argument is passed to the decorated function.

General Decorators

We can also create a decorator that accepts any amount of arguments, positional and keyword arguments, and passes them to the decorated function. Say we updated our speak_language function to accept more arguments, we would have to update all our decorators to accept those arguments. Imagine we have a complex codebase with a lot of decorators, it would be hard to update all of them. So we should make sure our decorators can handle multiple arguments in the future.

Best Code:

def split_string(func):
    def inner(*args, **kwargs):
        return func(*args, **kwargs).split()
    return inner

def uppercase_string(func):
    def inner(*args, **kwargs):
        return func(*args, **kwargs).upper()
    return inner

@split_string
@uppercase_string
def speak_language(word, language):
    return f"I {word} speaking {language}"

print(speak_language("hate", "Python"))

Enter fullscreen mode Exit fullscreen mode

Output:

['I', 'HATE', 'SPEAKING', 'PYTHON']

Enter fullscreen mode Exit fullscreen mode

_args and *_kwargs allow you to pass multiple arguments or keyword arguments to a function. So we used it in the inner functions to accept any number of arguments and also pass them all to the decorated function. Now no matter how many arguments are passed to the speak_language function in the future, we don't need to update the decorators. One less problem to think about. 😁

Learn more about _args and *_kwargs

Decorator Factory

Let's go back to our increase_number function Code:

def increase_number(func):

    def wrapper():
        return func() + 1

    return wrapper

@increase_number
def calculate_number():
    return 5

print(calculate_number())

Enter fullscreen mode Exit fullscreen mode

This decorator only increases this number by 1, what if we want to apply an increase by 10? We can edit the decorator to increase it by 10. What if we have another function that needs a decorator to increase it by 5, we can create another decorator like increase_number that increases it by 5. This is not good because we are repeating code, we should try not to repeat codes anywhere possible because if there turns out to be a bug in the code we will have to change all places where the code has been replicated.

It is also possible with decorators. With the understanding of Closures, we know that a nested function has access to the scope of the outer function. Then we can create a function that returns a decorator. Doing this means if we pass an argument to the function creating the decorator, the decorator would have access to the arguments passed to its outer function and the inner function of the decorator should have access to these arguments too. Let's take a look at the code below.

Code:

def A(arg1):
    def B():
        def C():
            def D():
                def E():
                    print(arg1)
                return E
            return D
        return C
    return B

A("Good Python")()()()()

Enter fullscreen mode Exit fullscreen mode

Output:

Good Python

Enter fullscreen mode Exit fullscreen mode

I think with this piece of code, I have done justice to the fact that nested functions always have access to the outer function scope. Function E was nested four levels down, but still has access to the scope of function A.

This is the same idea behind a decorator factory, a function that returns a decorator. So to make the applied increase dynamic, we can create a function that accepts the number to increase by as an argument and then return a decorator which has access to this number. Code:

def apply_increase(increase):

    def increase_number(func):

        def wrapper():
            return func() + increase

        return wrapper

    return increase_number

@apply_increase(10)
def calculate_number():
    return 5

@apply_increase(1)
def myother_number():
    return 1

print(calculate_number())
print(myother_number())

Enter fullscreen mode Exit fullscreen mode

Output:

15
2

Enter fullscreen mode Exit fullscreen mode

The apply_increase function accepts the number as an argument, then returns the decorator. Since the decorator function returned for each of the functions above has access to the scope of their outer functions, we get unique decorators because the numbers in apply_increase(10) and apply_increase(1) are different. For both of the decorators returned, their increase argument will be different.

Conclusion 👍

Decorator is a very amazing pattern in python, with it we can extend the functionality of the wrapped function. We have covered a lot in this article, but there are other things we haven't covered. Class decorators, method decorators, and python functools module will be covered in another article. Thanks for reading, Arigato.

If you love this article, you can give this article likes and 10 reactions, comment below if you have any questions or views, and follow me for more updates on Software programming.

KissThankYouGIF.gif

Top comments (0)