Skip to content
intermediate Phase 11 · Smart Pointers and Interior Mutability

Box for Heap Allocation

Put values on the heap and enable recursive data structures.

45m
0 problems
Topic Progress 0%

Stack Handle, Heap Value: What Box<T> Does to Layout

The first question to answer about Box<T> is "how big is it?" — because the answer never depends on T:

let big = [0u8; 4 * 1024 * 1024];       // 4 MiB
let b = Box::new(big);                  // one heap allocation, value moved in

assert_eq!(std::mem::size_of::<Box<[u8; 4_194_304]>>(), 8);   // 8 bytes on 64-bit
assert_eq!(std::mem::size_of_val(&big), 4 * 1024 * 1024);      // the payload itself

A Box<T> is a stack-resident handle containing one pointer to a heap allocation that holds the T. Box::new(v) performs exactly two things: it allocates, then it moves v into that allocation — same move semantics as any other ownership transfer, with the binding going stale right after. Accessing the value goes through one indirection (*b), which is the entire performance price: an extra pointer chase per read, no reference count to maintain, no atomic instruction, no second field. That is why the standard library can implement Box in terms of the global allocator with no runtime of its own.

The second thing to internalize is what that handle buys you: unique ownership with mutable access. Because exactly one Box owns the value, &mut T is available without any runtime check:

let mut counts = Box::new(vec![0_u32; 3]);
counts.push(1);                         // DerefMut -> &mut Vec<u32>, no counters involved

That is the sharpest contrast with its cousins, and it shows up as a table interviewers love to hear you reproduce:

Box<T> Rc<T> Arc<T>
Owners exactly one many (strong count) many (atomic strong count)
Per-pointer overhead 0 extra fields 2 × usize (strong/weak) 2 × atomic usize
&mut T to target always, unchecked only while count == 1 only while count == 1
Threads Send if T: Send neither Send nor Sync Send + Sync when T: Send + Sync
Freed at owner's scope end when last strong ref drops when last strong ref drops

Two cautions keep the choice honest. Boxing a small Copy value (Box<u32>) buys nothing — you pay an allocation plus an indirection to store four bytes, so heap-allocate what is large, dynamically sized, or meant to outlive the stack. And note that Box<dyn Trait> is a fat pointer: data pointer plus vtable pointer, two words on 64-bit, while Box<Concrete> stays one word. The trait object is the case where the handle itself grows, and it still does not depend on the payload's size.

Where does Box show up in real code? Trait objects — Box<dyn Error> from ?, Box<dyn FnMut(u32) -> u32> for callbacks that cannot be inlined — because a dynamically sized value still needs a statically sized handle, and the box stores a data pointer plus (for dyn) a vtable pointer. It shows up behind Vec<T> and String, whose real layout is a pointer, length, and capacity over a heap buffer. And it shows up when a struct is large but moved rarely: heap-allocate once, then move the 8-byte handle instead of 4 MiB of payload.

Interview framing: "When would you reach for Box?" — the three answers are trait objects, recursive types, and moving large values cheaply. The previous topic, Building a Custom derive, was macros; from here the roadmap turns to the smart pointers those macros' generated code often wraps.

Why Your Linked List Will Not Compile: E0072 and Infinite Size

Attempt the textbook linked list and the compiler stops you before you write a single function:

enum List {
    Cons(i32, List),     // error[E0072]: recursive type `List` has infinite size
    Nil,
}
error[E0072]: recursive type `List` has infinite size
 --> src/main.rs:2:5
  |
2 |     Cons(i32, List),
  |     ^^^^^^^^^^^^^^ `List` has infinite size
  |
help: insert indirection, e.g., `Box<List>` or `&List`

The diagnosis is not a quirk of enums. Every type in Rust must have a size the compiler knows statically, and inline storage means embedding the whole child inside the parent. A Cons cell would contain a List, which is a Cons cell containing a List, forever — each layer adds its own bytes, so no finite stack frame or struct layout could ever hold one. The compiler suggests indirection because a pointer is the thing that breaks the recursion: pointers have a fixed size regardless of what they point at.

enum List {
    Cons(i32, Box<List>),    // each node: 4 bytes payload + 4 padding + 8 byte pointer
    Nil,
}

fn sum(list: &List) -> i32 {
    match list {
        List::Cons(head, tail) => head + sum(tail),
        List::Nil => 0,
    }
}

let list = List::Cons(1, Box::new(List::Cons(2, Box::new(List::Cons(3, Box::new(List::Nil))))));
assert_eq!(sum(&list), 6);
assert_eq!(std::mem::size_of::<List>(), 16);   // finite, and the compiler knew it at line 1

The Box<List> field is one pointer wide, so List is 16 bytes and the recursion terminates at a memory address instead of inlining forever. Note the pattern generalizes: any self-referential or mutually recursive data structure pays indirection exactly where the cycle is — ASTs do it with Expr::Binary(Box<Expr>, Box<Expr>), trees with Box<Node<T>> children, and intrusive graphs reach for Rc/Weak (which is where this roadmap takes them). The alternative the compiler also suggests, &'a List, works but drags lifetimes through every consumer; Box keeps the list self-owned.

Look closely at sum and you get a preview of the next topic: matching &List with default binding modes binds tail as &Box<List>, yet sum(tail) compiles because &Box<List> deref-coerces to &List — the function's expected type pulls the value through the box automatically. A second practical note: sum recurses once per node, so a ten-million-element list overflows the stack long before the heap runs out. The fix is the same as for any linked structure — iterate instead:

let mut cur = &list;
let mut total = 0;
while let List::Cons(head, tail) = cur {
    total += head;
    cur = tail;                 // &Box<List> coerces back to &List at the assignment
}

The data shape dictates the traversal shape, and only one of the two shapes is stack-safe.

Two practical footnotes. First, this is why the Vec-based "list" people write in Rust tutorials is a Vec<i32> pretending: a real singly linked list trades Vec's contiguous locality for O(1) front insertion, and that trade is only expressible with indirection. Second, arrays are the same story from the other side: [List; 100] needs each element's size now, which is another reason unsized values only ever live behind a pointer.

Interview framing: implement a linked list in a Rust interview and E0072 is the plot twist. Answer it by explaining static size requirement → inline recursion is infinite → Box inserts a fixed-size pointer, then note Vec may still be the better engineering answer — interviewers are listening for both the mechanism and the judgment.

Destructor First, Deallocation Second: Box Drop Semantics

Box's lifetime story is the simplest of any smart pointer, and the difference from Rc is the point:

struct Widget { name: String }

impl Drop for Widget {
    fn drop(&mut self) { println!("closing {}", self.name); }
}

fn main() {
    let b = Box::new(Widget { name: String::from("printer") });
    println!("using {}", b.name);
}   // prints: closing printer   — then the 8+ bytes of Widget are freed immediately

When the binding goes out of scope, two things happen in order: the T's destructor runs (Widget::drop, which drops its String in turn), then the allocator reclaims the block. There is no count to decrement and no other handle that could keep the memory alive, so "when is it freed?" has one answer: at the end of the owner's scope, not at program exit and not after some later sweep. Drop order inside a scope stays reverse-declaration-order, so a box declared after its collaborators is torn down first — the same guarantee that makes plain ownership predictable.

That single-owner rule also makes two API behaviors obvious. Rc::get_mut must refuse when others share the allocation; Box never has to ask:

let mut b = Box::new(vec![1, 2]);
*Box::get_mut(&mut b).unwrap() = vec![9];   // always Some — exactly one owner by construction

And moving a Box moves only the handle: let a = b; invalidates b (error[E0382]: borrow of moved value) while the heap value never moves — which is precisely the property that keeps big allocations cheap to pass around.

For resources, that determinism is the feature. A box holding a file handle, a socket, or a database guard releases it on a line you can point to in the source, after every earlier local has dropped in reverse declaration order — no other owner can stretch the lifetime. Contrast the count-driven path you will meet next:

let a = Rc::new(Widget { name: String::from("shared") });
let b = Rc::clone(&a);       // strong count 1 -> 2
drop(a);                     // 2 -> 1: no destructor runs, nothing freed
drop(b);                     // 1 -> 0: Widget::drop runs, then the block is freed

Same destructor, radically different timing: Box ties it to the one owner's scope, Rc to the last strong reference dropping — which may be later, elsewhere, or (with a cycle) never.

The escape hatch is ownership hand-off, not garbage: Box::into_raw(b) consumes the box and returns *const T without freeing, transferring the obligation to whoever calls Box::from_raw later. That is the correct pattern at FFI boundaries and in arena-style allocators — leak only if you mem::forget the box or drop the raw pointer on the floor, and Rc-style cycles remain the only structural way safe Rust leaks (the subject of Reference Cycles and Weak). There is also a deliberate version: Box::leak(b) returns &'static mut T and intentionally never frees — the idiomatic way to build a &'static str at startup when the allocation genuinely must outlive every scope. One asymmetry to carry into the next topic: a Box<T> derefs to T with no ceremony, and the rules governing that dereference — including the automatic conversions it enables at call sites — are Deref Coercion.

Interview framing: "What happens when a Box is dropped?" is a two-beat answer — destructor runs, then deallocation, immediately and deterministically — and the strong follow-up is contrasting it with Rc (free only when the strong count hits zero) and with a GC (eventual, non-deterministic). Mention into_raw/from_raw to show you know how safe code hands memory to C and back.

Quiz

1. On a 64-bit target, what is `size_of::<Box<String>>()` and what does accessing the String cost?

Question 1 options

2. `enum List { Cons(i32, List), Nil }` fails with E0072. What is the underlying reason, and the fix?

Question 2 options

3. How does Box's freeing behavior differ from Rc<T>?

Question 3 options

4. You call Box::into_raw on a Box<String> to pass it to C, and the pointer eventually comes back to Rust. How do you avoid a leak?

Question 4 options

Flashcards

Question

What is the layout of Box<T>, and what does Box::new actually do?

Answer

One stack pointer (8 bytes on 64-bit, regardless of T) to a heap slot holding the value; Box::new allocates the block and moves the value in, invalidating the source binding like any move. Access costs one indirection and zero refcount/atomic work.

Question

Why does a recursive enum need Box, and what error proves it?

Answer

Types must have statically knowable finite sizes; embedding the child inline (Cons(i32, List)) is infinitely large, so rustc rejects it with E0072 "recursive type has infinite size" and suggests indirection. `Cons(i32, Box<List>)` stores one pointer per node, making the type 16 bytes and terminating recursion at a heap address.

Question

Box vs Rc vs Arc in one line each — ownership, threads, and mutability.

Answer

Box: one owner, always `&mut`, `Send` if T: Send, freed at scope end. Rc: many owners via a non-atomic strong count, neither Send nor Sync, `&mut` only when count == 1, freed at last strong drop. Arc: same sharing with atomic counts, Send + Sync when T: Send + Sync — the cross-thread version of Rc.

Question

What is the exact drop sequence for a Box<T>?

Answer

T's destructor runs first (recursively dropping its fields), then the allocator reclaims the block — immediately, at the single owner's scope end, in reverse declaration order relative to other locals. No counting, no deferred sweep: deterministic cleanup you can point to in the source.

Question

What do Box::into_raw and Box::from_raw give you, and what invariant do they carry?

Answer

into_raw consumes the Box and returns *const T without freeing — transferring the free obligation; from_raw rebuilds a Box around such a pointer. The unsafe invariant: the pointer came from into_raw (or an equivalent allocation) and exactly one Box ever wraps it, so Drop runs once. Used for FFI and custom arenas.

Revision Notes

Key Takeaways

  • 1. A Box<T> is a fixed-size stack handle to a uniquely owned heap value: move on Box::new, one indirection on access, no reference count anywhere
  • 2. E0072 "recursive type has infinite size" comes from the static-size requirement; inserting Box (or a reference) at the recursive field fixes it because pointers are finite regardless of target
  • 3. Box is the only smart pointer with unique ownership, so &mut is free and unconditional — Rc/Arc can offer it only while their strong count is exactly 1
  • 4. Dropping a Box is two ordered steps — T's destructor, then deallocation — both happening immediately at the single owner's scope end
  • 5. into_raw/from_raw transfer ownership across the FFI boundary without leaking; safe Rust's only structural leak is the Rc cycle (and deliberate mem::forget / Box::leak)

Interview Tips

  • • "When do you use Box?" — trait objects, recursive data, and cheap moves of large values; give one line of code for each and you have answered better than most candidates
  • • "Explain E0072" — state the static-size requirement, show the inline-infinite-embedding argument, then apply the compiler's own help text (insert indirection); mention that Vec may still be the better engineering choice
  • • "How is Box different from Rc?" — ownership count (1 vs many), free timing (scope end vs last strong drop), mutability (always vs count==1), thread bounds (Send vs neither Send nor Sync)
  • • "What happens on drop?" — destructor first, deallocation second, immediately and deterministically; contrast with a GC's eventual sweep and note into_raw as the deliberate non-freeing path

Cheat Sheet

Box for Heap Allocation

Layout: Box<T> = one stack pointer → heap slot with the T. Size is always size_of::<usize>(); access = one indirection; no refcount, no atomics.

let b = Box::new(42_i32);            // allocate + move in
let x: i32 = *b;                     // deref (Copy types)
*Box::get_mut(&mut b).unwrap();      // &mut — always available, single owner
let p: *const i32 = Box::into_raw(b); // hand off without freeing
let b = unsafe { Box::from_raw(p) };  // reclaim; Drop frees exactly once

When to reach for Box:

Need Why Box
Box<dyn Trait> trait object: data ptr (+ vtable) in a statically sized handle
recursive types (List, ASTs) breaks E0072 — pointer size terminates the recursion
move a big value cheaply move the 8-byte handle, not the payload
own with &mut unique owner → mutable access, no runtime check

Box vs Rc vs Arc:

owners overhead &mut threads freed
Box<T> 1 none always Send if T: Send owner's scope end
Rc<T> many 2×usize counts count==1 only neither Send nor Sync last strong drop
Arc<T> many 2×atomic counts count==1 only Send+Sync if T: Send+Sync last strong drop

Drop: T::drop runs → block returned to the allocator, immediately, at scope end. help: insert indirection, e.g., Box<List> is the E0072 fix.

Continue Learning

Before You Start

Next Topics

Related Reading