Advanced Python
functools, itertools & Dataclasses
You met this briefly in Module 2, Chapter 4 to fix slow recursive Fibonacci — now the full picture, using what you know about decorators (Chapter 2).
Jr Codex Python Notes
Level: Advanced Prerequisites: Chapter 3 Time to complete: ~30 minutes
Table of Contents
- Why These Modules?
functools.lru_cache— Memoizationfunctools.partial— Pre-Filling Argumentsfunctools.reduceRevisiteditertools— Combinatorics & Efficient Loopingitertools.islice,chain,groupby@dataclass— Classes Without Boilerplate- Dataclass Options: Defaults, Frozen, Comparisons
- Summary & Next Steps
1. Why These Modules?
functools and itertools are standard-library toolkits for common patterns you'd otherwise write by hand repeatedly — caching, argument binding, and efficient iteration. dataclasses removes the repetitive boilerplate from Module 4's __init__/__repr__/__eq__ pattern. All three show up constantly in real, professional Python code, including the ML/data libraries used later in this curriculum.
2. functools.lru_cache — Memoization
You met this briefly in Module 2, Chapter 4 to fix slow recursive Fibonacci — now the full picture, using what you know about decorators (Chapter 2).
from functools import lru_cache
import time
@lru_cache(maxsize=None) # maxsize=None means "cache unlimited results"
def slow_square(n):
time.sleep(1) # simulate an expensive computation
return n ** 2
print(slow_square(4)) # takes ~1 second (first call, not cached yet)
print(slow_square(4)) # instant! — result was cached from the call above
print(slow_square(5)) # takes ~1 second (different argument, not cached yet)
print(slow_square.cache_info())
# CacheInfo(hits=1, misses=2, maxsize=None, currsize=2)How lru_cache Works
─────────────────────────────────────────
slow_square(4) → not in cache → COMPUTE, store result keyed by (4,)
slow_square(4) → found in cache! → return immediately, skip the function body
slow_square(5) → not in cache → COMPUTE, store result keyed by (5,)
─────────────────────────────────────────
Requirement: arguments must be hashable (same rule as dict keys and set items from Module 1) — lru_cache can't cache a call made with a list or dict argument.
@lru_cache(maxsize=128) # limits cache to 128 most-recently-used entries
def fibonacci(n):
if n <= 1:
return n
return fibonacci(n - 1) + fibonacci(n - 2)
print(fibonacci(50)) # instant, thanks to caching — would otherwise take ages3. functools.partial — Pre-Filling Arguments
partial creates a new function with some arguments already "locked in" — useful for adapting a general function to a more specific use, without writing a wrapper by hand.
from functools import partial
def power(base, exponent):
return base ** exponent
square = partial(power, exponent=2) # locks in exponent=2
cube = partial(power, exponent=3) # locks in exponent=3
print(square(5)) # 25 — power(5, exponent=2)
print(cube(5)) # 125 — power(5, exponent=3)# A realistic use: adapting a callback to fit an API that only passes one argument
def log_message(level, message):
print(f"[{level}] {message}")
log_error = partial(log_message, "ERROR")
log_warning = partial(log_message, "WARNING")
log_error("Database connection failed") # [ERROR] Database connection failed
log_warning("Disk space running low") # [WARNING] Disk space running lowpartial is essentially a lightweight alternative to writing a small wrapper function or a closure (Module 2, Chapter 3) purely to fix some arguments in place.
4. functools.reduce Revisited
Already covered in Module 2, Chapter 6 — included here as a reminder that it lives in functools, alongside these other tools, not as a built-in:
from functools import reduce
numbers = [1, 2, 3, 4, 5]
product = reduce(lambda acc, x: acc * x, numbers)
print(product) # 1205. itertools — Combinatorics & Efficient Looping
itertools provides fast, memory-efficient building blocks for iteration patterns — all lazy (Chapter 1), producing values on demand rather than building full lists upfront.
from itertools import product, permutations, combinations
# product — Cartesian product (all combinations, WITH repetition, order matters)
sizes = ["S", "M", "L"]
colors = ["red", "blue"]
for size, color in product(sizes, colors):
print(size, color)
# S red / S blue / M red / M blue / L red / L blue
# permutations — all possible ORDERINGS of a fixed length
for p in permutations([1, 2, 3], 2):
print(p)
# (1,2) (1,3) (2,1) (2,3) (3,1) (3,2)
# combinations — like permutations, but order does NOT matter (no duplicates like (1,2) AND (2,1))
for c in combinations([1, 2, 3], 2):
print(c)
# (1,2) (1,3) (2,3)| Function | Order Matters? | Repetition? |
|---|---|---|
product(a, b) | Yes | Yes — combines everything with everything |
permutations(items, r) | Yes | No — each item used once per arrangement |
combinations(items, r) | No | No — each item used once, no reordering |
6. itertools.islice, chain, groupby
from itertools import islice, chain, groupby
# islice — take a SLICE of any iterable, including infinite generators (see Chapter 1)
def infinite_counter():
n = 0
while True:
yield n
n += 1
first_five = list(islice(infinite_counter(), 5))
print(first_five) # [0, 1, 2, 3, 4]
middle_five = list(islice(infinite_counter(), 10, 15))
print(middle_five) # [10, 11, 12, 13, 14]
# chain — iterate over multiple iterables as if they were ONE, without copying them
list1 = [1, 2, 3]
list2 = [4, 5, 6]
for item in chain(list1, list2):
print(item) # 1 2 3 4 5 6
# groupby — group consecutive items sharing a key (data must be pre-SORTED for this to be useful!)
people = [
{"name": "Alice", "dept": "Engineering"},
{"name": "Bob", "dept": "Engineering"},
{"name": "Charlie", "dept": "Sales"},
]
people.sort(key=lambda p: p["dept"]) # groupby only groups CONSECUTIVE matches
for dept, group in groupby(people, key=lambda p: p["dept"]):
names = [p["name"] for p in group]
print(dept, names)
# Engineering ['Alice', 'Bob']
# Sales ['Charlie']Common groupby pitfall: it only groups items that are already adjacent — always sort by the same key first, or items with the same key scattered throughout the list will end up in separate groups.
7. @dataclass — Classes Without Boilerplate
Module 4 showed how much repetitive code goes into a "plain data" class — __init__, __repr__, __eq__, all written by hand. @dataclass generates all of that automatically from type-hinted class attributes.
# The Module 4 way — verbose, for a class that's JUST data
class Point:
def __init__(self, x, y):
self.x = x
self.y = y
def __repr__(self):
return f"Point(x={self.x}, y={self.y})"
def __eq__(self, other):
return self.x == other.x and self.y == other.y
# The @dataclass way — identical behavior, far less code
from dataclasses import dataclass
@dataclass
class Point:
x: int
y: int
p1 = Point(3, 4)
p2 = Point(3, 4)
print(p1) # Point(x=3, y=4) — __repr__ generated automatically
print(p1 == p2) # True — __eq__ generated automatically, compares field values
print(p1.x, p1.y) # 3 4 — __init__ generated automatically too@dataclass Automatically Generates:
─────────────────────────────────────────
__init__ from the type-hinted class attributes
__repr__ a readable string representation
__eq__ compares all fields for equality
─────────────────────────────────────────
8. Dataclass Options: Defaults, Frozen, Comparisons
from dataclasses import dataclass, field
@dataclass
class Product:
name: str
price: float
quantity: int = 0 # default value, just like a regular parameter default
tags: list[str] = field(default_factory=list) # mutable defaults need field(default_factory=...)!
p1 = Product("Widget", 9.99)
p2 = Product("Gadget", 19.99, quantity=5, tags=["sale", "new"])
print(p1) # Product(name='Widget', price=9.99, quantity=0, tags=[])
print(p2) # Product(name='Gadget', price=19.99, quantity=5, tags=['sale', 'new'])Why field(default_factory=list) instead of tags: list = []? This is the exact mutable-default-argument trap from Module 2, Chapter 2 — field(default_factory=list) ensures every instance gets its own fresh list, rather than one shared list reused across every Product.
@dataclass(frozen=True) # makes instances IMMUTABLE after creation
class ImmutablePoint:
x: int
y: int
point = ImmutablePoint(1, 2)
point.x = 99 # FrozenInstanceError: cannot assign to field 'x'
@dataclass(order=True) # adds __lt__, __le__, __gt__, __ge__ automatically
class Version:
major: int
minor: int
versions = [Version(2, 1), Version(1, 5), Version(1, 0)]
print(sorted(versions)) # sorted by (major, minor) automatically, field order matters| Option | Effect |
|---|---|
frozen=True | Makes instances immutable — like Module 1's tuples, but for custom classes |
order=True | Auto-generates comparison dunders (__lt__, etc.) based on field order |
field(default_factory=...) | Safe way to give a mutable default (list, dict) — a fresh one per instance |
9. Summary & Next Steps
Key Takeaways
functools.lru_cachememoizes a function's results automatically — a decorator (Chapter 2) that turns slow recursive/repeated calls into near-instant ones for repeated arguments.functools.partiallocks in some arguments of a function, producing a more specific, ready-to-use version.itertoolsprovides lazy, memory-efficient tools for combinatorics (product,permutations,combinations) and iteration (islice,chain,groupby).@dataclassauto-generates__init__,__repr__, and__eq__for classes that are primarily data containers — usefield(default_factory=...)for mutable defaults, andfrozen=True/order=Truefor immutability/comparisons.
Concept Check
- Why does
lru_cacherequire function arguments to be hashable? - What's the difference between
itertools.permutationsanditertools.combinations? - Why does
tags: list[str] = field(default_factory=list)matter more thantags: list[str] = []in a dataclass?
Next Chapter
→ Chapter 5: Testing & Logging
Jr Codex — 1-on-1 Personalized Coaching | Back to Module Index