Box & dynamic types

Box & dynamic types

1. `Box<T>`: a value on the heap

let b = Box::new(5);           // 5 is on the heap, b (a pointer) on the stack
println!("{}", b);             // Box dereferences automatically
let x: i32 = *b;               // * to get the value back

A Box<T> owns its value: when the Box is freed, the value is freed too. It behaves like the value itself (methods, operators) thanks to Deref.

let s = Box::new(String::from("abc"));
println!("{}", s.len());      // String method, called through the Box
let owned: String = *s;       // move the value out of the Box (the Box is consumed)

Its size on the stack is always that of a pointer (8 bytes on a 64-bit machine), whatever the size of T.

2. When to use a `Box`

2.1 Recursive type

// enum List { End, Item(i32, List) }       // ERROR E0072: recursive type has infinite size

enum List {
    End,
    Item(i32, Box<List>),
}

let l = List::Item(1, Box::new(List::Item(2, Box::new(List::End))));

To compute the size of List, the compiler would need the size of List. With Box, the next item becomes a fixed-size pointer.

2.2 Large value that is moved often

struct Image { pixels: [u8; 1_000_000] }

fn process(img: Box<Image>) -> Box<Image> { img }    // only moves a pointer

Moving a value copies its bytes; moving a Box only copies the pointer.

2.3 Reducing the size of an enum

enum Message {
    Ping,
    Big(Box<[u8; 4096]>),     // without Box, every Ping would take 4 KB
}

2.4 Owned trait object

trait Shape { fn area(&self) -> f64; }
let f: Box<dyn Shape>;          // see the rest of the course

2.5 Fixed-size slices and strings

let slice: Box<[i32]> = vec![1, 2, 3].into_boxed_slice();   // like a Vec, without extra capacity
let text: Box<str> = String::from("fixed").into_boxed_str();

What it is for: storing a lot of data that will not grow anymore, with only a pointer and a length (16 bytes instead of 24 for a Vec or a String).

2.6 When not to use `Box`

For an ordinary value, with no recursion or trait object: the stack is faster and Box only adds an allocation. Box<Vec<T>> is almost always useless: the Vec is already on the heap.

3. Trait objects

A trait object is a value of which you only know the trait, not the concrete type. It is written dyn Trait.

trait Animal {
    fn name(&self) -> String;
    fn sound(&self) -> String;
}

struct Dog;
struct Cat;

impl Animal for Dog {
    fn name(&self) -> String { "dog".into() }
    fn sound(&self) -> String { "woof".into() }
}

impl Animal for Cat {
    fn name(&self) -> String { "cat".into() }
    fn sound(&self) -> String { "meow".into() }
}

fn introduce(a: &dyn Animal) {
    println!("the {} says {}", a.name(), a.sound());
}

fn main() {
    introduce(&Dog);
    introduce(&Cat);
    let zoo: Vec<Box<dyn Animal>> = vec![Box::new(Dog), Box::new(Cat)];
    for a in &zoo {
        introduce(a.as_ref());
    }
}

dyn Animal has no known size (a Dog and a Cat do not necessarily have the same one): you handle it only behind a pointer (&, Box, Arc, Rc).

4. The different ways to hold a trait object

SyntaxOwns the valueSharingUsage
&dyn Traitno—function parameter, temporary access
&mut dyn Traitno—parameter that modifies
Box<dyn Trait>yes, alonenostruct field, collection element, function return
Rc<dyn Trait>yes, several ownersone threadgraph, value shared within a single thread
Arc<dyn Trait>yes, several ownersacross threadsservice shared in a server
use std::rc::Rc;
use std::sync::Arc;

let a: &dyn Animal = &Dog;
let b: Box<dyn Animal> = Box::new(Dog);
let c: Rc<dyn Animal> = Rc::new(Cat);
let d: Arc<dyn Animal + Send + Sync> = Arc::new(Cat);

4.1 Adding constraints

Box<dyn Animal + Send>             // can be sent to another thread
Box<dyn Animal + Send + Sync>      // can be shared between threads
Box<dyn Animal + 'a>               // can hold references of lifetime 'a
Box<dyn Animal>                    // = Box<dyn Animal + 'static>

For a web server (tokio, axum), shared trait objects are almost always Send + Sync.

4.2 Parameter: `&dyn` or generic

fn introduce_dyn(a: &dyn Animal) { println!("{}", a.name()); }
fn introduce_gen(a: &impl Animal) { println!("{}", a.name()); }
fn introduce_gen2<A: Animal + ?Sized>(a: &A) { println!("{}", a.name()); }   // also accepts &dyn Animal

All three accept &Dog. The generic version is copied for each type (fast), the dyn version exists in a single copy (more compact). ?Sized lets the generic version accept a &dyn Animal too.

5. How a dynamic call works

5.1 The wide pointer

use std::mem::size_of;

size_of::<&Dog>();             // 8 bytes: one address
size_of::<&dyn Animal>();      // 16 bytes: one address + one vtable address
size_of::<Box<dyn Animal>>();  // 16 bytes

A reference to a trait object holds two pointers:

&dyn Animal
┌────────────┬────────────┐
│ data    ───┼──► Dog     │
│ vtable  ───┼──► [ drop, size, alignment, name(), sound() ] for Dog
└────────────┴────────────┘

5.2 The cost

Static call (generic, concrete type)Dynamic call (dyn)
Choice of the functionat compile timeat run time, through the vtable
Inliningpossiblegenerally impossible
Code sizeone copy per typea single copy
Cost of a callnoneone memory read + one indirect call

This cost is negligible compared to a network request or a database read. It matters in a very tight loop (millions of calls per second).

6. `dyn` compatibility of a trait

Not every trait can become a trait object. A trait is "dyn-compatible" (formerly object safe) if its methods can be called without knowing the concrete type.

6.1 What prevents `dyn`

trait Copyable {
    fn copy(&self) -> Self;                    // returns Self: unknown size behind dyn
}

trait Converter {
    fn convert<T>(&self, x: T) -> String;      // generic method: infinitely many versions
}

trait Buildable {
    fn create() -> Self;                       // no self: nothing to call on an object
}

// let x: Box<dyn Copyable>;                   // ERROR E0038: the trait cannot be made into an object
RuleRejected example
no method that returns Selffn clone(&self) -> Self
no generic methodfn f<T>(&self, x: T)
no associated function without selffn new() -> Self
no associated constantconst N: usize;
the trait must not require Sizedtrait T: Sized

This is why Clone is not dyn-compatible: Box<dyn Clone> does not exist.

6.2 Excluding a method with `where Self: Sized`

trait Shape {
    fn area(&self) -> f64;

    fn new_square(side: f64) -> Self           // cannot be called on a dyn Shape...
    where
        Self: Sized;                           // ...but no longer prevents dyn Shape

    fn duplicate(&self) -> Self where Self: Sized;
}

Methods marked where Self: Sized only exist on concrete types. The rest of the trait remains usable as dyn.

6.3 Replacing a generic method

// trait Journal { fn write<T: std::fmt::Display>(&self, x: T); }    // not dyn

trait Journal {
    fn write(&self, x: &dyn std::fmt::Display);                          // dyn
}

7. Trait objects in structs

7.1 An injectable behavior

trait Notifier {
    fn send(&self, to: &str, msg: &str) -> Result<(), String>;
}

struct Email;
impl Notifier for Email {
    fn send(&self, to: &str, msg: &str) -> Result<(), String> {
        println!("email to {to}: {msg}");
        Ok(())
    }
}

struct Sms;
impl Notifier for Sms {
    fn send(&self, to: &str, msg: &str) -> Result<(), String> {
        println!("sms to {to}: {msg}");
        Ok(())
    }
}

struct Service {
    notifier: Box<dyn Notifier>,
}

impl Service {
    fn new(notifier: Box<dyn Notifier>) -> Self {
        Self { notifier }
    }
    fn register(&self, contact: &str) -> Result<(), String> {
        self.notifier.send(contact, "Welcome")
    }
    fn switch(&mut self, n: Box<dyn Notifier>) {
        self.notifier = n;               // the behavior changes at run time
    }
}

fn main() {
    let mut s = Service::new(Box::new(Email));
    s.register("[email protected]").unwrap();
    s.switch(Box::new(Sms));
}

7.2 The same struct with a generic

struct ServiceGen<N: Notifier> {
    notifier: N,
}

impl<N: Notifier> ServiceGen<N> {
    fn register(&self, contact: &str) -> Result<(), String> {
        self.notifier.send(contact, "Welcome")
    }
}

let s = ServiceGen { notifier: Email };
// cannot replace Email with Sms in the same variable: ServiceGen<Email> and ServiceGen<Sms> are two types
Box<dyn Notifier>ServiceGen<N>
Change implementation at run timeyesno
Choose from the configuration (.env)yes, easilypossible with a match that creates different types
Type parameter to propagatenoyes, everywhere ServiceGen appears
Performanceindirect calldirect call

7.3 Option and default value

struct Config {
    journal: Option<Box<dyn Fn(&str)>>,      // optional behavior
}

impl Config {
    fn log(&self, msg: &str) {
        if let Some(f) = &self.journal {
            f(msg);
        }
    }
}

8. Collections of trait objects

8.1 Different types in the same `Vec`

trait Shape {
    fn area(&self) -> f64;
    fn name(&self) -> &str;
}

struct Square(f64);
struct Circle(f64);

impl Shape for Square {
    fn area(&self) -> f64 { self.0 * self.0 }
    fn name(&self) -> &str { "square" }
}
impl Shape for Circle {
    fn area(&self) -> f64 { std::f64::consts::PI * self.0 * self.0 }
    fn name(&self) -> &str { "circle" }
}

fn main() {
    let shapes: Vec<Box<dyn Shape>> = vec![Box::new(Square(2.0)), Box::new(Circle(1.0))];

    let total: f64 = shapes.iter().map(|f| f.area()).sum();
    let largest = shapes.iter().max_by(|a, b| a.area().total_cmp(&b.area()));
    for f in &shapes {
        println!("{}: {:.2}", f.name(), f.area());
    }
}

8.2 A table of handlers

use std::collections::HashMap;

type Command = Box<dyn Fn(&[&str]) -> String>;

fn commands() -> HashMap<&'static str, Command> {
    let mut m: HashMap<&'static str, Command> = HashMap::new();
    m.insert("echo", Box::new(|args| args.join(" ")));
    m.insert("count", Box::new(|args| args.len().to_string()));
    m
}

fn main() {
    let c = commands();
    if let Some(f) = c.get("echo") {
        println!("{}", f(&["a", "b"]));
    }
}

8.3 Insertion order

IndexMap (the indexmap crate) keeps insertion order, which a HashMap does not. Useful for a list of form fields, displayed in the declared order.

9. Cloning a trait object

Clone is not dyn-compatible (section 6): Box<dyn Shape> cannot be cloned directly.

9.1 Writing the method yourself

trait Shape {
    fn area(&self) -> f64;
    fn boxed_clone(&self) -> Box<dyn Shape>;
}

#[derive(Clone)]
struct Square(f64);

impl Shape for Square {
    fn area(&self) -> f64 { self.0 * self.0 }
    fn boxed_clone(&self) -> Box<dyn Shape> { Box::new(self.clone()) }
}

impl Clone for Box<dyn Shape> {
    fn clone(&self) -> Self { self.boxed_clone() }
}

9.2 With the `dyn-clone` crate

use dyn_clone::DynClone;

trait Shape: DynClone {
    fn area(&self) -> f64;
}
dyn_clone::clone_trait_object!(Shape);

#[derive(Clone)]
struct Circle(f64);
impl Shape for Circle {
    fn area(&self) -> f64 { 3.14 * self.0 * self.0 }
}

let a: Box<dyn Shape> = Box::new(Circle(1.0));
let b = a.clone();          // works: the macro wrote the Clone impl

The crate generates exactly the code of section 9.1. Runique uses it for its form fields (section 14).

9.3 Sharing instead of cloning

use std::sync::Arc;

let a: Arc<dyn Shape> = Arc::new(Circle(1.0));
let b = Arc::clone(&a);     // same object, reference count increased

If the copies do not need to be modified separately, Arc avoids any copy.

10. Getting the concrete type back: `Any`

use std::any::Any;

trait Component: Any {
    fn name(&self) -> &str;
    fn as_any(&self) -> &dyn Any;
}

struct Button { label: String }

impl Component for Button {
    fn name(&self) -> &str { "button" }
    fn as_any(&self) -> &dyn Any { self }
}

fn label(c: &dyn Component) -> Option<&str> {
    c.as_any().downcast_ref::<Button>().map(|b| b.label.as_str())
}

downcast_ref returns Some if the concrete type is the right one, None otherwise. If you need it often, it is a sign that a method is missing from the trait, or that an enum would fit better.

11. Upcasting to a supertrait

Since Rust 1.86, a trait object converts to the trait object of a supertrait:

trait Named {
    fn name(&self) -> String;
}

trait Animal: Named {
    fn sound(&self) -> String;
}

struct Dog;
impl Named for Dog { fn name(&self) -> String { "dog".into() } }
impl Animal for Dog { fn sound(&self) -> String { "woof".into() } }

fn show_name(n: &dyn Named) {
    println!("{}", n.name());
}

fn main() {
    let a: Box<dyn Animal> = Box::new(Dog);
    show_name(&*a);                            // &dyn Animal -> &dyn Named
    let n: Box<dyn Named> = a;                 // Box<dyn Animal> -> Box<dyn Named>
}

Before 1.86, you had to add a method fn as_named(&self) -> &dyn Named to the trait.

12. Enum, generic or `dyn`: the same feature three times

A payment system with several payment methods.

// 1. Enum: CLOSED set, known in advance
enum Method {
    Card { number: String },
    Transfer { iban: String },
}

impl Method {
    fn pay(&self, amount: u32) -> String {
        match self {
            Method::Card { number } => format!("card {}: {amount}", &number[number.len() - 4..]),
            Method::Transfer { iban } => format!("transfer {iban}: {amount}"),
        }
    }
}

// 2. Trait + generic: open set, one type per use
trait Payment {
    fn pay(&self, amount: u32) -> String;
}

struct Card { number: String }
impl Payment for Card {
    fn pay(&self, amount: u32) -> String { format!("card: {amount}") }
}

fn collect<P: Payment>(p: &P, amount: u32) -> String { p.pay(amount) }

// 3. Trait object: open set, types mixed at run time
fn collect_all(methods: &[Box<dyn Payment>], amount: u32) -> Vec<String> {
    methods.iter().map(|m| m.pay(amount)).collect()
}
CriterionEnumGenericdyn Trait
Add a case without touching existing codeno (modify the enum and the matches)yes (new impl)yes (new impl)
Add an operation to all caseseasy (one more match)modify the trait and every implsame
The compiler checks that all cases are handledyes——
Mixed types in a collectionyesnoyes
A library user can add a casenoyesyes
Performancedirect matchdirect callindirect call
No allocationyesyesno (Box) except &dyn

Rule: enum if the cases are known and few (states, commands, message types); generic for an abstraction chosen at compile time; dyn for plugins, behaviors chosen at run time or a heterogeneous collection.

13. Design patterns

13.1 Strategy: changing an algorithm at run time

This is the Service of section 7.1: the algorithm (email, SMS) is a replaceable trait object.

13.2 State: each state decides the next one

trait State {
    fn publish(self: Box<Self>) -> Box<dyn State>;
    fn name(&self) -> &'static str;
}

struct Draft;
struct InReview;
struct Published;

impl State for Draft {
    fn publish(self: Box<Self>) -> Box<dyn State> { Box::new(InReview) }
    fn name(&self) -> &'static str { "draft" }
}
impl State for InReview {
    fn publish(self: Box<Self>) -> Box<dyn State> { Box::new(Published) }
    fn name(&self) -> &'static str { "in review" }
}
impl State for Published {
    fn publish(self: Box<Self>) -> Box<dyn State> { self }
    fn name(&self) -> &'static str { "published" }
}

struct Post { state: Option<Box<dyn State>> }

impl Post {
    fn publish(&mut self) {
        if let Some(s) = self.state.take() {
            self.state = Some(s.publish());
        }
    }
}

self: Box<Self> takes ownership of the state in order to replace it. The same state machine is written more simply with an enum (enum State { Draft, InReview, Published }) and a match: the dyn version is only preferable if new states must be addable from the outside.

13.3 Command: stored and undoable actions

trait Action {
    fn execute(&mut self, doc: &mut String);
    fn undo(&mut self, doc: &mut String);
}

struct Append(String);

impl Action for Append {
    fn execute(&mut self, doc: &mut String) { doc.push_str(&self.0); }
    fn undo(&mut self, doc: &mut String) {
        let n = doc.len() - self.0.len();
        doc.truncate(n);
    }
}

struct Editor { doc: String, history: Vec<Box<dyn Action>> }

impl Editor {
    fn apply(&mut self, mut a: Box<dyn Action>) {
        a.execute(&mut self.doc);
        self.history.push(a);
    }
    fn revert(&mut self) {
        if let Some(mut a) = self.history.pop() {
            a.undo(&mut self.doc);
        }
    }
}

13.4 Observer: notifying several subscribers

struct Events {
    subscribers: Vec<Box<dyn Fn(&str) + Send + Sync>>,
}

impl Events {
    fn subscribe(&mut self, f: impl Fn(&str) + Send + Sync + 'static) {
        self.subscribers.push(Box::new(f));
    }
    fn emit(&self, e: &str) {
        for s in &self.subscribers {
            s(e);
        }
    }
}

14. Real examples in Runique

14.1 Form fields

// runique/src/forms/base.rs
pub trait FormField: CommonFieldConfig + DynClone + std::fmt::Debug + Send + Sync { /* ... */ }

// runique/src/forms/field.rs
dyn_clone::clone_trait_object!(FormField);

// runique/src/utils/aliases/definition.rs
pub type FieldsMap = IndexMap<String, Box<dyn FormField>>;

A form holds fields of different types (TextField, MediaField, SelectField...) in a single ordered table: this is exactly the use case of a collection of trait objects (section 8). IndexMap keeps the declaration order for HTML rendering; DynClone allows cloning the whole form; Send + Sync allows sharing it between server tasks.

14.2 Password hashing algorithms

// runique/src/utils/password/mod.rs
pub trait PasswordHandler: Send + Sync + DynClone { /* ... */ }
dyn_clone::clone_trait_object!(PasswordHandler);

The algorithm (Argon2, bcrypt, scrypt) is chosen by the configuration: a trait object lets you decide it at startup without a generic parameter propagated through the whole framework (the strategy pattern of section 13.1).

15. Exercises

Exercise 1: virtual file system

Define a trait Node with name(&self) -> &str and size(&self) -> u64. Implement File { name, size } and Folder { name, children: Vec<Box<dyn Node>> } whose size is the sum of its children.

// Solution:
trait Node {
    fn name(&self) -> &str;
    fn size(&self) -> u64;
}

struct File { name: String, size: u64 }
struct Folder { name: String, children: Vec<Box<dyn Node>> }

impl Node for File {
    fn name(&self) -> &str { &self.name }
    fn size(&self) -> u64 { self.size }
}

impl Node for Folder {
    fn name(&self) -> &str { &self.name }
    fn size(&self) -> u64 { self.children.iter().map(|e| e.size()).sum() }
}

Exercise 2: extensible calculator

Create a trait Operation { fn symbol(&self) -> char; fn compute(&self, a: f64, b: f64) -> Option<f64>; } and a calculator that stores its operations in a HashMap<char, Box<dyn Operation>>. Add addition and division (which returns None on zero).

// Solution:
use std::collections::HashMap;

trait Operation {
    fn symbol(&self) -> char;
    fn compute(&self, a: f64, b: f64) -> Option<f64>;
}

struct Addition;
struct Division;

impl Operation for Addition {
    fn symbol(&self) -> char { '+' }
    fn compute(&self, a: f64, b: f64) -> Option<f64> { Some(a + b) }
}
impl Operation for Division {
    fn symbol(&self) -> char { '/' }
    fn compute(&self, a: f64, b: f64) -> Option<f64> { (b != 0.0).then(|| a / b) }
}

struct Calculator { ops: HashMap<char, Box<dyn Operation>> }

impl Calculator {
    fn new() -> Self { Self { ops: HashMap::new() } }
    fn add(&mut self, op: Box<dyn Operation>) { self.ops.insert(op.symbol(), op); }
    fn compute(&self, a: f64, s: char, b: f64) -> Option<f64> {
        self.ops.get(&s)?.compute(a, b)
    }
}

Exercise 3: same problem, enum

Rewrite exercise 2 with an enum Operation { Addition, Division }. Which version lets a user of your library add multiplication without changing your code?

Answer: the dyn version. With the enum, adding an operation requires modifying the enum and its match.

16. Key points

  • Box<T> places a value on the heap and owns it; it is used for recursive types, large values and trait objects.
  • dyn Trait is always handled behind a pointer: &dyn, Box<dyn>, Arc<dyn>.
  • A pointer to a trait object is two words wide: the data and the vtable.
  • A trait is only usable as dyn without Self in a return type, without generic methods, and without functions lacking self (except where Self: Sized).
  • Clone on a trait object: a boxed_clone method or the dyn-clone crate.
  • Enum for a closed set, generic for a compile-time choice, dyn for an open set or one mixed at run time.
  • Add Send + Sync to trait objects shared in a server.