Skip to content
Codeloom
Rust

Understanding Rust Smart Pointers: Box, Rc, and Arc

A hands-on guide to Rust smart pointers covering heap allocation with Box, shared ownership with Rc, and thread-safe sharing with Arc.

·7 min read · By Codeloom
Intermediate 12 min read

What you'll learn

  • Why Rust needs smart pointers beyond plain references
  • How to use Box for heap allocation and recursive types
  • How Rc and Arc enable shared ownership patterns
  • How to combine smart pointers with interior mutability

Prerequisites

None — this post is self-contained.

Rust’s ownership system ensures memory safety at compile time, but single ownership is not always enough. Smart pointers extend Rust’s ownership model by wrapping values with additional semantics: heap allocation, reference counting, or atomic sharing. This guide explores the three most important smart pointers and the patterns they enable.

Why Smart Pointers Exist

A regular reference &T borrows data without owning it. The borrow checker enforces that references do not outlive the data they point to. But some patterns cannot be expressed with references alone:

  • Recursive data structures need indirection because their size would be infinite
  • Multiple parts of a program need to own the same data
  • Data must live on the heap rather than the stack
  • Ownership must be shared across thread boundaries

Smart pointers solve these problems while maintaining Rust’s safety guarantees.

Box: Owned Heap Allocation

Box<T> is the simplest smart pointer. It allocates a value on the heap and provides a single owner on the stack. When the Box is dropped, both the stack pointer and the heap data are freed.

Recursive Types

The most common use for Box is enabling recursive types. Without indirection, the compiler cannot compute the size of a recursive enum:

// This won't compile: infinite size
// enum List { Cons(i32, List), Nil }

// Box provides the indirection the compiler needs
enum List {
    Cons(i32, Box<List>),
    Nil,
}

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

fn main() {
    let list = List::Cons(1, Box::new(List::Cons(2, Box::new(List::Cons(3, Box::new(List::Nil))))));
    println!("Sum: {}", sum(&list)); // 6
}

Trait Objects

Box<dyn Trait> lets you store different concrete types behind a uniform interface. This is essential for building heterogeneous collections and plugin systems.

trait Validator {
    fn validate(&self, input: &str) -> Result<(), String>;
}

struct LengthCheck(usize);
impl Validator for LengthCheck {
    fn validate(&self, input: &str) -> Result<(), String> {
        if input.len() >= self.0 {
            Ok(())
        } else {
            Err(format!("Must be at least {} chars", self.0))
        }
    }
}

struct NoSpaces;
impl Validator for NoSpaces {
    fn validate(&self, input: &str) -> Result<(), String> {
        if input.contains(' ') {
            Err("Must not contain spaces".into())
        } else {
            Ok(())
        }
    }
}

fn run_validators(validators: &[Box<dyn Validator>], input: &str) {
    for v in validators {
        match v.validate(input) {
            Ok(()) => println!("  PASS"),
            Err(e) => println!("  FAIL: {e}"),
        }
    }
}

fn main() {
    let validators: Vec<Box<dyn Validator>> = vec![
        Box::new(LengthCheck(5)),
        Box::new(NoSpaces),
    ];

    println!("Validating 'hello':");
    run_validators(&validators, "hello");

    println!("Validating 'hi there':");
    run_validators(&validators, "hi there");
}

Transferring Large Data

Box can prevent expensive stack copies when moving large data structures:

struct LargeBuffer {
    data: [u8; 1_000_000],
}

fn create_buffer() -> Box<LargeBuffer> {
    Box::new(LargeBuffer { data: [0; 1_000_000] })
}

fn main() {
    let buffer = create_buffer(); // No 1MB stack copy
    println!("Buffer size: {} bytes", buffer.data.len());
}

Rc: Single-Threaded Shared Ownership

Rc<T> (reference counted) lets multiple owners share the same heap-allocated data. Each call to Rc::clone increments an internal counter. When the last Rc is dropped, the data is freed.

use std::rc::Rc;

struct Database {
    name: String,
}

struct QueryEngine {
    db: Rc<Database>,
}

struct MigrationRunner {
    db: Rc<Database>,
}

fn main() {
    let db = Rc::new(Database {
        name: String::from("production"),
    });

    let engine = QueryEngine { db: Rc::clone(&db) };
    let runner = MigrationRunner { db: Rc::clone(&db) };

    println!("Engine uses: {}", engine.db.name);
    println!("Runner uses: {}", runner.db.name);
    println!("Reference count: {}", Rc::strong_count(&db)); // 3
}

Rc with Interior Mutability

Rc only provides shared (immutable) access. To mutate the inner value, combine it with RefCell for runtime borrow checking:

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

#[derive(Debug)]
struct EventLog {
    events: RefCell<Vec<String>>,
}

impl EventLog {
    fn new() -> Rc<Self> {
        Rc::new(EventLog {
            events: RefCell::new(Vec::new()),
        })
    }

    fn log(&self, event: &str) {
        self.events.borrow_mut().push(event.to_string());
    }

    fn dump(&self) {
        for event in self.events.borrow().iter() {
            println!("  {event}");
        }
    }
}

fn main() {
    let log = EventLog::new();

    let log_a = Rc::clone(&log);
    let log_b = Rc::clone(&log);

    log_a.log("User signed in");
    log_b.log("Query executed");
    log_a.log("Response sent");

    println!("All events:");
    log.dump();
}

RefCell enforces borrowing rules at runtime rather than compile time. If you violate them (e.g., two mutable borrows at once), the program panics.

Avoiding Reference Cycles with Weak

Circular references between Rc values will leak memory because the reference count never reaches zero. Use Weak<T> to break cycles:

use std::cell::RefCell;
use std::rc::{Rc, Weak};

struct Employee {
    name: String,
    manager: RefCell<Weak<Employee>>,
    reports: RefCell<Vec<Rc<Employee>>>,
}

fn main() {
    let manager = Rc::new(Employee {
        name: "Alice".into(),
        manager: RefCell::new(Weak::new()),
        reports: RefCell::new(vec![]),
    });

    let report = Rc::new(Employee {
        name: "Bob".into(),
        manager: RefCell::new(Rc::downgrade(&manager)),
        reports: RefCell::new(vec![]),
    });

    manager.reports.borrow_mut().push(Rc::clone(&report));

    // The Weak reference does not prevent Alice from being dropped
    if let Some(mgr) = report.manager.borrow().upgrade() {
        println!("{}'s manager is {}", report.name, mgr.name);
    }

    println!("Manager refs: {}", Rc::strong_count(&manager)); // 1
    println!("Report refs: {}", Rc::strong_count(&report));   // 2
}

Arc: Thread-Safe Shared Ownership

Arc<T> (atomically reference counted) is the thread-safe counterpart to Rc. It uses atomic operations to manage the reference count, making it safe to share across threads.

use std::sync::Arc;
use std::thread;

fn main() {
    let config = Arc::new(vec![
        ("timeout", 30),
        ("retries", 3),
        ("pool_size", 10),
    ]);

    let mut handles = vec![];

    for id in 0..4 {
        let config = Arc::clone(&config);
        handles.push(thread::spawn(move || {
            println!("Thread {id} config: {:?}", config);
        }));
    }

    for handle in handles {
        handle.join().unwrap();
    }
}

Arc with Mutex for Shared Mutable State

Like Rc, Arc only provides shared access. For mutable access across threads, pair it with Mutex or RwLock:

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

struct Metrics {
    request_count: u64,
    error_count: u64,
}

fn main() {
    let metrics = Arc::new(Mutex::new(Metrics {
        request_count: 0,
        error_count: 0,
    }));

    let mut handles = vec![];

    for i in 0..10 {
        let metrics = Arc::clone(&metrics);
        handles.push(thread::spawn(move || {
            let mut m = metrics.lock().unwrap();
            m.request_count += 1;
            if i % 3 == 0 {
                m.error_count += 1;
            }
        }));
    }

    for handle in handles {
        handle.join().unwrap();
    }

    let m = metrics.lock().unwrap();
    println!("Requests: {}, Errors: {}", m.request_count, m.error_count);
}

For read-heavy workloads, RwLock offers better throughput by allowing multiple concurrent readers:

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

fn main() {
    let cache = Arc::new(RwLock::new(vec![1, 2, 3]));

    let mut handles = vec![];

    // Multiple readers can proceed concurrently
    for id in 0..4 {
        let cache = Arc::clone(&cache);
        handles.push(thread::spawn(move || {
            let data = cache.read().unwrap();
            println!("Reader {id}: {:?}", *data);
        }));
    }

    // Writer gets exclusive access
    let cache_w = Arc::clone(&cache);
    handles.push(thread::spawn(move || {
        let mut data = cache_w.write().unwrap();
        data.push(4);
        println!("Writer added element");
    }));

    for handle in handles {
        handle.join().unwrap();
    }
}

Choosing the Right Smart Pointer

ScenarioSmart PointerNotes
Heap allocation, single ownerBox<T>Cheapest option, zero overhead beyond allocation
Recursive typesBox<T>Provides required indirection
Trait objectsBox<dyn Trait>Owned dynamic dispatch
Shared ownership, single threadRc<T>Non-atomic ref counting
Shared + mutable, single threadRc<RefCell<T>>Runtime borrow checking
Shared ownership, multi-threadArc<T>Atomic ref counting
Shared + mutable, multi-threadArc<Mutex<T>>Thread-safe mutation
Read-heavy shared stateArc<RwLock<T>>Multiple concurrent readers

Common Mistakes

Using Arc when Rc suffices. Atomic operations are more expensive than non-atomic ones. If your data stays on one thread, use Rc.

Holding a Mutex lock across an await point. This can cause deadlocks in async code. Lock, extract what you need, and drop the guard before awaiting.

Forgetting Deref coercion. All three smart pointers implement Deref, so you can call methods on the inner type directly. You rarely need explicit dereferencing.

Wrapping Up

Smart pointers are how Rust extends its ownership model to cover heap allocation, shared ownership, and thread-safe data sharing. Box handles heap allocation with single ownership. Rc adds shared ownership within a single thread. Arc extends sharing across threads. Combined with RefCell, Mutex, and RwLock, these pointers cover every ownership pattern you will encounter in Rust. Use the simplest one that fits your requirements and upgrade to the next level only when the compiler or your architecture demands it.