Decorators, Generators & Iterators
Understand Python advanced concepts. Learn decorator decorators, generator yield states, custom iterators, and memory-efficient iterators.
Downloaded from PrepIQ (https://prepiq.online)
Q1. What is a decorator in Python and how does it work?
A decorator is a function that takes another function as input and returns a modified version of it, allowing you to add behavior without changing the original function's code.
def my_decorator(func):
def wrapper(*args, **kwargs):
print('Before the function runs')
result = func(*args, **kwargs)
print('After the function runs')
return result
return wrapper
@my_decorator
def greet(name):
print(f'Hello, {name}')
return name
greet('John')
# Before the function runs
# Hello, John
# After the function runs
# The @ syntax is just shorthand for:
def greet2(name):
print(f'Hello, {name}')
greet2 = my_decorator(greet2) # exactly what @my_decorator does
# Practical use: timing a function
import time
def timer(func):
def wrapper(*args, **kwargs):
start = time.time()
result = func(*args, **kwargs)
print(f'{func.__name__} took {time.time() - start:.4f}s')
return result
return wrapper
@timer
def slow_function():
time.sleep(1)
slow_function() # slow_function took 1.0002s
Q2. How do you write a decorator that accepts arguments?
A decorator with arguments needs an extra layer of nesting - an outer function that takes the decorator arguments and returns the actual decorator function.
def repeat(times): # outer function accepts the decorator's OWN arguments
def decorator(func): # actual decorator
def wrapper(*args, **kwargs):
result = None
for _ in range(times):
result = func(*args, **kwargs)
return result
return wrapper
return decorator
@repeat(times=3)
def greet(name):
print(f'Hello, {name}')
greet('John')
# Hello, John
# Hello, John
# Hello, John
# What @repeat(times=3) actually does under the hood:
def greet2(name):
print(f'Hello, {name}')
greet2 = repeat(times=3)(greet2) # repeat(3) returns 'decorator', which wraps greet2
# Real-world example: retry decorator with configurable attempts
def retry(max_attempts=3):
def decorator(func):
def wrapper(*args, **kwargs):
for attempt in range(max_attempts):
try:
return func(*args, **kwargs)
except Exception as e:
print(f'Attempt {attempt + 1} failed: {e}')
raise Exception('All attempts failed')
return wrapper
return decorator
@retry(max_attempts=5)
def unreliable_network_call():
# ... code that might fail ...
pass
Q3. What does functools.wraps do and why is it important in decorators?
Without functools.wraps, a decorated function loses its original name, docstring, and metadata, since the wrapper function replaces it entirely. wraps() copies this metadata onto the wrapper.
def my_decorator(func):
def wrapper(*args, **kwargs):
return func(*args, **kwargs)
return wrapper
@my_decorator
def greet(name):
"""Greets a person by name."""
print(f'Hello, {name}')
print(greet.__name__) # 'wrapper' - WRONG, lost the original name!
print(greet.__doc__) # None - lost the docstring!
# FIX using functools.wraps
from functools import wraps
def my_decorator_fixed(func):
@wraps(func) # copies __name__, __doc__, and other metadata from func
def wrapper(*args, **kwargs):
return func(*args, **kwargs)
return wrapper
@my_decorator_fixed
def greet2(name):
"""Greets a person by name."""
print(f'Hello, {name}')
print(greet2.__name__) # 'greet2' - correct!
print(greet2.__doc__) # 'Greets a person by name.' - correct!
# Why it matters: debugging, introspection tools, documentation generators
# (like Sphinx) and help() all rely on accurate __name__ and __doc__
help(greet2) # shows accurate info because of @wraps
Q4. What is a generator and how does yield work?
A generator is a function that produces a sequence of values lazily, one at a time, using yield instead of return - pausing its state between each value instead of computing everything upfront.
def count_up_to(n):
i = 1
while i <= n:
yield i # pauses here, remembers state, resumes on next call
i += 1
gen = count_up_to(5)
print(gen) # <generator object count_up_to at 0x...>
print(next(gen)) # 1
print(next(gen)) # 2
print(next(gen)) # 3
# Iterating a generator with a for loop (calls next() automatically)
for num in count_up_to(3):
print(num) # 1, 2, 3
# Generator function vs regular function - key difference
def regular_func():
return [1, 2, 3] # computes and returns the FULL list immediately
def generator_func():
yield 1
yield 2
yield 3 # each value computed lazily, only when requested
# Once exhausted, a generator raises StopIteration and cannot be reused
gen = count_up_to(2)
next(gen) # 1
next(gen) # 2
# next(gen) # StopIteration
# Memory efficiency - generators don't store all values in memory at once
def infinite_counter():
n = 0
while True:
yield n
n += 1
# This would be impossible as a regular function returning a list!
Q5. What is the iterator protocol and how do __iter__ and __next__ work?
The iterator protocol requires an object to implement __iter__ (returns the iterator itself) and __next__ (returns the next value or raises StopIteration). This is what powers 'for' loops behind the scenes.
class CountUpTo:
def __init__(self, limit):
self.limit = limit
self.current = 0
def __iter__(self):
return self # the object IS its own iterator
def __next__(self):
if self.current >= self.limit:
raise StopIteration # signals the end of iteration
self.current += 1
return self.current
counter = CountUpTo(3)
print(next(counter)) # 1
print(next(counter)) # 2
print(next(counter)) # 3
# print(next(counter)) # StopIteration
# 'for' loops use this protocol automatically
for num in CountUpTo(5):
print(num) # 1, 2, 3, 4, 5
# What a for loop actually does under the hood:
iterator = iter(CountUpTo(3)) # calls __iter__
while True:
try:
value = next(iterator) # calls __next__
print(value)
except StopIteration:
break
# Iterable vs Iterator - an important distinction
# Iterable: has __iter__, can produce an iterator (e.g. a list)
# Iterator: has __iter__ AND __next__, tracks its own state
my_list = [1, 2, 3] # iterable, but not an iterator itself
# next(my_list) # TypeError: 'list' object is not an iterator
my_iterator = iter(my_list) # NOW it's an iterator
print(next(my_iterator)) # 1
Q6. What is the difference between a generator expression and a list comprehension?
| Aspect | List comprehension | Generator expression |
|---|---|---|
| Syntax | [x for x in ...] | (x for x in ...) |
| Evaluation | Eager - computes all values immediately | Lazy - computes values on demand |
| Memory | Stores entire result in memory | Minimal memory - one value at a time |
| Reusability | Can be iterated multiple times | Single-use, exhausted after one pass |
import sys
# List comprehension - all values computed and stored immediately
list_comp = [x**2 for x in range(1000000)]
print(sys.getsizeof(list_comp)) # large - ~8MB+ for a million ints
# Generator expression - values computed lazily, one at a time
gen_exp = (x**2 for x in range(1000000))
print(sys.getsizeof(gen_exp)) # tiny - ~200 bytes regardless of range size
# Both can be iterated the same way
for val in gen_exp:
if val > 20:
break
print(val)
# Generators are exhausted after one full iteration
gen = (x for x in range(3))
print(list(gen)) # [0, 1, 2]
print(list(gen)) # [] - already exhausted, second call gets nothing
# Lists can be iterated repeatedly
lst = [x for x in range(3)]
print(list(lst)) # [0, 1, 2]
print(list(lst)) # [0, 1, 2] - works again
# When to use which: generator expressions for large/streaming data
# processed once; list comprehensions when you need random access,
# multiple iterations, or len()
total = sum(x**2 for x in range(1000000)) # generator, memory-efficient sum
Q7. How do you chain multiple generators together, and what is yield from used for?
yield from delegates iteration to a sub-generator or iterable, simplifying the common pattern of a generator that yields all values from another one.
# WITHOUT yield from - manual delegation with a loop
def inner_gen():
yield 1
yield 2
yield 3
def outer_gen_manual():
for value in inner_gen():
yield value # verbose way to delegate
# WITH yield from - concise delegation
def outer_gen():
yield from inner_gen() # equivalent to the manual loop above
print(list(outer_gen())) # [1, 2, 3]
# Combining multiple generators/iterables
def combined():
yield from range(3) # 0, 1, 2
yield from ['a', 'b'] # 'a', 'b'
yield from (10, 20) # 10, 20
print(list(combined())) # [0, 1, 2, 'a', 'b', 10, 20]
# Practical use: flattening nested structures with recursion
def flatten(nested):
for item in nested:
if isinstance(item, list):
yield from flatten(item) # recursively delegate to sub-lists
else:
yield item
nested_list = [1, [2, 3, [4, 5]], 6, [7, [8, 9]]]
print(list(flatten(nested_list))) # [1, 2, 3, 4, 5, 6, 7, 8, 9]
# Chaining generators with itertools.chain (alternative approach)
from itertools import chain
gen1 = (x for x in range(3))
gen2 = (x for x in range(10, 13))
combined_chain = chain(gen1, gen2)
print(list(combined_chain)) # [0, 1, 2, 10, 11, 12]
Q8. What are common built-in decorators like @staticmethod, @classmethod, and @property?
| Decorator | First parameter | Called on | Purpose |
|---|---|---|---|
| @staticmethod | None - no automatic self/cls | Class or instance | Utility function logically grouped in the class |
| @classmethod | cls (the class itself) | Class or instance | Alternative constructors, class-level operations |
| @property | self | Instance (accessed like an attribute) | Computed/read-only attributes |
class Pizza:
def __init__(self, radius, toppings):
self.radius = radius
self.toppings = toppings
# Regular instance method - needs self, operates on instance data
def area(self):
return 3.14159 * self.radius ** 2
# staticmethod - no access to self or cls, just grouped logically here
@staticmethod
def is_valid_topping(topping):
return topping in ['cheese', 'pepperoni', 'mushroom']
# classmethod - alternative constructor pattern
@classmethod
def margherita(cls):
return cls(radius=12, toppings=['cheese', 'tomato'])
# property - accessed like an attribute, computed on the fly
@property
def diameter(self):
return self.radius * 2
pizza = Pizza(10, ['cheese'])
print(pizza.area()) # instance method - needs ()
print(Pizza.is_valid_topping('cheese')) # static - called on class directly
margherita = Pizza.margherita() # classmethod - alternative constructor
print(margherita.toppings) # ['cheese', 'tomato']
print(pizza.diameter) # property - NO parentheses, accessed like attribute
Q9. How do you stack multiple decorators on a single function, and what order do they execute in?
When stacking decorators, they wrap the function from the BOTTOM up, but the wrapped calls execute from the TOP down when the function is actually invoked.
def decorator_a(func):
def wrapper(*args, **kwargs):
print('A: before')
result = func(*args, **kwargs)
print('A: after')
return result
return wrapper
def decorator_b(func):
def wrapper(*args, **kwargs):
print('B: before')
result = func(*args, **kwargs)
print('B: after')
return result
return wrapper
@decorator_a
@decorator_b
def greet():
print('Hello!')
greet()
# A: before
# B: before
# Hello!
# B: after
# A: after
# This is equivalent to writing:
def greet2():
print('Hello!')
greet2 = decorator_a(decorator_b(greet2))
# decorator_b wraps greet2 FIRST (innermost, closest to the function)
# decorator_a wraps the RESULT of that (outermost)
# Practical example: logging + authentication stacked
def require_auth(func):
def wrapper(*args, **kwargs):
print('Checking authentication...')
return func(*args, **kwargs)
return wrapper
def log_call(func):
def wrapper(*args, **kwargs):
print(f'Calling {func.__name__}')
return func(*args, **kwargs)
return wrapper
@require_auth # runs FIRST (outermost)
@log_call # runs SECOND (closer to the actual function)
def delete_user(user_id):
print(f'Deleting user {user_id}')
delete_user(5)
# Checking authentication...
# Calling delete_user
# Deleting user 5
Q10. What is the itertools module and what are some commonly used functions from it?
itertools is a standard library module providing fast, memory-efficient tools for working with iterators, covering common patterns like combinations, grouping, and infinite sequences.
| Function | Purpose |
|---|---|
| chain() | Combines multiple iterables into one sequence |
| count() | Infinite counting sequence |
| cycle() | Infinitely repeats an iterable |
| combinations() | All possible combinations of a given length |
| permutations() | All possible orderings of a given length |
| groupby() | Groups consecutive items by a key function |
from itertools import chain, count, cycle, combinations, permutations, groupby, islice
# chain - combine iterables
print(list(chain([1, 2], [3, 4]))) # [1, 2, 3, 4]
# count - infinite sequence, use islice to limit it
for i in islice(count(10, 2), 5): # start=10, step=2, limit 5 items
print(i) # 10, 12, 14, 16, 18
# cycle - infinite repetition, useful with islice
repeated = list(islice(cycle(['a', 'b']), 5))
print(repeated) # ['a', 'b', 'a', 'b', 'a']
# combinations - order doesn't matter
print(list(combinations([1, 2, 3], 2))) # [(1,2), (1,3), (2,3)]
# permutations - order matters
print(list(permutations([1, 2, 3], 2))) # [(1,2),(1,3),(2,1),(2,3),(3,1),(3,2)]
# groupby - groups CONSECUTIVE matching items (input should be sorted for full grouping)
data = [('fruit', 'apple'), ('fruit', 'banana'), ('veg', 'carrot')]
for key, group in groupby(data, key=lambda x: x[0]):
print(key, list(group))
# fruit [('fruit', 'apple'), ('fruit', 'banana')]
# veg [('veg', 'carrot')]
Decorators, Generators & Iterators
Understand Python advanced concepts. Learn decorator decorators, generator yield states, custom iterators, and memory-efficient iterators.
What is a decorator in Python and how does it work?
A decorator is a function that takes another function as input and returns a modified version of it, allowing...
How do you write a decorator that accepts arguments?
A decorator with arguments needs an extra layer of nesting - an outer function that takes the decorator argume...
What does functools.wraps do and why is it important in decorators?
Without functools.wraps, a decorated function loses its original name, docstring, and metadata, since the wrap...
What is a generator and how does yield work?
A generator is a function that produces a sequence of values lazily, one at a time, using yield instead of ret...
What is the iterator protocol and how do __iter__ and __next__ work?
The iterator protocol requires an object to implement __iter__ (returns the iterator itself) and __next__ (ret...
What is the difference between a generator expression and a list comprehension?
AspectList comprehensionGenerator expressionSyntax[x for x in ...](x for x in ...)EvaluationEager - computes a...
How do you chain multiple generators together, and what is yield from used for?
yield from delegates iteration to a sub-generator or iterable, simplifying the common pattern of a generator t...
What are common built-in decorators like @staticmethod, @classmethod, and @property?
DecoratorFirst parameterCalled onPurpose@staticmethodNone - no automatic self/clsClass or instanceUtility func...
How do you stack multiple decorators on a single function, and what order do they execute in?
When stacking decorators, they wrap the function from the BOTTOM up, but the wrapped calls execute from the TO...
What is the itertools module and what are some commonly used functions from it?
itertools is a standard library module providing fast, memory-efficient tools for working with iterators, cove...