Box in Rust

Box<T> is the simplest smart pointer in Rust. Let’s look at what it does, and at the three situations where we actually need it.

When we write let x = 5;, the value lives on the stack, inside the current function’s frame. That’s fast, but the compiler has to know the value’s size at compile time, and the value disappears when the function returns.

Box<T> puts the value on the heap instead. On the stack, we keep only a pointer to it. And a Box has exactly one owner, just like any other Rust value.

The Basics

Creating a Box is one call:

fn main() {
    let x = Box::new(5);

    println!("{}", x);
    println!("{}", *x + 1);
}

This prints:

5
6

Box<T> implements Deref, so most of the time we can use it just like the value inside. When we need the value itself, we dereference it with *.

Because a Box has one owner, that owner can also mutate the value inside:

fn main() {
    let mut x = Box::new(5);
    *x += 1;

    println!("{}", x);
}

This prints 6.

When the Box goes out of scope, it drops the value inside and frees the heap memory. There’s nothing to free manually, and no counter to track.

How Big Is a Box?

A Box is just a pointer, so its size doesn’t depend on what it points to (we’ll see one exception later):

use std::mem::size_of;

fn main() {
    println!("{}", size_of::<Box<i32>>());
    println!("{}", size_of::<Box<[u8; 1000]>>());
}

On a 64-bit machine, this prints:

8
8

A box holding 4 bytes and a box holding 1,000 bytes are both 8 bytes on the stack. That one fact explains most of the reasons we use Box.

Recursive Types

Say we’re building a linked list. Each node holds a value and the next node in the list.

What if we store the next node directly?

struct Node {
    value: i32,
    next: Option<Node>,
}
error[E0072]: recursive type `Node` has infinite size

To lay out a Node, the compiler needs its size. A Node contains an Option<Node>, which contains a Node, which contains another Option<Node>, and so on forever. There’s no finite answer.

The compiler even tells us the fix: “insert some indirection (e.g., a Box, Rc, or &) to break the cycle”. With a Box, the next node lives on the heap, and Node only stores a pointer to it:

struct Node {
    value: i32,
    next: Option<Box<Node>>,
}

Now the size is known. It’s an i32 plus a pointer, and on a 64-bit machine, size_of::<Node>() is 16 bytes. The extra 4 bytes are padding: a struct’s size is rounded up to a multiple of its alignment, which is 8 here because of the pointer.

Here’s a nice detail: Option<Box<Node>> is also 8 bytes, the same as Box<Node> alone. A Box can never be null, so Rust uses the null value to represent None. This isn’t just a compiler trick. The standard library documentation guarantees it.

We’ll build a complete linked list on top of this Node in a future post.

Trait Objects

Say we have a trait for sensors and two types that implement it:

trait Sensor {
    fn read(&self) -> String;
}

struct Thermometer {
    celsius: f32,
}

struct Hygrometer {
    humidity: f32,
}

impl Sensor for Thermometer {
    fn read(&self) -> String {
        format!("{} °C", self.celsius)
    }
}

impl Sensor for Hygrometer {
    fn read(&self) -> String {
        format!("{}% humidity", self.humidity)
    }
}

We want to keep all our sensors in one Vec and read them in a loop. But a Vec holds values of a single type, and Thermometer and Hygrometer are different types.

What about Vec<dyn Sensor>? That doesn’t compile either:

error[E0277]: the size for values of type `dyn Sensor` cannot be known at compilation time

dyn Sensor means “some type that implements Sensor”. Any type could implement it, so its size isn’t known.

A Box solves this. Box<dyn Sensor> has a fixed size, whatever is inside:

fn main() {
    let sensors: Vec<Box<dyn Sensor>> = vec![
        Box::new(Thermometer { celsius: 21.5 }),
        Box::new(Hygrometer { humidity: 40.0 }),
    ];

    for sensor in &sensors {
        println!("{}", sensor.read());
    }
}

This prints:

21.5 °C
40% humidity

This is the exception to the size rule from earlier. Box<dyn Sensor> is 16 bytes on a 64-bit machine, not 8. It holds two pointers: one to the data, and one to a table of the methods for that particular type. That second pointer is how sensor.read() knows which read to call at runtime.

The same trick lets a function return different types depending on its input, with a return type of Box<dyn Sensor>.

Large Values

Moving a value copies its bytes. Moving a Box copies only the pointer, so passing a large value around in a Box stays cheap no matter how big the value is.

But there’s a catch. Let’s put 10 MB on the heap:

fn main() {
    let big = Box::new([0u8; 10_000_000]);
    println!("{}", big.len());
}

In a debug build, this crashes:

thread 'main' (628) has overflowed its stack
fatal runtime error: stack overflow, aborting

We asked for the heap, so why did the stack overflow?

Box::new is an ordinary function. Its argument, the 10 MB array, is created first, on the stack, and only then moved to the heap. The main thread’s stack is usually just a few megabytes (8 MB is a common default on Linux), so the array doesn’t fit.

In a release build, the same program prints 10000000. The optimizer is free to skip the stack copy, and here it does. But that’s an optimization, not a guarantee.

The reliable fix is to never build the array on the stack at all. A Vec allocates on the heap from the start, and we can turn it into a box:

fn main() {
    let big: Box<[u8]> = vec![0u8; 10_000_000].into_boxed_slice();
    println!("{}", big.len());
}

This works in both debug and release builds.

When to Use Box

Reach for Box when:

  • A type contains itself, like a linked list node or a tree.
  • You need different types behind one trait, in a Vec, a struct field, or a return value.
  • A value is large and you want to move it around cheaply.

Otherwise, a plain value is simpler and faster. Every Box costs a heap allocation, and reading through it means following a pointer. Don’t box things by default.

Wrapping Up

Box<T> moves a value to the heap and keeps a pointer to it on the stack. It has exactly one owner, can be mutated through that owner, and frees its memory when it goes out of scope.

That single owner is also its limit. When a value needs several owners at once, we need a different smart pointer: Rc<T>.


Further reading

Sources