Overview
This page is the spaced-repetition companion to the Functional Programming topic: five fixed drills
that force active recall instead of passive re-reading. Work through them in order -- short-answer
recall first, then scenario judgment, then hands-on repetition, then a checklist to confirm real
automaticity, and finally why/why-not prompts that test whether you can explain the reasoning, not
just execute the syntax. Every answer is hidden in a <details> block; try each item yourself before
opening it. Together, the five drills below touch every one of this topic's 28 concepts and cite
specific examples spanning all 80 worked examples in the Beginner, Intermediate, and Advanced tiers,
plus the capstone.
Recall Q&A
Twenty-eight short-answer questions, one per concept. Answer from memory before opening each answer.
Q1 (co-01 -- Pure Functions). What does it mean for a function to be pure, and what does that buy the caller?
Answer
A pure function's output depends ONLY on its arguments, and it produces no observable side effect --
no mutating an argument, no writing to a global, no printing, no touching a file or the network. Call
it twice with the same arguments and it always gives the same result, which makes it trivially safe to
call twice, safe to call from multiple threads, and easy to test with nothing but its arguments and its
return value (Example 1 contrasts add_pure against add_impure; Example 58 property-tests purity
itself across 200 generated inputs).
Q2 (co-02 -- Side Effects and the Purity Boundary). What exactly counts as a side effect, and why does naming the purity boundary matter?
Answer
A side effect is anything a function does besides computing and returning a value -- printing, mutating an argument the caller can see, writing a file, incrementing state outside the function's own scope. Naming exactly where that boundary sits is a design decision, not an accident: a codebase that never draws it lets side effects creep in anywhere, which is precisely what makes large imperative code hard to test (Example 1's impure twin mutates a global as its side effect; Example 3 classifies three functions and confirms only the pure one is flagged pure).
Q3 (co-03 -- Referential Transparency). What does "referentially transparent" mean concretely, and what does it let a reader do?
Answer
A call is referentially transparent if it can be replaced by its own return value anywhere in the
program without changing what the program does -- add(2, 3) can always be replaced by 5. This is
what makes equational reasoning possible: a reader can simplify an expression by substituting a call for
its value the same way they simplify algebra, without tracing side effects to check the substitution is
safe (Example 2 replaces a call with its literal value inside a larger expression and confirms the
output is unchanged).
Q4 (co-04 -- Immutability). Name two of this topic's stdlib tools for immutable data, and state why "an object nobody can mutate" is valuable.
Answer
tuple and frozenset are built-in immutable containers; @dataclass(frozen=True) makes a custom
record immutable, raising FrozenInstanceError on an attempted attribute set; types.MappingProxyType
wraps a dict in a read-only view. An object nobody can mutate is always safe to hand to another
function, another thread, or another part of the codebase -- there is no way for that code to corrupt
what the caller still holds a reference to (Example 4 catches a TypeError on tuple mutation; Example 5
shows a frozen dataclass rejecting an attribute set).
Q5 (co-05 -- Persistent Data and Structural Sharing). What makes a data structure "persistent," and why does structural sharing matter for its cost?
Answer
A persistent data structure keeps every old version reachable after an "update," because the update never mutates anything -- it builds a new version that shares whatever structure didn't change with the old one. Structural sharing is what makes immutability affordable: without it, every update would have to deep-copy the entire structure, which is both slow and memory-hungry; sharing the unchanged parts can keep an update as cheap as O(1) (Example 7's cons-list prepend; Example 59's persistent binary tree update confirms the old root stays intact).
Q6 (co-06 -- First-Class Functions). What does it mean for functions to be "ordinary values" in Python, and what does that make possible?
Answer
Functions in Python can be assigned to a variable, stored in a list or dict, passed as an argument, and returned from another function, all without special syntax. Once functions are values, behavior itself becomes something you can pass around, store, and compose -- the seed that higher-order functions, closures, currying, composition, and decorators all grow from (Example 8 assigns a function to a variable and calls through it; Example 9 stores several functions in a list and calls each in turn).
Q7 (co-07 -- Higher-Order Functions). What is a higher-order function, and what does it let you factor apart from what?
Answer
A higher-order function takes another function as an argument, returns one, or both -- apply(fn, x)
returning fn(x) works identically whether fn doubles, squares, or does something else entirely,
because apply never needs to know what fn actually does. This factors "the shape of the computation"
apart from "what specifically happens at each step," the mechanism underneath decorators and
map/filter/reduce (Example 10's apply(fn, x) verified against two different functions).
Q8 (co-08 -- Closures for Configuration). What does a closure capture, and how is that different from a parameter passed in fresh on every call?
Answer
A closure captures a variable from its enclosing scope and keeps using that captured value even after
the enclosing function has returned -- multiplier(3) returns a function that remembers 3 forever, so
multiplier(3)(4) multiplies by the captured 3, not by something threaded through fresh each call.
This lets you "bake in" configuration once and get back a specialized function (Example 12's closure
captures a threshold; Example 32 contrasts a stateful closure counter against a pure fold, naming
exactly where each one's state lives).
Q9 (co-09 -- Currying). What does currying do to an n-argument function, and how is it distinct from partial application (co-10)?
Answer
Currying turns an n-argument function into a chain of one-argument functions -- add(a, b) becomes
add(a)(b), where add(a) returns a new one-argument function waiting for b. Currying is ALWAYS one
argument at a time by construction, while functools.partial can fix any number of arguments at once
in a single call (Example 17 hand-curries a 2-argument function; Example 62's @curry decorator
auto-curries by counting a function's arity).
Q10 (co-10 -- Partial Application). What does functools.partial do, and why reach for it over a
hand-written wrapping lambda?
Answer
Partial application fixes some of a function's arguments right now and returns a smaller function
waiting for the rest -- functools.partial(pow, 2) fixes the base at 2 and returns a one-argument
function computing 2**n, no lambda or manual closure required. It is more discoverable to a reviewer
and composes cleanly with functools.reduce and decorator pipelines elsewhere in this topic (Example 18
verified to compute 2**n; Example 33 chains several partial calls into a composed transform).
Q11 (co-11 -- Function Composition). What does compose(f, g) actually compute, and in what
order do f and g run?
Answer
compose(f, g) builds a new function so that calling it on x runs g(x) FIRST and feeds its result
into f -- it computes exactly f(g(x)), right-to-left relative to how the call is written. This is how
small, single-purpose functions become a pipeline without being fused into one large function body, each
piece staying independently testable (Example 19 verified to compute f(g(x)); Example 34's
compose(*fns) folds a whole list of functions with the application order verified).
Q12 (co-12 -- Pipe Utilities). How does pipe(x, f, g) differ from compose(f, g) in what it
reads like, given that both can compute the same thing?
Answer
pipe(x, f, g) reads left-to-right in the order it actually executes -- "start with x, apply f,
then apply g" -- instead of the inside-out reading order nested calls (or compose) require. pipe
and compose can compute the identical thing; the difference is purely a readability trade for a chain
of more than two or three steps (Example 20's pipe(x, f, g) verified equal to the nested-call
equivalent; Example 74 directly contrasts a deep pipe chain against nested calls on real data).
Q13 (co-13 -- Map, Filter, Reduce). Which loop pattern does each of map, filter, and reduce
replace, and why does recognizing that matter?
Answer
map applies a function to every element (replacing a "transform each item" loop), filter keeps only
the elements a predicate accepts (replacing a "collect matching items" loop), and reduce folds a
sequence to one accumulated value (replacing an "initialize then mutate a running total" loop). Together
they cover the overwhelming majority of "loop over a collection and do something" code without a single
mutable accumulator variable exposed to the caller (Example 13's map; Example 14's filter; Example
15's reduce; Example 37 chains all three for a data summary).
Q14 (co-14 -- Recursion and Python's Missing TCO). What specific limit does CPython's lack of tail-call optimization put on deep recursion, and what are the two standard workarounds?
Answer
CPython, by explicit design choice, performs NO tail-call optimization -- a deeply recursive call still
grows the call stack one frame per call and eventually raises RecursionError, unlike languages that
optimize a self-tail-call into a loop automatically. The two standard workarounds are an explicit stack
or a trampoline (Example 44 converts a deep recursion into an explicit stack; Example 66 builds a
trampoline and verifies a deep recursion completes without ever raising RecursionError).
Q15 (co-15 -- Lazy Evaluation and Generators). What does "lazy" mean concretely for a Python generator, and what does that make possible that an eager list cannot do?
Answer
A generator computes a value only when it is actually needed -- a generator expression builds no list
at all, and each value is produced on demand, one next() call at a time. That is what makes an
infinite sequence (like itertools.count()) usable in the first place, and it lets a pipeline over a
sequence of unknown or infinite size run without ever materializing that sequence in memory (Example 21
shows only pulled values are computed; Example 63 builds a lazy, infinite prime sieve).
Q16 (co-16 -- The itertools Toolkit). Name three itertools functions this topic uses, and what
do they all have in common?
Answer
islice takes a slice of any iterable (including an infinite one) without materializing it, chain
concatenates iterables, accumulate produces running totals, groupby groups consecutive equal keys,
tee splits one iterator into several independent ones, and pairwise yields consecutive element
pairs. Every one of them is itself lazy -- none build an intermediate list (Example 23 uses islice
over an infinite count(); Example 39 verifies prefix sums via accumulate).
Q17 (co-17 -- Memoization). Why is memoization only safe to apply to a PURE function, specifically?
Answer
Memoization caches a function's results keyed by its arguments, so a repeated call with the same
arguments returns the cached result instead of recomputing it -- which is only safe because the function
being cached is pure (co-01). Caching an impure function's result would silently return a stale value
(and any stale side effect) forever after the first call, even after whatever the function actually
depends on has changed (Example 25 puts @lru_cache on a recursive fib and confirms cache_info()
shows hits; Kata 8 reproduces the stale-cache bug directly by memoizing an impure lookup).
Q18 (co-18 -- Decorators as Higher-Order Functions). What is @log_calls on def greet(): ...
exactly equivalent to, spelled without the @ syntax?
Answer
@log_calls on def greet(): ... is exactly greet = log_calls(greet) -- a decorator is a
higher-order function that takes a function and returns a wrapped replacement. Decorators let you attach
cross-cutting behavior (logging, caching, retrying, timing) to a function without touching that
function's own body, and functools.wraps preserves the wrapped function's __name__ and metadata so
introspection still sees the original identity (Example 26 wraps a function with a logging decorator;
Example 43 confirms functools.wraps preserves __name__).
Q19 (co-19 -- Point-Free Style). What does "point-free" refer to, and what is the topic's stance on how far to take it?
Answer
Point-free style expresses a transformation by composing functions together without ever naming the
argument the data flows through -- a pipeline built entirely from compose/pipe calls has no
lambda x: ... anywhere, because the "point" (the argument) is never explicitly written. This topic
treats it as one more tool, not a stylistic mandate -- taken too far it can obscure what's actually
happening (Example 35 rewrites a lambda pipeline point-free and confirms identical output; Example 73
builds a small point-free combinator library).
Q20 (co-20 -- Algebraic Data Types in Python). How does an ADT built from a union of frozen dataclasses make illegal states unrepresentable?
Answer
Circle | Square (PEP 604 union syntax) says a Shape is EITHER a Circle or a Square, each
carrying only the fields relevant to that variant -- there is no way to construct a Circle with a
side_length field, because that field doesn't exist on Circle at all. This is a stronger guarantee
than one class with nullable fields, where nothing stops a Circle instance from also carrying a stray
field that means nothing (Example 45 models a shape as a union of frozen dataclasses and verifies each
variant).
Q21 (co-21 -- Structural Pattern Matching). What is the ONE fact to hold onto about Python's
match/case exhaustiveness, and how do you defend against it yourself?
Answer
Python's match/case (PEP 634) has NO compile-time exhaustiveness checking at the language level --
an unmatched value simply falls through with no error unless the developer adds an explicit wildcard
case _ and raises there themselves. (A static type checker like pyright CAN flag a non-exhaustive
match over a fully-known closed union as a best-effort heuristic, but CPython itself enforces nothing at
runtime.) (Example 46 matches over the Circle | Square ADT with an explicit note on this gap; Kata 7
reproduces the silent fall-through directly by omitting both the new variant's case and the wildcard.)
Q22 (co-22 -- The Option/Maybe Type). What does a hand-rolled Option force the caller to do that
a bare Optional[int] return type does not?
Answer
A function returning Optional[int] still lets a caller forget the None check and crash later with a
"NoneType has no attribute" error, far from where the None actually originated. An Option-returning
function (Some/Nothing, hand-rolled and stdlib-only) forces the caller to unwrap the result
explicitly (or map through it), turning a possible runtime crash into a type-level obligation the
caller cannot silently skip (Example 48 builds Some/Nothing with map; Example 49 chains
Option-returning lookups and confirms short-circuit on the first miss).
Q23 (co-23 -- The Result/Either Type). What does a Result return type make visible to a caller
that an exception-raising function's signature does not?
Answer
def parse(s: str) -> int tells a caller nothing about whether it can raise; def parse(s: str) -> Result[int, str] states the failure mode directly in the type the caller sees at the call site, without
reading the implementation. This makes failure an ordinary value you can map over, compose, and
inspect, instead of a special control-flow mechanism that unwinds the stack (Example 50 carries an error
as a value through a hand-rolled Ok/Err; Example 51 confirms a Result chain stops at the first
Err).
Q24 (co-24 -- Railway-Oriented Error Handling). What does railway-oriented programming replace, and what handles the short-circuiting behavior instead?
Answer
Railway-oriented programming threads a Result through a pipeline of steps, replacing a pyramid of
nested if err != nil checks (or scattered try/except blocks) with a single linear chain that reads
top-to-bottom. The short-circuiting is handled once, by the Result type's own and_then/map
machinery, instead of being re-implemented by hand at every step (Example 52's validation pipeline
confirms one bad field short-circuits the rest; Kata 9 reproduces the crash that results when a
hand-written and_then skips checking for Err before calling the next step).
Q25 (co-25 -- Functor Intuition). What is the functor identity law, and what does it state
concretely about map?
Answer
The functor identity law states container.map(identity) == container -- mapping the identity function
over a container changes nothing but confirms map genuinely just "applies this function to whatever's
inside, however many things that is" (zero, one, or many), the exact same idea whether the container is
a list, Some, or Nothing. Recognizing the functor pattern lets you transfer intuition about list
map directly onto Option, Result, and any wrapped-value type you build (Example 53 shows the
identity law by example; Example 70 property-tests the identity and composition laws together).
Q26 (co-26 -- Applicative Intuition). What can an applicative combine that a plain map/functor
cannot, on its own?
Answer
map/functor only handles a function of ONE wrapped argument; an applicative is the natural next step
for combining SEVERAL independently-wrapped values with a multi-argument function --
map2(add, Some(2), Some(3)) unwraps both Options, calls add(2, 3), and wraps the result back up as
Some(5), short-circuiting to Nothing if either input is absent (Example 55's map2 combines two
Options; Example 71 builds an applicative that accumulates every validation error instead of stopping
at the first one).
Q27 (co-27 -- Monad Intuition). Why can't map alone chain functions that each return an
already-wrapped value, and what does bind/and_then add?
Answer
map applied to a function that itself returns a wrapped value produces a nested wrapper --
Ok(5).map(lambda x: Ok(x + 1)) would give Ok(Ok(6)), not Ok(6). bind (also called and_then or
flat_map) is the extra piece that flattens the result instead: Ok(5).and_then(lambda x: Ok(x + 1))
correctly returns Ok(6) (Example 51's and_then on Result; Example 72 shows left-identity,
right-identity, and associativity by example, the three monad laws made concrete).
Q28 (co-28 -- Functional Core, Imperative Shell). What does a functional-core/imperative-shell split quarantine, and why does the core need no mocking to test?
Answer
A functional-core/imperative-shell design splits a program into a pure transformation core (parse,
transform, aggregate -- no I/O anywhere) surrounded by a thin imperative shell that holds every effect
(reading a file, writing output, talking to a network). The core is tested directly with no mocking,
because it has nothing to mock -- it is the same purity-boundary idea from co-02, made into a concrete
architectural pattern (Example 57 splits a CSV analyzer into a pure core and an I/O shell; Kata 10
reproduces a debug print() that leaks I/O into what was meant to be the pure core).
Applied problems
Ten scenarios. Each describes a symptom without naming the concept -- decide which one applies, then check.
AP1. A function has no print statements, mutates no arguments, and returns a value -- yet calling
it twice with the exact same arguments produces two different results, because internally it reads
random.random() to decide part of its output.
Answer
This is a purity violation (co-01, co-02) -- purity requires the OUTPUT to depend only on the arguments, and reading ANY external, non-deterministic, or mutable state (not just writing to it) breaks that guarantee just as thoroughly as printing or mutating an argument would (Example 1; Example 3; Kata 1's mutation-based variant of the same "hidden dependency" family).
AP2. A caller passes a tuple of settings into a function that reuses the SAME tuple safely across
three different threads, but a code reviewer objects that a list was used for the exact same purpose
in a sibling module and later blamed for a hard-to-reproduce bug.
Answer
This is an immutability question (co-04) -- a tuple cannot be mutated after construction, so handing
it to three threads is safe by construction; a list handed the same way is an accident waiting to
happen, because ANY of the three threads (or any other code holding the reference) can mutate it out
from under the others (Example 4; Kata 2's dataclasses.replace shows the safe way to "update" an
immutable record instead of mutating it in place).
AP3. Three UI validators are built inside a loop, one per configured threshold, and stored in a list to call later -- but when they are all called afterward, EVERY one of them enforces the SAME threshold: the last one in the loop.
Answer
This is closure late binding (co-08) -- a closure captures the VARIABLE, not a frozen snapshot of its value at closure-creation time. By the time any of the three lambdas is called, the loop variable has already reached its final value, and all three see that same final value; binding it as a default argument at each iteration is the fix (Example 11; Example 12; Kata 3).
AP4. A three-step transform pipeline is built by composing three small functions, and the pipeline PASSES its own unit tests (each step function tested in isolation), but the end-to-end output is wrong -- inspection shows the steps ran in the opposite order the developer intended.
Answer
This is a composition-ordering mistake (co-11, co-12) -- compose(f, g) applies g FIRST then f
(right-to-left, mathematical convention), while pipe(x, f, g) applies f first then g (left-to-right,
reading order). Reaching for the wrong helper silently reverses execution order even though every
individual step is correct in isolation (Example 19; Example 20; Kata 4).
AP5. A reduce-based helper that tallies word frequency across a document works correctly on the
FIRST document ever passed to it in a test run, but on a SECOND, unrelated document it returns totals
that are visibly too high, as if it remembered the previous document's counts.
Answer
This is a mutable-default-argument leak (co-04, co-13) -- a mutable default argument (counts={}) is
created ONCE, at function-definition time, and every call that omits the argument shares that SAME dict
object. The reduce logic itself is correct; it is the accumulator it folds into that leaks across
calls (Example 15; Example 36; Kata 5).
AP6. A generator built to stream values from a large computed sequence is passed to one function that sums the values, and then passed AGAIN to a second function that expects to also print each value -- but the second function silently receives nothing at all, and no exception is ever raised.
Answer
This is generator exhaustion (co-15) -- a generator is a single-use, statefully-exhausted iterator; once fully consumed by the sum, there is nothing left to yield on a second pass, and Python raises nothing to flag the mistake, it simply produces an empty iteration (Example 21; Example 22; Kata 6).
AP7. A new variant is added to an existing closed set of dataclasses that form an ADT, and the
match/case evaluator that walks it keeps running without any error -- but silently returns None
for every value of the new variant, instead of the value the codebase actually expects.
Answer
This is match/case's missing runtime exhaustiveness check (co-20, co-21) -- Python does not warn or
error at the LANGUAGE level when a match block fails to cover every variant of a closed union; an
unmatched value simply falls through, and with no explicit case _: raising an error, that fall-through
looks like a legitimate result instead of a caught bug (Example 45; Example 46; Example 67; Kata 7).
AP8. A price-lookup function is decorated with @lru_cache to "make it faster," and the very first
call after deployment returns the right price -- but every call for the rest of the process's lifetime
keeps returning that SAME first price, even after the underlying price table is updated at runtime.
Answer
This is memoization applied to an impure function (co-17, co-01) -- lru_cache is only safe on a
function whose result depends ONLY on its arguments; this function secretly reads a mutable,
externally-updated dict, so caching its result the first time freezes a value that was only ever correct
at that one instant (Example 25; Example 41; Kata 8).
AP9. A validation pipeline threading a hand-rolled Result type is supposed to stop at the FIRST
failing step and report only that one error, but a bug report shows it instead crashes with an unrelated
AttributeError deep inside a LATER step, on a value that was never a legitimate success in the first
place.
Answer
This is railway-oriented error handling done incorrectly (co-22, co-23, co-24) -- a correct
and_then/bind chain checks whether the PREVIOUS step returned Ok/Some before ever calling the
next step's function. Skip that check, and the next step receives the raw Err/Nothing wrapper as if
it were a success value; calling success-shaped code on it crashes instead of cleanly short-circuiting
(Example 51; Example 52; Kata 9).
AP10. A function named analyze_report is described in code review as "the pure core" of a small
tool, but the reviewer notices it cannot be unit-tested without redirecting sys.stdout, because it has
a debug print() statement buried three lines into its body.
Answer
This is a functional-core/imperative-shell boundary violation (co-28, co-02) -- a function that performs
I/O (even just a diagnostic print) is, by definition, no longer part of the pure core. The moment a
test needs to capture or redirect output to verify behavior, that is the signal the boundary has been
drawn in the wrong place, and the print call belongs in the imperative shell instead (Example 57;
Example 76; Kata 10).
Code katas
Ten hands-on repetition drills. Each is a before/after .py file colocated under drilling/code/.
Every "before" script is a real, runnable Python program that misapplies the concept being drilled --
run it yourself, diagnose the bug from the observed behavior, fix it from memory, then compare your fix
against the "after" script and the model solution before checking your work against the
actually-executed output shown. Every kata script, both before and after, is fully type-annotated and
pyright --strict clean.
Kata 1 -- purity: a function that looks pure secretly mutates its input
relates to co-01, co-02, Example 1
Task. apply_discount(cart, rate) should return a discounted cart WITHOUT changing the caller's
own list. The version below is broken: it mutates each CartItem in place, so calling it twice
compounds the discount onto itself instead of applying a fresh one each time.
Before (drilling/code/kata-01-hidden-mutation-breaks-purity/before/kata.py)
"""Kata 1 (before): purity violation -- a function that looks pure secretly mutates its input."""
from dataclasses import dataclass
@dataclass # => NOT frozen -- a mutable record, which is exactly what makes the bug below possible
class CartItem:
name: str
price: float
def apply_discount(cart: list[CartItem], rate: float) -> list[CartItem]:
for item in cart:
item.price *= rate # SMELL: mutates each CartItem IN PLACE -- the caller's own data changes
return cart
cart = [CartItem("pen", 10.0), CartItem("mug", 20.0)]
first_pass = apply_discount(cart, 0.9)
second_pass = apply_discount(cart, 0.9) # meant to apply a FRESH 0.9 discount a second time
print([item.price for item in second_pass])
print(first_pass is second_pass) # BUG: same object -- the discount compounded onto itselfObserved (buggy) output (captured by actually running the script above):
[8.1, 16.2]
TrueAfter (drilling/code/kata-01-hidden-mutation-breaks-purity/after/kata.py)
"""Kata 1 (after): purity fix -- builds and returns NEW records, the caller's input is never touched."""
from dataclasses import dataclass, replace
@dataclass # => still a plain mutable record -- the FIX is in apply_discount, not the type
class CartItem:
name: str
price: float
def apply_discount(cart: list[CartItem], rate: float) -> list[CartItem]:
return [replace(item, price=item.price * rate) for item in cart] # => new CartItem per element
cart = [CartItem("pen", 10.0), CartItem("mug", 20.0)]
first_pass = apply_discount(cart, 0.9)
second_pass = apply_discount(cart, 0.9) # cart itself was never mutated, so this is a FRESH discount
print([item.price for item in second_pass])
print(first_pass is second_pass) # different objects, and cart's own prices are untouchedModel solution
Root cause: item.price *= rate mutates the SAME CartItem objects the caller's cart list
holds references to. Two calls to apply_discount therefore compound: the second call discounts the
ALREADY-discounted prices, not the originals. Building a brand-new CartItem per element (via
dataclasses.replace) means the function only ever reads the input, never writes to it.
Run: python3 kata.py
Output:
[9.0, 18.0]
FalseKata 2 -- immutability: direct attribute assignment on a frozen dataclass
relates to co-04, co-05, Example 5
Task. RetryConfig is a frozen dataclass, so "updating" retries by direct assignment should be
impossible. The version below is broken: it tries anyway, and the program crashes instead of producing
an updated config.
Before (drilling/code/kata-02-frozen-dataclass-replace/before/kata.py)
"""Kata 2 (before): immutability violation -- direct attribute assignment on a frozen dataclass."""
from dataclasses import dataclass
@dataclass(frozen=True)
class RetryConfig:
retries: int
timeout_seconds: float
config = RetryConfig(retries=1, timeout_seconds=5.0)
config.retries = 5 # type: ignore[misc] # BUG: frozen dataclasses reject assignment -- raises at runtime
print(config)Observed (buggy) output (captured by actually running the script above -- an uncaught crash):
Traceback (most recent call last):
...
dataclasses.FrozenInstanceError: cannot assign to field 'retries'After (drilling/code/kata-02-frozen-dataclass-replace/after/kata.py)
"""Kata 2 (after): immutability fix -- dataclasses.replace() builds a NEW record, never mutates."""
from dataclasses import dataclass, replace
@dataclass(frozen=True)
class RetryConfig:
retries: int
timeout_seconds: float
config = RetryConfig(retries=1, timeout_seconds=5.0)
updated_config = replace(config, retries=5) # => a NEW RetryConfig, config itself is untouched
print(config)
print(updated_config)Model solution
Root cause: a frozen dataclass overrides __setattr__ to reject any assignment after
__init__, at RUNTIME -- # type: ignore[misc] only silences pyright's static warning, it does not
change what the program does when actually run. dataclasses.replace(config, retries=5) is the correct
"update": it builds a NEW instance with the named fields overridden, leaving config itself completely
untouched.
Run: python3 kata.py
Output:
RetryConfig(retries=1, timeout_seconds=5.0)
RetryConfig(retries=5, timeout_seconds=5.0)Kata 3 -- closures: every closure in the list captures the SAME loop variable
relates to co-08, Example 11, Example 12
Task. Three validators, one per threshold in [10, 20, 30], should each enforce a DIFFERENT
threshold. The version below is broken: all three closures capture the SAME loop variable t, so by
the time any of them is called, t has already reached its final value.
Before (drilling/code/kata-03-closure-late-binding/before/kata.py)
"""Kata 3 (before): closure violation -- every closure in the list captures the SAME loop variable."""
from typing import Callable
thresholds = [10, 20, 30]
validators: list[Callable[[int], bool]] = []
for t in thresholds:
validators.append(lambda x: x > t) # SMELL: captures the VARIABLE t, not its value at this point
print([v(15) for v in validators]) # every validator should differ; watch what actually happensObserved (buggy) output (captured by actually running the script above):
[False, False, False]After (drilling/code/kata-03-closure-late-binding/after/kata.py)
"""Kata 3 (after): closure fix -- a default argument binds EACH closure's own value at creation time."""
from typing import Callable
thresholds = [10, 20, 30]
validators: list[Callable[[int], bool]] = []
for t in thresholds:
validators.append(lambda x, t=t: x > t) # => t=t binds THIS iteration's value, not the variable
print([v(15) for v in validators]) # each validator now uses its own captured thresholdModel solution
Root cause: a closure captures the ENCLOSING VARIABLE, not a snapshot of its value at the moment
the lambda is defined. By the time validators is actually called (after the loop finishes), t holds
its LAST value (30) for all three lambdas equally. lambda x, t=t: x > t works because Python
evaluates a default-argument expression exactly ONCE, at the moment THAT lambda is created -- binding
each closure's own t=t default to that iteration's value permanently.
Run: python3 kata.py
Output:
[True, False, False]Kata 4 -- composition: compose() runs right-to-left, not the intended reading order
relates to co-11, co-12, Example 19, Example 20
Task. transform(5) should compute "add one, THEN double" -- (5 + 1) * 2 = 12. The version
below is broken: compose(add_one, double) reads left-to-right like a pipe call, but compose itself
runs its SECOND argument first.
Before (drilling/code/kata-04-compose-vs-pipe-order/before/kata.py)
"""Kata 4 (before): composition-order bug -- compose() runs right-to-left, not the intended order."""
from typing import Callable
def compose(f: Callable[[int], int], g: Callable[[int], int]) -> Callable[[int], int]:
return lambda x: f(g(x)) # => g runs FIRST, then f -- right-to-left, mathematical convention
def add_one(x: int) -> int:
return x + 1
def double(x: int) -> int:
return x * 2
# INTENT: "add one, THEN double" -- reading compose(add_one, double) left to right like a pipe call.
transform = compose(add_one, double) # SMELL: reads left-to-right but compose runs right-to-left
result = transform(5) # BUG: double(5) runs FIRST giving 10, then add_one gives 11 -- not (5+1)*2=12
print(result)Observed (buggy) output (captured by actually running the script above):
11After (drilling/code/kata-04-compose-vs-pipe-order/after/kata.py)
"""Kata 4 (after): composition-order fix -- pipe() runs left-to-right, matching the reading order."""
from functools import reduce
from typing import Callable
def pipe(x: int, *fns: Callable[[int], int]) -> int:
return reduce(lambda acc, fn: fn(acc), fns, x) # => applies fns in ORDER, left to right
def add_one(x: int) -> int:
return x + 1
def double(x: int) -> int:
return x * 2
# INTENT: "add one, THEN double" -- pipe(5, add_one, double) reads AND runs left to right.
result = pipe(5, add_one, double) # => add_one(5)=6 runs first, then double(6)=12
print(result)Model solution
Root cause: compose(f, g) computes f(g(x)) -- g ALWAYS runs first, regardless of how
natural the call compose(add_one, double) looks to read left to right. pipe(x, *fns) sidesteps the
whole ambiguity by reading AND executing in the same order: the first function argument after x is
the first one applied.
Run: python3 kata.py
Output:
12Kata 5 -- map/filter/reduce: a mutable default argument silently shares ONE accumulator
relates to co-04, co-13, Example 15, Example 36
Task. histogram(words) should start counting from a fresh, empty dict every time the counts
argument is omitted. The version below is broken: two DIFFERENT, unrelated calls end up sharing the
SAME accumulator dict.
Before (drilling/code/kata-05-mutable-default-accumulator/before/kata.py)
"""Kata 5 (before): a mutable default argument silently shares ONE accumulator across every call."""
def histogram(words: list[str], counts: dict[str, int] = {}) -> dict[str, int]: # SMELL: mutable default
for w in words:
counts[w] = counts.get(w, 0) + 1 # BUG: mutates the SHARED default dict object in place
return counts
first_doc = histogram(["a", "b", "a"])
print(first_doc)
second_doc = histogram(["c"]) # an UNRELATED document -- should start from a fresh, empty count
print(second_doc) # BUG: "c" is mixed in with leftover counts from the first, unrelated callObserved (buggy) output (captured by actually running the script above):
{'a': 2, 'b': 1}
{'a': 2, 'b': 1, 'c': 1}After (drilling/code/kata-05-mutable-default-accumulator/after/kata.py)
"""Kata 5 (after): fix -- a None sentinel builds a FRESH dict on every call, reduce folds over it."""
from functools import reduce
def histogram(words: list[str], counts: dict[str, int] | None = None) -> dict[str, int]:
start: dict[str, int] = counts if counts is not None else {} # => fresh dict every call
return reduce(lambda acc, w: {**acc, w: acc.get(w, 0) + 1}, words, start) # => builds NEW dicts
first_doc = histogram(["a", "b", "a"])
print(first_doc)
second_doc = histogram(["c"]) # a fresh, empty accumulator every time the default is used
print(second_doc) # correctly isolated from the first, unrelated callModel solution
Root cause: Python evaluates a default argument expression exactly ONCE, at function-definition
time -- {} is created a single time, and the before version's loop MUTATES that single shared dict
in place on every call that omits counts. Using None as a sentinel and building a fresh {} inside
the body (or, as shown, folding with functools.reduce into a NEW dict each step) removes the shared
mutable target entirely.
Run: python3 kata.py
Output:
{'a': 2, 'b': 1}
{'c': 1}Kata 6 -- laziness: a generator is iterated once, exhausted, then silently yields nothing again
relates to co-15, Example 21, Example 22
Task. values should be usable for a second pass after sum() consumes it once. The version
below is broken: squared(...) returns a single-use generator, and the second pass over the SAME
object silently produces nothing.
Before (drilling/code/kata-06-generator-exhaustion/before/kata.py)
"""Kata 6 (before): a generator is iterated once, exhausted, then silently yields nothing again."""
from typing import Iterator
def squared(nums: list[int]) -> Iterator[int]:
for n in nums:
yield n * n # => a lazy, single-use generator -- each value produced only on demand
values = squared([1, 2, 3]) # SMELL: ONE generator object, about to be consumed twice below
total = sum(values) # first pass -- fully consumes/exhausts the generator
print(total)
remaining = list(values) # BUG: the SAME exhausted generator -- nothing left to yield, no error raised
print(remaining)Observed (buggy) output (captured by actually running the script above):
14
[]After (drilling/code/kata-06-generator-exhaustion/after/kata.py)
"""Kata 6 (after): fix -- materialize a list when the SAME data is genuinely needed more than once."""
from typing import Iterator
def squared(nums: list[int]) -> Iterator[int]:
for n in nums:
yield n * n
values = list(squared([1, 2, 3])) # => a list, not a generator -- safe to iterate as many times as needed
total = sum(values) # first pass over the materialized list
print(total)
remaining = list(values) # second pass -- the list still has every element, nothing was consumed
print(remaining)Model solution
Root cause: a generator object tracks its own position and raises StopIteration once exhausted --
sum(values) fully drains it, so list(values) afterward has nothing left to pull and produces an
empty list with NO error to flag the mistake. Wrapping the generator in list(...) once, up front,
trades away the laziness (the whole sequence is now materialized in memory) in exchange for being safely
re-iterable as many times as needed -- the right trade whenever a value is genuinely consumed more than
once.
Run: python3 kata.py
Output:
14
[1, 4, 9]Kata 7 -- pattern matching: a new ADT variant falls through match/case silently
relates to co-20, co-21, Example 46, Example 67
Task. area(shape) should compute an area for every Shape variant, including the newly-added
Triangle. The version below is broken: the match block has no case for Triangle and no
wildcard case _, so the function falls through and returns None instead of an area or an error.
Before (drilling/code/kata-07-match-case-no-exhaustiveness/before/kata.py)
"""Kata 7 (before): a new ADT variant falls through match/case silently -- no compile-time warning."""
from dataclasses import dataclass
@dataclass(frozen=True)
class Circle:
radius: float
@dataclass(frozen=True)
class Square:
side: float
@dataclass(frozen=True)
class Triangle: # => a NEW variant added to the shape family after the evaluator below was written
base: float
height: float
Shape = Circle | Square | Triangle # => the ADT now has THREE variants
def area(shape: Shape) -> float: # type: ignore[misc] # pyright statically flags what CPython won't
match shape: # type: ignore[misc] # SMELL: no Triangle case, no wildcard -- pyright catches it, CPython doesn't
case Circle(radius=r):
return 3.14159 * r * r
case Square(side=s):
return s * s
# BUG: falls through here for Triangle -- Python raises NOTHING, the function returns None
result = area(Triangle(base=4.0, height=3.0)) # a legitimate Shape value the evaluator never handles
print(result) # BUG: prints None instead of raising or computing an areaObserved (buggy) output (captured by actually running the script above):
NoneAfter (drilling/code/kata-07-match-case-no-exhaustiveness/after/kata.py)
"""Kata 7 (after): fix -- an explicit wildcard case raises instead of silently falling through."""
from dataclasses import dataclass
@dataclass(frozen=True)
class Circle:
radius: float
@dataclass(frozen=True)
class Square:
side: float
@dataclass(frozen=True)
class Triangle:
base: float
height: float
Shape = Circle | Square | Triangle
def area(shape: Shape) -> float:
match shape:
case Circle(radius=r):
return 3.14159 * r * r
case Square(side=s):
return s * s
case Triangle(base=b, height=h): # => the new variant now has its OWN branch
return 0.5 * b * h
case _: # => a manual safety net for any FUTURE variant this evaluator hasn't been taught yet
raise ValueError(f"unhandled shape variant: {shape!r}")
result = area(Triangle(base=4.0, height=3.0))
print(result)Model solution
Root cause: Python's match/case has no LANGUAGE-level exhaustiveness check -- a match block
that doesn't cover every variant simply falls through with no error, and a function with no explicit
return on that path implicitly returns None. (Note: this specific example IS statically catchable --
pyright's reportMatchNotExhaustive correctly flags the before version, which is why it needs a
# type: ignore to demonstrate the runtime behavior at all; the underlying CPython runtime still
enforces nothing.) Adding both the missing case Triangle(...) branch AND a case _: wildcard that
raises makes any FUTURE unhandled variant a loud, immediate crash instead of a silent None.
Run: python3 kata.py
Output:
6.0Kata 8 -- memoization: @lru_cache applied to an impure function freezes a stale value
relates to co-17, co-01, Example 25, Example 41
Task. get_price("widget") should always reflect the CURRENT contents of prices. The version
below is broken: @lru_cache treats get_price as if it were pure, so it caches the first result and
never notices prices changed.
Before (drilling/code/kata-08-memoize-impure-function/before/kata.py)
"""Kata 8 (before): @lru_cache applied to an IMPURE function -- the cache freezes a stale value."""
from functools import lru_cache
prices: dict[str, float] = {"widget": 9.99}
@lru_cache # SMELL: memoization is only safe on a PURE function -- this one reads mutable module state
def get_price(sku: str) -> float:
return prices[sku] # BUG: depends on `prices`, not just on `sku` -- not actually pure
first_lookup = get_price("widget")
print(first_lookup)
prices["widget"] = 14.99 # the underlying price table changes at runtime, as real price tables do
second_lookup = get_price("widget")
print(second_lookup) # BUG: still 9.99 -- the cache never learns the price actually changedObserved (buggy) output (captured by actually running the script above):
9.99
9.99After (drilling/code/kata-08-memoize-impure-function/after/kata.py)
"""Kata 8 (after): fix -- no memoization on an impure lookup; the function always reads the current state."""
prices: dict[str, float] = {"widget": 9.99}
def get_price(sku: str) -> float: # => no @lru_cache -- this function depends on prices, not just sku
return prices[sku]
first_lookup = get_price("widget")
print(first_lookup)
prices["widget"] = 14.99 # the underlying price table changes at runtime
second_lookup = get_price("widget")
print(second_lookup) # correctly reflects the updated price -- nothing was cached to go staleModel solution
Root cause: get_price's result depends on the MODULE-LEVEL prices dict, not just on its own
sku argument -- it is not actually pure, even though its signature looks like a simple lookup.
@lru_cache assumes purity and caches by argument alone, so once get_price("widget") is called once,
every LATER call with "widget" returns the frozen first result forever, regardless of what prices
does afterward. The fix is not a smarter cache; it's recognizing this function was never safe to
memoize in the first place.
Run: python3 kata.py
Output:
9.99
14.99Kata 9 -- railway errors: a hand-written and_then skips the Err check before calling the next step
relates to co-22, co-23, co-24, Example 51, Example 52
Task. and_then(first_step, double_it) should propagate first_step's Err untouched when
first_step already failed, never calling double_it at all. The version below is broken: it calls
step(result.value) unconditionally, even when result is an Err with no .value field.
Before (drilling/code/kata-09-railway-skips-err-check/before/kata.py)
"""Kata 9 (before): a hand-written and_then forgets to check for Err before calling the next step."""
from dataclasses import dataclass
from typing import Callable, Generic, TypeVar
T = TypeVar("T")
E = TypeVar("E")
U = TypeVar("U")
@dataclass(frozen=True)
class Ok(Generic[T]):
value: T
@dataclass(frozen=True)
class Err(Generic[E]):
error: E
Result = Ok[T] | Err[E]
def and_then(result: Result[T, str], step: Callable[[T], Result[U, str]]) -> Result[U, str]:
# SMELL: calls step() on result.value UNCONDITIONALLY -- never checks whether result is an Err first
return step(result.value) # type: ignore[union-attr] # BUG: Err has no .value -- crashes on failure
def parse_positive(raw: str) -> Result[int, str]:
n = int(raw)
return Ok(n) if n > 0 else Err(f"{raw} is not positive")
def double_it(n: int) -> Result[int, str]:
return Ok(n * 2)
first_step = parse_positive("-5") # this is a legitimate FAILURE, not a bug in parse_positive itself
final = and_then(first_step, double_it) # BUG: crashes with AttributeError instead of propagating Err
print(final)Observed (buggy) output (captured by actually running the script above -- an uncaught crash):
Traceback (most recent call last):
...
AttributeError: 'Err' object has no attribute 'value'After (drilling/code/kata-09-railway-skips-err-check/after/kata.py)
"""Kata 9 (after): fix -- and_then checks the variant BEFORE calling the next step, short-circuiting on Err."""
from dataclasses import dataclass
from typing import Callable, Generic, TypeVar
T = TypeVar("T")
E = TypeVar("E")
U = TypeVar("U")
@dataclass(frozen=True)
class Ok(Generic[T]):
value: T
@dataclass(frozen=True)
class Err(Generic[E]):
error: E
Result = Ok[T] | Err[E]
def and_then(result: Result[T, str], step: Callable[[T], Result[U, str]]) -> Result[U, str]:
match result: # => checks the variant FIRST, exactly once, before ever calling step()
case Ok(value=v):
return step(v) # => only reachable when result actually succeeded
case Err(error=e):
return Err(e) # => short-circuits -- step() is never called on a failure
def parse_positive(raw: str) -> Result[int, str]:
n = int(raw)
return Ok(n) if n > 0 else Err(f"{raw} is not positive")
def double_it(n: int) -> Result[int, str]:
return Ok(n * 2)
first_step = parse_positive("-5")
final = and_then(first_step, double_it) # correctly short-circuits, double_it is never called
print(final)Model solution
Root cause: the broken and_then reaches for result.value before confirming result is
actually an Ok -- Err has an .error field, not .value, so the attribute access crashes the
instant a legitimate failure reaches it. Pattern-matching on result's variant FIRST (case Ok(value=v)
vs. case Err(error=e)) makes the check impossible to skip: step is only ever called inside the Ok
branch, and an Err is returned untouched from the Err branch, exactly the short-circuit railway
semantics are supposed to guarantee.
Run: python3 kata.py
Output:
Err(error='-5 is not positive')Kata 10 -- functional core: a debug print() leaks I/O into what should be the pure core
relates to co-28, co-02, Example 57, Example 76
Task. parse_and_total(rows) is meant to be the pure core of a small tool -- callable and
testable with zero I/O. The version below is broken: it has a debug print() buried inside it, so a
test that wants to verify its return value ALSO has to capture stdout to avoid noisy test output.
Before (drilling/code/kata-10-print-leaks-into-core/before/kata.py)
"""Kata 10 (before): a debug print() leaks I/O into what is supposed to be the pure core."""
import io
from contextlib import redirect_stdout
def parse_and_total(rows: list[str]) -> int: # meant to be the PURE core -- no I/O anywhere
print(f"parsing {len(rows)} rows") # SMELL: a debug print buried inside the "pure" core
return sum(int(row) for row in rows)
rows = ["10", "20", "30"]
captured = io.StringIO()
with redirect_stdout(captured): # BUG: a "pure" function should never need its stdout redirected
total = parse_and_total(rows)
print(total)
print(repr(captured.getvalue())) # BUG: proves the core actually performed I/O -- it isn't pureObserved (buggy) output (captured by actually running the script above):
60
'parsing 3 rows\n'After (drilling/code/kata-10-print-leaks-into-core/after/kata.py)
"""Kata 10 (after): fix -- the core stays pure; only the shell performs I/O, and only around the core."""
import io
from contextlib import redirect_stdout
def parse_and_total(rows: list[str]) -> int: # => the pure core -- no print, no I/O of any kind
return sum(int(row) for row in rows)
def run_shell(rows: list[str]) -> None: # => the imperative shell -- the ONLY place logging happens
print(f"parsing {len(rows)} rows")
total = parse_and_total(rows)
print(total)
rows = ["10", "20", "30"]
captured = io.StringIO()
with redirect_stdout(captured):
total = parse_and_total(rows) # calling the core directly needs NO stdout redirection to test
print(total)
print(repr(captured.getvalue())) # empty -- the core performed no I/O, confirming it stayed pure
run_shell(rows) # the shell is where logging + the final print belong -- the core stays silentModel solution
Root cause: a print() call, even a "harmless" diagnostic one, is I/O -- the moment parse_and_total
performs it, the function is no longer pure, and any test of its RETURN VALUE also has to manage its
side effect (here, by redirecting stdout) to avoid polluting test output. Moving the print into a
separate run_shell function draws the functional-core/imperative-shell boundary explicitly: the core
is called directly with no redirection needed at all, and the shell is the ONLY place any output happens.
Run: python3 kata.py
Output:
60
''
parsing 3 rows
60Self-check checklist
Work through this list without looking anything up. Every item should be something you can do from memory, not something you'd need to search for.
Purity, side effects, referential transparency, immutability, persistence
- I can call a function twice with identical arguments and explain why a PURE function must give the same result both times, while an impure one might not (co-01).
- I can name the specific hidden effect that turns a pure-looking function into an impure one -- printing, mutating an argument, reading external mutable state (co-02).
- I can replace a call with its own return value inside a larger expression and explain why that substitution is always safe for a referentially transparent function (co-03).
- I can reach for
tuple,frozenset,@dataclass(frozen=True), orMappingProxyTypewhen data needs to be safe to hand to another thread or function (co-04). - I can explain why structural sharing keeps a persistent update cheap, instead of requiring a full deep copy on every change (co-05).
Functions as values: first-class, higher-order, closures, currying, composition, pipe
- I can assign a function to a variable, store several in a list, and pass one as an argument, without any special syntax (co-06).
- I can write a higher-order function (like
apply(fn, x)) that works identically regardless of whatfnitself does (co-07). - I can write a closure that captures a configuration value once and reuses it on every later call, and name exactly where that captured state lives (co-08).
- I can hand-curry a two-argument function into
f(a)(b), and explain how currying differs from partial application (co-09). - I can reach for
functools.partialinstead of a hand-written wrapping lambda to fix some of a function's arguments (co-10). - I can write
compose(f, g)and correctly state thatgruns FIRST, computingf(g(x))(co-11). - I can write a
pipe(x, f, g)helper that reads AND executes left to right, and explain when that readability trade is worth it overcompose(co-12).
Sequence transforms, recursion, laziness, itertools, memoization
- I can recognize a hand-written accumulation loop as secretly a
map, afilter, or areduce, and rewrite it as one (co-13). - I can state CPython's specific limit on deep recursion (no tail-call optimization,
RecursionError) and name the two standard workarounds (co-14). - I can write a generator that produces values on demand, and explain why that makes an infinite sequence usable at all (co-15).
- I can reach for
itertools.islice,chain,accumulate,groupby,tee, orpairwiseinstead of hand-rolling the equivalent loop (co-16). - I can explain why memoization is only safe on a function that is genuinely PURE, and reproduce what goes wrong when it's applied to one that isn't (co-17).
Decorators, point-free style, ADTs, pattern matching
- I can write a decorator, explain it as
greet = log_calls(greet)spelled with@, and usefunctools.wrapsto preserve the wrapped function's identity (co-18). - I can rewrite a lambda-based pipeline in point-free style, and explain when doing so helps readability versus when it obscures it (co-19).
- I can model a "one of several shapes" value as a union of frozen dataclasses (an ADT), and explain what illegal state that construction makes unrepresentable (co-20).
- I can dispatch over an ADT's variants with
match/case, and state precisely what Python'smatch/casedoes NOT check for me at the language level (co-21).
Option/Result, railway errors, functor/applicative/monad
- I can build and use a hand-rolled
Some/Nothing, and explain what it forces a caller to do that a bareOptional[int]does not (co-22). - I can build and use a hand-rolled
Ok/Err, and explain what it makes visible in a function's signature that an exception does not (co-23). - I can thread a
Resultthrough a multi-step pipeline so the FIRST failure short-circuits every remaining step (co-24). - I can state the functor identity law (
container.map(identity) == container) and explain what it confirms aboutmap's behavior (co-25). - I can combine two independently-wrapped values with a multi-argument function (an applicative
map2), and explain how that differs from chaining twomapcalls (co-26). - I can chain functions that each return an already-wrapped value with
bind/and_then, and explain whymapalone would produce a nested wrapper instead (co-27).
Functional core, imperative shell
- I can split a small tool into a pure core (parse/transform/aggregate, zero I/O) and a thin imperative shell that holds every effect, and explain why the core needs no mocking to test (co-28).
Elaborative interrogation & self-explanation
Nine why/why-not prompts. Answer in your own words before checking -- the goal is explaining the reasoning, not reciting a definition.
W1. Why does this topic insist "push side effects to the edges and keep a pure core" instead of "eliminate side effects entirely" -- given that a real program unavoidably has to read a file or print something eventually?
Answer
Eliminating I/O is not actually an option for a program that has to do anything observable in the real
world -- the honest goal is taming-state: quarantining WHERE effects live instead of pretending they
don't exist. The functional-core/imperative-shell split (co-28) is the concrete architectural answer:
the core stays testable with zero mocking precisely because it holds none of the effects, and the shell
is small and thin precisely because it holds ALL of them, in one place a reviewer can scrutinize
directly.
W2. Why is determinism-vs-emergence -- purity buying deterministic, replayable behavior -- named
as one of this topic's cross-cutting big ideas, rather than treated as a property specific to co-01
alone?
Answer
Determinism shows up wherever purity does, under different names: memoization (co-17) is only safe because a pure function's result is deterministic by argument; a persistent data structure's old version (co-05) stays trustworthy because nothing about it can have silently changed; property-testing purity itself (Example 58) is really testing determinism directly. Recognizing "this is the same determinism-vs-emergence trade again" is what lets a developer transfer intuition from one FP feature to an unfamiliar one.
W3. Why does the Tensions & trade-offs section of this topic's overview call insisting on purity inside a tight numeric loop "dogma, not engineering," rather than treating purity as an unconditional good?
Answer
abstraction-and-its-cost is the honest counterweight this topic names explicitly: immutability
allocates, and a persistent update that shares structure is still not free -- Example 75 measures this
cost numerically rather than asserting it. A tight numeric loop or a huge in-place buffer is a place an
imperative core is honestly faster, and pretending otherwise trades a real performance cost for a
principle that buys nothing concrete in that specific spot.
W4. Why does Kata 9's and_then crash with an AttributeError instead of just returning the wrong
value quietly, the way several of this topic's other katas do?
Answer
Err genuinely has no .value field -- it has .error instead -- so step(result.value) is not
merely logically wrong, it is a type-shape mismatch the moment result is actually an Err. Contrast
Kata 7, where the bug (a missing match case) produces a plausible-looking None instead of a crash:
some correctness bugs fail loudly by construction (a crash you cannot miss), and others fail silently by
construction (a wrong value indistinguishable from a right one) -- recognizing which failure MODE a bug
class produces is itself a useful skill.
W5. Why does Kata 7's note explicitly point out that pyright's reportMatchNotExhaustive DOES catch
the missing Triangle case statically, when co-21's own concept description says match/case has "no
compile-time exhaustiveness checking"?
Answer
Both statements are true at different levels: the Python LANGUAGE itself (CPython, at runtime) enforces
nothing about match/case exhaustiveness -- an unmatched value simply falls through. A separate,
optional STATIC TYPE CHECKER (pyright) can layer a best-effort exhaustiveness heuristic on top, but only
when it can see the union's FULL set of variants statically; anything that defeats that visibility (a
dynamically-constructed type, a variant defined in another module pyright isn't shown) removes the
safety net entirely. The topic's own maxim -- always add a case _: that raises -- is what makes the
guarantee hold regardless of which type checker (if any) a project runs.
W6. Why does co-24 (railway-oriented error handling) matter as its own named concept, when it is
"just" and_then/bind (co-27) applied in a sequence?
Answer
Railway-oriented programming names the SHAPE of the resulting code, not a new mechanism: a chain of
and_then calls reads top-to-bottom as one linear success path, with every failure mode handled ONCE by
the Result type's own machinery instead of re-implemented as a nested if err != nil pyramid at every
step. Naming the pattern is what lets a developer recognize "this messy nested-conditional code is
secretly a railway that hasn't been written as one yet" the next time they see it.
W7. Why does this topic's capstone (and Example 80) combine a functional core, Result-based
errors, AND an applicative combine into ONE tool, rather than demonstrating each pattern in a separate,
smaller example?
Answer
Each pattern in isolation (Examples 1-56) proves the mechanism works; the capstone proves the mechanisms
COMPOSE -- a real tool needs a pure core (co-28) that produces Result-typed rows (co-23), some of
which fail independently and need combining via an applicative that accumulates every error instead of
stopping at the first (co-26), all while the imperative shell stays the ONLY place touching a file. That
composition is the actual skill a production codebase demands, not any one pattern recited alone.
W8. Why does co-17 (memoization) explicitly require co-01 (purity) as a PRECONDITION, rather than being a general-purpose "make any function faster" technique?
Answer
Memoization's entire correctness argument is "the same arguments always produce the same result, so
caching by argument is safe" -- which is EXACTLY the purity guarantee (co-01) and nothing else. Kata 8
demonstrates the failure directly: @lru_cache on a function reading mutable external state doesn't
just fail to speed anything up correctly, it actively returns WRONG, stale answers forever after the
first call, which is worse than not caching at all.
W9. Why does this topic teach functors, applicatives, and monads (co-25 to co-27) as "patterns you recognize and use" rather than with the rigorous, law-checking treatment a category-theory course would give them?
Answer
The topic's own scope note names this trade-off directly: a gentle, practical first exposure lets a
developer recognize the SAME shape (map on a list, map on an Option, and_then chaining
Result-returning steps) across libraries and languages immediately, which is the day-to-day payoff.
The deeper, law-checking rigor (why the functor laws must hold, what associativity actually guarantees)
is deliberately deferred to a later Type Systems topic -- teaching both at once here would spend this
topic's whole budget on formalism before a reader has even seen the patterns pay off in working code.
← Previous: Capstone · Next: 24 · Concurrency & Parallelism →
Last updated July 16, 2026