Lesson 63 of 94
Behavioral Patterns
Behavioral patterns describe how objects divide work and talk to each other. In class-based designs, most of them are built from small interfaces and a class per variant: a Strategy interface with a class per strategy, a State interface with a class per state, a Visitor with one visit method per node type.
Rust shrinks many of them, because two features cover what those class hierarchies were for:
- Closures replace one-method interfaces. A strategy, a command, or an observer is often just a closure.
- Enums replace small closed hierarchies. A state machine or a visitor over a fixed set of node types is a
match.
Traits and trait objects are still there for the cases that need them: several methods per variant, or variants that other code must be able to add.
| Pattern | Rust |
|---|---|
| Strategy | a closure (impl Fn), or a trait for multi-method strategies |
| Observer | Vec<Box<dyn Fn(&Event)>>, a subscription handle, or channels across threads |
| Command | an enum of commands, or boxed closures |
| State | an enum state machine, Box<dyn State>, or typestate (states as types) |
| Template method | trait default methods |
| Visitor | match on an enum, or a visitor trait with default walk methods |
| Chain of responsibility | a list of trait objects tried in order |
| Iterator | the Iterator trait |
| Mediator | one owner that routes messages between members, addressed by id |
| Memento | a Cloned snapshot with private fields |
| Null object | Option, or a no-op implementation |
Part 1: Strategy and observer
1. Strategy: just pass a closure
A classic strategy is an interface with one method and a class per variant. In Rust, a closure is the strategy.
fn sort_users(users: &mut Vec<(&str, u32)>, strategy: impl Fn(&(&str, u32), &(&str, u32)) -> std::cmp::Ordering) {
users.sort_by(strategy);
}
fn main() {
let mut users = vec![("cy", 29), ("ana", 34), ("ben", 29)];
sort_users(&mut users, |a, b| a.0.cmp(b.0));
println!("by name: {users:?}");
sort_users(&mut users, |a, b| b.1.cmp(&a.1).then(a.0.cmp(b.0)));
println!("by age desc: {users:?}");
let strategies: Vec<(&str, Box<dyn Fn(u32) -> u32>)> = vec![("flat", Box::new(|_| 500)), ("per-kg", Box::new(|kg| kg * 120)), ("free", Box::new(|_| 0))];
for (name, shipping) in &strategies {
println!("{name:<6} shipping for 7 kg: {}", shipping(7));
}
}by name: [("ana", 34), ("ben", 29), ("cy", 29)]
by age desc: [("ana", 34), ("ben", 29), ("cy", 29)]
flat shipping for 7 kg: 500
per-kg shipping for 7 kg: 840
free shipping for 7 kg: 02. Strategy as a trait
When a strategy has several related methods, a name, or configuration of its own, a trait is clearer than a bundle of closures. A generic parameter fixes the strategy at compile time; Box<dyn Trait> lets you choose it at runtime.
trait Pricing {
fn name(&self) -> String;
fn unit_price(&self, base_cents: u32, qty: u32) -> u32;
fn total(&self, base_cents: u32, qty: u32) -> u32 {
self.unit_price(base_cents, qty) * qty
}
}
struct ListPrice;
struct VolumeDiscount {
min_qty: u32,
percent_off: u32,
}
impl Pricing for ListPrice {
fn name(&self) -> String {
String::from("list price")
}
fn unit_price(&self, base_cents: u32, _qty: u32) -> u32 {
base_cents
}
}
impl Pricing for VolumeDiscount {
fn name(&self) -> String {
format!("{}% off from {} units", self.percent_off, self.min_qty)
}
fn unit_price(&self, base_cents: u32, qty: u32) -> u32 {
if qty >= self.min_qty { base_cents * (100 - self.percent_off) / 100 } else { base_cents }
}
}
// Chosen at compile time: one copy of `quote` per strategy type.
fn quote(pricing: &impl Pricing, qty: u32) -> String {
format!("{qty:>3} x 250 with {}: {}", pricing.name(), pricing.total(250, qty))
}
fn pricing_for(customer_tier: &str) -> Box<dyn Pricing> {
match customer_tier {
"wholesale" => Box::new(VolumeDiscount { min_qty: 50, percent_off: 20 }),
_ => Box::new(ListPrice),
}
}
fn main() {
println!("{}", quote(&ListPrice, 10));
println!("{}", quote(&VolumeDiscount { min_qty: 50, percent_off: 20 }, 60));
for tier in ["retail", "wholesale"] {
let pricing = pricing_for(tier); // chosen at runtime
println!("{tier}: {}", pricing.total(250, 100));
}
} 10 x 250 with list price: 2500
60 x 250 with 20% off from 50 units: 12000
retail: 25000
wholesale: 200003. Observer with closures
Observers subscribe to a subject and get notified of events. A Vec of boxed closures is the simplest form.
use std::cell::RefCell;
use std::rc::Rc;
#[derive(Debug, Clone)]
enum StockEvent {
PriceChanged { symbol: String, price: f64 },
}
#[derive(Default)]
struct Ticker {
observers: Vec<Box<dyn Fn(&StockEvent)>>,
}
impl Ticker {
fn subscribe(&mut self, f: impl Fn(&StockEvent) + 'static) {
self.observers.push(Box::new(f));
}
fn publish(&self, e: StockEvent) {
for o in &self.observers {
o(&e);
}
}
}
fn main() {
let alerts = Rc::new(RefCell::new(Vec::new()));
let mut ticker = Ticker::default();
ticker.subscribe(|e| println!("log: {e:?}"));
let a = Rc::clone(&alerts);
ticker.subscribe(move |StockEvent::PriceChanged { symbol, price }| {
if *price > 100.0 {
a.borrow_mut().push(format!("{symbol} crossed 100 at {price}"));
}
});
ticker.publish(StockEvent::PriceChanged { symbol: "ACME".into(), price: 98.5 });
ticker.publish(StockEvent::PriceChanged { symbol: "ACME".into(), price: 101.25 });
println!("alerts: {:?}", alerts.borrow());
}log: PriceChanged { symbol: "ACME", price: 98.5 }
log: PriceChanged { symbol: "ACME", price: 101.25 }
alerts: ["ACME crossed 100 at 101.25"]The second observer needs to change alerts from inside a closure while main keeps reading it, so it's shared through Rc<RefCell<...>>. For observers on other threads or async tasks, channels are the better tool; they come later, in Channels and Async Channels & Locks.
4. Unsubscribing by dropping a handle
Forgetting to unsubscribe is a classic memory leak. Here subscribe returns a Subscription handle, and dropping the handle unsubscribes. The handle holds only a Weak reference to the listener list, so it doesn't keep the bus alive either (Rc & Weak shows the related trick of observers held weakly).
use std::cell::{Cell, RefCell};
use std::rc::{Rc, Weak};
type Listeners = RefCell<Vec<(u64, Box<dyn Fn(&str)>)>>;
#[derive(Default)]
struct EventBus {
listeners: Rc<Listeners>,
next_id: Cell<u64>,
}
struct Subscription {
id: u64,
listeners: Weak<Listeners>,
}
impl Drop for Subscription {
fn drop(&mut self) {
if let Some(list) = self.listeners.upgrade() {
list.borrow_mut().retain(|(id, _)| *id != self.id);
}
}
}
impl EventBus {
fn subscribe(&self, f: impl Fn(&str) + 'static) -> Subscription {
let id = self.next_id.get();
self.next_id.set(id + 1);
self.listeners.borrow_mut().push((id, Box::new(f)));
Subscription { id, listeners: Rc::downgrade(&self.listeners) }
}
fn publish(&self, event: &str) {
for (_, listener) in self.listeners.borrow().iter() {
listener(event);
}
}
}
fn main() {
let bus = EventBus::default();
let _audit = bus.subscribe(|e| println!("audit: {e}"));
{
let _popup = bus.subscribe(|e| println!("popup: {e}"));
bus.publish("file saved");
} // `_popup` is dropped here, which unsubscribes it
bus.publish("file closed");
println!("{} listener(s) left", bus.listeners.borrow().len());
}audit: file saved
popup: file saved
audit: file closed
1 listener(s) leftNote the names: let _audit = ... keeps the handle alive until the end of main. Writing let _ = ... instead would drop it immediately and unsubscribe on the spot, because _ is not a variable. One limitation: a listener that subscribes or unsubscribes while publish is running would hit the RefCell borrow check and panic; collect such changes and apply them after publishing.
Part 2: Command and state
5. Command with undo
Each command is a value describing an operation, so it can be stored, replayed, or undone. An enum lists the commands; applying and undoing are two matches.
#[derive(Debug, Clone)]
enum Command {
Insert { pos: usize, text: String },
Delete { pos: usize, len: usize, removed: Option<String> },
}
struct Editor {
text: String,
history: Vec<Command>,
}
impl Editor {
fn execute(&mut self, mut cmd: Command) {
match &mut cmd {
Command::Insert { pos, text } => self.text.insert_str(*pos, text),
Command::Delete { pos, len, removed } => {
*removed = Some(self.text.drain(*pos..*pos + *len).collect());
}
}
self.history.push(cmd);
}
fn undo(&mut self) {
match self.history.pop() {
Some(Command::Insert { pos, text }) => {
self.text.replace_range(pos..pos + text.len(), "");
}
Some(Command::Delete { pos, removed: Some(r), .. }) => self.text.insert_str(pos, &r),
_ => {}
}
}
}
fn main() {
let mut ed = Editor { text: String::from("Hello world"), history: Vec::new() };
ed.execute(Command::Insert { pos: 5, text: String::from(",") });
ed.execute(Command::Delete { pos: 7, len: 5, removed: None });
ed.execute(Command::Insert { pos: 7, text: String::from("Rust") });
println!("{:?}", ed.text);
ed.undo();
println!("{:?}", ed.text);
ed.undo();
println!("{:?}", ed.text);
println!("{} command(s) left in history", ed.history.len());
}"Hello, Rust"
"Hello, "
"Hello, world"
1 command(s) left in history6. Commands as queued closures
When commands only need to run, not be inspected or undone, a boxed closure is enough. FnOnce closures may consume what they captured, so each one can take ownership of its own data and run exactly once.
use std::collections::VecDeque;
type Task = Box<dyn FnOnce() -> Result<String, String>>;
#[derive(Default)]
struct TaskQueue {
tasks: VecDeque<(String, Task)>,
}
impl TaskQueue {
fn push(&mut self, name: &str, task: impl FnOnce() -> Result<String, String> + 'static) {
self.tasks.push_back((name.to_string(), Box::new(task)));
}
fn run_all(&mut self) {
while let Some((name, task)) = self.tasks.pop_front() {
match task() {
Ok(msg) => println!("{name}: {msg}"),
Err(e) => println!("{name} FAILED: {e}"),
}
}
}
}
fn main() {
let mut queue = TaskQueue::default();
let amounts = vec![1_250, 4_990, 300];
queue.push("sum", move || Ok(format!("total {}", amounts.iter().sum::<u32>()))); // `amounts` moves into the task
let email = String::from("ana@example.com");
queue.push("mail", move || if email.contains('@') { Ok(format!("sent to {email}")) } else { Err(String::from("bad address")) });
queue.push("parse", || "12x".parse::<u32>().map(|n| n.to_string()).map_err(|e| e.to_string()));
println!("{} tasks queued", queue.tasks.len());
queue.run_all();
}3 tasks queued
sum: total 6540
mail: sent to ana@example.com
parse FAILED: invalid digit found in string7. State: an enum state machine
The GoF state pattern uses one class per state, each implementing the transitions. An enum with a transition function is shorter and makes every state/event combination visible in one match.
#[derive(Debug, Clone, Copy, PartialEq)]
enum Vending {
Idle,
HasCoins(u32),
Dispensing,
}
#[derive(Debug, Clone, Copy)]
enum Input {
Coin(u32),
Select { price: u32 },
Done,
}
fn next(state: Vending, input: Input) -> (Vending, String) {
match (state, input) {
(Vending::Idle, Input::Coin(c)) => (Vending::HasCoins(c), format!("credit {c}")),
(Vending::HasCoins(have), Input::Coin(c)) => (Vending::HasCoins(have + c), format!("credit {}", have + c)),
(Vending::HasCoins(have), Input::Select { price }) if have >= price => {
(Vending::Dispensing, format!("dispensing, change {}", have - price))
}
(Vending::HasCoins(have), Input::Select { price }) => (state, format!("need {} more", price - have)),
(Vending::Dispensing, Input::Done) => (Vending::Idle, String::from("ready")),
(s, i) => (s, format!("ignored {i:?} in {s:?}")),
}
}
fn main() {
let mut state = Vending::Idle;
for input in [Input::Select { price: 150 }, Input::Coin(100), Input::Select { price: 150 }, Input::Coin(100), Input::Select { price: 150 }, Input::Done] {
let (s, msg) = next(state, input);
state = s;
println!("{msg:<28} -> {state:?}");
}
}ignored Select { price: 150 } in Idle -> Idle
credit 100 -> HasCoins(100)
need 50 more -> HasCoins(100)
credit 200 -> HasCoins(200)
dispensing, change 50 -> Dispensing
ready -> Idle8. State with trait objects
The direct translation of the GoF pattern: each state is its own type implementing a State trait, and each transition method returns the next state. Default methods mean "this event is ignored in this state", so each state only implements the events it reacts to. New states can be added without touching a central match, which is the trade-off against example 7.
trait State {
fn name(&self) -> String;
// `None` means: stay in the current state.
fn submit(&self) -> Option<Box<dyn State>> {
None
}
fn approve(&self) -> Option<Box<dyn State>> {
None
}
fn reject(&self) -> Option<Box<dyn State>> {
None
}
fn is_public(&self) -> bool {
false
}
}
struct Draft;
struct InReview {
approvals: u32,
}
struct Published;
impl State for Draft {
fn name(&self) -> String {
String::from("draft")
}
fn submit(&self) -> Option<Box<dyn State>> {
Some(Box::new(InReview { approvals: 0 }))
}
}
impl State for InReview {
fn name(&self) -> String {
format!("in review ({}/2 approvals)", self.approvals)
}
fn approve(&self) -> Option<Box<dyn State>> {
if self.approvals + 1 >= 2 { Some(Box::new(Published)) } else { Some(Box::new(InReview { approvals: self.approvals + 1 })) }
}
fn reject(&self) -> Option<Box<dyn State>> {
Some(Box::new(Draft))
}
}
impl State for Published {
fn name(&self) -> String {
String::from("published")
}
fn is_public(&self) -> bool {
true
}
}
struct Post {
text: String,
state: Box<dyn State>,
}
impl Post {
fn apply(&mut self, event: &str) {
let next = match event {
"submit" => self.state.submit(),
"approve" => self.state.approve(),
"reject" => self.state.reject(),
_ => None,
};
match next {
Some(state) => self.state = state,
None => println!(" ({event} ignored while {})", self.state.name()),
}
}
fn visible_text(&self) -> &str {
if self.state.is_public() { &self.text } else { "" }
}
}
fn main() {
let mut post = Post { text: String::from("Rust for Java devs"), state: Box::new(Draft) };
for event in ["approve", "submit", "approve", "reject", "submit", "approve", "approve", "submit"] {
post.apply(event);
println!("{event:<8} -> {:<27} visible: {:?}", post.state.name(), post.visible_text());
}
} (approve ignored while draft)
approve -> draft visible: ""
submit -> in review (0/2 approvals) visible: ""
approve -> in review (1/2 approvals) visible: ""
reject -> draft visible: ""
submit -> in review (0/2 approvals) visible: ""
approve -> in review (1/2 approvals) visible: ""
approve -> published visible: "Rust for Java devs"
(submit ignored while published)
submit -> published visible: "Rust for Java devs"9. Typestate: invalid transitions don't compile
When the order of steps is fixed, you can make each state a separate type. Each transition consumes the old value and returns the new one, and methods exist only on the states where they're valid. Calling one in the wrong state isn't a runtime error: it doesn't compile.
struct DraftPost {
text: String,
}
struct PendingPost {
text: String,
}
struct PublishedPost {
text: String,
}
impl DraftPost {
fn new(text: &str) -> DraftPost {
DraftPost { text: text.to_string() }
}
fn edit(&mut self, more: &str) {
self.text.push_str(more);
}
fn submit(self) -> PendingPost {
PendingPost { text: self.text }
}
}
impl PendingPost {
fn approve(self) -> PublishedPost {
PublishedPost { text: self.text }
}
fn reject(self) -> DraftPost {
DraftPost { text: self.text }
}
}
impl PublishedPost {
fn content(&self) -> &str {
&self.text // only published posts can be read
}
}
fn main() {
let mut draft = DraftPost::new("Typestate");
draft.edit(" in Rust");
let rejected = draft.submit().reject();
let published = rejected.submit().approve();
println!("{}", published.content());
}Typestate in RustReading a draft is a compile error, found before the program runs:
struct DraftPost {
text: String,
}
struct PublishedPost {
text: String,
}
impl PublishedPost {
fn content(&self) -> &str {
&self.text
}
}
fn main() {
let draft = DraftPost { text: String::from("secret plans") };
println!("{}", draft.content());
}error[E0599]: no method named `content` found for struct `DraftPost` in the current scope
--> src/main.rs:17:26
|
1 | struct DraftPost {
| ---------------- method `content` not found for this struct
...
17 | println!("{}", draft.content());
| ^^^^^^^ method not found in `DraftPost`Because submit(self) takes ownership, the old DraftPost can't be used after submitting either. API Design Idioms, later in the course, applies the same technique to builders.
Part 3: Algorithms over structures
10. Template method: trait default methods
The template method pattern defines an algorithm's skeleton in a base class and lets subclasses fill in steps. A trait with a default method that calls required methods is exactly that, without a base class.
trait ReportGenerator {
fn title(&self) -> String;
fn rows(&self) -> Vec<(String, f64)>;
fn footer(&self, total: f64) -> String {
format!("TOTAL {total:.2}") // an optional hook with a default
}
// The template: fixed skeleton, customizable steps.
fn generate(&self) -> String {
let rows = self.rows();
let total: f64 = rows.iter().map(|(_, v)| v).sum();
let mut out = vec![self.title().to_uppercase()];
out.extend(rows.iter().map(|(k, v)| format!(" {k:<8} {v:>8.2}")));
out.push(self.footer(total));
out.join("\n")
}
}
struct SalesReport;
struct ExpenseReport;
impl ReportGenerator for SalesReport {
fn title(&self) -> String {
String::from("Q3 sales")
}
fn rows(&self) -> Vec<(String, f64)> {
vec![("north".into(), 1200.5), ("south".into(), 980.0)]
}
}
impl ReportGenerator for ExpenseReport {
fn title(&self) -> String {
String::from("Q3 expenses")
}
fn rows(&self) -> Vec<(String, f64)> {
vec![("travel".into(), 310.0), ("software".into(), 89.99)]
}
fn footer(&self, total: f64) -> String {
format!("SPENT {total:.2} (budget 500.00)")
}
}
fn main() {
println!("{}\n", SalesReport.generate());
println!("{}", ExpenseReport.generate());
}Q3 SALES
north 1200.50
south 980.00
TOTAL 2180.50
Q3 EXPENSES
travel 310.00
software 89.99
SPENT 399.99 (budget 500.00)11. Visitor: match on an enum
The visitor pattern exists because adding an operation to a class hierarchy otherwise means editing every class. With an enum, a new operation is just a new function with a match, and the compiler checks it covers every variant.
enum Expr {
Num(f64),
Var(&'static str),
Add(Box<Expr>, Box<Expr>),
Mul(Box<Expr>, Box<Expr>),
}
use Expr::*;
// "Visitor" 1: evaluate
fn eval(e: &Expr, x: f64) -> f64 {
match e {
Num(n) => *n,
Var(_) => x,
Add(a, b) => eval(a, x) + eval(b, x),
Mul(a, b) => eval(a, x) * eval(b, x),
}
}
// "Visitor" 2: pretty-print
fn show(e: &Expr) -> String {
match e {
Num(n) => n.to_string(),
Var(v) => v.to_string(),
Add(a, b) => format!("({} + {})", show(a), show(b)),
Mul(a, b) => format!("{} * {}", show(a), show(b)),
}
}
// "Visitor" 3: count variables
fn vars(e: &Expr) -> usize {
match e {
Num(_) => 0,
Var(_) => 1,
Add(a, b) | Mul(a, b) => vars(a) + vars(b),
}
}
fn main() {
// 3 * (x + 2)
let e = Mul(Box::new(Num(3.0)), Box::new(Add(Box::new(Var("x")), Box::new(Num(2.0)))));
println!("{} = {} at x = 4", show(&e), eval(&e, 4.0));
println!("variables used: {}", vars(&e));
}3 * (x + 2) = 18 at x = 4
variables used: 1The trade-off flips compared to a class hierarchy: adding an operation is easy (a new function), adding a variant means updating every match, which the compiler lists for you.
12. A visitor trait with default walking
With many node types, writing a full match for every small analysis gets repetitive. A visitor trait whose default methods walk the whole tree lets each visitor override only the nodes it cares about. Parsers and linters in Rust (the syn crate, for example) are built this way.
use std::collections::BTreeMap;
enum Expr {
Num(f64),
Var(String),
Add(Box<Expr>, Box<Expr>),
Call(String, Vec<Expr>),
}
trait Visitor {
fn visit_expr(&mut self, e: &Expr) {
walk(self, e) // default: just keep walking
}
fn visit_var(&mut self, _name: &str) {}
fn visit_call(&mut self, _name: &str, _args: &[Expr]) {}
}
// Visits every child, calling the hooks along the way.
fn walk<V: Visitor + ?Sized>(v: &mut V, e: &Expr) {
match e {
Expr::Num(_) => {}
Expr::Var(name) => v.visit_var(name),
Expr::Add(a, b) => {
v.visit_expr(a);
v.visit_expr(b);
}
Expr::Call(name, args) => {
v.visit_call(name, args);
for arg in args {
v.visit_expr(arg);
}
}
}
}
// Collects variable names; ignores everything else.
struct Vars(Vec<String>);
impl Visitor for Vars {
fn visit_var(&mut self, name: &str) {
if !self.0.iter().any(|v| v == name) {
self.0.push(name.to_string());
}
}
}
// Counts function calls and their arguments.
struct Calls(BTreeMap<String, Vec<usize>>);
impl Visitor for Calls {
fn visit_call(&mut self, name: &str, args: &[Expr]) {
self.0.entry(name.to_string()).or_default().push(args.len());
}
}
fn var(n: &str) -> Expr {
Expr::Var(n.to_string())
}
fn call(n: &str, args: Vec<Expr>) -> Expr {
Expr::Call(n.to_string(), args)
}
fn main() {
// max(x, y + 1) + min(x, abs(z), 0)
let e = Expr::Add(
Box::new(call("max", vec![var("x"), Expr::Add(Box::new(var("y")), Box::new(Expr::Num(1.0)))])),
Box::new(call("min", vec![var("x"), call("abs", vec![var("z")]), Expr::Num(0.0)])),
);
let mut vars = Vars(Vec::new());
vars.visit_expr(&e);
println!("variables: {:?}", vars.0);
let mut calls = Calls(BTreeMap::new());
calls.visit_expr(&e);
println!("calls (arg counts): {:?}", calls.0);
}variables: ["x", "y", "z"]
calls (arg counts): {"abs": [1], "max": [2], "min": [3]}13. Chain of responsibility: middleware
Each handler either processes a request or passes it on. A list of trait objects, tried in order, is the usual shape, and it's how web frameworks structure middleware.
struct Request {
path: String,
token: Option<String>,
}
enum Outcome {
Continue(Request),
Respond(u16, String),
}
trait Middleware {
fn handle(&self, req: Request) -> Outcome;
}
struct Auth;
struct RateLimit {
blocked_paths: Vec<&'static str>,
}
struct Router;
impl Middleware for Auth {
fn handle(&self, req: Request) -> Outcome {
match &req.token {
Some(t) if t == "valid" => Outcome::Continue(req),
_ if req.path == "/health" => Outcome::Continue(req),
_ => Outcome::Respond(401, String::from("unauthorized")),
}
}
}
impl Middleware for RateLimit {
fn handle(&self, req: Request) -> Outcome {
if self.blocked_paths.contains(&req.path.as_str()) {
Outcome::Respond(429, String::from("too many requests"))
} else {
Outcome::Continue(req)
}
}
}
impl Middleware for Router {
fn handle(&self, req: Request) -> Outcome {
Outcome::Respond(200, format!("content of {}", req.path))
}
}
fn run(chain: &[Box<dyn Middleware>], mut req: Request) -> (u16, String) {
for m in chain {
match m.handle(req) {
Outcome::Continue(r) => req = r,
Outcome::Respond(code, body) => return (code, body),
}
}
(404, String::from("no handler"))
}
fn main() {
let chain: Vec<Box<dyn Middleware>> = vec![Box::new(Auth), Box::new(RateLimit { blocked_paths: vec!["/export"] }), Box::new(Router)];
for (path, token) in [("/health", None), ("/orders", Some("valid")), ("/orders", None), ("/export", Some("valid"))] {
let req = Request { path: path.into(), token: token.map(String::from) };
println!("{path:<8} {:?}", run(&chain, req));
}
}/health (200, "content of /health")
/orders (200, "content of /orders")
/orders (401, "unauthorized")
/export (429, "too many requests")Each handler takes the Request by value and hands it back inside Continue, so ownership of the request moves down the chain and no handler can hold on to it by accident.
14. Iterator
The iterator pattern is built into the language: implement Iterator (one method, next) and you get map, filter, zip, sum, and dozens more for free (Custom Iterators).
struct Countdown {
from: u32,
}
impl Iterator for Countdown {
type Item = u32;
fn next(&mut self) -> Option<u32> {
if self.from == 0 {
return None;
}
self.from -= 1;
Some(self.from + 1)
}
}
fn main() {
let launch: Vec<String> = Countdown { from: 5 }.map(|n| n.to_string()).chain(std::iter::once(String::from("liftoff"))).collect();
println!("{}", launch.join(" "));
println!("sum of evens: {}", Countdown { from: 10 }.filter(|n| n % 2 == 0).sum::<u32>());
}5 4 3 2 1 liftoff
sum of evens: 30Part 4: Coordinating objects
15. Mediator: one owner routes the messages
In the mediator pattern, objects don't talk to each other directly; they go through a central mediator. It's a way to reduce coupling, and in Rust it's often the natural design anyway: objects holding references to each other fight the borrow checker, while one owner that holds everything and addresses members by id doesn't.
#[derive(Default)]
struct Member {
name: String,
inbox: Vec<String>,
muted: bool,
}
#[derive(Default)]
struct ChatRoom {
members: Vec<Member>,
}
type MemberId = usize;
impl ChatRoom {
fn join(&mut self, name: &str) -> MemberId {
self.members.push(Member { name: name.to_string(), ..Default::default() });
self.members.len() - 1
}
fn mute(&mut self, who: MemberId) {
self.members[who].muted = true;
}
// `to: None` broadcasts to everyone except the sender.
fn send(&mut self, from: MemberId, to: Option<MemberId>, text: &str) {
let sender = self.members[from].name.clone();
for (id, member) in self.members.iter_mut().enumerate() {
let addressed = match to {
Some(target) => target == id,
None => id != from,
};
if addressed && !member.muted {
member.inbox.push(format!("{sender}: {text}"));
}
}
}
}
fn main() {
let mut room = ChatRoom::default();
let ana = room.join("ana");
let ben = room.join("ben");
let cy = room.join("cy");
room.send(ana, None, "standup in 5 minutes");
room.mute(cy);
room.send(ben, Some(ana), "running late");
room.send(ben, None, "starting without cy?");
for m in &room.members {
println!("{:<4} {:?}", m.name, m.inbox);
}
}ana ["ben: running late", "ben: starting without cy?"]
ben ["ana: standup in 5 minutes"]
cy ["ana: standup in 5 minutes"]The ids are plain indexes, the same approach as the index-based graph in Cell & RefCell.
16. Memento: snapshots you can restore
A memento captures an object's state so it can be restored later, without exposing that state to whoever stores the snapshots. Clone makes the snapshot; private fields keep it opaque outside the module.
mod editor {
#[derive(Clone)]
pub struct Snapshot {
text: String, // private: the history can store it, but not look inside
cursor: usize,
}
pub struct Editor {
text: String,
cursor: usize,
}
impl Editor {
pub fn new() -> Editor {
Editor { text: String::new(), cursor: 0 }
}
pub fn type_text(&mut self, s: &str) {
self.text.insert_str(self.cursor, s);
self.cursor += s.len();
}
pub fn snapshot(&self) -> Snapshot {
Snapshot { text: self.text.clone(), cursor: self.cursor }
}
pub fn restore(&mut self, s: Snapshot) {
self.text = s.text;
self.cursor = s.cursor;
}
pub fn show(&self) -> String {
format!("{}|{}", &self.text[..self.cursor], &self.text[self.cursor..])
}
}
}
fn main() {
let mut ed = editor::Editor::new();
let mut history = Vec::new();
for word in ["Dear team,", " the release", " is delayed"] {
history.push(ed.snapshot());
ed.type_text(word);
println!("{}", ed.show());
}
history.pop(); // throw away the newest snapshot
ed.restore(history.pop().unwrap()); // go back two steps
println!("restored: {}", ed.show());
}Dear team,|
Dear team, the release|
Dear team, the release is delayed|
restored: Dear team,|17. Null object: Option and Default
The null object pattern avoids null checks by substituting a do-nothing implementation. Rust has no null, so the need mostly disappears: absence is Option. When you do want a do-nothing implementation, a unit struct implementing the trait plays the part.
trait Logger {
fn log(&self, msg: &str);
}
struct ConsoleLogger;
struct NoopLogger; // the null object
impl Logger for ConsoleLogger {
fn log(&self, msg: &str) {
println!("[log] {msg}");
}
}
impl Logger for NoopLogger {
fn log(&self, _msg: &str) {}
}
fn process(items: &[&str], logger: &dyn Logger) -> usize {
for i in items {
logger.log(&format!("processing {i}"));
}
items.len()
}
fn discount_for(code: Option<&str>) -> u32 {
code.map(|c| if c == "VIP" { 20 } else { 5 }).unwrap_or(0) // Option instead of a null check
}
fn main() {
println!("done: {}", process(&["a", "b"], &ConsoleLogger));
println!("done: {}", process(&["c"], &NoopLogger));
println!("{} {} {}", discount_for(Some("VIP")), discount_for(Some("WELCOME")), discount_for(None));
}[log] processing a
[log] processing b
done: 2
done: 1
20 5 018. Rust's own patterns
Some patterns come from Rust's own features and have no Gang of Four name. You've met most of them already:
| Pattern | What it does | Lesson |
|---|---|---|
| RAII guard | cleanup in Drop (locks, files, transactions, subscriptions) |
Drop, Deref & Cow |
| Newtype | a distinct type around another for type safety or the orphan rule | Structural Patterns |
| Typestate | states as types; wrong-order calls don't compile | example 9, and API Design Idioms |
| Extension trait | add methods to types you don't own | Structural Patterns |
| Interior mutability | mutate through &self with Cell/RefCell/Mutex |
Cell & RefCell |
| Sealed trait | a public trait others can't implement | Modules & Visibility |
| Actor | a thread or task owning state, driven by messages | Channels, later in the course |
19. Realistic example: a plugin-based order pipeline
A final example combining several patterns: a factory building steps from configuration, steps sharing one trait (strategy and chain of responsibility), a decorator reporting each step, and an observer collecting the events.
use std::cell::RefCell;
#[derive(Debug, Clone)]
struct Order {
id: u32,
total_cents: u32,
country: &'static str,
notes: Vec<String>,
}
trait Step {
fn name(&self) -> &str;
fn apply(&self, order: Order) -> Result<Order, String>;
}
struct Validate;
struct AddTax {
rate_percent: u32,
}
struct FraudCheck {
limit_cents: u32,
}
impl Step for Validate {
fn name(&self) -> &str {
"validate"
}
fn apply(&self, o: Order) -> Result<Order, String> {
if o.total_cents == 0 { Err(format!("order {} is empty", o.id)) } else { Ok(o) }
}
}
impl Step for AddTax {
fn name(&self) -> &str {
"tax"
}
fn apply(&self, mut o: Order) -> Result<Order, String> {
let tax = o.total_cents * self.rate_percent / 100;
o.total_cents += tax;
o.notes.push(format!("tax {tax}"));
Ok(o)
}
}
impl Step for FraudCheck {
fn name(&self) -> &str {
"fraud"
}
fn apply(&self, o: Order) -> Result<Order, String> {
if o.total_cents > self.limit_cents && o.country != "DE" {
Err(format!("order {} held for review", o.id))
} else {
Ok(o)
}
}
}
// Decorator: reports each step to an observer.
struct Observed<'a> {
inner: Box<dyn Step>,
events: &'a RefCell<Vec<String>>,
}
impl Step for Observed<'_> {
fn name(&self) -> &str {
self.inner.name()
}
fn apply(&self, o: Order) -> Result<Order, String> {
let id = o.id;
let result = self.inner.apply(o);
self.events.borrow_mut().push(format!("order {id}: {} {}", self.name(), if result.is_ok() { "ok" } else { "FAILED" }));
result
}
}
// Factory: build the pipeline from configuration.
fn build_pipeline<'a>(config: &[&str], events: &'a RefCell<Vec<String>>) -> Vec<Box<dyn Step + 'a>> {
config
.iter()
.filter_map(|name| -> Option<Box<dyn Step>> {
match *name {
"validate" => Some(Box::new(Validate)),
"tax" => Some(Box::new(AddTax { rate_percent: 19 })),
"fraud" => Some(Box::new(FraudCheck { limit_cents: 50_000 })),
_ => None,
}
})
.map(|inner| Box::new(Observed { inner, events }) as Box<dyn Step + 'a>)
.collect()
}
fn main() {
let events = RefCell::new(Vec::new());
let pipeline = build_pipeline(&["validate", "tax", "fraud", "unknown"], &events);
let orders = [
Order { id: 1, total_cents: 10_000, country: "DE", notes: vec![] },
Order { id: 2, total_cents: 0, country: "FR", notes: vec![] },
Order { id: 3, total_cents: 60_000, country: "US", notes: vec![] },
];
for order in orders {
let result = pipeline.iter().try_fold(order, |o, step| step.apply(o));
match result {
Ok(o) => println!("order {}: total {} {:?}", o.id, o.total_cents, o.notes),
Err(e) => println!("{e}"),
}
}
println!("{:#?}", events.borrow());
}order 1: total 11900 ["tax 1900"]
order 2 is empty
order 3 held for review
[
"order 1: validate ok",
"order 1: tax ok",
"order 1: fraud ok",
"order 2: validate FAILED",
"order 3: validate ok",
"order 3: tax ok",
"order 3: fraud FAILED",
]Summary
| Pattern | Rust |
|---|---|
| Strategy | a closure; a trait when it has several methods, generic or Box<dyn> |
| Observer | Vec<Box<dyn Fn(&Event)>>; a handle that unsubscribes on Drop; channels across threads |
| Command | an enum (inspectable, undoable) or Box<dyn FnOnce()> (just runnable) |
| State | an enum + match (closed), Box<dyn State> (open), or typestate (compile-time) |
| Template method | trait default methods calling required methods |
| Visitor | functions with match; a visitor trait with default walk methods for large trees |
| Chain of responsibility | a slice of trait objects, passing the request by value |
| Iterator | impl Iterator |
| Mediator | one owner holding all members, addressed by id |
| Memento | a Cloned snapshot with private fields |
| Null object | Option, or a no-op implementation |
This completes the OOP & Design Patterns section. Next: Threads, the start of Concurrency.
