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
yieldkeyword - Equivalent to Java Iterator/Iterable pattern
- Support lazy evaluation