Understand how iteration really works, produce values lazily with generators, and wrap functions with reusable decorators.
Why: anything you can loop over is "iterable". Behind the scenes a for loop calls iter() to get an iterator, then next() repeatedly until items run out. You rarely call these by hand, but knowing this explains generators.
letters = ['a', 'b', 'c']
it = iter(letters)
print(next(it)) # 'a'
print(next(it)) # 'b'
print(next(it)) # 'c'
# next(it) # raises StopIteration when exhaustedWhy: a generator is a function that produces values one at a time using yield instead of return. It computes each value only when asked, so it can represent huge or even infinite sequences without filling memory.
def count_up_to(limit):
n = 1
while n <= limit:
yield n # hand back one value, then pause here
n += 1
for number in count_up_to(3):
print(number) # 1, 2, 3Why: like a list comprehension but with round brackets — it produces items lazily instead of building the whole list at once. Ideal when you only need to pass through values, such as summing them.
# a list comprehension builds all 1,000,000 squares in memory:
total = sum([n * n for n in range(1_000_000)])
print(total)
# a generator expression produces them one at a time — far less memory:
total = sum(n * n for n in range(1_000_000))
print(total)Why: a decorator is a function that wraps another function to add behaviour — logging, timing, access checks — without changing the original code. You apply one with the @ symbol on the line above a function.
import time
def timed(func):
def wrapper(*args, **kwargs):
start = time.perf_counter()
result = func(*args, **kwargs)
print(f'{func.__name__} took {time.perf_counter() - start:.4f}s')
return result
return wrapper
@timed # same as: slow = timed(slow)
def slow():
time.sleep(0.1)
slow() # slow took 0.1003s