Decorators: Basics

Decorators are functions that modify or enhance other functions or classes without changing their source code. They are a powerful Python feature with no direct Java equivalent.

TrackJava to Python Journey
Current SectionAdvanced Python
Progress15 of 19

1. Decorators: Basics

Decorators are functions that modify or enhance other functions or classes without changing their source code. They are a powerful Python feature with no direct Java equivalent.

Simple Decorator

# Basic decorator
def my_decorator(func):
    def wrapper():
        print("Something before the function")
        func()
        print("Something after the function")
    return wrapper

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

# Calling the decorated function
say_hello()

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

What Happens:

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

# Is equivalent to:
def say_hello():
    print("Hello!")
say_hello = my_decorator(say_hello)

2. Decorators with Arguments

Passing Arguments to Decorated Function

def my_decorator(func):
    def wrapper(*args, **kwargs):  # Accept any arguments
        print(f"Calling function: {func.__name__}")
        result = func(*args, **kwargs)  # Pass arguments to original function
        return result
    return wrapper

@my_decorator
def add(a, b):
    return a + b

@my_decorator
def greet(name, greeting="Hello"):
    return f"{greeting}, {name}!"

print(add(5, 3))  # Calling function: add
                  # 8

print(greet("Alice", greeting="Hi"))  # Calling function: greet
                                       # Hi, Alice!

3. Decorators with Parameters

Decorator That Takes Arguments

def repeat(times):
    def decorator(func):
        def wrapper(*args, **kwargs):
            results = []
            for _ in range(times):
                result = func(*args, **kwargs)
                results.append(result)
            return results
        return wrapper
    return decorator

@repeat(3)
def say_hello(name):
    return f"Hello, {name}!"

print(say_hello("Alice"))
# Output:
# ['Hello, Alice!', 'Hello, Alice!', 'Hello, Alice!']

4. Practical Decorator Examples

Logging Decorator

import functools

def log_calls(func):
    @functools.wraps(func)  # Preserves original function metadata
    def wrapper(*args, **kwargs):
        print(f"Calling: {func.__name__}")
        print(f"Arguments: {args}, {kwargs}")
        result = func(*args, **kwargs)
        print(f"Result: {result}")
        return result
    return wrapper

@log_calls
def multiply(a, b):
    return a * b

multiply(5, 3)

# Output:
# Calling: multiply
# Arguments: (5, 3), {}
# Result: 15

Timing Decorator

import time
import functools

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

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

slow_function()

# Output:
# slow_function took 2.0005 seconds

Validation Decorator

def validate_positive(func):
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        for arg in args:
            if isinstance(arg, (int, float)) and arg <= 0:
                raise ValueError(f"All arguments must be positive. Got: {arg}")
        return func(*args, **kwargs)
    return wrapper

@validate_positive
def divide(a, b):
    return a / b

print(divide(10, 2))  # 5.0

try:
    divide(-10, 2)  # Raises ValueError
except ValueError as e:
    print(f"Error: {e}")

Caching/Memoization Decorator

import functools

def memoize(func):
    cache = {}
    
    @functools.wraps(func)
    def wrapper(*args):
        if args in cache:
            print(f"Returning cached result for {args}")
            return cache[args]
        
        result = func(*args)
        cache[args] = result
        return result
    
    return wrapper

@memoize
def fibonacci(n):
    if n < 2:
        return n
    return fibonacci(n - 1) + fibonacci(n - 2)

print(fibonacci(5))  # Calculates, caches results
print(fibonacci(5))  # Returns cached result

Retry Decorator

import functools
import time

def retry(max_attempts=3, delay=1):
    def decorator(func):
        @functools.wraps(func)
        def wrapper(*args, **kwargs):
            attempts = 0
            while attempts < max_attempts:
                try:
                    return func(*args, **kwargs)
                except Exception as e:
                    attempts += 1
                    if attempts >= max_attempts:
                        raise
                    print(f"Attempt {attempts} failed. Retrying in {delay}s...")
                    time.sleep(delay)
        return wrapper
    return decorator

@retry(max_attempts=3, delay=1)
def unstable_function():
    import random
    if random.random() < 0.7:
        raise Exception("Random failure")
    return "Success!"

print(unstable_function())

5. Class Decorators

def add_str_method(cls):
    def __str__(self):
        attrs = ", ".join(f"{k}={v}" for k, v in self.__dict__.items())
        return f"{cls.__name__}({attrs})"
    
    cls.__str__ = __str__
    return cls

@add_str_method
class Person:
    def __init__(self, name, age):
        self.name = name
        self.age = age

person = Person("Alice", 25)
print(person)  # Person(name=Alice, age=25)

6. Built-in Decorators

@property

class Circle:
    def __init__(self, radius):
        self._radius = radius
    
    @property
    def radius(self):
        return self._radius
    
    @radius.setter
    def radius(self, value):
        if value <= 0:
            raise ValueError("Radius must be positive")
        self._radius = value
    
    @property
    def area(self):
        return 3.14 * self._radius ** 2

circle = Circle(5)
print(circle.area)  # 78.5
circle.radius = 10
print(circle.area)  # 314.0

@staticmethod and @classmethod

class MathUtils:
    @staticmethod
    def add(a, b):
        return a + b
    
    @classmethod
    def from_string(cls, string):
        a, b = map(int, string.split(","))
        return cls.add(a, b)

print(MathUtils.add(5, 3))  # 8
print(MathUtils.from_string("10,20"))  # 30

7. Generators: Basics

Generators are functions that return values one at a time using yield, allowing for lazy evaluation and memory efficiency.

Simple Generator

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

# Using generator
for num in count_up_to(5):
    print(num)

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

Key Difference from Regular Functions:

# Regular function
def get_numbers(n):
    result = []
    for i in range(n):
        result.append(i)
    return result  # Returns entire list at once

print(get_numbers(5))  # [0, 1, 2, 3, 4] - all in memory

# Generator function
def number_generator(n):
    for i in range(n):
        yield i  # Returns one value at a time

gen = number_generator(5)
print(next(gen))  # 0
print(next(gen))  # 1

8. Generator Methods

def simple_generator():
    yield 1
    yield 2
    yield 3

gen = simple_generator()

# next() - get next value
print(next(gen))  # 1

# send() - send value back to generator
def echo_generator():
    value = yield "Ready"
    print(f"Received: {value}")
    yield "Done"

gen = echo_generator()
print(next(gen))  # "Ready"
print(gen.send("Hello"))  # Received: Hello, "Done"

# throw() - raise exception in generator
def error_generator():
    try:
        yield 1
        yield 2
    except ValueError:
        yield "Error caught"

gen = error_generator()
print(next(gen))  # 1
print(gen.throw(ValueError))  # "Error caught"

9. Generator Expressions

Similar to list comprehensions but use parentheses and generate values lazily.

# List comprehension (all in memory)
list_comp = [x * 2 for x in range(1000000)]

# Generator expression (lazy evaluation)
gen_exp = (x * 2 for x in range(1000000))

print(type(list_comp))  # <class 'list'>
print(type(gen_exp))    # <class 'generator'>

# Use generator
for val in gen_exp:
    print(val)  # Generates values one at a time
    if val > 10:
        break

10. Practical Generator Examples

Reading Large Files

def read_large_file(filepath, chunk_size=1024):
    with open(filepath, "r") as file:
        while True:
            chunk = file.read(chunk_size)
            if not chunk:
                break
            yield chunk

# Memory-efficient: only keeps one chunk in memory
for chunk in read_large_file("large_file.txt"):
    process(chunk)

Infinite Sequence Generator

def infinite_counter(start=0):
    count = start
    while True:
        yield count
        count += 1

# Can use with itertools.islice to get first N values
from itertools import islice

counter = infinite_counter(10)
print(list(islice(counter, 5)))  # [10, 11, 12, 13, 14]

Generator for Database Results

def get_users(db_connection, batch_size=100):
    offset = 0
    while True:
        users = db_connection.query(
            f"SELECT * FROM users LIMIT {batch_size} OFFSET {offset}"
        )
        if not users:
            break
        for user in users:
            yield user
        offset += batch_size

# Process users without loading all into memory
for user in get_users(db_connection):
    print(user["name"])

Filtering Generator

def filter_even(numbers):
    for num in numbers:
        if num % 2 == 0:
            yield num

numbers = range(1, 11)
evens = filter_even(numbers)
print(list(evens))  # [2, 4, 6, 8, 10]

11. Decorators vs Generators Comparison

  • Purpose

    • Decorator: Modify/enhance functions
    • Generator: Produce values lazily
  • Type

    • Decorator: Function
    • Generator: Function returning iterator
  • Keyword

    • Decorator: @ (decorator syntax)
    • Generator: yield
  • Memory

    • Decorator: Overhead per call
    • Generator: Efficient (lazy evaluation)
  • Return

    • Decorator: Modified function
    • Generator: Iterator object
  • Use Case

    • Decorator: Logging, validation, caching
    • Generator: Large data, sequences
  • Java Equivalent

    • Decorator: Aspect-oriented programming
    • Generator: Iterator pattern

12. Combined Example: Decorator + Generator

def debug_generator(gen_func):
    @functools.wraps(gen_func)
    def wrapper(*args, **kwargs):
        print(f"Starting generator: {gen_func.__name__}")
        gen = gen_func(*args, **kwargs)
        
        for value in gen:
            print(f"Yielding: {value}")
            yield value
        
        print(f"Finished generator: {gen_func.__name__}")
    
    return wrapper

@debug_generator
def count_to(n):
    for i in range(1, n + 1):
        yield i

for num in count_to(3):
    print(f"Got: {num}")

# Output:
# Starting generator: count_to
# Yielding: 1
# Got: 1
# Yielding: 2
# Got: 2
# Yielding: 3
# Got: 3
# Finished generator: count_to

Summary

Decorators:

  • Functions that modify other functions/classes
  • Used for logging, validation, caching, timing
  • Applied with @ syntax
  • No direct Java equivalent (Java uses Aspect-Oriented Programming)

Generators:

  • Functions that yield values one at a time
  • Memory-efficient for large datasets
  • Use yield keyword
  • Equivalent to Java Iterator/Iterable pattern
  • Support lazy evaluation