Design Patterns (GoF and modern) Module 4 – Behavioral patterns: algorithms and requests
Strategy: swap algorithms at runtime
Replace a growing if/else with interchangeable strategies: objects, functions or generics, picked from configuration, with code that runs in six languages.
What you will learn
- Replace conditional algorithm selection with interchangeable strategies
- Implement strategies as objects, functions and generic parameters, choosing per language
- Compare static and dynamic dispatch for strategies in Rust and C++
- Select a strategy from configuration without a growing switch
Before you start
What you’ll learn · Before you start
What you will learn
- Replace conditional algorithm selection with interchangeable strategies
- Implement strategies as objects, functions and generic parameters, choosing per language
- Compare static and dynamic dispatch for strategies in Rust and C++
- Select a strategy from configuration without a growing switch
Before you start
On this page
Strategy is a design pattern for code that can do one job in several ways. Each way of doing it, each algorithm,
goes behind one small interface, and the code that needs the job done holds one of them and calls it without knowing
which one it holds. Changing the behaviour then means handing over a different strategy, not adding a branch to a
growing if/elif chain. The Gang of Four catalogue states the intent as defining a family of algorithms, wrapping
each one up and making them interchangeable. Reach for it when one step of a program varies by customer, country,
configuration or experiment, and new variants keep arriving. In a language with first-class functions a strategy is
often just a function: Python’s sorted(key=…) and Java’s Comparator lambdas are strategies you have already used.
This lesson starts from a shipping-cost function with every rule in one place, turns the rules into strategies, runs the same table of orders in six languages, and ends with when the pattern is not worth it.
The problem: one function that knows every rule
A shop charges shipping by one of three rules, picked in its settings: a flat amount per order, a base amount plus a charge for every started kilogram, or nothing on orders above a threshold. The first version keeps all three in one function, chosen by the rule’s name:
# Shipping cost for an order, with every rule in one function. The rule comes from the shop's settings.
ORDERS = [ # (order id, subtotal, weight in grams)
("A", 120, 400),
("B", 499, 2000),
("C", 500, 2000),
("D", 1250, 7500),
("E", 60, 12000),
("F", 999, 0),
]
def shipping_cost(subtotal, grams, settings):
rule = settings["rule"]
if rule == "flat":
return settings["amount"]
elif rule == "weight":
kilos = (grams + 999) // 1000 # every started kilogram counts
return settings["base"] + settings["per_kg"] * kilos
elif rule == "free-above":
if subtotal >= settings["threshold"]:
return 0
return settings["otherwise"]
else:
return 0 # "nothing to charge" looked like a safe default
for settings in [
{"rule": "free-above", "threshold": 500, "otherwise": 60},
{"rule": "free_above", "threshold": 500, "otherwise": 60}, # one character different
]:
costs = [shipping_cost(subtotal, grams, settings) for _, subtotal, grams in ORDERS]
print(f"{settings['rule']:<11} {costs} shipping collected: {sum(costs)}") Output
free-above [60, 60, 0, 0, 60, 0] shipping collected: 180 free_above [0, 0, 0, 0, 0, 0] shipping collected: 0
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 before.py
Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
The first line of the output is right. The second runs the same settings with an underscore where a hyphen should
be, the kind of typo that a settings file or a feature flag makes easy, and it reaches the final else: every order
ships at no charge, and the shop collects nothing. Nothing failed, so nothing told anyone.
The typo is the visible problem; the shape of the function is the real one. Every new rule edits a function that
every order goes through, so each change puts the old rules at risk. No rule can be tested on its own, because all
of them live behind one name. The settings of one rule (threshold) sit in the same dictionary as the settings of
another (per_kg), and nothing checks that they belong together. And the else at the bottom has to invent an
answer for rules it has never heard of.
The pattern: a context, a strategy and the concrete strategies
Strategy splits that function into three roles. The strategy is the interface every rule shares: here one
operation, cost(order). Each concrete strategy is one rule that implements it, with its own settings. The
context is the code that needs a cost, the checkout, and it holds one strategy and calls it:
Strategy in the shipping example: the context, the strategy and three concrete strategies
Text description of the diagram
Five class boxes in three rows. Each box shows the class name, then its attributes, then its operations; a plus sign marks a public member and a minus sign a private one, and the type follows each name.
At the top is Checkout, labelled the context. It has a private attribute rule of type ShippingRule and a public operation total(order) returning int.
In the middle is ShippingRule, marked «interface» and labelled the strategy, with one operation, cost(order), returning int. A line with a hollow diamond at Checkout and an arrowhead at ShippingRule, labelled "holds 1, calls cost()", says that a checkout holds one rule and calls it without knowing which rule it is.
In the bottom row are the three concrete strategies, each with its own cost(order): FreeAbove, with a private threshold and otherwise; ByWeight, with a private base and per_kg; and FlatRate, with a private amount. Each is joined to ShippingRule by a dashed line with a hollow triangle at ShippingRule: it realises the interface.
The checkout depends only on the interface, so a new rule is a new class or function, and no line of the checkout changes. Someone still has to choose: the code that builds the checkout, usually at start-up, reads the settings and hands over the matching rule. That choice now happens once, in one place, instead of on every call.
Strategies as functions, kept in a registry
In Python a strategy rarely needs a class. A rule is any function from an order to a cost, and a rule with settings
is a function made by a factory, a closure that remembers its own settings. A dictionary from rule names to factories,
a registry, replaces the if chain:
# The same rules as strategies: each rule is a function from an order to a cost, made by a factory that takes the
# rule's settings. The checkout (the context) calls whichever rule it was given and knows none of them.
from dataclasses import dataclass
@dataclass(frozen=True)
class Order:
id: str
subtotal: int
grams: int
ORDERS = [Order("A", 120, 400), Order("B", 499, 2000), Order("C", 500, 2000),
Order("D", 1250, 7500), Order("E", 60, 12000), Order("F", 999, 0)]
RULES = {} # rule name -> factory that builds the rule from its settings
def register(name):
def add(factory):
RULES[name] = factory
return factory
return add
@register("flat")
def flat(amount):
return lambda order: amount
@register("weight")
def by_weight(base, per_kg):
return lambda order: base + per_kg * ((order.grams + 999) // 1000)
@register("free-above")
def free_above(threshold, otherwise):
return lambda order: 0 if order.subtotal >= threshold else otherwise
def make_rule(settings):
"""The rule the settings name, built with the other settings; unknown names fail here, at start-up."""
name, params = settings["rule"], {k: v for k, v in settings.items() if k != "rule"}
if name not in RULES:
raise ValueError(f"unknown shipping rule {name!r}; known rules: {', '.join(sorted(RULES))}")
return RULES[name](**params)
class Checkout:
def __init__(self, rule):
self.rule = rule # the strategy
def total(self, order):
return order.subtotal + self.rule(order)
SETTINGS = {
"flat": {"rule": "flat", "amount": 40},
"weight": {"rule": "weight", "base": 20, "per_kg": 12},
"free-above": {"rule": "free-above", "threshold": 500, "otherwise": 60},
}
checkouts = {name: Checkout(make_rule(s)) for name, s in SETTINGS.items()}
print("order subtotal grams | " + " ".join(f"{name:>10}" for name in checkouts))
for o in ORDERS:
costs = " ".join(f"{c.rule(o):>10}" for c in checkouts.values())
print(f"{o.id:<5} {o.subtotal:>8} {o.grams:>5} | {costs}")
print("total of order D with free-above:", checkouts["free-above"].total(ORDERS[3]))
# A new rule joins by registration: nothing above changes.
@register("express")
def express(base, per_kg, minimum):
weight_rule = by_weight(base, per_kg)
return lambda order: max(minimum, 2 * weight_rule(order))
fast = Checkout(make_rule({"rule": "express", "base": 20, "per_kg": 12, "minimum": 90}))
print("express:", [fast.rule(o) for o in ORDERS])
try:
make_rule({"rule": "free_above", "threshold": 500, "otherwise": 60})
except ValueError as e:
print("ValueError:", e) Output
order subtotal grams | flat weight free-above A 120 400 | 40 32 60 B 499 2000 | 40 44 60 C 500 2000 | 40 44 0 D 1250 7500 | 40 116 0 E 60 12000 | 40 164 60 F 999 0 | 40 20 0 total of order D with free-above: 1250 express: [90, 90, 90, 232, 328, 90] ValueError: unknown shipping rule 'free_above'; known rules: express, flat, free-above, weight
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 shipping.py
Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
Four things changed, and the output shows each of them:
- Each rule is a few lines that can be read and tested alone.
by_weight(20, 12)is a complete strategy; nothing else needs to exist to call it. Checkoutcallsself.rule(order)and knows no rule by name. The table calls the same three checkouts on six orders.- A new rule,
express, joins with@register("express")and reusesby_weightinside it. No existing line changed. make_rulerefuses a name it does not know, with a list of the names it does. The typo that cost the shop all its shipping in the first version now stops the program before the first order, where someone has to fix it.
Rules without settings, and rules whose settings never change, can be one shared instance for every checkout: a strategy holds no state about any single order, so two checkouts can call the same function at once.
Python Online Compiler Run shipping.py with a rule of your own, for example one that charges by distance band.The same table in five more languages
Every language in this track can hold a strategy, but each has its own way, and the natural one is rarely a class hierarchy. These five programs build the same three rules from the same settings and print the same six orders:
TypeScript · langs/shipping.ts
// Shipping rules as strategies in TypeScript: a rule is any function from an order to a cost.
type Order = { id: string; subtotal: number; grams: number };
type Rule = (order: Order) => number;
type Settings = { rule: string; [key: string]: string | number };
const RULES: Record<string, (s: Settings) => Rule> = {
flat: (s) => () => Number(s.amount),
weight: (s) => (o) => Number(s.base) + Number(s.per_kg) * Math.ceil(o.grams / 1000),
"free-above": (s) => (o) => (o.subtotal >= Number(s.threshold) ? 0 : Number(s.otherwise)),
};
function makeRule(settings: Settings): Rule {
const factory = RULES[settings.rule];
if (!factory) throw new Error(`unknown shipping rule "${settings.rule}"; known rules: ${Object.keys(RULES).sort().join(", ")}`);
return factory(settings);
}
const orders: Order[] = [
{ id: "A", subtotal: 120, grams: 400 }, { id: "B", subtotal: 499, grams: 2000 }, { id: "C", subtotal: 500, grams: 2000 },
{ id: "D", subtotal: 1250, grams: 7500 }, { id: "E", subtotal: 60, grams: 12000 }, { id: "F", subtotal: 999, grams: 0 },
];
const rules = [
makeRule({ rule: "flat", amount: 40 }),
makeRule({ rule: "weight", base: 20, per_kg: 12 }),
makeRule({ rule: "free-above", threshold: 500, otherwise: 60 }),
];
for (const o of orders) console.log(o.id, rules.map((rule) => rule(o)).join(" "));
try {
makeRule({ rule: "free_above", threshold: 500, otherwise: 60 });
} catch (e) {
console.log((e as Error).message);
} Java · langs/Shipping.java
// Shipping rules as strategies in Java 25: ShippingRule is a functional interface, so each rule is a lambda.
import java.util.List;
import java.util.Map;
import java.util.TreeMap;
import java.util.function.Function;
public class Shipping {
record Order(String id, int subtotal, int grams) {}
@FunctionalInterface
interface ShippingRule {
int cost(Order order);
}
static final Map<String, Function<Map<String, Integer>, ShippingRule>> RULES = new TreeMap<>(Map.of(
"flat", s -> order -> s.get("amount"),
"weight", s -> order -> s.get("base") + s.get("per_kg") * ((order.grams() + 999) / 1000),
"free-above", s -> order -> order.subtotal() >= s.get("threshold") ? 0 : s.get("otherwise")));
static ShippingRule makeRule(String name, Map<String, Integer> settings) {
var factory = RULES.get(name);
if (factory == null) {
throw new IllegalArgumentException("unknown shipping rule \"" + name + "\"; known rules: " + String.join(", ", RULES.keySet()));
}
return factory.apply(settings);
}
public static void main(String[] args) {
var orders = List.of(new Order("A", 120, 400), new Order("B", 499, 2000), new Order("C", 500, 2000),
new Order("D", 1250, 7500), new Order("E", 60, 12000), new Order("F", 999, 0));
var rules = List.of(
makeRule("flat", Map.of("amount", 40)),
makeRule("weight", Map.of("base", 20, "per_kg", 12)),
makeRule("free-above", Map.of("threshold", 500, "otherwise", 60)));
for (var o : orders) {
var line = new StringBuilder(o.id());
for (var rule : rules) line.append(' ').append(rule.cost(o));
System.out.println(line);
}
try {
makeRule("free_above", Map.of("threshold", 500, "otherwise", 60));
} catch (IllegalArgumentException e) {
System.out.println(e.getMessage());
}
}
} Go · langs/shipping.go
// Shipping rules as strategies in Go: a rule is a function type, and a map from names to factories picks one.
package main
import (
"fmt"
"maps"
"slices"
"strings"
)
type Order struct {
ID string
Subtotal, Grams int
}
type Rule func(Order) int
type Settings map[string]int
var rules = map[string]func(Settings) Rule{
"flat": func(s Settings) Rule { return func(Order) int { return s["amount"] } },
"weight": func(s Settings) Rule { return func(o Order) int { return s["base"] + s["per_kg"]*((o.Grams+999)/1000) } },
"free-above": func(s Settings) Rule { return func(o Order) int { return freeAbove(o, s) } },
}
func freeAbove(o Order, s Settings) int {
if o.Subtotal >= s["threshold"] {
return 0
}
return s["otherwise"]
}
func makeRule(name string, s Settings) (Rule, error) {
factory, ok := rules[name]
if !ok {
known := slices.Sorted(maps.Keys(rules))
return nil, fmt.Errorf("unknown shipping rule %q; known rules: %s", name, strings.Join(known, ", "))
}
return factory(s), nil
}
func main() {
orders := []Order{{"A", 120, 400}, {"B", 499, 2000}, {"C", 500, 2000}, {"D", 1250, 7500}, {"E", 60, 12000}, {"F", 999, 0}}
var chosen []Rule
for _, c := range []struct {
name string
s Settings
}{
{"flat", Settings{"amount": 40}},
{"weight", Settings{"base": 20, "per_kg": 12}},
{"free-above", Settings{"threshold": 500, "otherwise": 60}},
} {
rule, err := makeRule(c.name, c.s)
if err != nil {
panic(err)
}
chosen = append(chosen, rule)
}
for _, o := range orders {
costs := make([]string, len(chosen))
for i, rule := range chosen {
costs[i] = fmt.Sprint(rule(o))
}
fmt.Println(o.ID, strings.Join(costs, " "))
}
if _, err := makeRule("free_above", Settings{"threshold": 500, "otherwise": 60}); err != nil {
fmt.Println(err)
}
} C++ · langs/shipping.cpp
// Shipping rules as strategies in C++23: std::function holds any callable with the right signature.
#include <functional>
#include <map>
#include <print>
#include <stdexcept>
#include <string>
#include <vector>
struct Order {
std::string id;
int subtotal;
int grams;
};
using Rule = std::function<int(const Order&)>;
using Settings = std::map<std::string, int>;
const std::map<std::string, std::function<Rule(const Settings&)>> rules = {
{"flat", [](const Settings& s) -> Rule { return [a = s.at("amount")](const Order&) { return a; }; }},
{"weight", [](const Settings& s) -> Rule {
return [base = s.at("base"), per_kg = s.at("per_kg")](const Order& o) { return base + per_kg * ((o.grams + 999) / 1000); };
}},
{"free-above", [](const Settings& s) -> Rule {
return [t = s.at("threshold"), other = s.at("otherwise")](const Order& o) { return o.subtotal >= t ? 0 : other; };
}},
};
Rule make_rule(const std::string& name, const Settings& settings) {
auto it = rules.find(name);
if (it == rules.end()) {
std::string known;
for (const auto& [key, _] : rules) known += (known.empty() ? "" : ", ") + key;
throw std::invalid_argument("unknown shipping rule \"" + name + "\"; known rules: " + known);
}
return it->second(settings);
}
int main() {
std::vector<Order> orders = {{"A", 120, 400}, {"B", 499, 2000}, {"C", 500, 2000}, {"D", 1250, 7500}, {"E", 60, 12000}, {"F", 999, 0}};
std::vector<Rule> chosen = {
make_rule("flat", {{"amount", 40}}),
make_rule("weight", {{"base", 20}, {"per_kg", 12}}),
make_rule("free-above", {{"threshold", 500}, {"otherwise", 60}}),
};
for (const auto& o : orders) {
std::print("{}", o.id);
for (const auto& rule : chosen) std::print(" {}", rule(o));
std::println("");
}
try {
make_rule("free_above", {{"threshold", 500}, {"otherwise", 60}});
} catch (const std::invalid_argument& e) {
std::println("{}", e.what());
}
} Rust · langs/shipping.rs
// Shipping rules as strategies in Rust: a rule chosen at run time is a boxed closure, Box<dyn Fn>.
use std::collections::BTreeMap;
struct Order {
id: &'static str,
subtotal: u32,
grams: u32,
}
type Rule = Box<dyn Fn(&Order) -> u32>;
type Settings = BTreeMap<&'static str, u32>;
fn rules() -> BTreeMap<&'static str, fn(&Settings) -> Rule> {
let mut m: BTreeMap<&'static str, fn(&Settings) -> Rule> = BTreeMap::new();
m.insert("flat", |s| {
let amount = s["amount"];
Box::new(move |_| amount)
});
m.insert("weight", |s| {
let (base, per_kg) = (s["base"], s["per_kg"]);
Box::new(move |o| base + per_kg * o.grams.div_ceil(1000))
});
m.insert("free-above", |s| {
let (threshold, otherwise) = (s["threshold"], s["otherwise"]);
Box::new(move |o| if o.subtotal >= threshold { 0 } else { otherwise })
});
m
}
fn make_rule(name: &str, settings: &Settings) -> Result<Rule, String> {
let all = rules();
match all.get(name) {
Some(factory) => Ok(factory(settings)),
None => {
let known: Vec<&str> = all.keys().copied().collect();
Err(format!("unknown shipping rule \"{name}\"; known rules: {}", known.join(", ")))
}
}
}
fn main() {
let orders = [
Order { id: "A", subtotal: 120, grams: 400 },
Order { id: "B", subtotal: 499, grams: 2000 },
Order { id: "C", subtotal: 500, grams: 2000 },
Order { id: "D", subtotal: 1250, grams: 7500 },
Order { id: "E", subtotal: 60, grams: 12000 },
Order { id: "F", subtotal: 999, grams: 0 },
];
let chosen: Vec<Rule> = vec![
make_rule("flat", &Settings::from([("amount", 40)])).unwrap(),
make_rule("weight", &Settings::from([("base", 20), ("per_kg", 12)])).unwrap(),
make_rule("free-above", &Settings::from([("threshold", 500), ("otherwise", 60)])).unwrap(),
];
for o in &orders {
let costs: Vec<String> = chosen.iter().map(|rule| rule(o).to_string()).collect();
println!("{} {}", o.id, costs.join(" "));
}
if let Err(e) = make_rule("free_above", &Settings::from([("threshold", 500), ("otherwise", 60)])) {
println!("{e}");
}
} from session import run, versions
# The same six orders and three rules in five more languages. TypeScript runs on Node.js, which strips the types;
# Java runs as a single source file; the C++ file is built with the sanitizers on.
versions("node -p \"'Node.js ' + process.versions.node\"", "java -version", "go version", "clang++ --version", "rustc --version")
run("""
node shipping.ts
java Shipping.java
go run shipping.go
clang++ -std=c++23 -O1 -Wall -Wextra -fsanitize=address,undefined shipping.cpp -o shipping_cpp && ./shipping_cpp
rustc --edition 2024 -O shipping.rs -o shipping_rs && ./shipping_rs
""") Output
# Node.js 24.21.0 # openjdk version "25.0.4.1" LTS # go version go1.27.1 darwin/arm64 # Apple clang version 21.0.0 (clang-2100.3.34.2) # rustc 1.99.0 $ node shipping.ts A 40 32 60 B 40 44 60 C 40 44 0 D 40 116 0 E 40 164 60 F 40 20 0 unknown shipping rule "free_above"; known rules: flat, free-above, weight $ java Shipping.java A 40 32 60 B 40 44 60 C 40 44 0 D 40 116 0 E 40 164 60 F 40 20 0 unknown shipping rule "free_above"; known rules: flat, free-above, weight $ go run shipping.go A 40 32 60 B 40 44 60 C 40 44 0 D 40 116 0 E 40 164 60 F 40 20 0 unknown shipping rule "free_above"; known rules: flat, free-above, weight $ clang++ -std=c++23 -O1 -Wall -Wextra -fsanitize=address,undefined shipping.cpp -o shipping_cpp && ./shipping_cpp A 40 32 60 B 40 44 60 C 40 44 0 D 40 116 0 E 40 164 60 F 40 20 0 unknown shipping rule "free_above"; known rules: flat, free-above, weight $ rustc --edition 2024 -O shipping.rs -o shipping_rs && ./shipping_rs A 40 32 60 B 40 44 60 C 40 44 0 D 40 116 0 E 40 164 60 F 40 20 0 unknown shipping rule "free_above"; known rules: flat, free-above, weight
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 shipping_session.py
All five print the table of the Python version and refuse the misspelt rule with the same message. The strategies differ only in how a language spells “a function with this signature”:
- TypeScript: a function type,
(order: Order) => number. Node.js runs the file directly: it removes the type annotations and does no type checking, sotscis still the tool that checks them. - Java: a functional interface, an interface with a single abstract method, so a lambda can implement it.
Comparator, the standard library’s best-known strategy, is declared the same way, which is whyComparator.comparing(Person::name)reads like a sentence. - Go: a function type,
type Rule func(Order) int, stored in a map. The standard library takes strategies the same way:slices.SortFuncsorts with whatever comparison function it is given. - C++:
std::function<int(const Order&)>, a wrapper that can hold any callable with that signature, here lambdas that capture their settings. - Rust:
Box<dyn Fn(&Order) -> u32>, a boxed closure, because the rule is picked at run time; the next section shows the alternative.
Peter Norvig’s talk “Design Patterns in Dynamic Languages” counted 16 of the catalogue’s 23 patterns as invisible or simpler in languages such as Lisp and Dylan, and named first-class functions as the reason for four of them: Command, Strategy, Template Method and Visitor. In his words the strategy becomes “a variable whose value is a function”. A class still earns its place when a strategy has several related operations (a shipping rule that also estimates the delivery date and names itself on the invoice), when it validates its settings, or when it holds a resource such as a connection that must be opened and closed. The lesson How language features reshape patterns follows the same idea through the rest of the catalogue.
Static or dynamic dispatch: Rust and C++
Rust and C++ make you choose, in the type of the parameter, whether the strategy is fixed when the program is
compiled or picked while it runs. A generic parameter, F: Fn(&Order) -> u32 in Rust or a template parameter in
C++, is static dispatch: the compiler makes a separate copy of the function for each strategy type it is called
with, a process Rust calls monomorphisation, and every copy knows exactly which rule it calls. A trait object,
dyn Fn in Rust, or std::function in C++, is dynamic dispatch: one copy of the function, which looks up the
rule to call when it runs. These programs compute the same totals both ways, then count the compiled copies:
Rust · langs/dispatch.rs
// One strategy, two kinds of dispatch. total_generic is compiled once for every closure type it is called with
// (monomorphisation); total_dyn is compiled once and calls the closure through a vtable chosen at run time.
struct Order {
subtotal: u32,
grams: u32,
}
#[inline(never)]
fn total_generic<F: Fn(&Order) -> u32>(orders: &[Order], rule: F) -> u32 {
orders.iter().map(|o| rule(o)).sum()
}
#[inline(never)]
fn total_dyn(orders: &[Order], rule: &dyn Fn(&Order) -> u32) -> u32 {
orders.iter().map(|o| rule(o)).sum()
}
fn main() {
let orders = [
Order { subtotal: 120, grams: 400 },
Order { subtotal: 499, grams: 2000 },
Order { subtotal: 500, grams: 2000 },
Order { subtotal: 1250, grams: 7500 },
Order { subtotal: 60, grams: 12000 },
Order { subtotal: 999, grams: 0 },
];
let flat = |_: &Order| 40;
let weight = |o: &Order| 20 + 12 * o.grams.div_ceil(1000);
let free_above = |o: &Order| if o.subtotal >= 500 { 0 } else { 60 };
// Static dispatch: three closure types, so three copies of total_generic.
println!("generic: {} {} {}", total_generic(&orders, flat), total_generic(&orders, weight), total_generic(&orders, free_above));
// Dynamic dispatch: one copy of total_dyn; the closures can also live in one Vec, chosen by name at run time.
let by_name: Vec<(&str, Box<dyn Fn(&Order) -> u32>)> = vec![("flat", Box::new(flat)), ("weight", Box::new(weight)), ("free-above", Box::new(free_above))];
let totals: Vec<String> = by_name.iter().map(|(_, rule)| total_dyn(&orders, rule.as_ref()).to_string()).collect();
println!("dyn: {}", totals.join(" "));
} C++ · langs/dispatch.cpp
// One strategy, two kinds of dispatch in C++. total_template is instantiated once for every lambda type it is called
// with; total_function takes std::function, compiled once, and calls whatever callable it holds at run time.
#include <functional>
#include <print>
#include <vector>
struct Order {
int subtotal;
int grams;
};
template <typename Rule>
[[gnu::noinline]] int total_template(const std::vector<Order>& orders, Rule rule) {
int sum = 0;
for (const auto& o : orders) sum += rule(o);
return sum;
}
[[gnu::noinline]] int total_function(const std::vector<Order>& orders, const std::function<int(const Order&)>& rule) {
int sum = 0;
for (const auto& o : orders) sum += rule(o);
return sum;
}
int main() {
std::vector<Order> orders = {{120, 400}, {499, 2000}, {500, 2000}, {1250, 7500}, {60, 12000}, {999, 0}};
auto flat = [](const Order&) { return 40; };
auto weight = [](const Order& o) { return 20 + 12 * ((o.grams + 999) / 1000); };
auto free_above = [](const Order& o) { return o.subtotal >= 500 ? 0 : 60; };
std::println("template: {} {} {}", total_template(orders, flat), total_template(orders, weight), total_template(orders, free_above));
std::vector<std::function<int(const Order&)>> chosen = {flat, weight, free_above};
std::print("function:");
for (const auto& rule : chosen) std::print(" {}", total_function(orders, rule));
std::println("");
} Rust: mixed closures · langs/mixed.rs
// Two rules that capture their settings, in one Vec without a Box. Every closure has a type of its own, so this
// does not compile. (Closures that capture nothing would compile here: they turn into plain function pointers.)
fn main() {
let amount = 40;
let per_kg = 12;
let flat = move |_grams: u32| amount;
let weight = move |grams: u32| 20 + per_kg * grams.div_ceil(1000);
let rules = vec![flat, weight];
println!("{}", rules.len());
} from session import run, versions
# Built without optimisation, so every function the source defines stays in the program. nm lists a program's
# symbols (-C turns them back into source names, -j prints only the names), so the compiled copies can be counted.
versions("rustc --version", "clang++ --version")
run("""
rustc --edition 2024 -C opt-level=0 dispatch.rs -o dispatch_rs && ./dispatch_rs
nm -C -j dispatch_rs | grep -E '^dispatch::total_(generic::<[^>]*>|dyn)$' | sort
clang++ -std=c++23 -O0 dispatch.cpp -o dispatch_cpp && ./dispatch_cpp
nm -C -j dispatch_cpp | grep -E '^(int )?total_(template|function)' | sed 's/(.*//' | sort
rustc --edition 2024 mixed.rs -o mixed
""", expect_failure=("rustc --edition 2024 mixed.rs -o mixed",), keep=r"^error|= note: no two closures|= help: consider boxing") Output
# rustc 1.99.0
# Apple clang version 21.0.0 (clang-2100.3.34.2)
$ rustc --edition 2024 -C opt-level=0 dispatch.rs -o dispatch_rs && ./dispatch_rs
generic: 240 420 180
dyn: 240 420 180
$ nm -C -j dispatch_rs | grep -E '^dispatch::total_(generic::<[^>]*>|dyn)$' | sort
dispatch::total_dyn
dispatch::total_generic::<dispatch::main::{closure#0}>
dispatch::total_generic::<dispatch::main::{closure#1}>
dispatch::total_generic::<dispatch::main::{closure#2}>
$ clang++ -std=c++23 -O0 dispatch.cpp -o dispatch_cpp && ./dispatch_cpp
template: 240 420 180
function: 240 420 180
$ nm -C -j dispatch_cpp | grep -E '^(int )?total_(template|function)' | sed 's/(.*//' | sort
int total_template<main::$_0>
int total_template<main::$_1>
int total_template<main::$_2>
total_function
$ rustc --edition 2024 mixed.rs -o mixed
error[E0308]: mismatched types
= note: no two closures, even if identical, have the same type
= help: consider boxing your closure and/or using it as a trait object
error: aborting due to 1 previous error
[exit status 1]
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 dispatch_session.py
Three rules called through the generic function produced three copies of it, one per closure type, in both languages; the dynamic version produced one. Static dispatch lets the compiler inline each rule into its copy of the loop, and the Rust book notes that dynamic dispatch prevents that inlining. Which is faster for your program depends on the call site, the optimiser and the work each rule does, so measure it with your own data before choosing for speed; this lesson makes no speed claim.
The last command shows the constraint that usually decides. Every closure in Rust has a type of its own, even two
closures with identical code, so two rules that capture their settings cannot share a Vec until they are boxed as
dyn Fn. (Closures that capture nothing would compile: they become plain function pointers.) A registry that picks a
rule by name at run time therefore needs dynamic dispatch, while a rule fixed in the code, such as the comparison of
a sort written once, is a natural generic parameter.
Choosing a strategy from configuration
Most strategies are chosen from data: a settings file, a database row, a request parameter, a feature flag. The registry keeps that choice in one place, and three habits keep it safe:
- Resolve names once, at the edge. Read the settings, look up the strategy and hand the object to the context at start-up or at the top of a request. Code deeper down receives a strategy, never a name.
- Fail fast on an unknown name. Raise with the list of known names, as
make_ruledoes, rather than falling back to a default that hides the mistake. - Register, do not edit. A new strategy adds an entry; the code that picks and the code that calls stay as they are.
Feature flags are the same problem with a deadline. Pete Hodgson’s article “Feature Toggles” accepts an if around a
toggle that will be deleted within days, but advises against if-statement toggle points for flags that live long or
appear in many places; the alternative it suggests is to “implement alternative codepaths using some sort of Strategy
pattern”, decided once by the code that builds the object. Removing the flag later then deletes one registry entry
instead of a scattering of if statements.
Fees by payment method
Indian checkouts usually let the customer pick UPI, a card, net banking or a wallet at the moment of payment, so the fee strategy is chosen per payment rather than per shop. The four methods are not four algorithms: in the example below, UPI and net banking use the same flat-fee strategy (with 0 and 12) and card and wallet use the same percentage strategy, the wallet with a minimum of 5. The fee rules are invented for the example and are not any provider’s real rates.
# Convenience fees by payment method, picked for each payment when the customer chooses how to pay. The fee rules
# are invented for this example: they are not any provider's real rates.
from math import ceil
def flat(amount):
return lambda paid: amount
def percent(tenths_of_a_percent, minimum=0):
"""A share of the amount, rounded up to a whole rupee, never below the minimum."""
return lambda paid: max(minimum, ceil(paid * tenths_of_a_percent / 1000))
# Four payment methods, but only two algorithms: the methods are data, the algorithms are the strategies.
FEES = {
"UPI": flat(0),
"card": percent(18), # 1.8 %
"net banking": flat(12),
"wallet": percent(10, minimum=5), # 1 %, at least 5
}
def fee(method, paid):
return FEES[method](paid)
print("amount " + " ".join(f"{m:>11}" for m in FEES))
for paid in (150, 2000, 40000):
print(f"{paid:>6} " + " ".join(f"{fee(m, paid):>11}" for m in FEES)) Output
amount UPI card net banking wallet 150 0 3 12 5 2000 0 36 12 20 40000 0 720 12 400
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 fees.py
Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
When strategies differ only in their numbers, the numbers are data: one strategy per algorithm, with a row of settings per variant, keeps a new variant from needing new code. That table is also something a finance team can review without reading Python.
Strategy, State or Template Method?
Three patterns let one object behave in several ways, and they are easy to confuse in a design review. What tells them apart is who chooses the behaviour and when it changes:
| Criterion | Strategy | State | Template Method |
|---|---|---|---|
| Who chooses | The client | The states, as events arrive | The subclass |
| When it changes | When the client swaps it | On each event that moves it | Never, once created |
| Plugged in by | Composition | Composition | Inheritance |
| Typical use | Pricing, sorting, routing | Orders, connections, games | Frameworks with fixed steps |
Strategy also sits next to Command, the next lesson. A strategy is a way of doing a job (how to price this order); a command is a job to be done (place this order), packed up so it can be queued, logged or undone.
When not to use it
- Two variants that will not grow. An
ifwith two branches that are stable is shorter and clearer than an interface, two classes and a registry. - One function will do. In Python, TypeScript, Go and most modern languages, pass the function. An interface with a single method and one implementation per call site is ceremony.
- The variants differ only in numbers. Keep a table of settings and one strategy, as in the fee example; a class per customer or per country turns data into code.
- The strategy needs the context’s private details. If a rule has to reach back into the checkout for half its fields, the boundary is in the wrong place: pass what the rule needs, or reconsider what the context owns.
Practise: refactor a discount calculator
Exercise · Easy · Python
Refactor a discount calculator into strategies
discounts.py works out a cart's discount from a code with one if/elif chain of six branches. Every new code means editing that function, and a mistyped code silently gives no discount. Refactor it into strategies kept in a registry, without changing a single result.
DISCOUNTSis a dict from a code to its rule: a function that takes a cart and returns the discount.register(code)is a decorator that adds the function below it toDISCOUNTSundercode. Register the six existing codes with it.make_discount(code)returns the rule forcode, and raisesValueErrorat once for a code that is not registered, so a typo is caught where the code is read, not at the first order.discount(cart, code)keeps its name and arguments, looks the rule up withmake_discountand applies it. Noif,eliformatchmay be left in it: the registry does the choosing.
The sample tests check the six codes on six carts, register a seventh code from the test file without touching discounts.py, ask for an unknown code, and read the body of discount() to check that no branch is left.
Starter code · discounts.py
"""Discount codes for a shopping cart.
A cart is a dict: {"items": [prices of the items, whole currency units], "first_order": True or False}.
discount(cart, code) returns how much the code takes off the cart's total.
"""
def discount(cart, code):
total = sum(cart["items"])
if code == "NONE":
return 0
elif code == "FLAT100": # 100 off an order of 1,000 or more
return 100 if total >= 1000 else 0
elif code == "TEN": # 10 % off, at most 300
return min(total // 10, 300)
elif code == "CHEAPEST": # the cheapest item free, from two items up
return min(cart["items"]) if len(cart["items"]) >= 2 else 0
elif code == "BULK": # 5 % off from ten items up
return total * 5 // 100 if len(cart["items"]) >= 10 else 0
elif code == "WELCOME": # 15 % off a first order, at most 200
return min(total * 15 // 100, 200) if cart["first_order"] else 0
else:
return 0
# Your job: keep every result the same, but move each code's rule into a strategy of its own, kept in a registry.
# DISCOUNTS: a dict from code to rule (a function that takes a cart and returns the discount)
# register(code): a decorator that adds a rule to DISCOUNTS under that code
# make_discount(code): the rule for a code; an unknown code raises ValueError at once
# discount(cart, code): looks the rule up with make_discount and applies it, with no if or elif left in it The sample tests · test_discounts.py
import ast
import inspect
import discounts
from discounts import discount
CARTS = [
({"items": [400, 600], "first_order": False}, {"NONE": 0, "FLAT100": 100, "TEN": 100, "CHEAPEST": 400, "BULK": 0, "WELCOME": 0}),
({"items": [999], "first_order": True}, {"NONE": 0, "FLAT100": 0, "TEN": 99, "CHEAPEST": 0, "BULK": 0, "WELCOME": 149}),
({"items": [2500, 1500, 99], "first_order": True}, {"NONE": 0, "FLAT100": 100, "TEN": 300, "CHEAPEST": 99, "BULK": 0, "WELCOME": 200}),
({"items": [50] * 10, "first_order": False}, {"NONE": 0, "FLAT100": 0, "TEN": 50, "CHEAPEST": 50, "BULK": 25, "WELCOME": 0}),
({"items": [120] * 12, "first_order": True}, {"NONE": 0, "FLAT100": 100, "TEN": 144, "CHEAPEST": 120, "BULK": 72, "WELCOME": 200}),
({"items": [100, 900], "first_order": False}, {"NONE": 0, "FLAT100": 100, "TEN": 100, "CHEAPEST": 100, "BULK": 0, "WELCOME": 0}),
]
def test_every_code_gives_the_same_discounts():
"""keeps the discount of each of the six codes on six carts"""
for cart, expected in CARTS:
for code, amount in expected.items():
assert discount(cart, code) == amount, f"{code} on {cart}"
def test_a_seventh_code_joins_by_registration():
"""adds a STUDENT code from this test file, without editing discounts.py"""
@discounts.register("STUDENT")
def student(cart):
return 50 if sum(cart["items"]) >= 500 else 0
assert discount({"items": [500], "first_order": False}, "STUDENT") == 50
assert discount({"items": [499], "first_order": False}, "STUDENT") == 0
def test_an_unknown_code_fails_fast():
"""raises ValueError for a code that is not registered, instead of giving no discount"""
try:
discounts.make_discount("TEN_PERCENT")
except ValueError:
return
raise AssertionError("make_discount('TEN_PERCENT') returned instead of raising ValueError")
def test_discount_has_no_branches_left():
"""leaves no if or elif in discount(): the registry chooses the rule"""
tree = ast.parse(inspect.getsource(discounts.discount))
branches = [node for node in ast.walk(tree) if isinstance(node, (ast.If, ast.IfExp, ast.Match))]
assert branches == [], "discount() still chooses the rule with if, elif or match" A hint
Start with the decorator, because everything else uses it: register(code) returns a function add(rule) that stores rule in DISCOUNTS[code] and returns rule unchanged. Then turn each branch into a small function with the @register("…") line above it, copying the branch's expression into its return. make_discount is a dictionary lookup that turns the KeyError into a ValueError.
Results of the sample tests
| Test | Result | Details |
|---|
What your code printed
The sample tests run on this device, in your browser (Pyodide): nothing is sent to mysmartcopilot.com. The first run downloads Python (about 13.5 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.
Check yourself
Check yourself
5 questions about this lesson. Every answer and why it is right is on the page, behind “Show the answer”. Your score stays in this browser.
Interview questions
Fresher to mid-level · What problem does the Strategy pattern solve, and when is passing a plain function enough?
Strategy separates the choice of an algorithm from the code that uses it. The variants sit behind one interface,
the context holds one and calls it, and a new variant is a new implementation instead of another branch in a
conditional that every caller goes through. That keeps each variant small and testable on its own, and lets the
choice come from configuration in one place. In languages with first-class functions, a function is enough when the
variant is one operation without lifecycle or validation: sorted(key=...) and Comparator lambdas are strategies
of that kind. Use an object when a variant has several related operations, validates its settings, or holds a
resource that must be opened and closed.
Key takeaways
- Strategy puts each way of doing one job behind a shared interface; the context calls whichever strategy it holds.
- The choice moves to one place, usually start-up code reading the settings, and unknown names fail there, loudly.
- In Python, TypeScript, Java, Go and C++ a strategy is most often a function or a lambda; a class pays off when the strategy has several operations, settings to check or a resource to manage.
- Rust and C++ add a choice: generics or templates (one compiled copy per strategy, picked at compile time) or
dyn Fnandstd::function(one copy, picked at run time, required for a registry of mixed closures). - Strategies that differ only in numbers are one strategy with a table of settings.
- The client chooses a strategy; a state object replaces itself as events happen.
References
- Design Patterns: Elements of Reusable Object-Oriented Software (Addison-Wesley (Pearson))
- Sorting Techniques: key functions (Python Software Foundation)
- Package slices: SortFunc (The Go Authors)
- Interface Comparator (Java SE 25 API) (Oracle)
- Design Patterns in Dynamic Languages (Peter Norvig)
- Feature Toggles (aka Feature Flags) (Pete Hodgson (martinfowler.com))
- The Rust Programming Language: Using Trait Objects to Abstract over Shared Behavior (The Rust Project)
- The Rust Programming Language: Closures (The Rust Project)
- Working draft of the C++ standard: class template function [func.wrap.func] (ISO/IEC JTC1/SC22/WG21 (eel.is))
- Node.js v24 documentation: Modules: TypeScript (OpenJS Foundation)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress