Skip to main content
Rust · lesson 22 of 30
Rust advanced Lesson 22 of 30

Memory Management in Rust

Understand Rust memory: Box, Rc, RefCell, Arc and Cell, measured type sizes and the null-pointer niche, plus the five things unsafe Rust permits.

By SkillByExample · Updated

Box<T> — Heap Allocation

Box<T> allocates a value on the heap and gives you a single-owner pointer to it.

fn main() {
    let b = Box::new(5);
    println!("b = {}", b); // dereferences automatically

    // Explicit dereference
    println!("*b = {}", *b);

    // Useful for large values (avoids stack overflow)
    let big_array = Box::new([0u8; 1_000_000]);
    println!("allocated {} bytes on heap", big_array.len());
} // big_array is freed here
$ rustc memory_box_t_heap_allocation.rs && ./memory_box_t_heap_allocation
b = 5
*b = 5
allocated 1000000 bytes on heap

Recursive Data Structures

Without Box, the size of a recursive type would be infinite:

// ERROR: recursive type has infinite size
// enum List { Cons(i32, List), Nil }

// FIX: Box breaks the recursion — pointer has known size
#[derive(Debug)]
enum List {
    Cons(i32, Box<List>),
    Nil,
}

impl List {
    fn new() -> Self { List::Nil }

    fn prepend(self, val: i32) -> Self {
        List::Cons(val, Box::new(self))
    }

    fn sum(&self) -> i32 {
        match self {
            List::Cons(val, next) => val + next.sum(),
            List::Nil => 0,
        }
    }
}

fn main() {
    let list = List::new().prepend(3).prepend(2).prepend(1);
    println!("sum: {}", list.sum());
}
$ rustc memory_recursive_data_structures.rs && ./memory_recursive_data_structures
sum: 6

Rc<T> — Reference Counting (Single-Threaded)

Rc<T> allows multiple owners of the same heap value. The value is dropped when the reference count reaches zero.

use std::rc::Rc;

fn main() {
    let a = Rc::new(String::from("shared"));
    let b = Rc::clone(&a); // increment reference count
    let c = Rc::clone(&a);

    println!("count: {}", Rc::strong_count(&a));
    println!("{} {} {}", a, b, c);

    drop(b);
    println!("count after drop: {}", Rc::strong_count(&a));
} // a and c dropped here, count -> 0, string freed
$ rustc memory_rc_t_reference_counting_single_threaded.rs && ./memory_rc_t_reference_counting_single_threaded
count: 3
shared shared shared
count after drop: 2

Rc<T> is not thread-safe (!Send). Use Arc<T> for multi-threaded scenarios.

RefCell<T> — Interior Mutability

RefCell<T> allows mutation through a shared reference by deferring borrow checks to runtime:

use std::cell::RefCell;

fn main() {
    let data = RefCell::new(vec![1, 2, 3]);

    // Multiple shared borrows
    {
        let r1 = data.borrow();
        let r2 = data.borrow();
        println!("{:?} {:?}", *r1, *r2);
    } // r1, r2 released

    // Mutable borrow
    data.borrow_mut().push(4);
    println!("{:?}", data.borrow());
}
$ rustc memory_refcell_t_interior_mutability.rs && ./memory_refcell_t_interior_mutability
[1, 2, 3] [1, 2, 3]
[1, 2, 3, 4]

Rc<RefCell<T>> — Shared Mutable Data

use std::rc::Rc;
use std::cell::RefCell;

#[derive(Debug)]
struct Node {
    value: i32,
    children: Vec<Rc<RefCell<Node>>>,
}

impl Node {
    fn new(value: i32) -> Rc<RefCell<Self>> {
        Rc::new(RefCell::new(Node { value, children: Vec::new() }))
    }

    fn add_child(parent: &Rc<RefCell<Node>>, child: Rc<RefCell<Node>>) {
        parent.borrow_mut().children.push(child);
    }
}

fn main() {
    let root = Node::new(1);
    let child1 = Node::new(2);
    let child2 = Node::new(3);

    Node::add_child(&root, Rc::clone(&child1));
    Node::add_child(&root, child2);

    child1.borrow_mut().value = 20; // mutate through one handle...

    let r = root.borrow();
    let values: Vec<i32> = r.children.iter().map(|c| c.borrow().value).collect();
    println!("root {} has children {:?}", r.value, values); // ...observe through another
    println!("child1 strong count: {}", Rc::strong_count(&child1));
}
$ rustc memory_rc_refcell_t_shared_mutable_data.rs && ./memory_rc_refcell_t_shared_mutable_data
root 1 has children [20, 3]
child1 strong count: 2

child1 has two owners — the local handle and the entry in root.children — while child2 was moved in and has one. Changing value through child1 is visible when reading through root, because both handles reach the same RefCell<Node>.

Arc<T> — Atomic Reference Counting (Multi-Threaded)

Arc<T> is Rc<T> with atomic operations — safe to share across threads:

use std::sync::{Arc, Mutex};
use std::thread;

fn main() {
    let shared = Arc::new(Mutex::new(0));
    let mut handles = vec![];

    for _ in 0..5 {
        let s = Arc::clone(&shared);
        handles.push(thread::spawn(move || {
            *s.lock().unwrap() += 1;
        }));
    }

    for h in handles { h.join().unwrap(); }
    println!("{}", *shared.lock().unwrap());
}
$ rustc memory_arc_t_atomic_reference_counting_multi_th.rs && ./memory_arc_t_atomic_reference_counting_multi_th
5

Cell<T> — Copy-Type Interior Mutability

Cell<T> is a lightweight alternative to RefCell<T> for Copy types:

use std::cell::Cell;

struct Config {
    debug: Cell<bool>,
    level: Cell<u32>,
}

impl Config {
    fn new() -> Self {
        Config { debug: Cell::new(false), level: Cell::new(1) }
    }

    fn enable_debug(&self) { self.debug.set(true); }
    fn set_level(&self, l: u32) { self.level.set(l); }
}

fn main() {
    let cfg = Config::new();
    cfg.enable_debug(); // mutate through &self!
    cfg.set_level(3);
    println!("debug={}, level={}", cfg.debug.get(), cfg.level.get());
}
$ rustc memory_cell_t_copy_type_interior_mutability.rs && ./memory_cell_t_copy_type_interior_mutability
debug=true, level=3

Memory Layout

Understanding how Rust lays out data in memory:

fn main() {
    use std::mem::{size_of, align_of};

    println!("bool:   size={} align={}", size_of::<bool>(), align_of::<bool>());
    println!("u8:     size={} align={}", size_of::<u8>(), align_of::<u8>());
    println!("i32:    size={} align={}", size_of::<i32>(), align_of::<i32>());
    println!("f64:    size={} align={}", size_of::<f64>(), align_of::<f64>());
    println!("&str:   size={}", size_of::<&str>());   // ptr + len
    println!("String: size={}", size_of::<String>()); // ptr + len + cap
    println!("Vec<u8>: size={}", size_of::<Vec<u8>>());
    println!("Box<i32>: size={}", size_of::<Box<i32>>()); // one pointer
    println!("Option<&i32>: size={}", size_of::<Option<&i32>>());
    println!("Option<i32>: size={}", size_of::<Option<i32>>());
}
$ rustc memory_memory_layout.rs && ./memory_memory_layout
bool:   size=1 align=1
u8:     size=1 align=1
i32:    size=4 align=4
f64:    size=8 align=8
&str:   size=16
String: size=24
Vec<u8>: size=24
Box<i32>: size=8
Option<&i32>: size=8
Option<i32>: size=8

The last two lines are the interesting pair. Option<&i32> is the same size as &i32 because a reference can never be null, so the compiler uses the null bit pattern to mean None — the “null pointer optimization”. i32 has no spare bit pattern, so Option<i32> needs a separate tag; with 4-byte alignment that tag costs a full 4 bytes, doubling the size.

Unsafe Rust

unsafe unlocks five additional capabilities: dereferencing raw pointers, calling unsafe functions, implementing unsafe traits, accessing mutable statics, and reading union fields. It does not switch off the borrow checker or any other check — it only permits those five operations. Use it sparingly and document the invariants it relies on:

fn main() {
    // 1. Dereference raw pointers
    let x = 42;
    let r = &x as *const i32; // create raw pointer (safe)

    unsafe {
        println!("{}", *r); // dereference (unsafe)
    }

    // Mutable raw pointer
    let mut y = 10;
    let p = &mut y as *mut i32;
    unsafe {
        *p += 1;
    }
    println!("{}", y);
}
$ rustc memory_unsafe_rust.rs && ./memory_unsafe_rust
42
11

Creating a raw pointer is safe; only dereferencing it needs unsafe, because only then could it point at freed or misaligned memory. Here both pointers come from live references, so the invariant holds.

// 2. Call unsafe functions
/// # Safety
/// `ptr` must point to `len` initialized bytes that stay alive for `'a`.
unsafe fn dangerous<'a>(ptr: *const u8, len: usize) -> &'a str {
    let slice = unsafe { std::slice::from_raw_parts(ptr, len) };
    std::str::from_utf8(slice).unwrap()
}

// Safe wrapper: a &[u8] guarantees exactly what the Safety comment requires
fn safe_from_bytes(bytes: &[u8]) -> &str {
    unsafe { dangerous(bytes.as_ptr(), bytes.len()) }
}

fn main() {
    let bytes = b"hello";
    println!("{}", safe_from_bytes(bytes));
}
$ rustc memory_unsafe_rust_2.rs && ./memory_unsafe_rust_2
hello

The pattern that matters is the wrapper: safe_from_bytes can only be called with a real slice, so the raw-pointer contract is upheld by construction and callers never write unsafe themselves. The lifetime parameter 'a matters too: declaring the return type as &'static str would let callers keep the string after the borrowed bytes are freed — the signature must not promise more than the raw pointer guarantees.

// 3. Implementing unsafe traits
/// # Safety
/// Implement only for types where all-zero bytes is a valid value.
unsafe trait Zeroable {}
unsafe impl Zeroable for u8 {}
unsafe impl Zeroable for i32 {}

fn zero_out<T: Zeroable>(val: &mut T) {
    unsafe {
        let size = std::mem::size_of::<T>();
        let ptr = val as *mut T as *mut u8;
        std::ptr::write_bytes(ptr, 0, size);
    }
}

fn main() {
    let mut n: i32 = -12345;
    zero_out(&mut n);
    println!("{}", n);
}
$ rustc memory_unsafe_rust_3.rs && ./memory_unsafe_rust_3
0

unsafe trait moves the promise to the implementer: writing unsafe impl Zeroable for i32 asserts that zero bytes form a valid i32, which is what lets zero_out itself be a safe function. Implementing it for &i32 would be a lie — an all-zero reference is a null pointer — and the compiler would not stop you.

Smart Pointer Summary

TypeOwnershipThread safeInterior mutUse case
Box<T>UniqueYes (T: Send)NoHeap alloc, recursive types, dyn Trait
Rc<T>SharedNoNoSingle-threaded shared ownership
Arc<T>SharedYesNoMulti-threaded shared ownership
Cell<T>UniqueNoYes (Copy)Cheap interior mutation
RefCell<T>UniqueNoYes (any)Runtime borrow checking
Mutex<T>SharedYesYesThread-safe mutation
RwLock<T>SharedYesYesMany readers OR one writer

Practice

  1. Predict size_of for Option<Box<i32>>, Option<u8>, (u8, u32), and Option<Option<bool>>, then print them.

    Solution
    Option<Box<i32>>: 8
    Option<u8>: 2
    (u8, u32): 8
    Option<Option<bool>>: 1

    Box is never null, so it gets the same niche as &T. Option<u8> needs a tag byte. (u8, u32) is padded to the u32’s 4-byte alignment. And bool only uses the values 0 and 1, leaving 254 spare bit patterns — enough to encode both Nones inside a single byte.

  2. In the Rc<RefCell<Node>> example, hold let r = root.borrow(); and then call Node::add_child(&root, Node::new(4)); while r is still alive. Does it compile? Does it run?

    Solution

    It compiles — RefCell moves the check to runtime — and then panics inside add_child’s borrow_mut() with RefCell already borrowed. Drop r (or end its scope) before mutating.

  3. Call zero_out on a u8 holding 255 and on an i32 holding i32::MIN, printing both before and after.

    Solution

    Both become 0: 255 -> 0 and -2147483648 -> 0. write_bytes zeroes size_of::<T>() bytes — 1 for u8, 4 for i32 — which the Zeroable bound guarantees is a valid value.

Frequently Asked Questions

When should I use Box<T>?
Use Box<T> when you need to heap-allocate a value, when you have a recursive data structure (a type that contains itself), or when you need to return a trait object from a function.
What is the difference between Rc<T> and Arc<T>?
Both are reference-counted pointers for shared ownership. Rc<T> is single-threaded (not Send); Arc<T> uses atomic operations and is safe to share across threads.
What does unsafe mean in Rust?
unsafe unlocks five capabilities not available in safe Rust: dereferencing raw pointers, calling unsafe functions, accessing mutable statics, implementing unsafe traits, and accessing union fields. It does not disable the borrow checker.