Python is a versatile programming language known for its simplicity and flexibility. Writing Python feels like writing english. Some would even call Python “executable pseudocode”. Python’s simplicity does not in any way limit its ability to tackle complex problems. As with any other thing in life, solving a problem using a tool is a reflection of the wielder’s skill level and not the tool itself. So you can solve advanced problems with Python is you are patient enough to learn its internals and not-so-popular side. In this article, you will learn some advanced Python concepts like decorators (add others later, lol).

Decorators in Python

A decorator is a wrapper function that takes a regular function as an argument and either returns that function or a function that wraps the initial function. An example of a decorator is the function log_calls that prints that a function has been called to stdout.

def log_calls(func):
    print(f"{func.__name__} was called")
    return func

@log_calls
def compute_pi(digit_accuracy):
    iterations = 10 ** digit_accuracy
    approximation = 0

    for i in range(iterations):
        approximation += ((-1) ** i) / (2 * i + 1)

    return (4 * approximation)

compute_pi(3)

Now, when this function is called, the string “compute_pi was called” will be printed to stdout. This decoration logic can be improved if the function is called within another wrapper function that is returned from the decorator. In this way, the inputs to the function can be modified. This will look something like this:

def log_calls(func):
    def wrapper(*args, **kwargs):
        result = func(*args, **kwargs)
        print(f"{func.__name__} was called with args: {list(args)} and kwargs: {kwargs} and returned {result}")

    return wrapper

@log_calls
def compute_pi(significant_figures):
    iterations = 10 ** significant_figures
    approximation = 0

    for i in range(iterations):
        approximation += ((-1) ** i) / (2 * i + 1)

    return round(4 * approximation, significant_figures - 1)

compute_pi(3)

So when the compute_pi function is called, the string “compute_pi was called with args: [4] and kwargs: {} and returned 3.141”. In this way, our decorator can access the name of the function and its arguments.

In truth, the decorator thing is a fancy way to call a higher order function. From functional proramming, a higher order function is one that takes a function as argument or return a function as an output. So you could call the log_calls function with the compute pi as input. So it would look like this:

def log_calls(func):
    def wrapper(*args, **kwargs):
        result = func(*args, **kwargs)
        print(f"{func.__name__} was called with args: {list(args)} and kwargs: {kwargs} and returned {result}")

    return wrapper

def compute_pi(significant_figures):
    iterations = 10 ** significant_figures
    approximation = 0

    for i in range(iterations):
        approximation += ((-1) ** i) / (2 * i + 1)

    return round(4 * approximation, significant_figures - 1)

decorated_compute_pi = log_calls(compute_pi)

pi = decorated_compute_pi(3)

Essentially, you’re defining a new function decorated_compute_pi that can receive the same input as compute pi, but with the decorations applied. This is a roundabout way of achieving the same thing, but goes to show that a decorator is simply a function.