Python Decorators

Demonstration of decorators

import time

def timer(func):

def wrapper(*args,**kwargs):

start = time.time()

result = func(*args,**kwargs)

end = time.time()

print(f'{func.__name__} ran in {end-start} time')

return result

return wrapper

@timer

def example_function(n):

time.sleep(n)

example_function(2)

This code is a demonstration of decorators in Python. A decorator is a function that wraps another function, allowing you to add functionality (like timing) to it without changing the function itself.

Let’s break down each part of this code:

1. Importing the time module

import time

The time module provides time-related functions. Here, we're using time.time() to capture the current time in seconds since the epoch (January 1, 1970). This allows us to measure the duration a function takes to run.

2. Defining the timer Decorator

def timer(func):

def wrapper(*args, **kwargs):

start = time.time()

result = func(*args, **kwargs)

end = time.time()

print(f'{func.__name__} ran in {end - start} time')

return result

return wrapper

Explanation:

  • Function Definition: timer(func) is a function that takes another function func as an argument. This is the basis of creating a decorator in Python.

  • The Wrapper Function: Inside timer, we define another function wrapper(*args, **kwargs).

    • args and *kwargs allow wrapper to accept any number of positional and keyword arguments, so it can work with functions of any signature.
  • Starting the Timer: start = time.time() records the current time in seconds before func runs.

  • Calling the Wrapped Function: result = func(*args, **kwargs) calls the original function (func) that was passed to timer, and passes all the arguments it received (*args and **kwargs). This allows wrapper to run func with the same arguments the user provides.

  • Stopping the Timer: end = time.time() captures the current time after func finishes.

  • Calculating and Printing Execution Time: print(f'{func.__name__} ran in {end - start} time') calculates the time difference (end - start), which is the time taken by func to run. It then prints the function's name (func.__name__) and the duration.

  • Returning the Result: Finally, return result sends back the output of func, so the decorator doesn't interfere with the function's return value.

  • Returning the Wrapper: return wrapper allows timer to return the wrapper function, which now has additional timing functionality added to the original func.

3. Using the @timer Decorator

@timer

def example_function(n):

time.sleep(n)

  • @timer: This is a decorator syntax. Applying @timer above example_function is equivalent to writing:

example_function = timer(example_function)

After this, example_function is now wrapped with the timer functionality.

  • Defining example_function(n): This function takes an integer n and calls time.sleep(n), which pauses execution for n seconds.

4. Calling example_function

example_function(2)

When we call example_function(2), the following happens:

  1. Timing Starts: start = time.time() records the start time.

  2. Original Function Runs: time.sleep(2) causes a 2-second delay.

  3. Timing Ends: end = time.time() records the end time.

  4. Execution Time Printed: The function name and time taken to execute are printed, e.g., example_function ran in 2.0002 time.

  5. Return Value: Since example_function has no return, None is returned by default.

Full Output

When you run this code, you’ll see something like:

example_function ran in 2.0002 time

This output tells you that example_function took approximately 2 seconds to run, as expected.

# Create a a decorator to print a function name and the vlaues of the vlaues of its arguments every time the function is called

def debug(func):

def wrapper(*args,**kwargs):

args_value = ', '.join(str(arg) for arg in args)

kwargs_value = ', '.join(f" {k} = {v} "for k,v in kwargs.items())

print(f'calling : {func.__name__} with args {args_value} and kwargs {kwargs_value}')

return func(*args,**kwargs)

return wrapper

@debug

def hello():

print('hello')

@debug

def greet(name,greeting='hello'):

print(f'{greeting} , {name}')

hello()

greet('chai',greeting='hanji')

This code demonstrates the use of a decorator to add debugging functionality to functions by printing their arguments before they are called. It logs the function name, the positional arguments (args), and keyword arguments (kwargs) each time a decorated function is called.

Code Walkthrough

Let's go through each part of this code step-by-step.


1. Defining the debug Decorator

def debug(func):

def wrapper(*args, **kwargs):

args_value = ', '.join(str(arg) for arg in args)

kwargs_value = ', '.join(f"{k} = {v}" for k, v in kwargs.items())

print(f'calling: {func.__name__} with args {args_value} and kwargs {kwargs_value}')

return func(*args, **kwargs)

return wrapper

  • debug(func): This function takes another function func as an argument. It's the decorator function that will be applied to any function you want to debug.

  • Wrapper Function: Inside debug, we define a nested wrapper function. The purpose of wrapper is to add debugging functionality around the original func.

  • args and *kwargs: These are used so that the wrapper can accept any number of positional (*args) and keyword arguments (**kwargs). This makes the decorator flexible enough to handle functions with any argument structure.

  • Formatting args:

args_value = ', '.join(str(arg) for arg in args)

This line uses a generator expression to convert each argument in args to a string. It then joins them with a comma and space, producing a single string of all positional arguments.

  • Formatting kwargs:

kwargs_value = ', '.join(f"{k} = {v}" for k, v in kwargs.items())

This line uses a generator expression to format each keyword argument as key = value. It joins them into a single string, similar to the args formatting.

  • Debug Output:

print(f'calling: {func.__name__} with args {args_value} and kwargs {kwargs_value}')

This line prints the function's name (func.__name__), along with its positional and keyword arguments.

  • Calling the Original Function:

return func(*args, **kwargs)

This calls the original function with all provided arguments, allowing it to execute as normal. The return statement ensures the original function's output (if any) is returned.

  • Returning the Wrapper: return wrapper allows the debug decorator to return the wrapper function, effectively replacing func with wrapper.

2. Decorating the hello and greet Functions

@debug

def hello():

print('hello')

@debug

def greet(name, greeting='hello'):

print(f'{greeting}, {name}')

  • @debug: Applying @debug above a function is syntactic sugar for hello = debug(hello) and greet = debug(greet). This replaces the hello and greet functions with versions wrapped in the debug decorator.

  • Defining the Functions:

    • hello() is a simple function that prints "hello".

    • greet(name, greeting='hello') takes a name and an optional greeting argument, printing a personalized greeting.


3. Calling the Decorated Functions

hello()

greet('chai', greeting='hanji')

  • hello():

    • When hello() is called, the wrapper function inside debug is executed first.

    • It prints debugging information: calling: hello with args and kwargs, as hello has no arguments.

    • The original hello function then runs, printing "hello".

  • greet('chai', greeting='hanji'):

    • Similarly, calling greet('chai', greeting='hanji') triggers the wrapper function.

    • The debug statement prints: calling: greet with args chai and kwargs greeting = hanji.

    • The greet function then runs, printing "hanji, chai".


Full Output

The complete output of the code is as follows:

calling: hello with args and kwargs

hello

calling: greet with args chai and kwargs greeting = hanji

hanji, chai

Summary

  • The debug decorator adds functionality to print the function name and its arguments each time a decorated function is called.

  • The wrapper function handles this by formatting the arguments and printing them before calling the original function.

  • This is a useful tool for debugging, as it allows you to see the inputs to each function call without modifying the function itself.

Implement a decorator that cashes the return values

# Implement a decorator that cashes the return values of a function, so that when it's called with the same arguments, the cashed value is returned

# instead of re-executing the function.

def cashe(func):

cashe_value = {}

print(cashe_value)

def wrapper(*args,**kwargs):

if args in cashe_value:

return cashe_value[args]

result = func(*args,**kwargs)

cashe_value[args] = result

return result

return wrapper

@cashe

def long_running_function(a,b):

time.sleep(4)

return a + b

print(long_running_function(2,3))

print(long_running_function(2,3))

This code demonstrates how to use a decorator to cache (or store) the results of function calls, which can make repeated calls with the same arguments faster by returning the stored result instead of recalculating it. This technique is known as memoization.

Let's go through the code step-by-step.


1. Defining the cashe Decorator

def cashe(func):

cashe_value = {}

print(cashe_value)

def wrapper(*args, **kwargs):

if args in cashe_value:

return cashe_value[args]

result = func(*args, **kwargs)

cashe_value[args] = result

return result

return wrapper

  • cashe(func): This is a decorator function that takes another function, func, as an argument. This decorator will add caching functionality to func.

  • cashe_value Dictionary: Inside the cashe function, we initialize an empty dictionary, cashe_value = {}, which will be used to store results of previous function calls. Each unique set of arguments will act as a key, and the corresponding result will be the value.

  • Printing cashe_value: print(cashe_value) is executed once when cashe is defined, and it displays the empty cache before any function calls.

  • Defining the wrapper Function:

    • wrapper is an inner function that performs the caching functionality.

    • Arguments (*args and **kwargs): wrapper accepts any number of positional (*args) and keyword arguments (**kwargs). However, only args are used as keys in this example, so the decorator assumes each call to func will use unique positional arguments to store the results in the cache.

Inside the wrapper Function:

  • Checking the Cache:

if args in cashe_value:

return cashe_value[args]

    • This line checks if the function has been called before with the same arguments (args).

      • If args are found in cashe_value, the cached result is returned immediately without calling func again.
  • Calling and Caching the Result:

result = func(*args, **kwargs)

cashe_value[args] = result

    • If the result is not cached, func is called with the provided arguments, and the result is stored in cashe_value using args as the key.

      • This result is then returned.
  • Returning wrapper: The decorator returns the wrapper function, which replaces func with a cached version.


2. Decorating long_running_function with @cashe

@cashe

def long_running_function(a, b):

time.sleep(4)

return a + b

  • @cashe: This decorator syntax is equivalent to long_running_function = cashe(long_running_function). It replaces long_running_function with the decorated, cached version.

  • Function Definition:

    • long_running_function(a, b) takes two arguments, a and b, and returns their sum after a delay of 4 seconds (using time.sleep(4)).

    • The delay simulates a time-consuming computation, making this function ideal for caching.


3. Calling the Decorated long_running_function

print(long_running_function(2, 3))

print(long_running_function(2, 3))

Let’s break down these calls in detail.

  • First Call (long_running_function(2, 3)):

    1. The wrapper function is called with arguments (2, 3).

    2. It checks if (2, 3) is in cashe_value. Since the cache is initially empty, it proceeds to calculate the result.

    3. long_running_function(2, 3) is called, which takes 4 seconds due to time.sleep(4).

    4. The result, 5 (sum of 2 + 3), is stored in cashe_value as cashe_value[(2, 3)] = 5.

    5. 5 is then returned and printed.

  • Second Call (long_running_function(2, 3)):

    1. The wrapper function is called again with (2, 3).

    2. This time, (2, 3) is found in cashe_value.

    3. The cached result 5 is immediately returned without any delay.

    4. 5 is printed again.


Full Output

The output of this code is as follows:

{}

5

5

Summary

  1. First Call: The function runs normally, calculates the result, and caches it.

  2. Second Call: The cached result is used, skipping the time-consuming calculation.

This pattern can improve performance for functions that are called repeatedly with the same inputs by avoiding redundant calculations.

0
Subscribe to my newsletter

Read articles from pranav madhukar sirsufale directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

pranav madhukar sirsufale
pranav madhukar sirsufale

🚀 Tech Enthusiast | Computer Science Graduate | Passionate about web development, app development, and data science. Skilled in Java, Node.js, React, HTML, MySQL, and Python. Always learning and sharing insights on tech, programming tutorials, and practical guides.