Lesson 62 of 94
Structural Patterns
Structural patterns are about combining types: wrapping an object to change its interface (adapter), add behavior (decorator), control access (proxy), or hide complexity (facade), and building trees where one node and a group of nodes look the same (composite).
In Rust, almost all of them take the same shape: a wrapper struct that implements a trait, usually by calling the value it wraps. Generics let wrappers stack at compile time with no overhead; Box<dyn Trait> lets them stack at runtime.
| Pattern | Rust |
|---|---|
| Adapter | a wrapper (newtype) implementing your trait; a blanket impl for closures |
| Decorator | a generic wrapper Logged<T: Trait>, or Box<dyn Trait> layers |
| Composite | a recursive enum, or a trait object holding Vec<Box<dyn Trait>> |
| Facade | a module with a few public functions |
| Proxy | a wrapper with the same trait; OnceCell for lazy, RefCell<HashMap> for caching |
| Flyweight | Rc<T> / Arc<T> sharing |
| Bridge | pass the implementation as a trait parameter |
| Extension methods | extension traits |
1. Adapter
An adapter makes an existing type fit an interface it wasn't written for. Implement the trait for a thin wrapper around the existing type.
// Our application's interface.
trait TemperatureSensor {
fn celsius(&self) -> f64;
}
// A third-party type with a different API (imagine it's from another crate).
struct LegacyFahrenheitProbe {
raw_reading: i32, // tenths of a degree Fahrenheit
}
impl LegacyFahrenheitProbe {
fn read_tenths_f(&self) -> i32 {
self.raw_reading
}
}
// The adapter.
struct ProbeAdapter(LegacyFahrenheitProbe);
impl TemperatureSensor for ProbeAdapter {
fn celsius(&self) -> f64 {
(self.0.read_tenths_f() as f64 / 10.0 - 32.0) * 5.0 / 9.0
}
}
struct ModernSensor(f64);
impl TemperatureSensor for ModernSensor {
fn celsius(&self) -> f64 {
self.0
}
}
fn main() {
let sensors: Vec<Box<dyn TemperatureSensor>> = vec![Box::new(ModernSensor(21.5)), Box::new(ProbeAdapter(LegacyFahrenheitProbe { raw_reading: 986 }))];
for s in &sensors {
println!("{:.1} C", s.celsius());
}
}21.5 C
37.0 CWhen the trait is yours, you could also implement it directly for LegacyFahrenheitProbe, with no wrapper at all. The wrapper is needed when both the trait and the type come from other crates, as example 3 shows.
2. Adapting closures
In Rust, a blanket implementation lets closures act as implementations of a trait: implement your trait for every closure with the right signature, and closures can be used wherever the trait is expected, side by side with structs.
trait Validator {
fn validate(&self, input: &str) -> Result<(), String>;
}
struct MinLength(usize);
impl Validator for MinLength {
fn validate(&self, input: &str) -> Result<(), String> {
if input.chars().count() >= self.0 { Ok(()) } else { Err(format!("must be at least {} characters", self.0)) }
}
}
// Every closure with the right signature is a Validator too.
impl<F> Validator for F
where
F: Fn(&str) -> Result<(), String>,
{
fn validate(&self, input: &str) -> Result<(), String> {
self(input)
}
}
fn problems(input: &str, rules: &[Box<dyn Validator>]) -> Vec<String> {
rules.iter().filter_map(|rule| rule.validate(input).err()).collect()
}
fn main() {
let rules: Vec<Box<dyn Validator>> = vec![
Box::new(MinLength(8)),
Box::new(|s: &str| if s.chars().any(|c| c.is_ascii_digit()) { Ok(()) } else { Err(String::from("needs a digit")) }),
Box::new(|s: &str| if s.to_lowercase().contains("password") { Err(String::from("too obvious")) } else { Ok(()) }),
];
for pw in ["hunter2", "MyPassword1", "correct-horse-7"] {
println!("{pw:<16} {:?}", problems(pw, &rules));
}
}hunter2 ["must be at least 8 characters"]
MyPassword1 ["too obvious"]
correct-horse-7 []3. The orphan rule, and newtypes as adapters
You may implement a trait for a type only if the trait or the type is defined in your crate. This "orphan rule" stops two crates from providing conflicting implementations. So you can't implement the standard library's Display for the standard library's Vec:
use std::fmt;
impl fmt::Display for Vec<String> {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
write!(f, "{}", self.join(", "))
}
}
fn main() {}error[E0117]: only traits defined in the current crate can be implemented for types defined outside of the crate
--> src/main.rs:3:1
|
3 | impl fmt::Display for Vec<String> {
| ^^^^^^^^^^^^^^^^^^^^^^-----------
| |
| `Vec` is not defined in the current crate
|
= note: impl doesn't have any local type before any uncovered type parameters
= note: for more information see https://doc.rust-lang.org/reference/items/implementations.html#orphan-rules
= note: define and implement a trait or new type insteadThe fix is a newtype: a one-field tuple struct that is your own type, wrapping the foreign one. It costs nothing at runtime.
use std::fmt;
struct Tags(Vec<String>);
impl fmt::Display for Tags {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
match self.0.as_slice() {
[] => write!(f, "(no tags)"),
[first, rest @ ..] => {
write!(f, "#{first}")?;
for tag in rest {
write!(f, " #{tag}")?;
}
Ok(())
}
}
}
}
fn main() {
let posts = [Tags(vec![]), Tags(vec![String::from("rust")]), Tags(vec![String::from("rust"), String::from("java"), String::from("jvm")])];
for tags in &posts {
println!("{tags}");
}
println!("{} tags on the last post", posts[2].0.len()); // the Vec is still inside
}(no tags)
#rust
#rust #java #jvm
3 tags on the last post4. Decorator at compile time
A decorator wraps an object with the same interface and adds behavior. Generic decorators (Uppercase<D>) stack at compile time: the whole chain is one concrete type, with no allocation and no dynamic dispatch.
trait DataSource {
fn read(&self) -> String;
}
struct FileSource(String);
impl DataSource for FileSource {
fn read(&self) -> String {
self.0.clone()
}
}
struct Uppercase<D: DataSource>(D);
struct Trimmed<D: DataSource>(D);
struct Counted<D: DataSource>(D);
impl<D: DataSource> DataSource for Uppercase<D> {
fn read(&self) -> String {
self.0.read().to_uppercase()
}
}
impl<D: DataSource> DataSource for Trimmed<D> {
fn read(&self) -> String {
self.0.read().trim().to_string()
}
}
impl<D: DataSource> DataSource for Counted<D> {
fn read(&self) -> String {
let s = self.0.read();
format!("{s} ({} chars)", s.chars().count())
}
}
fn main() {
let raw = FileSource(String::from(" hello decorators "));
let decorated = Counted(Uppercase(Trimmed(raw)));
println!("{}", decorated.read());
}HELLO DECORATORS (16 chars)5. Decorators stacked at runtime
When the layers come from configuration, each decorator holds a Box<dyn Trait> and the chain is built in a loop. This is the textbook notifier example: a base email notifier, with SMS and Slack layers added on request.
trait Notifier {
fn send(&self, message: &str) -> Vec<String>;
}
struct Email {
to: String,
}
impl Notifier for Email {
fn send(&self, message: &str) -> Vec<String> {
vec![format!("email to {}: {message}", self.to)]
}
}
// Each decorator wraps "some notifier" and adds a channel.
struct Sms {
inner: Box<dyn Notifier>,
phone: String,
}
struct Slack {
inner: Box<dyn Notifier>,
channel: String,
}
impl Notifier for Sms {
fn send(&self, message: &str) -> Vec<String> {
let mut sent = self.inner.send(message);
sent.push(format!("sms to {}: {message}", self.phone));
sent
}
}
impl Notifier for Slack {
fn send(&self, message: &str) -> Vec<String> {
let mut sent = self.inner.send(message);
sent.push(format!("slack {}: {message}", self.channel));
sent
}
}
fn build_notifier(channels: &[&str]) -> Box<dyn Notifier> {
let mut notifier: Box<dyn Notifier> = Box::new(Email { to: String::from("ops@example.com") });
for channel in channels {
notifier = match *channel {
"sms" => Box::new(Sms { inner: notifier, phone: String::from("+49 170 1234567") }),
"slack" => Box::new(Slack { inner: notifier, channel: String::from("#alerts") }),
_ => notifier,
};
}
notifier
}
fn main() {
let configs: [&[&str]; 3] = [&[], &["slack"], &["sms", "slack"]];
for config in configs {
println!("{config:?}:");
for line in build_notifier(config).send("disk 90% full") {
println!(" {line}");
}
}
}[]:
email to ops@example.com: disk 90% full
["slack"]:
email to ops@example.com: disk 90% full
slack #alerts: disk 90% full
["sms", "slack"]:
email to ops@example.com: disk 90% full
sms to +49 170 1234567: disk 90% full
slack #alerts: disk 90% full6. Decorating functions
For single functions, a decorator is a function that takes a closure and returns (or runs) a wrapped version. logged returns a new closure that prints each call; retry runs a fallible operation until it succeeds or runs out of attempts.
use std::fmt::{Debug, Display};
// Returns a new function that behaves like `f` and logs each call.
fn logged<A: Debug, R: Debug>(name: &'static str, f: impl Fn(A) -> R) -> impl Fn(A) -> R {
move |arg| {
println!("-> {name}({arg:?})");
let result = f(arg);
println!("<- {name} = {result:?}");
result
}
}
// Runs `op` until it succeeds, at most `attempts` times.
fn retry<T, E: Display>(attempts: u32, mut op: impl FnMut() -> Result<T, E>) -> Result<T, E> {
let mut attempt = 1;
loop {
match op() {
Err(e) if attempt < attempts => {
println!("attempt {attempt} failed: {e}; retrying");
attempt += 1;
}
result => return result,
}
}
}
fn main() {
let area = logged("area", |(w, h): (u32, u32)| w * h);
let total = area((3, 4)) + area((5, 5));
println!("total area: {total}");
let mut calls = 0;
let result = retry(5, || {
calls += 1;
if calls < 3 { Err(format!("timeout #{calls}")) } else { Ok("payload") }
});
println!("{result:?} after {calls} calls");
println!("{:?}", retry(2, || Err::<(), _>("still down")));
}-> area((3, 4))
<- area = 12
-> area((5, 5))
<- area = 25
total area: 37
attempt 1 failed: timeout #1; retrying
attempt 2 failed: timeout #2; retrying
Ok("payload") after 3 calls
attempt 1 failed: still down; retrying
Err("still down")7. Composite: an enum tree
The composite pattern treats individual objects and groups uniformly. A recursive enum expresses it directly: a node is either a leaf or a group of nodes, and one match handles both.
enum Node {
File { name: String, bytes: u64 },
Dir { name: String, children: Vec<Node> },
}
impl Node {
fn size(&self) -> u64 {
match self {
Node::File { bytes, .. } => *bytes,
Node::Dir { children, .. } => children.iter().map(Node::size).sum(),
}
}
fn render(&self, depth: usize, out: &mut Vec<String>) {
let indent = " ".repeat(depth);
match self {
Node::File { name, bytes } => out.push(format!("{indent}{name} ({bytes} B)")),
Node::Dir { name, children } => {
out.push(format!("{indent}{name}/ ({} B)", self.size()));
for c in children {
c.render(depth + 1, out);
}
}
}
}
}
fn file(name: &str, bytes: u64) -> Node {
Node::File { name: name.into(), bytes }
}
fn main() {
let tree = Node::Dir {
name: "project".into(),
children: vec![
file("Cargo.toml", 420),
Node::Dir { name: "src".into(), children: vec![file("main.rs", 2_100), file("lib.rs", 5_300)] },
Node::Dir { name: "docs".into(), children: vec![] },
],
};
let mut lines = Vec::new();
tree.render(0, &mut lines);
println!("{}", lines.join("\n"));
}project/ (7820 B)
Cargo.toml (420 B)
src/ (7400 B)
main.rs (2100 B)
lib.rs (5300 B)
docs/ (0 B)8. Composite with trait objects
When other code must be able to add new kinds of nodes, as with widgets in a UI toolkit, make the node a trait. A container is just one more implementation that holds Vec<Box<dyn Widget>> and delegates to its children.
trait Widget {
fn render(&self, indent: usize) -> Vec<String>;
fn count(&self) -> usize {
1
}
}
struct Text(String);
struct Button {
label: String,
}
struct Panel {
title: String,
children: Vec<Box<dyn Widget>>,
}
impl Widget for Text {
fn render(&self, indent: usize) -> Vec<String> {
vec![format!("{}\"{}\"", " ".repeat(indent), self.0)]
}
}
impl Widget for Button {
fn render(&self, indent: usize) -> Vec<String> {
vec![format!("{}[{}]", " ".repeat(indent), self.label)]
}
}
impl Widget for Panel {
fn render(&self, indent: usize) -> Vec<String> {
let mut lines = vec![format!("{}{}:", " ".repeat(indent), self.title)];
for child in &self.children {
lines.extend(child.render(indent + 2));
}
lines
}
fn count(&self) -> usize {
1 + self.children.iter().map(|c| c.count()).sum::<usize>()
}
}
fn main() {
let form = Panel {
title: String::from("Login"),
children: vec![
Box::new(Text(String::from("Welcome back"))),
Box::new(Panel {
title: String::from("Credentials"),
children: vec![Box::new(Text(String::from("user name"))), Box::new(Text(String::from("password")))],
}),
Box::new(Button { label: String::from("Sign in") }),
],
};
println!("{}", form.render(0).join("\n"));
println!("{} widgets", form.count());
}Login:
"Welcome back"
Credentials:
"user name"
"password"
[Sign in]
6 widgets9. Facade
A facade offers one simple entry point over a complicated subsystem. In Rust, a module with a few public functions over private submodules is a facade, as the Modules lesson showed with pub use.
mod video {
mod decoder {
pub fn decode(file: &str) -> Vec<u8> {
file.bytes().collect()
}
}
mod scaler {
pub fn scale(frames: Vec<u8>, width: u32) -> Vec<u8> {
frames.into_iter().take(width as usize).collect()
}
}
mod encoder {
pub fn encode(frames: &[u8], codec: &str) -> String {
format!("{} frames encoded as {codec}", frames.len())
}
}
// The facade: one call hides three subsystems and their ordering.
pub fn convert_for_mobile(file: &str) -> String {
let frames = decoder::decode(file);
let small = scaler::scale(frames, 8);
encoder::encode(&small, "h264")
}
}
fn main() {
println!("{}", video::convert_for_mobile("holiday_video.mov"));
}8 frames encoded as h26410. Proxy: lazy loading
A proxy stands in for another object with the same interface. A virtual proxy delays creating an expensive object until it's needed. OnceCell (or OnceLock for thread-safe use) makes one a few lines long.
use std::cell::OnceCell;
struct HighResImage {
pixels: Vec<u8>,
}
impl HighResImage {
fn load(path: &str) -> HighResImage {
println!(" (loading {path} from disk)");
HighResImage { pixels: vec![0; 4_000_000] }
}
}
struct ImageProxy {
path: String,
image: OnceCell<HighResImage>,
}
impl ImageProxy {
fn new(path: &str) -> Self {
ImageProxy { path: path.to_string(), image: OnceCell::new() }
}
fn file_name(&self) -> &str {
&self.path // doesn't need the image
}
fn pixel_count(&self) -> usize {
self.image.get_or_init(|| HighResImage::load(&self.path)).pixels.len()
}
}
fn main() {
let gallery: Vec<ImageProxy> = ["a.png", "b.png", "c.png"].map(ImageProxy::new).into_iter().collect();
println!("thumbnails: {:?}", gallery.iter().map(|i| i.file_name()).collect::<Vec<_>>());
println!("opened b.png: {} pixels", gallery[1].pixel_count());
println!("opened b.png again: {} pixels", gallery[1].pixel_count());
}thumbnails: ["a.png", "b.png", "c.png"]
(loading b.png from disk)
opened b.png: 4000000 pixels
opened b.png again: 4000000 pixels11. Caching proxy
A caching proxy implements the same trait as a slow service and answers repeated requests from memory. The trait method takes &self, so the cache lives in a RefCell. Callers written against the trait can't tell the difference.
use std::cell::{Cell, RefCell};
use std::collections::HashMap;
trait WeatherService {
fn forecast(&self, city: &str) -> String;
}
struct RemoteWeather {
calls: Cell<u32>,
}
impl WeatherService for RemoteWeather {
fn forecast(&self, city: &str) -> String {
self.calls.set(self.calls.get() + 1); // pretend this is a slow HTTP call
format!("{city}: {} C", city.bytes().map(u32::from).sum::<u32>() % 30)
}
}
struct CachedWeather<S: WeatherService> {
inner: S,
cache: RefCell<HashMap<String, String>>,
}
impl<S: WeatherService> WeatherService for CachedWeather<S> {
fn forecast(&self, city: &str) -> String {
if let Some(hit) = self.cache.borrow().get(city) {
return hit.clone();
}
let fresh = self.inner.forecast(city);
self.cache.borrow_mut().insert(city.to_string(), fresh.clone());
fresh
}
}
fn dashboard(service: &impl WeatherService, cities: &[&str]) {
for city in cities {
println!("{}", service.forecast(city));
}
}
fn main() {
let service = CachedWeather { inner: RemoteWeather { calls: Cell::new(0) }, cache: RefCell::new(HashMap::new()) };
dashboard(&service, &["Oslo", "Lima", "Oslo", "Oslo", "Lima"]);
println!("remote calls: {}", service.inner.calls.get());
}Oslo: 23 C
Lima: 27 C
Oslo: 23 C
Oslo: 23 C
Lima: 27 C
remote calls: 2A real cache also needs an expiry time and a size limit. Crates such as moka provide them; the structure stays the same.
12. Protection proxy
A protection proxy checks permissions before forwarding each call. Deriving PartialOrd on the Role enum orders the variants as they're declared, so "at least an editor" is a plain comparison.
#[derive(Debug, Clone, Copy, PartialEq, PartialOrd)]
enum Role {
Viewer,
Editor,
Admin,
}
trait Documents {
fn read(&self, id: u32) -> Result<String, String>;
fn update(&mut self, id: u32, text: &str) -> Result<(), String>;
fn delete(&mut self, id: u32) -> Result<(), String>;
}
struct Store {
docs: Vec<(u32, String)>,
}
impl Documents for Store {
fn read(&self, id: u32) -> Result<String, String> {
self.docs.iter().find(|(d, _)| *d == id).map(|(_, text)| text.clone()).ok_or(format!("no document {id}"))
}
fn update(&mut self, id: u32, text: &str) -> Result<(), String> {
let doc = self.docs.iter_mut().find(|(d, _)| *d == id).ok_or(format!("no document {id}"))?;
doc.1 = text.to_string();
Ok(())
}
fn delete(&mut self, id: u32) -> Result<(), String> {
let before = self.docs.len();
self.docs.retain(|(d, _)| *d != id);
if self.docs.len() < before { Ok(()) } else { Err(format!("no document {id}")) }
}
}
// The proxy: same interface, checks first.
struct Guarded<D: Documents> {
inner: D,
role: Role,
}
impl<D: Documents> Guarded<D> {
fn require(&self, needed: Role, action: &str) -> Result<(), String> {
if self.role >= needed { Ok(()) } else { Err(format!("{:?} may not {action}", self.role)) }
}
}
impl<D: Documents> Documents for Guarded<D> {
fn read(&self, id: u32) -> Result<String, String> {
self.require(Role::Viewer, "read")?;
self.inner.read(id)
}
fn update(&mut self, id: u32, text: &str) -> Result<(), String> {
self.require(Role::Editor, "update")?;
self.inner.update(id, text)
}
fn delete(&mut self, id: u32) -> Result<(), String> {
self.require(Role::Admin, "delete")?;
self.inner.delete(id)
}
}
fn main() {
for role in [Role::Viewer, Role::Editor, Role::Admin] {
let store = Store { docs: vec![(1, String::from("draft")), (2, String::from("notes"))] };
let mut docs = Guarded { inner: store, role };
println!("{role:?}:");
println!(" read {:?}", docs.read(1));
println!(" update {:?}", docs.update(1, "final"));
println!(" delete {:?}", docs.delete(2));
println!(" now {:?} {:?}", docs.read(1), docs.read(2));
}
}Viewer:
read Ok("draft")
update Err("Viewer may not update")
delete Err("Viewer may not delete")
now Ok("draft") Ok("notes")
Editor:
read Ok("draft")
update Ok(())
delete Err("Editor may not delete")
now Ok("final") Ok("notes")
Admin:
read Ok("draft")
update Ok(())
delete Ok(())
now Ok("final") Err("no document 2")13. Flyweight: shared immutable data
Flyweights share heavy immutable data among many objects. Rc<str> (or Arc<str> across threads) shares one allocation; cloning it copies a pointer (Rc & Weak).
use std::collections::HashMap;
use std::rc::Rc;
struct Tree {
x: u32,
y: u32,
species: Rc<str>, // shared, not copied per tree
}
#[derive(Default)]
struct SpeciesPool(HashMap<String, Rc<str>>);
impl SpeciesPool {
fn get(&mut self, name: &str) -> Rc<str> {
Rc::clone(self.0.entry(name.to_string()).or_insert_with(|| Rc::from(name)))
}
}
fn main() {
let mut pool = SpeciesPool::default();
let forest: Vec<Tree> = (0..1000).map(|i| Tree { x: i % 40, y: i / 40, species: pool.get(if i % 3 == 0 { "oak" } else { "birch" }) }).collect();
let oak = pool.get("oak");
println!("{} trees, {} species strings stored", forest.len(), pool.0.len());
println!("oak is shared by {} handles", Rc::strong_count(&oak) - 2); // minus the pool's and ours
println!("tree 999 at ({}, {}) is {}", forest[999].x, forest[999].y, forest[999].species);
}1000 trees, 2 species strings stored
oak is shared by 334 handles
tree 999 at (39, 24) is oak14. Bridge: two dimensions that vary independently
The bridge pattern separates what is drawn from how it's drawn, so that shapes and renderers can each grow without a subclass for every combination (SvgCircle, SvgSquare, CanvasCircle, ...). In Rust, the "how" is a trait that the "what" receives as a parameter.
trait Renderer {
fn circle(&self, x: f64, y: f64, r: f64) -> String;
fn rect(&self, x: f64, y: f64, w: f64, h: f64) -> String;
}
struct Svg;
struct Canvas2d;
impl Renderer for Svg {
fn circle(&self, x: f64, y: f64, r: f64) -> String {
format!(r#"<circle cx="{x}" cy="{y}" r="{r}"/>"#)
}
fn rect(&self, x: f64, y: f64, w: f64, h: f64) -> String {
format!(r#"<rect x="{x}" y="{y}" width="{w}" height="{h}"/>"#)
}
}
impl Renderer for Canvas2d {
fn circle(&self, x: f64, y: f64, r: f64) -> String {
format!("ctx.arc({x}, {y}, {r}, 0, 2 * Math.PI)")
}
fn rect(&self, x: f64, y: f64, w: f64, h: f64) -> String {
format!("ctx.rect({x}, {y}, {w}, {h})")
}
}
trait Shape {
fn draw(&self, renderer: &dyn Renderer) -> Vec<String>;
}
struct Dot {
x: f64,
y: f64,
}
struct Badge {
x: f64,
y: f64,
size: f64,
}
impl Shape for Dot {
fn draw(&self, renderer: &dyn Renderer) -> Vec<String> {
vec![renderer.circle(self.x, self.y, 1.0)]
}
}
impl Shape for Badge {
fn draw(&self, renderer: &dyn Renderer) -> Vec<String> {
let half = self.size / 2.0;
vec![renderer.rect(self.x, self.y, self.size, self.size), renderer.circle(self.x + half, self.y + half, half / 2.0)]
}
}
fn main() {
let shapes: Vec<Box<dyn Shape>> = vec![Box::new(Dot { x: 5.0, y: 5.0 }), Box::new(Badge { x: 10.0, y: 0.0, size: 8.0 })];
let renderers: [(&str, &dyn Renderer); 2] = [("svg", &Svg), ("canvas", &Canvas2d)];
for (name, renderer) in renderers {
println!("{name}:");
for shape in &shapes {
for line in shape.draw(renderer) {
println!(" {line}");
}
}
}
}svg:
<circle cx="5" cy="5" r="1"/>
<rect x="10" y="0" width="8" height="8"/>
<circle cx="14" cy="4" r="2"/>
canvas:
ctx.arc(5, 5, 1, 0, 2 * Math.PI)
ctx.rect(10, 0, 8, 8)
ctx.arc(14, 4, 2, 0, 2 * Math.PI)Two shapes and two renderers take four impl blocks, and a third renderer is one more, not one more per shape.
15. Extension traits: methods on types you don't own
Rust can add methods to types you don't own, such as String or Duration, with an extension trait: define a trait with the new methods and implement it for the existing type. The methods are available wherever the trait is in scope.
use std::time::Duration;
trait StrExt {
fn is_blank(&self) -> bool;
fn truncate_with_ellipsis(&self, max_chars: usize) -> String;
}
impl StrExt for str {
fn is_blank(&self) -> bool {
self.trim().is_empty()
}
fn truncate_with_ellipsis(&self, max_chars: usize) -> String {
if self.chars().count() <= max_chars {
return self.to_string();
}
let kept: String = self.chars().take(max_chars.saturating_sub(3)).collect();
format!("{kept}...")
}
}
trait DurationExt {
fn human(&self) -> String;
}
impl DurationExt for Duration {
fn human(&self) -> String {
let s = self.as_secs();
match s {
0..=59 => format!("{s}s"),
60..=3_599 => format!("{}m {}s", s / 60, s % 60),
_ => format!("{}h {}m", s / 3_600, s % 3_600 / 60),
}
}
}
fn main() {
println!("{} {}", " ".is_blank(), " x ".is_blank());
let title = String::from("Rust for Java developers: the complete course");
println!("{}", title.truncate_with_ellipsis(24)); // works on String too, through deref
for secs in [42, 754, 7_380] {
println!("{}", Duration::from_secs(secs).human());
}
}true false
Rust for Java develop...
42s
12m 34s
2h 3mThis is how crates extend standard types: itertools::Itertools adds dozens of iterator methods, and async crates add StreamExt methods to streams. Because the trait must be imported, nobody gets the new methods by surprise.
16. Realistic example: a layered repository
A user directory built from four structural patterns: an adapter makes a legacy CSV source look like a UserRepo, a caching proxy avoids repeated lookups, a logging decorator records each call, and a facade hides the whole stack behind one method.
use std::cell::RefCell;
use std::collections::HashMap;
#[derive(Debug, Clone)]
struct User {
id: u32,
name: String,
team: String,
}
trait UserRepo {
fn find(&self, id: u32) -> Option<User>;
}
// A legacy source with an API of its own: raw CSV lines.
struct LegacyCsv {
lines: Vec<&'static str>,
}
impl LegacyCsv {
fn line_for(&self, id: u32) -> Option<&'static str> {
self.lines.iter().copied().find(|line| line.split(',').next().and_then(|f| f.parse::<u32>().ok()) == Some(id))
}
}
// Adapter: CSV lines become `User`s.
struct CsvRepo(LegacyCsv);
impl UserRepo for CsvRepo {
fn find(&self, id: u32) -> Option<User> {
println!(" (scanning CSV for {id})");
let fields: Vec<&str> = self.0.line_for(id)?.split(',').collect();
Some(User { id, name: fields.get(1)?.to_string(), team: fields.get(2)?.to_string() })
}
}
// Proxy: remembers answers, including "not found".
struct Cached<R: UserRepo> {
inner: R,
cache: RefCell<HashMap<u32, Option<User>>>,
}
impl<R: UserRepo> UserRepo for Cached<R> {
fn find(&self, id: u32) -> Option<User> {
if let Some(answer) = self.cache.borrow().get(&id) {
return answer.clone();
}
let answer = self.inner.find(id);
self.cache.borrow_mut().insert(id, answer.clone());
answer
}
}
// Decorator: logs every call, whatever is underneath.
struct Logged<R: UserRepo>(R);
impl<R: UserRepo> UserRepo for Logged<R> {
fn find(&self, id: u32) -> Option<User> {
let result = self.0.find(id);
println!(" find({id}) -> {}", if result.is_some() { "found" } else { "none" });
result
}
}
// Facade: callers see one type with one method.
struct UserDirectory {
repo: Box<dyn UserRepo>,
}
impl UserDirectory {
fn from_csv(lines: Vec<&'static str>) -> UserDirectory {
let stack = Logged(Cached { inner: CsvRepo(LegacyCsv { lines }), cache: RefCell::new(HashMap::new()) });
UserDirectory { repo: Box::new(stack) }
}
fn display_name(&self, id: u32) -> String {
match self.repo.find(id) {
Some(u) => format!("{} ({}, #{})", u.name, u.team, u.id),
None => format!("unknown user #{id}"),
}
}
}
fn main() {
let directory = UserDirectory::from_csv(vec!["1,Ana Lima,payments", "2,Ben Okafor,search", "3,Cy Park,infra"]);
for id in [2, 2, 9, 9, 1] {
println!("{}", directory.display_name(id));
}
} (scanning CSV for 2)
find(2) -> found
Ben Okafor (search, #2)
find(2) -> found
Ben Okafor (search, #2)
(scanning CSV for 9)
find(9) -> none
unknown user #9
find(9) -> none
unknown user #9
(scanning CSV for 1)
find(1) -> found
Ana Lima (payments, #1)Summary
| Pattern | Rust |
|---|---|
| Adapter | a newtype implementing your trait; a blanket impl<F: Fn(...)> to accept closures |
| Orphan rule workaround | wrap the foreign type in a newtype |
| Decorator | generic wrappers for compile-time stacks, Box<dyn Trait> layers for runtime stacks, higher-order functions for single functions |
| Composite | a recursive enum (closed set) or Vec<Box<dyn Trait>> children (open set) |
| Facade | a module or struct with a small public API |
| Proxy | a wrapper implementing the same trait: OnceCell (lazy), RefCell<HashMap> (cache), checks before forwarding (protection) |
| Flyweight | Rc<T> / Arc<T> |
| Bridge | the implementation passed in as a trait object or generic parameter |
| Extension methods | extension traits |
Next: Behavioral Patterns: strategy, observer, command, state, visitor, and the rest, often replaced by a closure or an enum.
