Foundations
built behavior from small, pure, composable pieces. The standard
library supplies two modules of such pieces.
functools operates on functions themselves.
itertools assembles lazy iterators from composable
parts. This chapter tours both toolkits, then turns to two
techniques that pair naturally with them, recursion and lazy
evaluation, and closes with a case study that puts several of
the pieces to work on one problem.
functools
ToolkitThe standard library ships the building blocks of functional
Python under functools, from a single fold to an
alternate dispatch mechanism. Each one replaces code you would
otherwise write and debug yourself. Caching logic, an eviction
policy, a dispatch table, each one hides an edge case that’s
easy to miss on the first attempt. These tools are already
written, already correct, and implemented in C for speed. What
follows starts with the simplest tools and works up to the ones
with the most moving parts.
reduceFolds a sequence into a single value by repeatedly applying a two-argument function.
# functools_reduce.py
from functools import reduce
from operator import add
print(reduce(add, [1, 2, 3, 4]))
#: 10For addition specifically, sum() is the built-in
spelling, and math.prod() covers multiplication.
reduce() earns its keep for every other fold, where
no dedicated built-in exists.
cacheRemembers every result forever, so repeated calls with the same arguments cost nothing. Note that this only works correctly for pure functions. Caching a side-effecting function skips the effects.
# functools_cache.py
from functools import cache
@cache
def fib(n: int) -> int:
return n if n < 2 else fib(n - 1) + fib(n - 2)
print(fib(30))
#: 832040Because fib() is recursive, the values up to and
including 30 are now cached. This accelerates future calls to
fib().
One trap: decorating a method with
@cache keys every entry on self, so
the cache holds a strong reference to each instance forever, the
lapsed-listener leak of Observer
in cache form. For the usual case, one expensive value per
instance, use @cached_property below, which stores
the result on the instance and dies with it.
lru_cacheLike cache(), but bounds memory by discarding
the least recently used entry once maxsize is
reached.
# functools_lru_cache.py
from functools import lru_cache
@lru_cache(maxsize=2)
def square(n: int) -> int:
return n * n
square(1)
square(2)
square(3) # Evicts 1, the least recently used
print(square.cache_info())
#: CacheInfo(hits=0, misses=3, maxsize=2, currsize=2)partialFixes some of a function’s arguments and returns a new function that expects the rest. Partial Application covers it in depth.
# functools_partial.py
from functools import partial
shout = partial(print, end="!\n")
shout("hello")
#: hello!partialmethodThe same idea as partial(), but for a method.
The descriptor binds self automatically when
accessed on an instance.
# functools_partialmethod.py
from functools import partialmethod
class Text:
def __init__(self, value: str) -> None:
self.value = value
def pad(self, width: int, fill: str = " ") -> str:
return self.value.rjust(width, fill)
zero_pad = partialmethod(pad, fill="0")
print(Text("7").zero_pad(3))
#: 007cached_propertyRuns a property’s code once, on first access, then reuses the
stored result. Classes
covers it alongside @property.
# functools_cached_property.py
from functools import cached_property
class Lazy:
def __init__(self, n: int) -> None:
self.n = n
@cached_property
def squared(self) -> int:
print("computing")
return self.n * self.n
x = Lazy(5)
print(x.squared)
#: computing
#: 25
print(x.squared) # No second "computing"
#: 25
x.n = 10 # Doesn't change the cached result
print(x.squared)
#: 25Note that you must be careful with caching, because mutating
a property doesn’t cause the cached result to be recalculated.
The escape hatch is del x.squared: deleting the
cached attribute discards the stored value, and the next access
recomputes it from the current state.
wrapsCopies a wrapped function’s name and docstring onto its
wrapper, so introspection still sees the original. Decorators
covers its sibling, update_wrapper(), for wrapping
with a class instance.
# functools_wraps.py
from collections.abc import Callable
from functools import wraps
def trace(func: Callable[[str], str]) -> Callable[[str], str]:
@wraps(func)
def wrapper(name: str) -> str:
return func(name)
return wrapper
@trace
def greet(name: str) -> str:
"Say hello."
return f"Hello, {name}!"
print(greet.__name__, "-", greet.__doc__)
#: greet - Say hello.cmp_to_keyWraps an old-style comparator, a function returning negative,
zero, or positive, into a key function sorted() can
use directly.
# functools_cmp_to_key.py
from functools import cmp_to_key
def by_length_desc(a: str, b: str) -> int:
return len(b) - len(a)
words = ["a", "ccc", "bb"]
print(sorted(words, key=cmp_to_key(by_length_desc)))
#: ['ccc', 'bb', 'a']total_orderingFills in the rest of the comparison methods from
__eq__ and one of __lt__,
__le__, __gt__, or
__ge__, so a class needs two methods instead of six
to sort and compare correctly.
# functools_total_ordering.py
from functools import total_ordering
@total_ordering
class Weight:
def __init__(self, kg: float) -> None:
self.kg = kg
def __eq__(self, other: object) -> bool:
return isinstance(other, Weight) and self.kg == other.kg
def __lt__(self, other: Weight) -> bool:
return self.kg < other.kg
light = Weight(2)
heavy = Weight(5)
print(light < heavy, light <= heavy, light > heavy)
#: True True FalseThe plain class exists to show the tool. In real code this
Weight would be
@dataclass(frozen=True, order=True), which
generates all six comparisons from the field order and makes
total_ordering unnecessary.
total_ordering earns its keep when the class cannot
be a dataclass, or when the ordering is not simply the fields in
declaration order.
singledispatchTurns a plain function into one that dispatches on the type
of its first argument, with per-type implementations registered
separately. Visitor
uses singledispatch() as an alternative to the
Visitor pattern, including why the registered function
below is named _.
# functools_singledispatch.py
from functools import singledispatch
@singledispatch
def describe(value: object) -> str:
return f"a {type(value).__name__}"
@describe.register
def _(value: int) -> str:
return f"the number {value}"
print(describe("hi"), "|", describe(5))
#: a str | the number 5singledispatchmethodThe same dispatch, written as a method so it reads as
self.op(x) instead of a bare function call. The
registered method below is again named _, explained
in Visitor.
# functools_singledispatchmethod.py
from functools import singledispatchmethod
class Describer:
@singledispatchmethod
def describe(self, value: object) -> str:
return f"a {type(value).__name__}"
@describe.register
def _(self, value: int) -> str:
return f"the number {value}"
d = Describer()
print(d.describe("hi"), "|", d.describe(5))
#: a str | the number 5itertools, covered next, applies the same idea
to lazy iteration.
itertools
Toolkititertools builds lazy iterators from a small set
of composable pieces. Each one produces values on demand instead
of building a list up front. Each is also a loop you would
otherwise write by hand, already tuned in C and already correct
on the edge cases a hand-rolled version tends to miss, the empty
iterable, the single element, the point where two sequences run
out at different lengths. Combine them the way you combine any
small function, by feeding one’s output to the next. What
follows starts with the simplest tools and works up to the ones
with the most moving parts.
repeatYields the same object over and over, forever or a fixed number of times.
# itertools_repeat.py
from itertools import repeat
print(list(repeat("x", 3)))
#: ['x', 'x', 'x']isliceSlices any iterable, including an infinite one, the way
[start:stop:step] slices a list.
# itertools_islice.py
from itertools import islice
print(list(islice(range(10), 2, 8, 2)))
#: [2, 4, 6]countCounts up (or down) forever from a start value, with a fixed step.
# itertools_count.py
from itertools import count, islice
print(list(islice(count(10, 2), 5)))
#: [10, 12, 14, 16, 18]cycleRepeats an iterable forever.
# itertools_cycle.py
from itertools import cycle, islice
print(list(islice(cycle("AB"), 5)))
#: ['A', 'B', 'A', 'B', 'A']chainIterates several iterables one after another, as if they were
one. chain.from_iterable(iterables) does the same
when the iterables themselves arrive as one lazy sequence,
rather than as separate arguments.
# itertools_chain.py
from itertools import chain
print(list(chain([1, 2], [3, 4])))
#: [1, 2, 3, 4]pairwiseYields consecutive overlapping pairs from an iterable, without indexing by hand and risking an off-by-one at the ends.
# itertools_pairwise.py
from itertools import pairwise
print(list(pairwise([1, 2, 3, 4])))
#: [(1, 2), (2, 3), (3, 4)]batchedGroups an iterable into fixed-size tuples, with a shorter final batch if the length does not divide evenly, the kind of remainder logic that’s easy to get wrong in a hand-written loop.
# itertools_batched.py
from itertools import batched
print(list(batched(range(7), 3)))
#: [(0, 1, 2), (3, 4, 5), (6,)]accumulateYields the running total of an iterable, or the running result of any two-argument function.
# itertools_accumulate.py
from itertools import accumulate
print(list(accumulate([1, 2, 3, 4])))
#: [1, 3, 6, 10]compressKeeps the elements of one iterable wherever the matching selector is true.
# itertools_compress.py
from itertools import compress
print(list(compress("ABCD", [1, 0, 1, 0])))
#: ['A', 'C']takewhileYields elements while a predicate holds, then stops at the first failure.
# itertools_takewhile.py
from itertools import takewhile
print(list(takewhile(lambda n: n < 3, [1, 2, 3, 4, 1])))
#: [1, 2]dropwhileSkips elements while a predicate holds, then yields everything after.
# itertools_dropwhile.py
from itertools import dropwhile
print(list(dropwhile(lambda n: n < 3, [1, 2, 3, 4, 1])))
#: [3, 4, 1]filterfalseKeeps the elements a predicate rejects, the mirror of
filter().
# itertools_filterfalse.py
from itertools import filterfalse
print(list(filterfalse(lambda n: n % 2 == 0, range(6))))
#: [1, 3, 5]starmapLike map(), but unpacks each element as the
arguments to the function.
# itertools_starmap.py
from itertools import starmap
print(list(starmap(pow, [(2, 5), (3, 2)])))
#: [32, 9]zip_longestZips iterables of different lengths, filling the gaps instead
of stopping at the shortest. The default filler is
None. When None is a valid element,
pass a distinct sentinel as the fillvalue keyword
argument:
# itertools_zip_longest.py
from itertools import zip_longest
print(list(zip_longest([1, 2, 3], [4, 5])))
#: [(1, 4), (2, 5), (3, None)]
MISSING = sentinel("MISSING")
print(list(zip_longest([1, 2, 3], [4, 5], fillvalue=MISSING)))
#: [(1, 4), (2, 5), (3, MISSING)]groupbyGroups consecutive elements that share a key. The input must already be sorted by that key, since it only merges neighbors.
# itertools_groupby.py
from itertools import groupby
data = ["a", "a", "b", "b", "b", "c"]
print([(k, list(g)) for k, g in groupby(data)])
#: [('a', ['a', 'a']), ('b', ['b', 'b', 'b']), ('c', ['c'])]teeSplits one iterable into several independent iterators over the same data, so two consumers can each walk it once without collecting it into a list first.
# itertools_tee.py
from itertools import tee
a, b = tee([1, 2, 3])
print(list(a), list(b))
#: [1, 2, 3] [1, 2, 3]Two cautions. After tee(), use only the returned
iterators; advancing the original source steals values the
copies never see. And tee() buffers every value one
copy has consumed and the other has not, so draining
a completely before touching b, as
this demo does, stores the whole sequence anyway. When one
consumer runs far ahead of the other, list() is
simpler and no more expensive. tee() wins when the
consumers stay roughly in step.
productThe Cartesian product of the input iterables, the same pairs
a nested for loop would build, without writing and
re-testing that loop yourself.
# itertools_product.py
from itertools import product
print(list(product("AB", [1, 2])))
#: [('A', 1), ('A', 2), ('B', 1), ('B', 2)]permutationsEvery ordering of r elements from the
iterable.
# itertools_permutations.py
from itertools import permutations
print(list(permutations("AB")))
#: [('A', 'B'), ('B', 'A')]combinationsEvery way to choose r elements where order does
not matter and nothing repeats.
# itertools_combinations.py
from itertools import combinations
print(list(combinations("ABC", 2)))
#: [('A', 'B'), ('A', 'C'), ('B', 'C')]combinations_with_replacementLike combinations(), but the same element can
appear more than once.
# itertools_combinations_with_replacement.py
from itertools import combinations_with_replacement
print(list(combinations_with_replacement("AB", 2)))
#: [('A', 'A'), ('A', 'B'), ('B', 'B')]Recursion expresses a repeated computation as a function that calls itself. Each recursive function needs a base case that stops the recursion and a recursive case that moves toward it:
# recursion.py
import sys
def factorial(n: int) -> int:
# Base case stops the recursion:
if n <= 1:
return 1
# Recursive case moves toward the base case:
return n * factorial(n - 1)
print(factorial(5))
#: 120
# Python caps how deep recursion can go:
print(sys.getrecursionlimit())
#: 1000A for loop computes this same factorial in about
the same number of lines, with no risk of hitting that limit.
Recursion is not a faster or shorter way to count down to zero.
Its payoff shows up once the problem branches, not just repeats,
which is shown in the next example.
Recursion suits problems that are naturally self-similar,
such as walking a tree. Python does not optimize tail calls and
limits the call stack, so deep recursion will raise
RecursionError. For long flat sequences, a loop or
one of the itertools tools is the better
choice.
Recursion is beneficial when the data is recursive. Code that walks a tree, nested data, or a directory reads most clearly when its shape matches the data’s shape. The function handles one node and trusts itself for the rest:
# nested_sum.py
type Nested = int | list[Nested]
def deep_sum(items: list[Nested]) -> int:
total = 0
for item in items:
if isinstance(item, list):
total += deep_sum(item) # Recurse into a sublist
else:
total += item # A plain number
return total
print(deep_sum([1, [2, [3, 4], 5], 6]))
#: 21deep_sum() states what to do with one element
and delegates the nesting to itself. Writing this as a loop
means building your own stack to track which sublists are still
open, and getting the push and pop correct at every depth. The
recursive version gets that bookkeeping from the call stack,
which is why it stays three lines instead of growing with every
level of nesting you support.
Lazy evaluation computes a value only when something
needs it. A generator is the canonical example. It yields one
value at a time instead of building a whole list up front.
Combined with itertools, you can describe an
infinite sequence and take only the part you use:
# lazy.py
from collections.abc import Iterator
from itertools import count, islice
def squares() -> Iterator[int]:
for n in count(1):
print(f"computing square {n}") # Proves this runs on demand
yield n * n
# count() is infinite; islice() pulls only what we ask for:
first_five = list(islice(squares(), 5))
print(first_five)
#: computing square 1
#: computing square 2
#: computing square 3
#: computing square 4
#: computing square 5
#: [1, 4, 9, 16, 25]squares() never finishes on its own, yet the
program terminates because islice() requests five
values. Each computing square N line appears only
when islice() pulls that value, one at a time, the
same way any for loop consumes a generator. Nothing
here is a batch. squares() never runs ahead to
precompute several values before handing one back. No sixth
computing square line appears, because
islice() stops asking when it has delivered five.
The Performance chapter looks
at laziness from the perspective of memory and speed.
Laziness matters most at scale. A generator pipeline can
process a multi-gigabyte file or a live network stream one item
at a time, so memory use doesn’t grow with the size of the
source. Stages chain together without building intermediate
lists between them, and a consumer that stops early, such as
any() or next(), means no upstream
work for the items it never reaches.
Here is a recurring practical problem. Pair up participants for an activity across several rounds, and avoid repeating a pairing until every possible pairing has had a turn. This is a good place to see these chapters’ ideas working together on one small, real program instead of one at a time.
The circle method solves the pairs-only version
exactly, by direct construction. Fix one player, arrange the
rest in a circle, and each round pair players sitting across
from each other, then rotate everyone but the fixed player by
one seat. With n players that produces
n - 1 rounds where no pair repeats, which is the
best any schedule can do, since it uses up every one of the
n * (n - 1) / 2 possible pairs exactly once.
None of that trick survives a request for groups of three,
four, or any other size. The circle method is a closed-form
answer to one narrow question, “how do you 1-factorize a
complete graph into perfect matchings,” and pairs are the only
group size where that question has a tidy rotation-based answer.
Scheduling groups of three without repeats is the far harder
problem that Kirkman’s schoolgirl problem poses,
solvable only for specific roster sizes and with no simple
formula behind it. Rather than chase an exact answer that may
not exist for a given students and
size, a general version can settle for a good one:
build each group by always adding whoever its current members
have met the fewest times before, tracked in a running history
instead of computed fresh from a round number:
# student_pairs.py
import random
from collections import Counter
from collections.abc import Iterator
from itertools import combinations, islice
type Student = str
type Group = tuple[Student, ...]
type Round = list[Group]
def group_rounds(
students: list[Student], size: int, seed: int = 0
) -> Iterator[Round]:
history: Counter[frozenset[Student]] = Counter()
rng = random.Random(seed)
while True:
pool = list(students)
rng.shuffle(pool)
groups: list[list[Student]] = []
while len(pool) >= size:
leader = pool.pop()
group = [leader]
while len(group) < size:
closest = min(pool, key=lambda c: sum(
history[frozenset((m, c))] for m in group))
pool.remove(closest)
group.append(closest)
groups.append(group)
for extra in pool: # Too few left for a full group of `size`
roomiest = min(groups, key=lambda g: sum(
history[frozenset((m, extra))] for m in g))
roomiest.append(extra)
round_result: Round = [tuple(g) for g in groups]
for g in round_result:
for pair in combinations(g, 2):
history[frozenset(pair)] += 1
yield round_result
students = ["Ana", "Bo", "Cy", "Di", "Eve", "Fi", "Gia"]
rounds = list(islice(group_rounds(students, 2), len(students)))
for i, grouping in enumerate(rounds[:3]):
print(i, grouping)
#: 0 [('Gia', 'Eve', 'Ana'), ('Di', 'Cy'), ('Fi', 'Bo')]
#: 1 [('Di', 'Bo', 'Eve'), ('Cy', 'Ana'), ('Gia', 'Fi')]
#: 2 [('Eve', 'Fi', 'Ana'), ('Bo', 'Gia'), ('Cy', 'Di')]
met = [frozenset(pair) for r in rounds for group in r
for pair in combinations(group, 2)]
possible = set(map(frozenset, combinations(students, 2)))
print(len(set(met)), "of", len(possible), "pairs met at least once")
#: 21 of 21 pairs met at least once
print(len(met) - len(set(met)), "repeat meetings")
#: 14 repeat meetings
trios = list(islice(group_rounds(students, 3), 3))
for i, grouping in enumerate(trios):
print(i, grouping)
#: 0 [('Gia', 'Eve', 'Cy', 'Fi'), ('Di', 'Bo', 'Ana')]
#: 1 [('Di', 'Eve', 'Bo', 'Gia'), ('Cy', 'Ana', 'Fi')]
#: 2 [('Eve', 'Ana', 'Gia'), ('Bo', 'Fi', 'Di', 'Cy')]Called with size=2, group_rounds()
covers all 21 possible pairs across the seven
rounds, at the cost of 14 repeat meetings. An odd
roster cannot pair everyone, so each round folds the leftover
player into an existing pair, and those triples are where the
repeats come from. It does that with no rotation and no notion
of a fixed player, just a shuffle and a greedy choice repeated
until the roster runs out. Called with size=3, the
same function schedules trios instead. Seven students do not
split evenly into threes, so one group grows to four rather than
leaving anyone out, the same join-instead-of-sit-out choice the
pair rounds made above.
Generality cost something. The circle method needed no
memory. Which pair sits where in round r followed
from r alone. group_rounds() needs the
history Counter, because there is no
formula that predicts, from a round number alone, which grouping
of arbitrary size keeps every pair’s meeting count lowest. It is
still a pure function in the sense that matters for testing. The
same students, size, and
seed always produce the same infinite sequence of
rounds, since random.Random(seed) never reaches
outside itself for randomness. What changed is that computing
round 100 now means having already generated rounds
0 through 99, where the circle method
could compute round 100 directly, from its
arithmetic alone. That trade, memory for generality, is the same
one Recursion makes when a loop’s
simple counter is not enough and the problem needs a stack
instead.