Decorators & Generators

Decorators and Generators are advanced Python features that offer flexible, concise, and powerful ways to manage functionality. Both provide significant utility in writing cleaner, more efficient, and more modular code.

1. Python Decorators

A decorator is a function that takes another function (or class) and extends or alters its behavior without changing its code. Decorators are commonly used for tasks like logging, enforcing access control, memoization, timing functions, and more.

Decorators are applied to functions using the @decorator_name syntax and are a form of higher-order function since they accept functions as arguments and may return functions.

Basic Structure of a Decorator

A simple decorator wraps a function, modifies or extends its behavior, and returns the modified function.

def my_decorator(func):
    def wrapper():
        print("Something before the function runs.")
        func()
        print("Something after the function runs.")
    return wrapper

You can apply the decorator to a function with the @ syntax:

@my_decorator
def say_hello():
    print("Hello!")

say_hello()

# Output:
# Something before the function runs.
# Hello!
# Something after the function runs.

Explanation

  • my_decorator takes a function func as an argument.
  • wrapper adds behavior before and after calling func.
  • @my_decorator syntax is equivalent to say_hello = my_decorator(say_hello).

Common Uses of Decorators

1. Logging

Decorators are often used to log function calls or return values for debugging or monitoring.

def log_decorator(func):
    def wrapper(*args, **kwargs):
        print(f"Calling {func.__name__} with {args} and {kwargs}")
        result = func(*args, **kwargs)
        print(f"{func.__name__} returned {result}")
        return result
    return wrapper

@log_decorator
def add(a, b):
    return a + b
add(3, 5)

# Output:
# Calling add with (3, 5) and {}
# add returned 8

2. Timing

Timing decorators are useful to measure the execution time of functions.

import time

def timer(func):
    def wrapper(*args, **kwargs):
        start = time.time()
        result = func(*args, **kwargs)
        end = time.time()
        print(f"{func.__name__} took {end - start} seconds")
        return result
    return wrapper

@timer
def slow_function():
    time.sleep(2)
    return "Done"

slow_function()
# Output: slow_function took ~2.0 seconds

3. Access Control

Decorators can also be used for access control, such as restricting function access based on user roles.

def require_admin(func):
    def wrapper(user_role):
        if user_role != "admin":
            print("Access denied.")
            return
        return func(user_role)
    return wrapper

@require_admin
def view_admin_panel(user_role):
    print("Welcome to the admin panel.")

view_admin_panel("guest")  # Output: Access denied.
view_admin_panel("admin")   # Output: Welcome to the admin panel.

Decorators with Arguments

To create decorators that accept arguments, define a decorator function that returns another decorator function.

def repeat(num_times):
    def decorator(func):
        def wrapper(*args, **kwargs):
            for _ in range(num_times):
                result = func(*args, **kwargs)
            return result
        return wrapper
    return decorator

@repeat(3)
def greet(name):
    print(f"Hello, {name}!")

greet("Alice")

# Output:
# Hello, Alice!
# Hello, Alice!
# Hello, Alice!

2. Python Generators

A generator is a function that produces a sequence of values lazily, meaning values are generated only as needed. Generators use the yield keyword instead of return, which allows them to return a value and then pause the function’s state until the next value is requested.

Generators are useful for creating sequences or handling large data streams efficiently since they don’t store all values in memory at once.

Creating a Generator

To create a generator function, use yield to produce a value and suspend the function until the next value is requested.

def count_up_to(max):
    count = 1
    while count <= max:
        yield count
        count += 1

# Using the generator
for number in count_up_to(5):
    print(number)

# Output:
# 1
# 2
# 3
# 4
# 5

Explanation

  • The function count_up_to yields values from 1 up to max.
  • Each time yield is called, the function’s state is saved, and execution resumes from the next line when the next value is requested.

Generator Expressions

Generator expressions are similar to list comprehensions but return generators instead of lists. They are created using parentheses instead of square brackets.

squares = (x*x for x in range(1,6))

for square in squares:
    print(square)

# Output:
# 1
# 4
# 3
# 16
# 25

Use Cases for Generators

1. Infinite Sequences

Generators are ideal for creating infinite sequences, such as Fibonacci numbers or prime numbers, as they generate values only when requested.

def fibonacci():
    a, b = 0, 1
    while True:
        yield a
        a, b = b, a + b

fib = fibonacci()

for _ in range(5):
    print(next(fib))

# Output:
# 0
# 1
# 1
# 2
# 3

2. Processing Large Data Streams

Generators can process large files or data streams without loading the entire file into memory.

def read_large_file(file_path):
    with open(file_path, "r") as file:
        for line in file:
            yield line

# Process each line one by one
for line in read_large_file("large_file.txt"):
    print(line.strip())

3. Pipelining Operations

Generators can be chained to create data pipelines, where each generator processes data and passes it to the next.

def integers():
    for i in range(1, 11):
        yield i

def squared(numbers):
    for n in numbers:
        yield n * n

def negated(numbers):
    for n in numbers:
        yield -n

# Pipeline: generate integers, square them, then negate
pipeline = negated(squared(integers()))

for value in pipeline:
    print(value)

# Output:
# -1
# -4
# -9
# -16
# -25
# -36
# -49
# -64
# -81
# -100

Track your progress

Mark this subtopic as completed when you finish reading.