Adventures in Machine Learning

Python Decorators: Advanced Techniques for Flexible Programming

Python is a powerful programming language that encourages writing clean, concise and efficient code. One of the unique features of Python is the ability to use decorators to modify the behavior of functions.

In this article, we will explore what decorators are and how they work in Python. We will also discuss the various aspects of Python functions, such as first-class objects, inner functions, and returning functions from functions.

1) Explanation of Decorators:

Decorators are a feature in Python that allows you to modify the behavior of a function or a class. They are essentially functions that take another function as an input and return a new function that modifies the behavior of the original function.

Decorators are often used to add additional functionality to a function, such as logging or timing the function execution. To define a decorator, you simply define a function that takes another function as its input and returns a new function that modifies the original function.

For example, here is a simple decorator that prints the name of a function before it is executed:

“`

def my_decorator(f):

def wrapper(*args, **kwargs):

print(“Calling function:”, f.__name__)

return f(*args, **kwargs)

return wrapper

@my_decorator

def

my_function():

print(“Hello world!”)

my_function()

“`

In this example, the `my_decorator()` function takes another function as its input, and returns a new function called `wrapper()`. `wrapper()` prints the name of the original function and then calls the original function `f()`.

The `@my_decorator` syntax is used to apply the decorator to the `

my_function()` function. When `

my_function()` is called, it is actually the modified `wrapper()` function that gets executed. 2) Functions:

Functions are an important part of programming in general, and Python specifically.

In Python, functions are first-class objects, which means they can be treated just like any other object, such as integers, strings, or lists. This means that functions can be passed as arguments to other functions, returned from functions, and assigned to variables.

a) First-Class Objects:

Functions are first-class objects because they can be assigned to variables, passed as arguments to other functions, and returned from functions. For example, here is a simple function that takes another function as an argument and calls it:

“`

def call_function(callback):

callback()

def

my_function():

print(“Hello world!”)

call_function(my_function)

“`

In this example, `call_function()` takes another function as an argument and calls it. The `

my_function()` function is defined separately and then passed as an argument to `call_function()`. When `call_function()` is called with `my_function` as the argument, it simply calls `

my_function()`. b) Inner Functions:

Python allows you to define functions inside other functions.

These are called inner functions. Inner functions are only accessible within the scope of the outer function, which means they cannot be called from outside the outer function.

Here is an example of an inner function:

“`

def

outer_function():

print(“This is the outer function”)

def inner_function():

print(“This is the inner function”)

inner_function()

outer_function()

“`

In this example, the `

outer_function()` function defines an inner function called `inner_function()`. Inside `

outer_function()`, we call `inner_function()`, which results in the print statement `”This is the inner function”` being executed.

c) Returning Functions From Functions:

Python allows you to define a function inside another function and then return the inner function. This has the effect of creating a closure, which is a function that remembers the values in the enclosing scope even if they are not present in memory.

Here is an example of returning a function from another function:

“`

def outer_function(university):

def inner_function():

print(“I am studying at {}”.format(university))

return inner_function

harvard = outer_function(“Harvard University”)

mit = outer_function(“MIT”)

harvard()

mit()

“`

In this example, the `

outer_function()` function takes a string argument `university`. It then defines an inner function that prints the string `”I am studying at “`.

The `

outer_function()` then returns the `inner_function()`. We then assign the results of calling `

outer_function()` to the variables `harvard` and `mit`.

When we then call `harvard()` and `mit()`, they both print out the string `”I am studying at “`, which corresponds to the different universities that were passed as arguments to `

outer_function()` when the functions were created. Conclusion:

In this article, we have explored what decorators are and how they can be used in Python to modify the behavior of functions.

We have also discussed various aspects of Python functions, such as first-class objects, inner functions, and returning functions from functions. With this knowledge, you should be able to write more powerful and flexible Python code that takes advantage of the unique features of the language.

3) Simple Decorators:

In the previous section, we covered the basics of Python decorators. Now, we will look at some simple decorators and their implementation in Python.

Simple decorators are often used for wrapping functions with additional functionality, such as logging or timing the execution of the function. a) Basic Decorator Example:

Here is a simple example of a decorator that prints the execution time of a function:

“`

import time

def time_it(func):

def wrapper(*args, **kwargs):

start_time = time.time()

result = func(*args, **kwargs)

end_time = time.time()

print(f”Function {func.__name__} took {end_time-start_time} seconds to execute.”)

return result

return wrapper

@time_it

def

my_function():

time.sleep(1)

print(“Hello world!”)

my_function()

“`

In this example, we defined a decorator `time_it()` that takes a function as its argument. `time_it()` then defines an inner function `wrapper()` that times the execution of the original function and prints the execution time to the console.

To use the decorator, we simply add the `@time_it` syntax above the function definition. Now, when we call `

my_function()`, it will automatically execute the `time_it()` decorator first and then execute the original function. b) Syntactic Sugar:

Python provides a useful feature called “pie syntax” for defining decorators.

The pie syntax involves using the “@” symbol to apply a decorator to a function. This makes it easier to apply multiple decorators to a single function.

Here is an example of using pie syntax to apply multiple decorators to a function:

“`

def my_decorator1(func):

def wrapper(*args, **kwargs):

print(“Decorator 1 called”)

return func(*args, **kwargs)

return wrapper

def my_decorator2(func):

def wrapper(*args, **kwargs):

print(“Decorator 2 called”)

return func(*args, **kwargs)

return wrapper

@my_decorator2

@my_decorator1

def

my_function():

print(“Hello world!”)

my_function()

“`

In this example, we defined two decorators, `my_decorator1()` and `my_decorator2()`. We applied both decorators to the `

my_function()` function using the pie syntax. When we call `

my_function()`, the decorators will be called in the order in which they were applied. c) Real-World Examples:

Decorators are not only useful for timing and logging, but they can also be used for a variety of other purposes.

For example, decorators can be used for debugging code, slowing down code for animations or simulations, registering plugins, and more. Here is an example of a decorator that slows down the execution of a function for animation purposes:

“`

def animate(func):

def wrapper(*args, **kwargs):

time.sleep(0.1)

return func(*args, **kwargs)

return wrapper

@animate

def

my_function():

print(“Hello world!”)

for i in range(5):

my_function()

“`

In this example, the `animate()` decorator simply adds a 0.1 second delay before executing the original function. We then called the `

my_function()` function in a loop to simulate an animation. This can be useful for creating simple animations or simulations.

4) Fancy Decorators:

In addition to simple decorators, Python also offers more advanced features for implementing decorators. These include decorating classes, nesting and stacking decorators, and decorators with arguments.

a) Decorating Classes:

In Python, you can also use decorators to modify the behavior of classes. This can be useful for adding or modifying methods of a class, for example.

Here is an example of a decorator that adds a new method to a class:

“`

def add_method(method):

def decorator(cls):

setattr(cls, method.__name__, method)

return cls

return decorator

@add_method

class MyClass:

def my_function(self):

print(“Hello world!”)

def new_function(self):

print(“This is a new function added by the decorator!”)

MyClass().

my_function()

MyClass = add_method(new_function)(MyClass)

MyClass().new_function()

“`

In this example, we defined a `add_method()` decorator that takes a method as its argument and returns another decorator that takes a class as its argument. The inner decorator then adds the given method to the class and returns the modified class.

We then applied the `add_method()` decorator to the `MyClass` class. This adds the `

my_function()` method to the class. We then define a new function called `new_function()` and add it to the class using the same `add_method()` decorator.

We can now call both `

my_function()` and `new_function()` on an instance of the `MyClass` class. b) Nesting and Stacking Decorators:

Similar to nesting functions, it is also possible to nest and stack decorators in Python.

This allows you to apply multiple decorators to a single function or class. Here is an example of nested decorators:

“`

def decorator1(func):

def wrapper1(*args, **kwargs):

print(“Decorator 1 called”)

return func(*args, **kwargs)

return wrapper1

def decorator2(func):

def wrapper2(*args, **kwargs):

print(“Decorator 2 called”)

return func(*args, **kwargs)

return wrapper2

@decorator2

@decorator1

def

my_function():

print(“Hello world!”)

my_function()

“`

In this example, we defined two decorators, `decorator1()` and `decorator2()`. We then applied both decorators to the `

my_function()` function using the pie syntax. The decorators will be called in the order in which they were applied.

c) Decorators with Arguments:

Finally, Python also allows decorators to accept arguments. This makes decorators much more flexible and powerful.

Here is an example of a decorator that takes an argument:

“`

def repeat(num):

def decorator(func):

def wrapper(*args, **kwargs):

for i in range(num):

func(*args, **kwargs)

return wrapper

return decorator

@repeat(3)

def

my_function():

print(“Hello world!”)

my_function()

“`

In this example, we defined a `repeat()` decorator that takes an integer argument `num`. The inner decorator then defines a `wrapper()` function that executes the original function `num` times.

We then applied the `repeat()` decorator to the `

my_function()` function and passed the argument `3`. This causes the function to be executed 3 times when called.

Conclusion:

In this article, we explored more advanced topics related to Python decorators, such as decorating classes, nesting and stacking decorators, decorators with arguments, and more. We have seen that decorators can be used for a variety of purposes and offer an elegant way to modify or extend the behavior of functions and classes in Python.

With this knowledge, you can create more powerful and flexible Python code that takes full advantage of this unique feature of Python. 5) More Real World Examples:

In this section, we will cover some additional real-world examples of how decorators can be used in Python to solve common programming problems.

a) Slowing Down Code, Revisited:

In an earlier section, we looked at an example of slowing down code for animation purposes. In this example, we will show how decorators can be used to control the rate of execution of a function more generally.

Here is an example of a decorator that controls the rate of execution of a function:

“`

import time

def rate_limited(num_calls=1, per_sec=1):

min_pause = 1.0 / float(per_sec)

last_call = [0.0]

def decorated_function(func):

def rate_limited_function(*args, **kwargs):

elapsed = time.monotonic() – last_call[0]

if elapsed < min_pause:

time.sleep(min_pause – elapsed)

last_call[0] = time.monotonic()

return func(*args, **kwargs)

return rate_limited_function

return decorated_function

@rate_limited(num_calls=2, per_sec=1)

def

my_function():

for i in range(5):

print(i)

time.sleep(1)

my_function()

“`

In this example, we defined a `rate_limited()` decorator that takes two arguments, `num_calls` and `per_sec`. The function then returns another decorator, which takes a function `func` as an argument.

The inner decorator then defines a `rate_limited_function()` that controls the rate of execution of the original function by adding a pause between calls. We then applied the `rate_limited()` decorator to the `

my_function()` function, specifying that the function should be allowed 2 calls per second. When we call `

my_function()`, we see that it executes at a rate of 2 calls per second. b) Creating Singletons:

A Singleton is a design pattern in software engineering that restricts the instantiation of a class to one object.

This can be useful when we only need one instance of an object throughout our program. Here is an example of a decorator that implements a Singleton class:

“`

def singleton(cls):

instances = {}

def get_instance(*args, **kwargs):

if cls not in instances:

instances[cls] = cls(*args, **kwargs)

return instances[cls]

return get_instance

@singleton

class MyClass:

pass

obj1 = MyClass()

obj2 = MyClass()

print(id(obj1))

print(id(obj2))

“`

In this example, we defined a `singleton()` decorator that takes a class as input and returns a new function `get_instance()`. The `get_instance()` function creates only one instance of the class and returns it every time the class is instantiated.

We then applied the `singleton()` decorator to the `MyClass` class. Now, no matter how many times we instantiate `MyClass`, we will always get the same object.

c) Caching Return Values:

Another common use case for decorators is caching the return value of a function. This can be useful when a function is computationally expensive and is called frequently.

Here is an example of a decorator that caches the return value of a function:

“`

import time

def cache(func):

memory = {}

def wrapper(*args, **kwargs):

key = str(args) + str(kwargs)

if key in memory:

return memory[key]

else:

result = func(*args, **kwargs)

memory[key] = result

return result

return wrapper

@cache

def expensive_function(n):

time.sleep(1)

return n*n

print(expensive_function(2))

print(expensive_function(3))

print(expensive_function(2))

“`

In this example, we defined a `cache()` decorator that uses a dictionary `memory` to store the return values of the function. The inner function `wrapper()` checks if the result is already present in memory, and if so, returns it.

Otherwise, it calls the original function and stores its result in memory. We then applied the `cache()` decorator to the `expensive_function()` function.

When we call `expensive_function()` multiple times with the same argument, we can see that the result is cached and returned instantly for subsequent calls. d) Adding Information About Units:

A common programming problem is working with units of measurement.

We often need a way to add information about units to variables or functions in a program. Decorators provide a simple way to add this information without cluttering up the code.

Here is an example of a decorator that adds information about units to variables and functions:

“`

Popular Posts