Overview
This page is the spaced-repetition companion to the Programming Paradigms 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 25 concepts and cite
specific examples spanning all 80 worked examples in the Beginner, Intermediate, and Advanced tiers,
plus the capstone.
Recall Q&A
Twenty-five short-answer questions, one per concept. Answer from memory before opening each answer.
Q1 (co-01 -- Imperative Programming). What makes code "imperative," and what specific cost does that buy?
Answer
Imperative code is an explicit sequence of statements that change state step by step -- a loop that mutates a counter, an assignment that overwrites a variable. It maps directly onto how a CPU actually executes instructions, which makes it predictable to trace one line at a time -- but nothing stops a later statement from silently depending on state set several lines earlier (Example 1's mutating word-count loop; Example 33's turnstile modeled as a mutable state variable).
Q2 (co-02 -- Procedural Abstraction). What changes, and what stays exactly the same, when an inline imperative block is refactored into named procedures?
Answer
The underlying execution model stays 100% imperative -- tokenize() and tally() are still just as
mutating internally. What changes is that the logic now has a name, a boundary, and an independent
test surface, so main() shrinks to a readable sequence of calls (Example 2's tokenize()/tally()
refactor; Example 27's procedural area_via_tag()).
Q3 (co-03 -- Structured Programming). Why do sequence, selection, and iteration replace
goto-style jumps specifically, rather than just being "cleaner syntax"?
Answer
A goto-shaped jump (in Python, usually a boolean flag standing in for a jump target) can transfer
control from anywhere to anywhere, so understanding one line can require understanding the whole
program. Sequence, selection, and iteration each have exactly one entry and one exit, so a reader can
understand a block by reading its own lines, never hunting for where control jumped in from (Example 3
replaces a boolean goto flag with if/elif/else; Example 26 flattens a nested-if pyramid into
guard clauses).
Q4 (co-04 -- Mutable State and Assignment). What's the concrete difference between rebinding a name and mutating an object through an alias, and why does conflating them cause bugs?
Answer
Rebinding (x = 20) points a name at a new object; mutating through an alias (alias.append(4))
changes the SAME object every name pointing at it can see. An aliased mutable object can be changed by
code that has no visible connection to the code reading it later -- two names, one box (Example 4
demonstrates both directly; Example 53 dispatches transitions off a plain enum.Enum).
Q5 (co-05 -- Object-Oriented Paradigm). What does bundling state and behavior into an object buy that a bare data structure plus a separate function does not?
Answer
Every method on a class has guaranteed access to that instance's own data without it being passed in
separately, and two instances of the same class never share state by accident. That isolation is what
makes encapsulation (co-06) possible in the first place (Example 6's WordCounter class; Example 30's
OO leg of the four-paradigm comparison).
Q6 (co-06 -- Encapsulation as State Containment). Why does forcing every mutation through a small set of methods matter more than just "hiding" a field?
Answer
When every mutation is forced through deposit()/withdraw(), those methods become the ONLY place an
invariant (balance never negative) can be broken -- which means they're also the only place it needs
to be checked, tested, and trusted. Direct field access scatters that responsibility across every
caller instead (Example 7's BankBalance guards its invariant, and a rejected withdrawal is shown to
leave it intact).
Q7 (co-07 -- Message-Passing vs Method Call). Why can two unrelated classes with no shared base class both respond correctly to the same message?
Answer
Structural typing (a Protocol) means the caller only needs to know the message SHAPE, never the
concrete class receiving it -- each receiver decides for itself how to respond. This is what makes
polymorphic dispatch work: the same call site produces different behavior depending purely on which
object receives the message at runtime (Example 8 sends speak() to an unrelated Duck and Dog).
Q8 (co-08 -- Declarative vs Imperative). What does the reader gain, and what do they have to trust blindly, when code is declarative instead of imperative?
Answer
Declarative code hands the "how" to the language or engine, so the reader only verifies the "what" -- there's no accumulator variable or loop index to trace. The cost is that the "how" becomes opaque; you trust the engine (a comprehension, a rules table, a SQL planner) to do it correctly (Example 9's comprehension vs. loop; Example 54's declarative validation rules table).
Q9 (co-09 -- Functional Paradigm Overview). Why is a function that never mutates anything the caller can observe "trivially safe" in a way an equivalent mutating loop is not?
Answer
A function that only reads its arguments and returns a new value is safe to call twice, safe to call
from multiple threads, and easy to test in isolation -- no setup beyond the arguments is ever needed
(Example 11's Counter/reduce word count with no visible mutation; Example 64's state rebuilt by
folding an event log).
Q10 (co-10 -- Expressions vs Statements). Why does "threading values through expressions" read differently than "threading values through assigned variables," even when both compute the same result?
Answer
An expression composes -- it can be passed as an argument, embedded in a larger expression, or
returned directly -- while a statement can only be executed for its effect. Chaining expressions is a
large part of what makes declarative code read as one flowing computation rather than a sequence of
steps (Example 12 contrasts a conditional expression against an if/assign block; Example 44's lazy
pipeline is built entirely from composed expressions).
Q11 (co-11 -- Pure vs Impure). What does "referentially transparent" mean concretely, and what specifically breaks it?
Answer
A pure function called twice with the same argument always produces the same result and touches
nothing else -- it can be reasoned about, cached, and reused without knowing its calling context. Any
hidden effect (appending to a log, mutating an argument) breaks that guarantee even if the return value
looks identical (Example 13 contrasts normalize() against normalize_and_log(); Example 67 refactors
a mutation-heavy routine into a pure fold).
Q12 (co-12 -- First-Class and Higher-Order Functions). What becomes possible once functions are ordinary values that can be passed as arguments?
Answer
Behavior itself becomes a parameter -- the same apply_all(fn, items) helper can double every item,
square every item, or apply an arbitrary lambda, all without apply_all's own code changing at all.
This is the seed that grows into decorators, callbacks, and strategy objects (Example 14 passes
double, square, and a lambda into the same helper).
Q13 (co-13 -- Logic Programming). What's the fundamental mental-model shift from "write the algorithm that finds the answer" to logic programming?
Answer
In a logic program you state relationships (facts and rules) and ask questions; the engine's search finds answers you never explicitly computed -- "who is Alice's grandchild" was never stored anywhere, yet the query resolves it (Example 19 infers a grandparent relationship via composed facts; Example 60 builds a mini backtracking engine resolving a transitive-closure query).
Q14 (co-14 -- Unification and Backtracking). What does "backtrack" mean mechanically, and why does skipping it corrupt a search?
Answer
Backtracking means: try a candidate binding, recurse deeper, and if that path fails, UNDO the choice
before trying the next candidate. Skip the undo and a rejected choice leaks into sibling branches that
never should have seen it, producing a wrong or oversized result (Example 37's N-Queens
is_safe()/backtrack() pair; Kata 6 reproduces exactly this bug by omitting the pop).
Q15 (co-15 -- Constraint Programming). Why does one generic CSPSolver work unchanged across map
coloring, Sudoku, and scheduling?
Answer
Separating "what must be true" (the declared constraints) from "how to search for it" (the solver)
means the same solver reuses across entirely different puzzles -- only the variables, domains, and
constraints change (Example 61's generic CSPSolver reused for map coloring and a Latin-square
puzzle; Example 71 schedules tasks under precedence constraints).
Q16 (co-16 -- Event-Driven Paradigm). What's the control-flow inversion that defines event-driven code, and what do you give up in exchange?
Answer
In you-call-library code, your code decides exactly when things happen; in event-driven code, the framework decides when your handler runs, based on events it observes. You give up control of the timeline in exchange for not writing the loop that watches for events yourself (Example 45 contrasts both control-flow shapes directly; Example 55's typed publish/subscribe bus).
Q17 (co-17 -- Reactive Programming). What entire bug class does automatic reactive propagation eliminate?
Answer
"I changed A but forgot to also update the thing that depends on A" -- reactive propagation makes the
dependency wiring itself responsible for keeping derived values current, automatically, every time
(Example 17's ObservableValue pushes to subscribers on set(); Example 42 contrasts a
manually-maintained derived value that goes stale against the same thing done reactively).
Q18 (co-18 -- Dataflow Programming). What can a scheduler do with a computation expressed as a dependency graph that it cannot do with a fixed, hand-written sequence of steps?
Answer
It can find independent nodes and run them in parallel, skip recomputing nodes whose inputs never changed (memoization), and reorder execution freely as long as every edge is respected -- none of which a fixed sequence gives you for free (Example 43 executes a DAG in topological order; Example 57 memoizes dataflow nodes, skipping recompute on an unchanged subtree).
Q19 (co-19 -- Relational/Set-Based Thinking). Why does a declared relational query stay fast regardless of dataset size, while a hand-written nested loop's performance is locked in?
Answer
Relational operations (select, project, join) compose freely and are optimizable by a query planner
precisely because they're declared as set operations rather than a specific loop order -- the same
declared query can run efficiently at any scale, while a hand-written loop's performance is fixed by
the loop structure you wrote (Example 15's SQL top-3-words query; Example 47 contrasts a SQL JOIN
against an equivalent nested loop).
Q20 (co-20 -- Multi-Paradigm Languages). Why does a language blending several paradigms make BOTH good judgment and paradigm soup equally easy to write?
Answer
Python doesn't force a single paradigm -- a genuine strength -- but that also means the discipline of choosing ONE paradigm per boundary (co-25) has to come from the developer, not the language (Example 20 mixes a class, a comprehension, and a generator in one script; Example 78's comparison matrix across every paradigm solution built in this topic).
Q21 (co-21 -- Paradigm as Constraint Buys Property). What's the topic's central abstraction for understanding every paradigm at once?
Answer
Each paradigm trades a constraint for a guarantee -- purity buys reasoning, immutability buys safe
sharing, encapsulation buys change isolation, declarative style buys optimizability. Naming the
specific constraint and the specific property it buys turns "which paradigm should I use" into an
engineering question, not a taste question (Example 21 shares a frozen tuple/frozenset and confirms
neither function can mutate it).
Q22 (co-22 -- State as the Fault Line). Why is "where does mutable state live, and who's allowed to touch it" described as the fault line every other concept in this topic runs along?
Answer
OO's encapsulated instance state, functional programming's insistence on no shared mutable state, reactive programming's automatic propagation instead of manual mutation -- every one of those distinctions is ultimately a different answer to that same question (Example 22 contrasts a mutable-global running total against an immutable fold; Example 74 reproduces a real data race from shared mutable state under two threads and shows the immutable-design twin has none).
Q23 (co-23 -- Matching Paradigm to Problem). What does "fluency in matching" mean, as opposed to "loyalty to one paradigm"?
Answer
Recognizing the shape a problem already has -- search-and-satisfy fits constraint programming, a UI that must stay synchronized fits reactive, a batch transformation with no shared state fits functional -- and reaching for the paradigm built for that shape, rather than forcing every problem into one default lens (Example 66's decision table; Example 80's final choose-and-defend grounded in this topic's own code).
Q24 (co-24 -- Paradigm Cost and Tradeoff). Why is naming a paradigm's benefit without naming its cost "marketing, not engineering"?
Answer
Every paradigm's benefit is paid for with a specific cost -- readability, testability, or change-cost. A comprehension is fewer lines but hides the "how"; a constraint solver reuses across problems but costs more to write the first time. Every contrast example in this topic is really asking "which cost am I willing to pay here" (Example 58 measures lines-of-code trade-offs directly; Example 79 measures a concrete performance cost of immutability).
Q25 (co-25 -- Mixing Paradigms at Boundaries). What's the actual difference between legitimate multi-paradigm code and "paradigm soup"?
Answer
Discipline at the boundary: know which side of a function call is "the functional part" and which is "the OO part," and never let a paradigm's guarantee (like "this value can't be mutated") get silently violated by code that only LOOKS like it respects that guarantee (Example 49 crosses a functional-to-OO boundary passing only immutable data; Example 50 reproduces the paradigm-soup failure mode directly -- a mutable object aliased and silently corrupted through a "functional-looking" chain).
Applied problems
Ten scenarios. Each describes a task without naming the paradigm -- decide which concept applies, then check.
AP1. A for loop's exit condition depends on a boolean flag that a nested if, three levels deep,
sets on one specific iteration -- and a teammate just spent twenty minutes tracing where that flag gets
set before they could trust the loop's exit behavior.
Answer
This is a structured-programming violation (co-03) -- the flag is a goto-style jump target standing
in for a proper exit. Replace it with the specific construct that actually needs to fire: an early
return, a break at the point of decision, or a continue scoped to just that iteration, so a
reader never has to hunt for where control "jumped in from" (Example 26; Kata 2).
AP2. Two functions compute the identical checkout total from the identical inputs, but one of them
also silently appends a line to a shared audit_log list every time it's called -- and a caller who
calls it twice to redisplay a total on screen doubles the log.
Answer
This is a pure-vs-impure boundary problem (co-11) -- the "total" computation and the "log an audit
entry" side effect are two different responsibilities wearing one function's name. Split them: a pure
compute_total() safe to call any number of times, and an explicit, separately-called log_audit()
(Example 13; Kata 1's mutable-default variant of the same "hidden side effect" family).
AP3. A UI panel shows "3 items in cart," but after removing an item the panel still says "3" until
the page is manually refreshed -- the underlying items list is correct; only the displayed number is
wrong.
Answer
This is a reactive-programming gap (co-17) -- total (or the displayed count) is stored as a plain
field kept "in sync" by hand at every call site that touches items, and one call site was missed.
Making the count a computed property (or a subscriber pushed to on every items change) makes it
structurally impossible to forget (Example 42; Kata 4).
AP4. A puzzle-solving routine explores placements one at a time, and when a placement turns out to be invalid several moves later, the routine's next attempt inexplicably starts from a board state that still has the invalid piece sitting on it.
Answer
This is a backtracking violation (co-14) -- a failed branch must undo (pop, unset) its choice before
trying the alternative. Without the undo, state from a REJECTED branch leaks into the next branch tried
(Example 37's N-Queens; Kata 6 reproduces this exact bug with a missing chosen.pop()).
AP5. A dispatcher processes a queue of pending events, and events fired as a SIDE EFFECT of processing an earlier event in the same batch (a signup that should trigger a welcome email) never get processed at all -- yet the queue variable clearly has them appended.
Answer
This is an event-driven-paradigm violation (co-16) -- the dispatch loop iterates a frozen snapshot of
the queue taken at loop start, rather than draining the live queue. Replace the snapshot iteration with
a while queue: drain so events appended mid-dispatch are also seen (Example 40; Kata 7).
AP6. A node in a computation graph caches its result the first time it's computed, and a change to one of its declared inputs later in the program has zero effect on what that node returns when asked again.
Answer
This is a dataflow-programming violation (co-18) -- a memoized node must be invalidated (or its staleness detected) whenever a dependency it's declared to depend on actually changes; caching once and never checking again silently breaks the "recompute on change" contract dataflow depends on (Example 57's correctly-memoized nodes; Kata 8 reproduces the stale-cache bug directly).
AP7. Four threads each increment what's supposed to be a running total 200,000 times, and the final total is consistently and reproducibly lower than the arithmetic would predict, even though every individual increment "looks correct" in isolation.
Answer
This is state-as-the-fault-line (co-22) -- a shared mutable cell's read-modify-write is not atomic, so two threads can interleave and lose an update. The fix is not a bigger lock budget; it's removing the shared mutable target entirely: give each thread its own private slot and combine with a pure fold only after every thread finishes (Example 74; Kata 9).
AP8. A code review flags a map()-based pipeline as "not actually functional" even though every
individual function in the chain returns a value and looks side-effect-free at a glance.
Answer
This is paradigm soup (co-25) -- somewhere in the chain a closure captures and mutates state that outlives a single call (a module-level list, a mutable default argument), so calling the SAME pipeline twice with the SAME input produces different results. A genuinely pure pipeline depends only on its own arguments, nothing else (Example 50; Kata 10).
AP9. A scheduling problem (which task runs on which day, respecting "task B must follow task A" and
"only one task per resource per day") is currently solved with three hand-nested loops and a growing
pile of special-case ifs for every new rule that gets added.
Answer
This calls for constraint programming (co-15) -- declare the precedence and single-resource-per-day
rules as constraints and hand them to a generic solver, instead of hand-encoding the search as nested
loops. A new rule becomes "add a constraint," not "add another special-case if to an already-deep
loop nest" (Example 61's generic CSPSolver; Example 71's task scheduling).
AP10. A fifteen-line one-off script that reads a CSV and prints a total gets rewritten by a
well-meaning teammate into a Strategy-pattern hierarchy with an abstract base class, a factory, and
three concrete implementations -- for a computation that will only ever be run once.
Answer
This is paradigm cost ignored (co-24), and specifically the "choose the paradigm whose grain fits the problem" skill (co-23) applied backwards -- the script's actual shape (read, sum, print, done) needs no paradigm machinery at all. Naming a pattern's benefit without weighing its cost against a problem this small is exactly the mistake co-24 warns about (Example 28's explicit "paradigm choice is noise" case).
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.
Kata 1 -- mutable state: a default argument is silently shared across every instance
relates to co-04, Example 4
Task. WordCounter() with no arguments should start with a fresh, empty words list every time
it's constructed. The version below is broken: two DIFFERENT instances end up sharing the SAME list.
Before (drilling/code/kata-01-mutable-default-aliasing/before/kata.py)
"""Kata 1 (before): mutable-state aliasing bug -- a mutable default argument is shared across calls."""
class WordCounter:
def __init__(self, words: list[str] = []) -> None: # SMELL: mutable default shared across every instance
self.words = words
def add(self, word: str) -> None:
self.words.append(word)
first = WordCounter()
first.add("alpha")
second = WordCounter() # meant to start EMPTY -- but shares the same default list object as `first`
second.add("beta")
print(first.words)
print(second.words)Observed (buggy) output (captured by actually running the script above):
['alpha', 'beta']
['alpha', 'beta']After (drilling/code/kata-01-mutable-default-aliasing/after/kata.py)
"""Kata 1 (after): mutable-state aliasing bug fixed -- each instance gets its own fresh list."""
class WordCounter:
def __init__(self, words: list[str] | None = None) -> None:
self.words: list[str] = words if words is not None else [] # fresh list per instance, never shared
def add(self, word: str) -> None:
self.words.append(word)
first = WordCounter()
first.add("alpha")
second = WordCounter()
second.add("beta")
print(first.words)
print(second.words)Model solution
Root cause: Python evaluates a default argument expression exactly ONCE, at function-definition
time -- not once per call. [] is created a single time and every call that omits words receives
that SAME list object. The fix uses None as a sentinel and constructs a genuinely fresh [] inside
the body, on every call.
Run: python3 kata.py
Output:
['alpha']
['beta']Kata 2 -- structured programming: a goto-style flag leaks past its intended scope
relates to co-03, Example 26
Task. process_orders() should skip only orders that are themselves refunded, and process every
other order normally. The version below is broken: a flag meant to skip ONE refunded order never gets
reset, so it silently skips every order that comes after it too.
Before (drilling/code/kata-02-goto-flag-leak/before/kata.py)
"""Kata 2 (before): structured-programming violation -- a goto-style flag leaks past its intended scope."""
def process_orders(orders: list[dict[str, object]]) -> list[str]:
processed: list[str] = []
skip = False # SMELL: this flag is meant to skip ONE refunded order, but nothing ever resets it
for order in orders:
if order["refunded"]:
skip = True # goto-style: "jump past" this one order
if skip:
continue # BUG: skip is never turned back off, so every order AFTER the first refund is also skipped
processed.append(str(order["id"]))
return processed
orders: list[dict[str, object]] = [
{"id": "A", "refunded": False},
{"id": "B", "refunded": True},
{"id": "C", "refunded": False}, # should still be processed -- it was never refunded
]
print(process_orders(orders))Observed (buggy) output (captured by actually running the script above):
['A']After (drilling/code/kata-02-goto-flag-leak/after/kata.py)
"""Kata 2 (after): structured-programming fix -- a per-iteration `continue` replaces the leaking flag."""
def process_orders(orders: list[dict[str, object]]) -> list[str]:
processed: list[str] = []
for order in orders:
if order["refunded"]: # decision scoped to THIS iteration only -- nothing leaks to the next one
continue
processed.append(str(order["id"]))
return processed
orders: list[dict[str, object]] = [
{"id": "A", "refunded": False},
{"id": "B", "refunded": True},
{"id": "C", "refunded": False},
]
print(process_orders(orders))Model solution
Root cause: skip is a variable that outlives the single iteration it was meant to govern -- a
goto-style stand-in for "jump past this one order" that structured programming's single-entry/single-
exit constructs make unnecessary. A continue scoped to the current iteration expresses the SAME
intent without any state that can leak into later iterations.
Run: python3 kata.py
Output:
['A', 'C']Kata 3 -- encapsulation: direct field mutation bypasses the invariant
relates to co-06, Example 7
Task. A BankBalance should never go negative -- withdraw() already guards that invariant. The
version below is broken: the balance is a public field, so code outside the class can change it without
ever calling withdraw(), bypassing the guard entirely.
Before (drilling/code/kata-03-encapsulation-bypass/before/kata.py)
"""Kata 3 (before): encapsulation violation -- direct field mutation bypasses the never-negative invariant."""
class BankBalance:
def __init__(self, amount: int) -> None:
self.balance = amount # SMELL: public field -- nothing stops direct mutation
def withdraw(self, amount: int) -> bool:
if self.balance - amount < 0:
return False
self.balance -= amount
return True
account = BankBalance(50)
account.balance = account.balance - 100 # BUG: bypasses withdraw()'s guard entirely
print(account.balance)Observed (buggy) output (captured by actually running the script above):
-50After (drilling/code/kata-03-encapsulation-bypass/after/kata.py)
"""Kata 3 (after): encapsulation fix -- state hidden behind methods, the invariant can't be bypassed."""
class BankBalance:
def __init__(self, amount: int) -> None:
self._balance = amount # underscore-prefixed -- convention signals "route through methods only"
def withdraw(self, amount: int) -> bool:
if self._balance - amount < 0:
return False
self._balance -= amount
return True
def read(self) -> int:
return self._balance
account = BankBalance(50)
succeeded = account.withdraw(100) # the ONLY way to change balance -- and it correctly refuses
print(succeeded)
print(account.read())Model solution
Root cause: a public field is an open door -- ANY code with a reference to the object can mutate it
without going through the method that enforces the invariant. Renaming the field _balance and
exposing only withdraw()/read() makes the invariant-checking method the ONLY path that can ever
change the value.
Run: python3 kata.py
Output:
False
50Kata 4 -- reactive programming: a manually maintained derived value goes stale
relates to co-17, Example 42
Task. cart.total should always equal the sum of cart.items. The version below is broken:
total is a plain field updated by hand inside add_item(), and the symmetric update was forgotten
inside remove_item().
Before (drilling/code/kata-04-reactive-stale-derived/before/kata.py)
"""Kata 4 (before): reactive-programming violation -- a manually maintained derived value goes stale."""
class Cart:
def __init__(self) -> None:
self.items: list[int] = []
self.total = 0 # SMELL: a derived value stored as a plain field, kept "in sync" by hand
def add_item(self, price: int) -> None:
self.items.append(price)
self.total = sum(self.items) # easy to forget this line at ANY future call site
def remove_item(self, price: int) -> None:
self.items.remove(price) # BUG: forgot to also update self.total here
cart = Cart()
cart.add_item(10)
cart.add_item(20)
cart.remove_item(10)
print(cart.items)
print(cart.total) # stale -- still reflects the removed itemObserved (buggy) output (captured by actually running the script above):
[20]
30After (drilling/code/kata-04-reactive-stale-derived/after/kata.py)
"""Kata 4 (after): reactive fix -- total is a computed property, impossible to forget updating."""
class Cart:
def __init__(self) -> None:
self.items: list[int] = []
@property
def total(self) -> int:
return sum(self.items) # ALWAYS current -- there is no separate field that could go stale
def add_item(self, price: int) -> None:
self.items.append(price)
def remove_item(self, price: int) -> None:
self.items.remove(price)
cart = Cart()
cart.add_item(10)
cart.add_item(20)
cart.remove_item(10)
print(cart.items)
print(cart.total)Model solution
Root cause: total was a plain field, so keeping it in sync depended on every mutating method
remembering to also update it -- a manual contract with no enforcement. Making total a @property
that recomputes from items every time it's read removes the separate field entirely; there is nothing
left that could ever go stale.
Run: python3 kata.py
Output:
[20]
20Kata 5 -- purity: a function that looks pure secretly mutates its input
relates to co-11, Example 13
Task. sorted_scores() should return a new, sorted list without changing the caller's original
list. The version below is broken: it sorts the list IN PLACE, so the caller's original is silently
changed too.
Before (drilling/code/kata-05-impure-mutates-input/before/kata.py)
"""Kata 5 (before): purity violation -- a function that LOOKS pure secretly mutates its input."""
def sorted_scores(scores: list[int]) -> list[int]:
scores.sort() # BUG: list.sort() mutates IN PLACE -- the caller's list is silently changed too
return scores
original = [3, 1, 2]
result = sorted_scores(original)
print(result)
print(original) # SMELL: caller never asked for `original` to change, but it didObserved (buggy) output (captured by actually running the script above):
[1, 2, 3]
[1, 2, 3]After (drilling/code/kata-05-impure-mutates-input/after/kata.py)
"""Kata 5 (after): purity fix -- `sorted()` returns a NEW list, the caller's input is untouched."""
def sorted_scores(scores: list[int]) -> list[int]:
return sorted(scores) # pure -- builds and returns a new list, never touches the argument
original = [3, 1, 2]
result = sorted_scores(original)
print(result)
print(original) # unchanged -- proves the function is referentially transparentModel solution
Root cause: list.sort() mutates its receiver in place and returns None -- calling it on the
argument the caller passed in changes THEIR list, not a copy. sorted() builds and returns a brand-new
list, leaving the argument completely untouched, which is what makes the function safe to call without
worrying about the caller's data.
Run: python3 kata.py
Output:
[1, 2, 3]
[3, 1, 2]Kata 6 -- backtracking: a failed branch never undoes its choice
relates to co-14, Example 37
Task. find_subset([5, 3, 2], 2) should return [2] -- the only subset of the given numbers that
sums to 2. The version below is broken: it forgets to undo (pop) a rejected number before trying the
"exclude this number" branch, so the rejected number stays in the result.
Before (drilling/code/kata-06-backtracking-forgets-undo/before/kata.py)
"""Kata 6 (before): backtracking violation -- a failed branch never undoes its choice before retrying."""
def find_subset(numbers: list[int], target: int) -> list[int] | None:
chosen: list[int] = []
def backtrack(index: int, remaining: int) -> bool:
if remaining == 0:
return True
if index >= len(numbers) or remaining < 0:
return False
chosen.append(numbers[index]) # try INCLUDING this number
if backtrack(index + 1, remaining - numbers[index]):
return True
# BUG: no chosen.pop() here -- the rejected number stays in `chosen` when we try EXCLUDING it
return backtrack(index + 1, remaining)
if backtrack(0, target):
return chosen
return None
result = find_subset([5, 3, 2], 2)
print(result) # correct answer is [2] -- watch what the missing undo actually returnsObserved (buggy) output (captured by actually running the script above):
[5, 3, 2]After (drilling/code/kata-06-backtracking-forgets-undo/after/kata.py)
"""Kata 6 (after): backtracking fix -- a failed branch undoes (pops) its choice before trying the alternative."""
def find_subset(numbers: list[int], target: int) -> list[int] | None:
chosen: list[int] = []
def backtrack(index: int, remaining: int) -> bool:
if remaining == 0:
return True
if index >= len(numbers) or remaining < 0:
return False
chosen.append(numbers[index])
if backtrack(index + 1, remaining - numbers[index]):
return True
chosen.pop() # undo the rejected choice BEFORE trying the "exclude this number" branch
return backtrack(index + 1, remaining)
if backtrack(0, target):
return chosen
return None
result = find_subset([5, 3, 2], 2)
print(result)Model solution
Root cause: chosen.append() mutates a SHARED list across the entire recursive search tree. When
an inclusion branch fails, the number appended for that branch is still sitting in chosen when the
sibling "exclude" branch runs -- chosen.pop() is what removes it, restoring chosen to exactly the
state it had before the rejected choice was tried.
Run: python3 kata.py
Output:
[2]Kata 7 -- event-driven: a snapshot iteration drops events fired during processing
relates to co-16, Example 40
Task. run_dispatcher(["signup", "login"]) should also process a "welcome_email" event fired as
a side effect of handling "signup". The version below is broken: it iterates a frozen snapshot of the
queue taken at loop start, so anything appended mid-loop is never seen.
Before (drilling/code/kata-07-event-snapshot-drops-events/before/kata.py)
"""Kata 7 (before): event-driven violation -- a snapshot iteration drops events fired during processing."""
def run_dispatcher(initial_events: list[str]) -> list[str]:
queue = list(initial_events)
processed: list[str] = []
for event in list(queue): # SMELL: `list(queue)` takes a frozen snapshot at loop start
processed.append(event)
if event == "signup":
queue.append("welcome_email") # BUG: appended to `queue`, but the loop iterates the SNAPSHOT, not `queue`
return processed
print(run_dispatcher(["signup", "login"]))Observed (buggy) output (captured by actually running the script above):
['signup', 'login']After (drilling/code/kata-07-event-snapshot-drops-events/after/kata.py)
"""Kata 7 (after): event-driven fix -- draining the live queue processes events added during dispatch too."""
def run_dispatcher(initial_events: list[str]) -> list[str]:
queue = list(initial_events)
processed: list[str] = []
while queue: # keeps going as long as ANY event -- including ones added mid-dispatch -- remains
event = queue.pop(0)
processed.append(event)
if event == "signup":
queue.append("welcome_email")
return processed
print(run_dispatcher(["signup", "login"]))Model solution
Root cause: list(queue) copies the queue's CURRENT contents once, at loop start -- the for loop
then walks that frozen copy, completely disconnected from any later appends to queue itself.
Replacing the snapshot iteration with while queue: event = queue.pop(0) drains the LIVE queue, so an
event appended mid-dispatch is picked up on a later trip through the loop.
Run: python3 kata.py
Output:
['signup', 'login', 'welcome_email']Kata 8 -- dataflow: a memoized node is never invalidated when its input changes
relates to co-18, Example 57
Task. Doubler.total_doubled() should reflect the CURRENT contents of source, including changes
made after the first call. The version below is broken: it caches its result once and never checks
whether source has changed since.
Before (drilling/code/kata-08-dataflow-stale-memo/before/kata.py)
"""Kata 8 (before): dataflow violation -- a memoized node is never invalidated when its input changes."""
class Doubler:
def __init__(self, source: list[int]) -> None:
self.source = source
self._cache: int | None = None # SMELL: cached once, nothing ever clears it
def total_doubled(self) -> int:
if self._cache is None:
self._cache = sum(n * 2 for n in self.source)
return self._cache # BUG: returns the STALE cached value even after `source` has changed
doubler = Doubler([1, 2, 3])
print(doubler.total_doubled()) # correct: 12
doubler.source.append(10) # dependency changed
print(doubler.total_doubled()) # should be 32, but the stale cache still says 12Observed (buggy) output (captured by actually running the script above):
12
12After (drilling/code/kata-08-dataflow-stale-memo/after/kata.py)
"""Kata 8 (after): dataflow fix -- the node recomputes whenever its source has actually changed."""
class Doubler:
def __init__(self, source: list[int]) -> None:
self.source = source
self._cache: int | None = None
self._cached_length = -1 # tracks the source length the cache was computed FOR
def total_doubled(self) -> int:
if self._cache is None or self._cached_length != len(self.source): # dependency changed -> stale
self._cache = sum(n * 2 for n in self.source)
self._cached_length = len(self.source)
return self._cache
doubler = Doubler([1, 2, 3])
print(doubler.total_doubled())
doubler.source.append(10)
print(doubler.total_doubled())Model solution
Root cause: _cache is None is the ONLY staleness check, and once the cache is populated it's
None never again -- there is no mechanism that notices source changed. Tracking a cheap signal of
the dependency's state (here, its length) and recomputing whenever that signal disagrees with what the
cache was built for is a minimal, real dataflow "dirty check."
Run: python3 kata.py
Output:
12
32Kata 9 -- state fault line: two threads share one mutable counter and lose an update
relates to co-22, Example 74
Task. Two threads should each record one page view, for a total of 2. The version below is broken:
both threads read the SAME stale value before either writes, so one increment is silently lost -- and
this reproduces the SAME way every single run, via the deterministic threading.Event handshake
Example 74 uses.
Before (drilling/code/kata-09-shared-mutable-race/before/kata.py)
"""Kata 9 (before): state-fault-line violation -- two threads share one mutable counter and lose an update."""
import threading
class PageViewCounter:
def __init__(self) -> None:
self.views = 0 # SMELL: one shared mutable box, read-then-written by two threads
def record_view(counter: PageViewCounter, read_done: threading.Event, may_write: threading.Event) -> None:
current = counter.views # STEP 1: read the shared value
read_done.set() # signal "I have read" -- forces a specific, deterministic interleaving
may_write.wait() # STEP 2: wait until it's safe to write (forces the race window open)
counter.views = current + 1 # BUG: STEP 3 writes back based on the STALE value read in step 1
counter = PageViewCounter()
event_a_read = threading.Event()
event_b_read = threading.Event()
thread_a = threading.Thread(target=record_view, args=(counter, event_a_read, event_b_read))
thread_b = threading.Thread(target=record_view, args=(counter, event_b_read, event_a_read))
# Each thread waits on the OTHER thread's "read done" signal before it's allowed to write -- this
# deterministically forces BOTH threads to read 0 before EITHER of them writes 1, every single run.
thread_a.start()
thread_b.start()
thread_a.join()
thread_b.join()
print(counter.views) # two views were recorded, but only ONE survived -- a lost updateObserved (buggy) output (captured by actually running the script above, five times in a row -- deterministic, not a coin flip):
1After (drilling/code/kata-09-shared-mutable-race/after/kata.py)
"""Kata 9 (after): state-fault-line fix -- each thread owns a private slot; combine with a pure fold at the end."""
import threading
results: list[int] = [0, 0] # each thread writes to its OWN index -- never the same memory location
def record_view(index: int) -> None:
results[index] = 1 # a single write to a private slot -- nothing else can read or write it mid-flight
thread_a = threading.Thread(target=record_view, args=(0,))
thread_b = threading.Thread(target=record_view, args=(1,))
thread_a.start()
thread_b.start()
thread_a.join()
thread_b.join()
total_views = sum(results) # combine ONLY after both threads finished -- no concurrent write to `total_views`
print(total_views) # both views correctly counted, every single runModel solution
Root cause: counter.views is one shared mutable cell; a read-then-write is not atomic, and the
threading.Event handshake deterministically forces BOTH threads to read the stale value 0 before
EITHER writes 1 -- so the second write simply overwrites the first with the same value, and one
increment vanishes. The fix removes the shared mutable target entirely: each thread owns a disjoint
slot in results, and the two counts are combined with a pure sum() only after both threads have
finished -- there is no memory location either thread could ever race on.
Run: python3 kata.py
Output:
2Kata 10 -- paradigm soup: a map-based pipeline secretly mutates shared state
relates to co-25, Example 50
Task. Calling build_report(rows) twice with the identical rows should produce identical
results -- that's what "functional-looking" code implies. The version below is broken: enrich()
looks like a pure map() callback, but it secretly depends on and mutates module-level state, so the
second call disagrees with the first.
Before (drilling/code/kata-10-paradigm-soup-hidden-state/before/kata.py)
"""Kata 10 (before): paradigm-soup violation -- a `map`-based pipeline secretly mutates shared MODULE state."""
from typing import cast
_seen_ids: list[int] = [] # SMELL: module-level mutable state captured by a "functional-looking" map
def enrich(row: dict[str, object]) -> dict[str, object]:
_seen_ids.append(cast(int, row["id"])) # BUG: side effect hidden inside what looks like a pure transform
return {"id": row["id"], "seen_count": len(_seen_ids)}
def build_report(rows: list[dict[str, object]]) -> list[dict[str, object]]:
return list(map(enrich, rows))
rows: list[dict[str, object]] = [{"id": 1}, {"id": 2}]
report_a = build_report(rows)
report_b = build_report(rows) # same input, called again
print([r["seen_count"] for r in report_a])
print([r["seen_count"] for r in report_b]) # BUG: NOT equal to report_a -- proves it's not referentially transparentObserved (buggy) output (captured by actually running the script above):
[1, 2]
[3, 4]After (drilling/code/kata-10-paradigm-soup-hidden-state/after/kata.py)
"""Kata 10 (after): paradigm-soup fix -- the transform depends ONLY on its own arguments, no shared state."""
def enrich(row: dict[str, object], index: int) -> dict[str, object]:
return {"id": row["id"], "seen_count": index + 1} # depends only on its OWN inputs -- no closure, no shared list
def build_report(rows: list[dict[str, object]]) -> list[dict[str, object]]:
return [enrich(row, i) for i, row in enumerate(rows)] # pure -- same input always produces same output
rows: list[dict[str, object]] = [{"id": 1}, {"id": 2}]
report_a = build_report(rows)
report_b = build_report(rows)
print([r["seen_count"] for r in report_a])
print([r["seen_count"] for r in report_b]) # now IDENTICAL -- referentially transparentModel solution
Root cause: enrich() reads its "how many seen so far" number from module-level _seen_ids,
mutated as a side effect of every call, EVER -- across BOTH invocations of build_report(). It looks
functional (it's passed to map(), it returns a value) but it is not pure, because its result depends
on something other than its own argument. Passing the index explicitly (enumerate) makes the same
information available WITHOUT any shared, mutated state.
Run: python3 kata.py
Output:
[1, 2]
[1, 2]Self-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.
Imperative, procedural, structured, mutable state
- I can identify code as imperative by its explicit, mutating, step-by-step statements, and name the specific tracing cost that buys (co-01).
- I can refactor an inline imperative block into named procedures without changing the underlying execution model at all (co-02).
- I can replace a
goto-style boolean flag or a nested-ifpyramid with plain sequence, selection, and iteration (co-03). - I can distinguish rebinding a name from mutating an object through an alias, and explain why conflating them causes bugs (co-04).
OO and declarative/functional
- I can bundle state and the behavior that acts on it into a class, and explain what isolation that buys over a bare data structure plus a separate function (co-05).
- I can force every mutation of an object's state through a small set of methods so an invariant has exactly one place it can be checked (co-06).
- I can send the same message shape to two unrelated classes via structural typing and get correct polymorphic dispatch (co-07).
- I can state a computation declaratively (a comprehension, a rules table, a query) and name what the reader trusts blindly in exchange (co-08).
- I can write a pure, immutable computation using
Counter/reduceinstead of a mutating loop (co-09). - I can thread a value through composed expressions instead of a sequence of assigned-variable statements (co-10).
- I can name the specific hidden effect that turns a pure-looking function into an impure one (co-11).
- I can pass a function as an ordinary value into a higher-order helper, making behavior itself a parameter (co-12).
Logic, constraint, event, reactive, dataflow
- I can state facts and rules and let an engine's search resolve a query I never explicitly computed (co-13).
- I can explain what "backtrack" means mechanically and correctly undo a rejected choice before trying the alternative (co-14).
- I can separate "what must be true" (constraints) from "how to search for it" (a generic solver) and reuse one solver across different puzzles (co-15).
- I can register a handler and explain the you-call-library vs framework-calls-you control-flow inversion (co-16).
- I can make a derived value recompute automatically on a source change instead of manually maintaining it (co-17).
- I can express a computation as a dependency graph and explain what a scheduler gains over a fixed, hand-written sequence (co-18).
Relational and multi-paradigm
- I can state a query as a declared set operation (select/project/join) instead of a hand-written nested loop (co-19).
- I can name the specific discipline a multi-paradigm language like Python demands from the developer, since the language itself won't enforce it (co-20).
Paradigm as a design decision
- I can name the specific constraint a paradigm gives up and the specific property it buys in exchange (co-21).
- I can identify where mutable shared state lives in a design and explain why that's the fault line separating most paradigm distinctions (co-22).
- I can recognize a problem's shape and match it to the paradigm built for that shape, rather than forcing every problem into one default lens (co-23).
- I can name a paradigm's concrete cost (readability, testability, change-cost), not just its benefit (co-24).
- I can keep one paradigm per boundary and explain the specific difference between legitimate multi-paradigm code and paradigm soup (co-25).
Elaborative interrogation & self-explanation
Eight why/why-not prompts. Answer in your own words before checking -- the goal is explaining the reasoning, not reciting a definition.
W1. Why is co-22 (state as the fault line) described as running under every OTHER concept in this topic, rather than being just one paradigm distinction among many?
Answer
OO's encapsulated instance state, functional programming's insistence on no shared mutable state, and reactive programming's automatic propagation are all, at bottom, different answers to "where does mutable state live, and who's allowed to touch it." Once you can name a design's answer to that one question, you can predict most of its other paradigm-level properties without inspecting anything else.
W2. Why does this topic insist a paradigm be chosen by matching the problem's shape (co-23), rather than by which paradigm the team happens to be most comfortable with?
Answer
Comfort is about the DEVELOPER; shape-matching is about the PROBLEM. Forcing a search-and-satisfy problem into hand-written imperative loops doesn't just cost more code -- it actively hides structure the problem already has (a constraint solver would find the feasible space directly). The durable skill is recognizing that shape, independent of which paradigm feels most familiar.
W3. Why does Kata 6's missing chosen.pop() corrupt the RESULT and not just waste some extra
recursive calls?
Answer
chosen is one shared, mutable list referenced by the entire recursive call tree, not a fresh copy per
branch. When an inclusion branch fails and the code falls through to the exclusion branch, the
already-appended number is still physically present in chosen -- so the final returned list reflects
a combination of "excluded" and "included" choices that never should have coexisted, not merely a
slower search.
W4. Why does Kata 9's threading.Event handshake make a race condition reproduce identically on
every run, when races are usually described as "non-deterministic"?
Answer
A real race's timing is non-deterministic because the OS scheduler decides when each thread runs. The
Event handshake replaces "hope the scheduler interleaves badly" with an explicit protocol: each
thread signals "I have read" and then WAITS for the other thread's signal before writing. That forces
the exact interleaving that causes the bug, every single time, which is what makes it teachable and
testable rather than a flaky coin flip.
W5. Why is co-08's "declarative vs imperative" framed as a trade (something gained, something trusted blindly), rather than declarative simply being the "better" style?
Answer
Declarative code hands the "how" to an engine, so the reader only verifies the "what" -- a real readability and correctness win. But that same "how" becomes opaque: a slow SQL query or an unexpectedly expensive comprehension is harder to diagnose precisely because the mechanics are hidden from the code that expresses the intent. Neither style dominates; each is a different bet on what the reader needs to see.
W6. Why does co-25 (mixing paradigms at boundaries) explicitly distinguish "legitimate multi-paradigm code" from "paradigm soup," instead of treating any paradigm-mixing as automatically suspect?
Answer
A multi-paradigm language like Python is often at its best mixing paradigms deliberately -- a functional pipeline handing immutable data to an OO service is fine. What makes it soup is a guarantee being silently violated: a "functional-looking" chain that secretly threads a mutable object through every step collects the COSTS of both paradigms (indirection, hidden effects) while delivering the BENEFITS of neither.
W7. Why does Kata 4's fix (a @property) count as "reactive" when it contains no subscriber list,
no on_change() callback, and no push notification at all?
Answer
Reactive programming's actual goal is eliminating "I changed A but forgot to update the thing that depends on A." A push/subscribe mechanism is ONE way to guarantee that; a computed property that recomputes from source data on every read is a simpler way to guarantee the exact same thing for a value with no independent state of its own. The mechanism differs; the guarantee -- no possible staleness -- is identical.
W8. Why does co-21 (paradigm as constraint buys property) matter more as a way of THINKING about paradigms than as a fact about any one paradigm?
Answer
Once "purity buys reasoning," "immutability buys safe sharing," and "encapsulation buys change isolation" are all instances of the SAME pattern (give up a capability, receive a guarantee), a new, unfamiliar paradigm can be evaluated the same way: what does it force you to give up, and what does it promise in return? That question turns "which paradigm should I use" from a taste question into an engineering one, for paradigms this topic never even names.
← Previous: Capstone
Last updated July 16, 2026