Python

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).

JrCodex·8 min read

Jr Codex Python Notes

Level: Advanced Prerequisites: Chapter 3 Time to complete: ~30 minutes


Table of Contents

  1. Why These Modules?
  2. functools.lru_cache — Memoization
  3. functools.partial — Pre-Filling Arguments
  4. functools.reduce Revisited
  5. itertools — Combinatorics & Efficient Looping
  6. itertools.islice, chain, groupby
  7. @dataclass — Classes Without Boilerplate
  8. Dataclass Options: Defaults, Frozen, Comparisons
  9. 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 ages

3. 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 low

partial 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)     # 120

5. 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)
FunctionOrder Matters?Repetition?
product(a, b)YesYes — combines everything with everything
permutations(items, r)YesNo — each item used once per arrangement
combinations(items, r)NoNo — 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
OptionEffect
frozen=TrueMakes instances immutable — like Module 1's tuples, but for custom classes
order=TrueAuto-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_cache memoizes a function's results automatically — a decorator (Chapter 2) that turns slow recursive/repeated calls into near-instant ones for repeated arguments.
  • functools.partial locks in some arguments of a function, producing a more specific, ready-to-use version.
  • itertools provides lazy, memory-efficient tools for combinatorics (product, permutations, combinations) and iteration (islice, chain, groupby).
  • @dataclass auto-generates __init__, __repr__, and __eq__ for classes that are primarily data containers — use field(default_factory=...) for mutable defaults, and frozen=True/order=True for immutability/comparisons.

Concept Check

  1. Why does lru_cache require function arguments to be hashable?
  2. What's the difference between itertools.permutations and itertools.combinations?
  3. Why does tags: list[str] = field(default_factory=list) matter more than tags: list[str] = [] in a dataclass?

Next Chapter

Chapter 5: Testing & Logging


Jr Codex — 1-on-1 Personalized Coaching | Back to Module Index