Monday, 13 June 2022

python @ decorators, how it differs with generators its best practices

Decorators and generators are both powerful features in Python, but they serve different purposes.

A decorator is a function that takes another function as input and returns a new function that usually modifies the behavior of the original function in some way. Decorators are commonly used for adding functionality to existing functions, such as caching, logging, or authentication.


Here is an example of a decorator that adds logging functionality to a function:

import functools def log(func): @functools.wraps(func) def wrapper(*args, **kwargs): print(f"Calling {func.__name__} with arguments {args}, {kwargs}") return func(*args, **kwargs) return wrapper @log def add(x, y): return x + y result = add(2, 3) # Output: Calling add with arguments (2, 3), {} # 5


we have defined a decorator function log() that takes a function as input and returns a new function wrapper() that logs the name of the function and its arguments before calling the original function. We have then applied the @log decorator to the add() function, which modifies its behavior to include logging.


A generator, on the other hand, is a function that returns an iterator that can be used to iterate over a sequence of values. Generators are commonly used for generating large sequences of values on-the-fly, such as when processing large files or databases.

Here is an example of a generator that generates blog content:

def generate_blog_content(): topics = ["Python basics", "Data structures", "Object-oriented programming", "Web development"] for i in range(10): topic = topics[i % len(topics)] yield f"Blog post {i+1}: Introduction to {topic}" for content in generate_blog_content(): print(content)



we have defined a generator function generate_blog_content() that yields blog post content based on a list of topics. The function generates 10 blog posts by cycling through the list of topics and appending a post number to the beginning of each post. We have then used a for loop to iterate over the generator and print each blog post.

decorators and generators are both powerful features in Python, but they serve different purposes. Decorators are used for adding functionality to existing functions, while generators are used for generating large sequences of values on-the-fly.

Another key difference between decorators and generators is that decorators modify the behavior of a function by returning a new function that wraps the original function, while generators are functions that produce a sequence of values using the yield statement.

Here is an example that combines decorators and generators to generate blog content with a logging decorator:

import functools def log(func): @functools.wraps(func) def wrapper(*args, **kwargs): print(f"Calling {func.__name__} with arguments {args}, {kwargs}") return func(*args, **kwargs) return wrapper @log def generate_blog_content(): topics = ["Python basics", "Data structures", "Object-oriented programming", "Web development"] for i in range(10): topic = topics[i % len(topics)] yield f"Blog post {i+1}: Introduction to {topic}" for content in generate_blog_content(): print(content)




we have applied the @log decorator to the generate_blog_content() generator function to add logging functionality. The decorator modifies the behavior of the generator by wrapping it in a new function that logs its name and arguments before calling it. We have then used a for loop to iterate over the generator and print each blog post, with the logging information included.

decorators and generators are powerful features in Python that can be used in many different ways. Decorators are commonly used for adding functionality to existing functions, while generators are used for generating large sequences of values on-the-fly. Combining the two can lead to even more powerful and flexible code.


Here are a few more examples of how decorators and generators can be used in Python:

Example 1: A memoization decorator

Memoization is a technique used to optimize function performance by caching the results of expensive computations. Here is an example of a memoization decorator that can be used to cache the results of a function:

import functools def memoize(func): cache = {} @functools.wraps(func) def wrapper(*args): if args not in cache: cache[args] = func(*args) return cache[args] return wrapper @memoize def fibonacci(n): if n < 2: return n return fibonacci(n-1) + fibonacci(n-2) for i in range(10): print(fibonacci(i))


we have defined a memoization decorator memoize() that caches the results of a function in a dictionary. We have then applied the @memoize decorator to the fibonacci() function, which calculates the nth Fibonacci number using recursion. The decorator modifies the behavior of the function by wrapping it in a new function that checks if the result is already in the cache before computing it.

Example 2: A file reading generator

Generators are commonly used for processing large files, since they can read and process the file one line at a time instead of loading the entire file into memory. Here is an example of a generator that reads lines from a file:

def read_file(filename): with open(filename) as file: for line in file: yield line.strip() for line in read_file("my_file.txt"): print(line)


we have defined a generator function read_file() that opens a file and yields each line of the file. We have then used a for loop to iterate over the generator and print each line.

Example 3: A timing decorator

Decorators can also be used for profiling and timing functions, to see how long they take to execute.

Here is an example of a timing decorator that can be used to time the execution of a function:

import time import functools def timeit(func): @functools.wraps(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:.4f} seconds to execute") return result return wrapper @timeit def my_function(): time.sleep(1) my_function()



we have defined a timing decorator timeit() that measures the execution time of a function using the time module. We have then applied the @timeit decorator to the my_function() function, which simply sleeps for one second. The decorator modifies the behavior of the function by wrapping it in a new function that measures its execution time and prints it.

Example 4: A retry decorator

Sometimes, a function might fail due to external factors such as network connectivity issues, server errors, etc. In such cases, it can be useful to retry the function a few times before giving up. Here is an example of a retry decorator that can be used to retry a function a specified number of times before raising an exception:

import functools import time def retry(num_retries): def decorator(func): @functools.wraps(func) def wrapper(*args, **kwargs): for i in range(num_retries): try: result = func(*args, **kwargs) except Exception as e: print(f"Attempt {i+1} failed with error: {e}") if i == num_retries - 1: raise time.sleep(1) else: return result return wrapper return decorator @retry(3) def get_data(url): # perform a network request to fetch data from the given URL pass


we have defined a retry decorator retry() that takes the number of retries as an argument. We have then defined a nested function decorator() that takes the function to be retried as an argument. The decorator modifies the behavior of the function by wrapping it in a new function wrapper() that retries the function a specified number of times before raising an exception.

Example 5: A lazy property decorator

Sometimes, it can be useful to compute a property lazily, i.e., only when it is first accessed, instead of eagerly computing it every time the object is instantiated. Here is an example of a lazy property decorator that can be used to lazily compute a property:

import functools def lazy_property(func): attribute_name = "_lazy_" + func.__name__ @property @functools.wraps(func) def wrapper(self): if not hasattr(self, attribute_name): setattr(self, attribute_name, func(self)) return getattr(self, attribute_name) return wrapper class Person: def __init__(self, name): self.name = name @lazy_property def reversed_name(self): print("Computing reversed_name...") return self.name[::-1] person = Person("Alice") print(person.reversed_name) print(person.reversed_name)


we have defined a lazy property decorator lazy_property() that takes a function as an argument. We have then defined a nested function wrapper() that is decorated with the @property decorator to make it a property. The decorator modifies the behavior of the property by wrapping it in a new function that lazily computes and caches the property.

We have then defined a Person class that has a reversed_name property that computes the reverse of the person's name lazily. We have instantiated a Person object and accessed the reversed_name property twice. The first time, the property is computed and printed, and the second time, the cached value is returned without recomputing the property.

Labels: , ,

0 Comments:

Post a Comment

Note: only a member of this blog may post a comment.

<< Home