Diving Deep into Python Decorators: A Beginner's Guide

Diving Deep into Python Decorators: A Beginner's Guide

Hey everyone! Today, we're going to explore a powerful and elegant feature of Python: decorators. If you've ever seen a mysterious @ symbol above a function definition and wondered what it was all about, you're in the right place.

What are Decorators?

Let's start with the basics. As the Python wiki puts it, decorators are a way to

dynamically alter the functionality of a function, method, or class without having to directly use subclasses or change the source code of the function being decorated.

Think of it like this: decorators are like wrappers that add extra functionality to existing functions without directly modifying them. It’s a clean and reusable way to enhance your code.

Why Use Decorators?

Why bother with decorators at all? Well, they make your code easier to read and maintain, especially when you need to modify or add behavior to multiple functions. Instead of tangling functions inside each other, decorators provide a more explicit and readable way to alter a function.

For example, instead of something like this can you easily figure out what is going on:

foo(bar(baz(biz())), arg1, arg2=True) # What is going on here?

You get to organize the process with something like this:

@foo(arg1, arg2=True)
@bar
@baz
def biz():
    pass

Much more readable, right?

Key Concepts to Understand Decorators

Before we dive into writing decorators, let’s review some critical Python concepts:

  1. First-Class Citizens: In Python, functions are first-class objects. This means they can be:
    • Created at runtime.
    • Assigned to a variable.
    • Passed as an argument to a function.
    • Returned as the result of a function.
  2. Nested Functions: These are functions defined within the body of another function. Crucially, nested functions have access to the local scope of the enclosing function.
  3. Closures: This is where it gets interesting! Closures mean that inner functions remember the variables in their enclosing namespaces, even after the outer function has completed.

First-Class Functions in Action

Let’s illustrate the first-class nature of functions. Here’s an example of a function being assigned to a variable:

def foo(var):
    print(var)

variable = foo
variable('test') # Output: test

And here's a simple Higher-Order function example that takes another function as an argument:

def double_number(x):
    return x * 2

def double_array(func, double_list):
    return_list = []
    for x in double_list:
       return_list.append(func(x))
    return return_list

doubled_list = double_array(double_number, [0, 1, 2, 3, 4])
print(doubled_list) # Output: [0, 2, 4, 6, 8]

# Or using map:
doubled_list = map(double_number, [0, 1, 2, 3, 4])
print(list(doubled_list)) # Output: [0, 2, 4, 6, 8]

How To Write Your Own Decorators

Okay, time for the fun part. Here's how to create your own decorators:

def add_one(func):
    def inner():
        print("Before func")
        func_value = func()
        return func_value + 1
    return inner

@add_one
def foo():
    return 1

value = foo()
print(value)
# Output
# Before func
# 2

In this example, add_one is a decorator that takes a function as input and returns a modified version of it. The @ symbol is just syntactic sugar for:

decorated = add_one(foo)
value = decorated()

Decorators and Arguments

Often, your decorated functions will need arguments. You can handle this using *args and **kwargs:

def logger(func):
    def wrapper(*args, **kwargs):
        print('logger running')
        print('Arguments were: {} {}'.format(args, kwargs))
        return func(*args, **kwargs)
    return wrapper

@logger
def logger1(x, y):
    print('logging to logger 1')

@logger
def logger2():
    print('logging to logger 2')

logger1(5, y=20)
logger2()
# Output:
# logger running
# logger running
# Arguments were: (5,) {'y': 20}
# logging to logger 1
# Arguments were: () {}
# logging to logger 2

Important Note: Notice how the decorators logger running ran twice before the calls to logger1 and logger2? This is because decorators are executed at import time.

Metadata and @wraps

By default, when you apply a decorator, you lose metadata about your original function like its __name__ and __doc__ strings. To fix this, use @wraps from functools:

from functools import wraps

def logger(func):
    @wraps(func)
    def wrapper(*args, **kwargs):
      print('logger running')
      print('Arguments were: {} {}'.format(args, kwargs))
      return func(*args, **kwargs)
    return wrapper
  
@logger
def logger1(x, y):
    """
    logger 1 doc string
    """
    print('logging to logger 1')

logger1(2, 3)

print(logger1.__name__)
print(logger1.__doc__)

# Output:
# logger running
# Arguments were: (2, 3) {}
# logging to logger 1
# logger1
#     logger 1 doc string

Running Undecorated Function

If for some reason, you want to run the raw undecorated function you can do so with the __wrapped__ attribute

from functools import wraps
def logger(func):
  @wraps(func)
  def wrapper(*args, **kwargs):
    print('logger running')
    print('Arguments were: {} {}'.format(args, kwargs))
    return func(*args, **kwargs)
  return wrapper

@logger
def logger1(x, y):
  """
  logger 1 doc string
  """
  print('logging to logger 1')

logger1.__wrapped__(1,2)
# Output:
# logging to logger 1

Decorators in the Wild

While you might not write your own decorators often, understanding them is still crucial because they’re widely used in Python, including built-in decorators like:

  • @staticmethod: For methods that don't need access to a class instance.
  • @classmethod: For methods that receive the class object as the first parameter.
  • @property: To create properties with getter and setter methods.

Frameworks and Decorators

Many frameworks utilize decorators extensively. For instance, in Flask, you define routes using the @app.route() decorator or in Fastapi with @app.get().

Conclusion

Python decorators are a powerful tool that enhance code readability and reusability. Although you may not write your own regularly, understanding how they work is crucial for working with many Python libraries and frameworks.

Thanks for reading! We hope this post has demystified decorators and that you’re ready to explore using them in your projects. Happy coding!