Low-Level Design · lesson 17 of 18
Beginner
4Intermediate
10- 5 Design a Parking Lot: Allocating and Freeing Spots
- 6 Design an Elevator System: Scheduling Requests
- 7 Design a Library Management System: Borrowing and Returning Books
- 8 Design an Expense-Splitting App: Balances and Minimal Settlements
- 9 Design a URL Shortener: Sequential Codes vs Real Hash Collisions
- 10 Wrapping Objects: Adapter, Facade, and Proxy in Python
- 11 The Composite Pattern in Python: Sizing a File Tree Without isinstance
- 12 The State Pattern in Python: Refactoring a Vending Machine Into State Classes
- 13 Undo and Redo in Python: the Memento and Command Patterns Compared
- 14 Chain of Responsibility in Python: Expense Approvals Without an if/elif Ladder
Advanced
4- 15 Concurrency in LLD: A Real Race Condition in the Parking Lot, Then the Fix
- 16 The Observer Pattern in Practice: A Stock Ticker Notifying Multiple Displays
- 17 Design Patterns That Are Just Language Features: Strategy, Command, Iterator
- 18 Abstract Factory and Prototype in Python: Matching Families and Cloning Objects
Also part of Low-Level Design
12- + SOLID Principles in Java from Java
- + Composition vs Inheritance in Java from Java
- + Design Patterns in Java from Java
- + Python Design Patterns from Python
- + Design Patterns in TypeScript from TypeScript
- + JavaScript Design Patterns from JavaScript
- + Design Patterns in C# from C#
- + Design Patterns in C++ from C++
- + Design Patterns in Go from Go
- + Design Patterns in Rust from Rust
- + Abstract Classes in Java from Java
- + Sealed Classes and Interfaces in Java from Java
Also part of: Go
Design Patterns That Are Just Language Features: Strategy, Command, Iterator
Strategy becomes a function, Command a closure, Iterator a generator, run in Python, Java 21 and Go 1.23, with the cases where the class should stay.
By SkillByExample · Published
Several Gang of Four patterns describe what a 1994 C++ programmer had to build by hand: an object that holds one function, an object that remembers a call for later, an object that walks a collection. Languages with first-class functions, closures and generators give you those objects for free. This lesson runs each pattern the classic way and then the language-feature way, in Python, Java and Go. It also shows the cases where the class is still the better choice.
Strategy is a function
The classic Strategy is an interface with one method, plus one class per algorithm. Here are three discount rules at a checkout.
from abc import ABC, abstractmethod
class Discount(ABC):
@abstractmethod
def apply(self, total: float) -> float: ...
class NoDiscount(Discount):
def apply(self, total): return total
class PercentOff(Discount):
def __init__(self, pct): self.pct = pct
def apply(self, total): return total * (1 - self.pct / 100)
class FlatOff(Discount):
def __init__(self, amount): self.amount = amount
def apply(self, total): return max(total - self.amount, 0)
def checkout(total, discount: Discount):
return round(discount.apply(total), 2)
for d in [NoDiscount(), PercentOff(10), FlatOff(15)]:
print(type(d).__name__, checkout(120.0, d))
$ python discount_classes.py
NoDiscount 120.0
PercentOff 108.0
FlatOff 105.0
Each class has a single method, and checkout only calls that one method. An interface with
one method is a function type with extra ceremony. Python can say so directly:
from typing import Callable
Discount = Callable[[float], float]
def no_discount(total): return total
def percent_off(pct) -> Discount:
return lambda total: total * (1 - pct / 100)
def flat_off(amount) -> Discount:
return lambda total: max(total - amount, 0)
def checkout(total, discount: Discount):
return round(discount(total), 2)
for name, d in [("none", no_discount), ("10%", percent_off(10)),
("15 off", flat_off(15)), ("adhoc", lambda t: t - 1)]:
print(name, checkout(120.0, d))
$ python discount_functions.py
none 120.0
10% 108.0
15 off 105.0
adhoc 119.0
The first three totals match the class version. The constructor arguments (pct, amount)
moved into closures, and the base class became a type alias. The fourth line shows what
the class version could not do cheaply: a one-off rule written inline at the call site,
with no new class.
In Java the interface stays, because lambdas need a target type. What disappears is the
implementing classes. A lambda and a method reference satisfy the same interface that
PercentOff implements:
import java.util.Map;
public class Discounts {
static double memberPrice(double total) { return total * 0.8; }
public static void main(String[] args) {
Map<String, Discount> all = Map.of(
"class", new PercentOff(10),
"lambda", total -> Math.max(total - 15, 0),
"method ref", Discounts::memberPrice);
for (var name : new String[] {"class", "lambda", "method ref"}) {
System.out.println(name + " -> " + all.get(name).apply(120.0));
}
}
}
@FunctionalInterface
interface Discount { double apply(double total); }
class PercentOff implements Discount { // the classic version
private final double pct;
PercentOff(double pct) { this.pct = pct; }
public double apply(double total) { return total * (1 - pct / 100); }
}
$ java Discounts.java
class -> 108.0
lambda -> 105.0
method ref -> 96.0
Discount has exactly one abstract method, so it is a functional interface. The
@FunctionalInterface annotation only makes the compiler enforce that rule. memberPrice
is an ordinary static method, and Discounts::memberPrice turns it into a strategy without
changing it. public class Discounts comes first in the file because java File.java
runs the first class it finds.
Go adds an idiom that lets you keep both forms. A named func type can have methods, so a plain
function can satisfy the interface. The standard library uses the same trick for
http.HandlerFunc:
package main
import "fmt"
type Discount interface{ Apply(total float64) float64 }
type PercentOff struct{ Pct float64 } // the classic version
func (p PercentOff) Apply(t float64) float64 { return t * (1 - p.Pct/100) }
// DiscountFunc lets a plain func satisfy Discount, like http.HandlerFunc.
type DiscountFunc func(float64) float64
func (f DiscountFunc) Apply(t float64) float64 { return f(t) }
func checkout(total float64, d Discount) float64 { return d.Apply(total) }
func main() {
flat := DiscountFunc(func(t float64) float64 { return max(t-15, 0) })
fmt.Println("struct:", checkout(120, PercentOff{Pct: 10}))
fmt.Println("func: ", checkout(120, flat))
}
$ go run discounts.go
struct: 108
func: 105
checkout accepts a Discount interface, and both a struct and a converted function satisfy
it. A library can therefore expose the interface for callers that need a full type, and
callers with a one-line rule can pass a function. (max on floats is a builtin since Go
1.21.)
When Strategy should stay a class
The function version wins while a strategy is one stateless operation. It loses in three situations, and each one shows up in real output.
It needs a name when it fails. The same bad config value, the string "10", goes through
three Python strategies:
import traceback
class PercentOff:
def __init__(self, pct): self.pct = pct
def apply(self, total): return total * (1 - self.pct / 100)
def percent_off_lambda(pct):
return lambda total: total * (1 - pct / 100)
def percent_off_def(pct):
def percent_off(total): return total * (1 - pct / 100)
return percent_off
bad = "10" # a percentage read from config, still a string
for strategy in [PercentOff(bad).apply, percent_off_lambda(bad), percent_off_def(bad)]:
try:
strategy(120.0)
except TypeError as e:
frame = traceback.extract_tb(e.__traceback__)[-1]
print(f"line {frame.lineno}, in {frame.name}")
$ python frame_names.py
line 5, in apply
line 8, in <lambda>
line 11, in percent_off
Every lambda in a Python program is named <lambda>, so in a log full of tracebacks only the
line number identifies it. A nested def costs one extra line and gets a real name, which is
usually all a Python strategy needs. Java’s names are less helpful:
public class StackNames {
public static void main(String[] args) {
Discount flat = total -> {
if (total < 0) throw new IllegalArgumentException("negative total");
return total - 15;
};
for (Discount d : new Discount[] {new PercentOff(10), flat}) {
try {
d.apply(-1);
} catch (IllegalArgumentException e) {
System.out.println(e.getStackTrace()[0]);
}
}
}
}
interface Discount { double apply(double total); }
class PercentOff implements Discount {
private final double pct;
PercentOff(double pct) { this.pct = pct; }
public double apply(double total) {
if (total < 0) throw new IllegalArgumentException("negative total");
return total * (1 - pct / 100);
}
}
$ java StackNames.java
PercentOff.apply(StackNames.java:23)
StackNames.lambda$main$0(StackNames.java:4)
The class shows up as PercentOff.apply. The lambda shows up as lambda$main$0, a name
javac generates from the enclosing method and a counter. It says nothing about discounts, and
it can change when someone adds another lambda to main. If on-call engineers will read
this strategy’s stack traces, give it a class name.
It carries state and has more than one method. Take a promotion that halves the first two orders and can also report how many uses are left:
class FirstNOrders:
"""Half price for the first n orders, then full price."""
def __init__(self, n): self.left = n
def apply(self, total):
if self.left == 0:
return total
self.left -= 1
return total / 2
def describe(self): return f"{self.left} half-price uses left"
def first_n_orders(n):
left = n
def apply(total):
nonlocal left
if left == 0:
return total
left -= 1
return total / 2
def describe(): return f"{left} half-price uses left"
return apply, describe # two functions sharing one hidden variable
promo = FirstNOrders(2)
apply, describe = first_n_orders(2)
for _ in range(3):
print(promo.apply(100.0), promo.describe(), "|", apply(100.0), describe())
$ python stateful_promo.py
50.0 1 half-price uses left | 50.0 1 half-price uses left
50.0 0 half-price uses left | 50.0 0 half-price uses left
100.0 0 half-price uses left | 100.0 0 half-price uses left
The two columns behave the same, so closures can carry state. Look at what the closure
version returns, though: two functions that share a hidden left, which every caller has to
unpack and pass around together. That is an object with its name and its self removed.
Once a strategy has state and a second method, the class is the more honest shape.
You need to compare it. Go makes this one a compile error:
package main
import "fmt"
type PercentOff struct{ Pct float64 }
func main() {
a, b := PercentOff{10}, PercentOff{10}
fmt.Println("structs equal:", a == b)
half := func(t float64) float64 { return t / 2 }
current := half
fmt.Println("same func:", current == half)
}
$ go run compare.go
# command-line-arguments
./compare.go:13:28: invalid operation: current == half (func can only be compared to nil)
Column 28 of line 13 is current, because the leading tab counts as one column. Go func
values cannot be compared, so code that needs to ask “is the active discount the 10% one?”,
remove a strategy from a list, or use one as a map key cannot do it with funcs. Two
PercentOff structs with the same field compare equal. Python and Java accept the comparison
but only check identity: in Python, percent_off(10) == percent_off(10) is False.
Command is a closure
A classic Command is an object that stores a receiver and its arguments, so that a call can run later, be queued, or be undone. That is what a closure does: it captures variables from the scope where it was created. The Python design patterns lesson already builds class-based commands for a text editor, so this Python version starts with closures. Each operation pushes its own inverse onto an undo stack:
class Account:
def __init__(self): self.balance = 0
def deposit(self, amount): self.balance += amount
def withdraw(self, amount): self.balance -= amount
def main():
acct, undo_stack = Account(), []
def run(action, undo):
action()
undo_stack.append(undo)
run(lambda: acct.deposit(100), lambda: acct.withdraw(100))
run(lambda: acct.withdraw(30), lambda: acct.deposit(30))
run(lambda: acct.deposit(5), lambda: acct.withdraw(5))
print("balance:", acct.balance)
while undo_stack:
undo_stack.pop()()
print("undo ->", acct.balance)
main()
$ python undo_closures.py
balance: 75
undo -> 70
undo -> 100
undo -> 0
Each lambda captures acct from main’s scope, plus a literal amount. That is the receiver and arguments a
Deposit command class would store in __init__. The undo stack is a list of zero-argument
functions, and undo_stack.pop()() pops one and calls it. The undos run in reverse: first
the +5 is removed, then the -30 is restored, then the +100 is removed.
Java shows both forms in one file. Since Java 16 a record makes the classic command almost
as short as the lambda. Runnable is the JDK’s built-in “no arguments, no result” function
type:
import java.util.ArrayDeque;
import java.util.Deque;
public class BankUndo {
static class Account { int balance; }
interface Command { void execute(); void undo(); } // the classic version
record Deposit(Account acct, int amount) implements Command {
public void execute() { acct.balance += amount; }
public void undo() { acct.balance -= amount; }
}
public static void main(String[] args) {
Account acct = new Account();
Command classic = new Deposit(acct, 100);
classic.execute();
Deque<Runnable> undo = new ArrayDeque<>(); // closures instead
acct.balance -= 30;
undo.push(() -> acct.balance += 30);
acct.balance += 5;
undo.push(() -> acct.balance -= 5);
System.out.println("balance: " + acct.balance);
while (!undo.isEmpty()) {
undo.pop().run();
System.out.println("undo -> " + acct.balance);
}
classic.undo();
System.out.println("classic undo -> " + acct.balance);
}
}
$ java BankUndo.java
balance: 75
undo -> 70
undo -> 100
classic undo -> 0
The Deposit record needs a two-method interface because its undo is a separate operation
from its execute. The closure version only stores the undo, since the action already ran
inline. The lambdas can capture acct because that variable is never reassigned. The
Account object is mutated, but the reference to it is effectively final, which is all
Java requires.
When Command should stay a class
The loop-variable trap. Here are the same deposits pushed from a loop:
class Account:
def __init__(self): self.balance = 0
def deposit(self, amount): self.balance += amount
def withdraw(self, amount): self.balance -= amount
def main():
acct, undo_stack = Account(), []
for amount in [100, 30, 5]:
acct.deposit(amount)
undo_stack.append(lambda: acct.withdraw(amount))
print("balance:", acct.balance)
print("captured:", undo_stack[0].__code__.co_freevars)
while undo_stack:
undo_stack.pop()()
print("after undoing everything:", acct.balance)
main()
$ python late_binding.py
balance: 135
captured: ('acct', 'amount')
after undoing everything: 120
Undoing all three deposits should reach 0, but the program reaches 120. co_freevars lists
the names the lambda closes over. A Python closure captures the variable amount, not its
value at that moment. By the time any undo runs,
the loop has finished and amount is 5, so all three undos withdraw 5 (135 − 15 = 120). A
Withdraw(acct, amount) object would have copied the value in its constructor. Java refuses
to compile the same shape:
import java.util.ArrayDeque;
import java.util.Deque;
public class UndoLoop {
static class Account { int balance; }
public static void main(String[] args) {
Account acct = new Account();
int[] amounts = {100, 30, 5};
Deque<Runnable> undo = new ArrayDeque<>();
for (int i = 0; i < amounts.length; i++) {
acct.balance += amounts[i];
undo.push(() -> acct.balance -= amounts[i]);
}
while (!undo.isEmpty()) undo.pop().run();
System.out.println("after undoing everything: " + acct.balance);
}
}
$ java UndoLoop.java
UndoLoop.java:13: error: local variables referenced from a lambda expression must be final or effectively final
undo.push(() -> acct.balance -= amounts[i]);
^
1 error
error: compilation failed
Line 13 is the undo.push line, and the caret points at i. The loop’s i++ reassigns i,
so Java will not let a lambda capture it. This rule is why Java has no late-binding bug: a
lambda can only capture a variable that never changes, so capturing the variable and
capturing its value are the same thing. Go gives a third answer:
package main
import "fmt"
type Account struct{ Balance int }
func main() {
acct := &Account{}
var undo []func()
for _, amount := range []int{100, 30, 5} {
acct.Balance += amount
undo = append(undo, func() { acct.Balance -= amount })
}
fmt.Println("balance:", acct.Balance)
for len(undo) > 0 {
last := undo[len(undo)-1]
undo = undo[:len(undo)-1]
last()
}
fmt.Println("after undoing everything:", acct.Balance)
}
$ go run undo_loop.go
balance: 135
after undoing everything: 0
Since Go 1.22, each loop iteration gets a fresh amount, so every closure keeps its own
value. Before 1.22 Go had Python’s bug. You can see it by copying the file to
undo_loop_go121.go and adding //go:build go1.21 plus a blank line above package main.
That build constraint sets the file’s language version back to 1.21:
$ go run undo_loop_go121.go
balance: 135
after undoing everything: 120
The output is 120, Python’s wrong answer. So a closure command is only as safe as the
language’s capture rule, and that rule changed in a Go minor release. The same goes for a
module whose go.mod still says go 1.21.
It has to leave the process. Commands are often queued for a worker, written to a log, or replayed later. All of these need the command as data:
import pickle
from dataclasses import dataclass
@dataclass
class Deposit:
account_id: str
amount: int
def deposit_closure(account_id, amount):
return lambda ledger: ledger.update({account_id: ledger[account_id] + amount})
for cmd in [Deposit("alice", 100), deposit_closure("alice", 100)]:
try:
payload = pickle.dumps(cmd) # e.g. to put it on a job queue
print("queued:", pickle.loads(payload))
except Exception as e:
print(f"{type(e).__name__}: {e}")
$ python queue_commands.py
queued: Deposit(account_id='alice', amount=100)
AttributeError: Can't get local object 'deposit_closure.<locals>.<lambda>'
The dataclass pickles, comes back intact, and prints a readable repr you could put in an
audit log. The closure fails, because pickle stores functions by importable name and a
lambda has none. A lambda at module level fails too, with PicklingError. Java and Go hit the same
wall: a lambda or a func value has no data you can serialize. If a command must be stored,
sent over the network, or displayed in an audit trail, make it a data type.
Iterator is a generator
An Iterator walks a structure one element at a time and hides how the structure is laid out. The interesting case is a structure that isn’t a flat list. Walking a binary search tree in order (left subtree, node, right subtree) as a hand-written iterator means managing your own stack:
class Node:
def __init__(self, value, left=None, right=None):
self.value, self.left, self.right = value, left, right
class InOrder:
def __init__(self, root):
self.stack = []
self._push_left(root)
def _push_left(self, node):
while node:
self.stack.append(node)
node = node.left
def __iter__(self):
return self
def __next__(self):
if not self.stack:
raise StopIteration
node = self.stack.pop()
self._push_left(node.right)
return node.value
tree = Node(4, Node(2, Node(1), Node(3)), Node(6, Node(5)))
print(list(InOrder(tree)))
$ python tree_iter_class.py
[1, 2, 3, 4, 5, 6]
The traversal is correct but hard to read. The algorithm “left, self, right” is split
between _push_left and __next__, and the stack does the job the call stack would do in a
recursive function. A generator lets you write the recursive function and still return one
value at a time:
class Node:
def __init__(self, value, left=None, right=None):
self.value, self.left, self.right = value, left, right
def in_order(node):
if node:
yield from in_order(node.left)
yield node.value
yield from in_order(node.right)
tree = Node(4, Node(2, Node(1), Node(3)), Node(6, Node(5)))
print(list(in_order(tree)))
values = in_order(tree)
print(next(values), next(values))
print(list(values))
print(list(values))
$ python tree_generator.py
[1, 2, 3, 4, 5, 6]
1 2
[3, 4, 5, 6]
[]
The four-line body says “left, self, right” directly. yield suspends the function and keeps
its local variables and position, which the class had to store in self.stack by hand. The
last two lines show the catch that applies to the class as well: an iterator is single-pass.
After two next calls and one list, the generator is exhausted, so the second list
returns [] with no error. To walk the tree again, call in_order(tree) again.
When the generator needs care
Recursion has a depth limit. A tree built from sorted input with no rebalancing is one long left spine. Here is a 5000-node one, walked by a recursive generator and by a generator that keeps the explicit stack:
class Node:
def __init__(self, value, left=None, right=None):
self.value, self.left, self.right = value, left, right
def in_order_recursive(node):
if node:
yield from in_order_recursive(node.left)
yield node.value
yield from in_order_recursive(node.right)
def in_order_stack(node):
stack = []
while stack or node:
while node:
stack.append(node)
node = node.left
node = stack.pop()
yield node.value
node = node.right
root = None
for value in range(5000, 0, -1): # a "tree" that is one long left spine
root = Node(value, left=root)
for walk in [in_order_stack, in_order_recursive]:
try:
print(walk.__name__, sum(1 for _ in walk(root)), "values")
except RecursionError as e:
print(walk.__name__, "RecursionError:", e)
$ python deep_tree.py
in_order_stack 5000 values
in_order_recursive RecursionError: maximum recursion depth exceeded
Each nested yield from is a live frame, and CPython’s default limit is 1000. The fix keeps
the generator: in_order_stack has the same explicit stack as the InOrder class but none of
the __iter__/__next__/StopIteration code. In Python, the generator replaces the
iterator class in both cases. Recursion is a separate choice that a deep structure can rule
out.
Java has no yield for iterators. Java’s yield keyword only returns a value from a
switch expression. With no generators, the hand-written iterator is still the standard way
to walk a tree lazily. A lambda can at least supply the Iterable so that for-each works:
import java.util.*;
public class TreeIterator {
record Node(int value, Node left, Node right) {}
static class InOrder implements Iterator<Integer> {
private final Deque<Node> stack = new ArrayDeque<>();
InOrder(Node root) { pushLeft(root); }
private void pushLeft(Node n) { for (; n != null; n = n.left()) stack.push(n); }
public boolean hasNext() { return !stack.isEmpty(); }
public Integer next() {
Node n = stack.pop();
pushLeft(n.right());
return n.value();
}
}
public static void main(String[] args) {
Node tree = new Node(4, new Node(2, new Node(1, null, null), new Node(3, null, null)),
new Node(6, new Node(5, null, null), null));
Iterable<Integer> values = () -> new InOrder(tree);
List<Integer> out = new ArrayList<>();
for (int v : values) out.add(v); // for-each works on any Iterable
System.out.println(out);
}
}
$ java TreeIterator.java
[1, 2, 3, 4, 5, 6]
Iterable has one abstract method, iterator(), so a lambda can implement it. That is the
Strategy lesson again, and here it is the only part of the pattern that shrinks. A Stream
looks like the generator-shaped answer, since you can write the recursion directly. Count the
nodes it touches to get just the first value:
import java.util.stream.Stream;
public class TreeStream {
record Node(int value, Node left, Node right) {}
static int visited = 0;
static Stream<Integer> inOrder(Node n) {
if (n == null) return Stream.empty();
visited++;
return Stream.concat(Stream.concat(inOrder(n.left()), Stream.of(n.value())),
inOrder(n.right()));
}
public static void main(String[] args) {
Node tree = new Node(4, new Node(2, new Node(1, null, null), new Node(3, null, null)),
new Node(6, new Node(5, null, null), null));
System.out.println(inOrder(tree).toList());
visited = 0;
System.out.println("first: " + inOrder(tree).findFirst().get()
+ ", nodes visited: " + visited);
}
}
$ java TreeStream.java
[1, 2, 3, 4, 5, 6]
first: 1, nodes visited: 6
The result is right, but findFirst touched all 6 nodes. The InOrder iterator only pushes
the left spine (4, 2, 1) before returning 1. The elements flow lazily, but the recursion
calls inOrder on every node to build the nested concat pipeline before the first element
is pulled. The Stream.concat Javadoc also warns that deeply nested concatenation can throw
StackOverflowError. In Java the iterator class survives this lesson.
Go 1.23 has iterator functions. An iter.Seq[int] is a function that takes a yield
callback, and for range accepts it directly. Go turns the loop body into the yield
function:
package main
import (
"fmt"
"iter"
)
type Node struct {
Value int
Left, Right *Node
}
func (n *Node) InOrder() iter.Seq[int] {
return func(yield func(int) bool) { n.walk(yield) }
}
func (n *Node) walk(yield func(int) bool) bool {
if n == nil {
return true
}
return n.Left.walk(yield) && yield(n.Value) && n.Right.walk(yield)
}
func main() {
leaf := func(v int) *Node { return &Node{Value: v} }
tree := &Node{4, &Node{2, leaf(1), leaf(3)}, &Node{6, leaf(5), nil}}
var got []int
for v := range tree.InOrder() {
if v > 4 {
break
}
got = append(got, v)
}
fmt.Println(got)
}
$ go run tree_seq.go
[1 2 3 4]
walk is the recursive in-order traversal, and yield returns false when the loop body
breaks. The && chain stops walking as soon as any call returns false, so 6 is never
yielded. Unlike a Python generator, this iter.Seq is not consumed: ranging over
tree.InOrder() again starts a new walk. Some iterators are single-use, though, such as
one that reads from a channel or a file. Go’s version also has a sharp edge:
the iterator function must respect that false. This one ignores it:
package main
import "fmt"
type Node struct {
Value int
Left, Right *Node
}
func (n *Node) walk(yield func(int) bool) { // ignores what yield returns
if n == nil {
return
}
n.Left.walk(yield)
yield(n.Value)
n.Right.walk(yield)
}
func main() {
leaf := func(v int) *Node { return &Node{Value: v} }
tree := &Node{4, &Node{2, leaf(1), leaf(3)}, &Node{6, leaf(5), nil}}
for v := range tree.walk {
if v > 2 {
break
}
fmt.Println(v)
}
}
$ go run tree_seq_bad.go
1
2
panic: runtime error: range function continued iteration after function for loop body returned false
goroutine 1 [running]:
main.main-range1(0xc0000a4ec8?)
/src/tree_seq_bad.go:22 +0xa5
main.(*Node).walk(0xc0000a4ee0, 0xc0000a4ea0)
/src/tree_seq_bad.go:15 +0x3b
main.main()
/src/tree_seq_bad.go:22 +0x107
exit status 2
The loop body hit break at 3, so yield returned false. walk called yield again
anyway for 4, and the runtime panicked instead of silently running a loop body that had
already exited. Line 15 is the yield(n.Value) call that ignored the result. Line 22 is the
for range loop, whose body Go compiled into the function main-range1. The hex addresses
differ from run to run. A method value with the right signature (tree.walk) can be ranged
over directly with no iter.Seq wrapper. A Python generator can’t make this mistake: it
pauses at yield until the consumer asks for more. A Go iterator function stays in control of
its own loop, so the stopping protocol is the author’s job.
The rule of thumb
| Pattern | Use the language feature when… | Keep the class when… |
|---|---|---|
| Strategy | it’s one stateless operation | it holds state, has several methods, must be compared, or needs a readable name in traces |
| Command | it runs or undoes within this process | it must be serialized, logged, replayed or inspected, or it captures loop variables in a language without per-iteration capture |
| Iterator | the language has generators (Python) or iterator functions (Go 1.23+) | the language has neither (Java), or the consumer needs more than “next value”, such as peek, remove or the remaining stack |
The patterns themselves did not go away. What changed is how much code each one costs. The class version is still the right choice when the extra code buys a name, a type, or data you can store. For how a pattern grows out of a refactor rather than a catalog, see the Observer lesson earlier in this track.
Practice
1. Fix late_binding.py so that undoing everything returns the balance to 0, using functools.partial.
from functools import partial
class Account:
def __init__(self): self.balance = 0
def deposit(self, amount): self.balance += amount
def withdraw(self, amount): self.balance -= amount
def main():
acct, undo_stack = Account(), []
for amount in [100, 30, 5]:
acct.deposit(amount)
undo_stack.append(partial(acct.withdraw, amount))
print("balance:", acct.balance)
print("queued undos:", [u.args for u in undo_stack])
while undo_stack:
undo_stack.pop()()
print("after undoing everything:", acct.balance)
main()
$ python late_binding_fixed.py
balance: 135
queued undos: [(100,), (30,), (5,)]
after undoing everything: 0
partial evaluates amount when it is created, so each undo holds the value from its own
iteration. It also fixes part of the inspection problem: a partial exposes .func and
.args, so you can print what is queued. You can’t do that with a lambda. The other common
fix, lambda amount=amount: acct.withdraw(amount), works for the same reason: default
arguments are evaluated once, when the function is defined.
2. Make UndoLoop.java compile without adding a final local copy.
import java.util.ArrayDeque;
import java.util.Deque;
public class UndoLoopFixed {
static class Account { int balance; }
public static void main(String[] args) {
Account acct = new Account();
int[] amounts = {100, 30, 5};
Deque<Runnable> undo = new ArrayDeque<>();
for (int amount : amounts) {
acct.balance += amount;
undo.push(() -> acct.balance -= amount);
}
System.out.println("balance: " + acct.balance);
while (!undo.isEmpty()) undo.pop().run();
System.out.println("after undoing everything: " + acct.balance);
}
}
$ java UndoLoopFixed.java
balance: 135
after undoing everything: 0
An enhanced for declares a new amount variable on every iteration, and nothing reassigns
it. That makes it effectively final, so the lambda may capture it, and each capture sees its
own value. This is the same per-iteration rule Go adopted in 1.22, though Java only applies it
to the enhanced for. The index variable i in a classic for is still reassigned by i++.
3. Keep the stateful FirstNOrders promotion a class, but let it go anywhere a function-typed Discount is expected.
from typing import Callable
Discount = Callable[[float], float]
def checkout(total, discount: Discount):
return round(discount(total), 2)
class FirstNOrders:
"""Half price for the first n orders, then full price."""
def __init__(self, n): self.left = n
def __call__(self, total):
if self.left == 0:
return total
self.left -= 1
return total / 2
def describe(self): return f"{self.left} half-price uses left"
promo = FirstNOrders(2)
for d in [lambda t: t - 10, promo, promo, promo]:
print(checkout(100.0, d), "|", promo.describe())
$ python callable_promo.py
90.0 | 2 half-price uses left
50.0 | 1 half-price uses left
50.0 | 0 half-price uses left
100.0 | 0 half-price uses left
Renaming apply to __call__ makes instances callable, so checkout cannot tell the
promotion from a lambda. Both satisfy Callable[[float], float]. You keep the class’s name,
state and second method, and simple strategies stay one-line lambdas. This is the Python
counterpart of Go’s DiscountFunc adapter, working in the opposite direction.
4. In Go, write a generic Filter that takes an iter.Seq and a predicate and returns a new iter.Seq.
package main
import (
"fmt"
"iter"
"slices"
)
// Filter combines both patterns: keep is a strategy, the result is an iterator.
func Filter[T any](seq iter.Seq[T], keep func(T) bool) iter.Seq[T] {
return func(yield func(T) bool) {
for v := range seq {
if keep(v) && !yield(v) {
return
}
}
}
}
func main() {
nums := slices.Values([]int{1, 2, 3, 4, 5, 6})
even := func(n int) bool { return n%2 == 0 }
fmt.Println(slices.Collect(Filter(nums, even)))
fmt.Println(slices.Collect(Filter(nums, func(n int) bool { return n > 4 })))
}
$ go run filter_seq.go
[2 4 6]
[5 6]
keep is a Strategy passed as a function, and the returned iter.Seq is an Iterator built
from a closure. !yield(v) followed by return is the stopping protocol that
tree_seq_bad.go skipped. nums is used twice and works both times because
slices.Values returns a function that starts a new walk on every call, which a Python
generator does not do.