Skip to main content

Command Palette

Search for a command to run...

Rust Smart Pointers: Box, Rc, and Arc Deep Dive

Updated
12 min readView as Markdown
Rust Smart Pointers: Box, Rc, and Arc Deep Dive
A

Olamide is my name. I am a blockchain/Frontend developer with experience building smart contracts on both EVM (Solidity) and non-EVM compatible blockchains.

😄 Pronouns: He/Him. 📫 How to reach me on handles

I'm a full-stack blockchain developer crafting the next generation of blockchain experiences with ReactJS and Solidity. I'm not just a developer; I'm a trailblazer, obsessed with pushing the boundaries of what's possible in this transformative space.

I'm not just about code, I'm about impact. I've built smart contracts that shatter the status quo, empower users, and scale without compromise. My playground? EVM-compatible blockchains and L2 solutions, where I ensure trust, security, and groundbreaking performance into every line of code.

Introduction

You've mastered ownership and borrowing. You can navigate the borrow checker without breaking a sweat.

But then you encounter smart pointers Box, Rc, Arc, RefCell and suddenly everything feels mysterious again.

Here's what's happening: smart pointers solve specific ownership problems that simple references can't. They give you flexibility when Rust's default ownership model is too restrictive.

In this guide, you'll learn:

  • What smart pointers are and why they exist

  • When to use Box for heap allocation

  • How Rc enables shared ownership

  • Why Arc is needed in concurrent code

  • What RefCell and Cell do (interior mutability)

  • Common patterns and anti-patterns

  • Performance implications of each

By the end, you'll know exactly which smart pointer solves which problem.

Concept Overview

A smart pointer is a data structure that acts like a pointer but has additional metadata and behavior.

Think of pointers like keys to a house. A regular pointer is a key it opens the door and lets you in. A smart pointer is a smart lock it tracks who has keys, automatically unlocks when needed, and has logic for shared access.

Common smart pointers:

  • Box — Sole ownership on the heap (one owner)

  • Rc — Shared ownership (reference counting, single-threaded)

  • Arc — Atomic shared ownership (safe in multiple threads)

  • RefCell — Interior mutability (runtime borrow checking)

  • Cell — Copy-only interior mutability

Why Smart Pointers Exist

Sometimes you need ownership patterns beyond what Rust's default model provides:

  1. Recursive data structures — Trees, linked lists. Regular ownership doesn't work because each node owns its children, but you need parent pointers too.

  2. Shared ownership — Multiple parts of your code own the same data. Regular ownership allows only one owner.

  3. Interior mutability — You need to mutate data through immutable references. Regular borrowing disallows this.

  4. Custom cleanup — You need special logic when data is dropped.

Technical Explanation

Box — Heap Allocation with Sole Ownership

Box allocates data on the heap and gives you sole ownership. It's a zero-cost abstraction—at runtime, it's just a pointer.

let b = Box::new(42);  // Allocate 42 on the heap
println!("{}", b);     // Dereference with *b or use implicitly
// b is dropped, heap memory is freed

Key characteristics:

  • Sole ownership (only one owner, like regular variables)

  • Heap allocation (for large data or when size is unknown)

  • Zero runtime overhead

  • Automatic deallocation

When to use Box:

  • Trait objects (Box<dyn Trait>)

  • Recursive data structures

  • Returning owned data when you don't want to specify the type

Rc — Reference Counted Shared Ownership

Rc allows multiple owners of the same data. It uses reference counting a counter tracks how many owners exist. When the count reaches zero, data is dropped.

use std::rc::Rc;

let a = Rc::new(42);
let b = Rc::clone(&a);  // Increment reference count
let c = Rc::clone(&a);  // Increment again

println!("{}", a);  // Reference count = 3
// When all are dropped, count reaches 0, data is freed

Key characteristics:

  • Multiple owners of the same data

  • Single-threaded only (not thread-safe)

  • Runtime overhead (reference count management)

  • Shared immutable access

When to use Rc:

  • Shared ownership in single-threaded code

  • Graph-like structures where nodes have multiple parents

  • Game entities with shared components

Arc — Atomic Reference Counted Shared Ownership

Arc is like Rc but thread-safe. It uses atomic operations to make reference counting safe in concurrent code.

use std::sync::Arc;

let a = Arc::new(42);
let b = Arc::clone(&a);

std::thread::spawn(move || {
    println!("{}", b);  // b is sent to thread
});

println!("{}", a);  // a remains in main

Key characteristics:

  • Multiple owners, thread-safe

  • Atomic reference counting

  • More overhead than Rc

  • Must be used with Mutex or RwLock for mutable access

When to use Arc:

  • Shared ownership in multi-threaded code

  • Sending data between threads

  • Concurrent applications

RefCell — Interior Mutability at Runtime

RefCell allows you to mutate data through an immutable reference—but with runtime checks instead of compile-time checks.

use std::cell::RefCell;

let value = RefCell::new(5);
*value.borrow_mut() = 10;  // Mutate through immutable reference
println!("{}", value.borrow());  // Read through immutable reference

Key characteristics:

  • Mutate through immutable references (interior mutability)

  • Runtime borrow checking (panics if violated)

  • Single-threaded only

  • Useful for shared mutable state

When to use RefCell:

  • When you need mutability but can't use &mut

  • Observer patterns (where observers need to record state)

  • Caching (immutable interface, mutable cache inside)

Cell — Copy Interior Mutability

Cell is like RefCell but simpler—it requires T: Copy and doesn't support borrow_mut().

use std::cell::Cell;

let value = Cell::new(5);
value.set(10);
let x = value.get();

When to use Cell:

  • Simple value types that implement Copy

  • When you don't need borrow_mut()

Code Examples

Example 1: Box for Recursive Structures

#[derive(Debug)]
enum List {
    Cons(i32, Box<List>),
    Nil,
}

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

Explanation: Without Box, this enum couldn't exist. Box breaks the infinite recursion because it's a fixed-size pointer.

Expected Behavior: Prints the linked list structure.

Common Mistake: Trying to use a recursive type without Box:

enum List {
    Cons(i32, List),  // ✗ ERROR - infinite size
    Nil,
}

Best Practice: Always use Box for recursive data structures.

Example 2: Rc for Shared Ownership

use std::rc::Rc;

#[derive(Debug)]
struct Node {
    value: i32,
    next: Option<Rc<Node>>,
}

fn main() {
    let node3 = Rc::new(Node { value: 3, next: None });
    let node2 = Rc::new(Node { value: 2, next: Some(Rc::clone(&node3)) });
    let node1 = Rc::new(Node { value: 1, next: Some(Rc::clone(&node2)) });
    
    println!("{:?}", node1);  // All nodes shared
}

Explanation: Multiple nodes can reference the same downstream node without taking ownership.

Expected Behavior: Prints the node structure with shared references.

Best Practice: Use Rc::clone() instead of .clone() to show you're incrementing the reference count.

Example 3: Arc for Multithreaded Sharing

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

fn main() {
    let data = Arc::new(vec![1, 2, 3, 4, 5]);
    
    let mut handles = vec![];
    
    for i in 0..3 {
        let data_clone = Arc::clone(&data);
        let handle = thread::spawn(move || {
            println!("Thread {}: {:?}", i, data_clone);
        });
        handles.push(handle);
    }
    
    for handle in handles {
        handle.join().unwrap();
    }
}

Explanation: Arc safely shares data between threads. Each thread gets its own Arc, incrementing the reference count.

Expected Behavior: All three threads print the same vector.

Best Practice: Use Arc::clone() when sending data to threads.

Example 4: RefCell for Interior Mutability

use std::cell::RefCell;

#[derive(Debug)]
struct Observer {
    name: String,
    messages: RefCell<Vec<String>>,
}

fn notify_observer(observer: &Observer, message: String) {
    observer.messages.borrow_mut().push(message);  // Mutate!
}

fn main() {
    let observer = Observer {
        name: "Alice".into(),
        messages: RefCell::new(vec![]),
    };
    
    notify_observer(&observer, "Hello".into());
    notify_observer(&observer, "World".into());
    
    println!("{:?}", observer.messages.borrow());
    // [Hello, World]
}

Explanation: RefCell allows mutation through an immutable reference to the observer.

Expected Behavior: Prints "["Hello", "World"]".

Common Mistake: Holding two mutable borrows:

let x = RefCell::new(5);
let mut a = x.borrow_mut();
let mut b = x.borrow_mut();  // ✗ PANIC at runtime!

Best Practice: Keep borrow_mut() scopes small.

Example 5: Combining Smart Pointers

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

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

fn main() {
    let node3 = Rc::new(RefCell::new(Node { value: 3, next: None }));
    let node2 = Rc::new(RefCell::new(Node { value: 2, next: Some(Rc::clone(&node3)) }));
    
    // Mutate through shared reference
    node2.borrow_mut().value = 20;
    
    println!("{:?}", node2);  // Value is now 20
}

Explanation: Rc<RefCell<T>> allows shared mutable ownership in single-threaded code.

Expected Behavior: Prints the modified node with value 20.

Best Practice: For multithreaded mutable sharing, use Arc<Mutex<T>> instead.

Common Errors

Error 1: Rc in Multithreaded Code

use std::rc::Rc;
use std::thread;

let data = Rc::new(vec![1, 2, 3]);
let data_clone = Rc::clone(&data);

thread::spawn(move || {  // ✗ ERROR
    println!("{:?}", data_clone);
});

Compiler Error:

error[E0277]: `Rc<Vec<i32>>` cannot be sent between threads safely

Why: Rc uses non-atomic operations, not thread-safe.

How to Fix: Use Arc instead:

use std::sync::Arc;

let data = Arc::new(vec![1, 2, 3]);
let data_clone = Arc::clone(&data);

thread::spawn(move || {
    println!("{:?}", data_clone);  // ✓ Works
});

Error 2: RefCell Runtime Panic

let x = RefCell::new(5);
let mut a = x.borrow_mut();
let mut b = x.borrow_mut();  // ✗ PANIC!

Runtime Error:

thread 'main' panicked at 'already mutably borrowed: BorrowError'

Why: RefCell enforces borrow rules at runtime. Two mutable borrows panic.

How to Fix: Drop the first borrow before creating the second:

let x = RefCell::new(5);
{
    let mut a = x.borrow_mut();
    *a = 10;
}  // a is dropped
let mut b = x.borrow_mut();  // ✓ Now allowed
*b = 20;

Error 3: Circular References with Rc

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

#[derive(Debug)]
struct Node {
    next: Option<Rc<RefCell<Node>>>,
}

fn main() {
    let a = Rc::new(RefCell::new(Node { next: None }));
    let b = Rc::new(RefCell::new(Node { next: Some(Rc::clone(&a)) }));
    
    // Create circular reference
    a.borrow_mut().next = Some(Rc::clone(&b));
    // Memory leak! Reference count never reaches 0
}

Why: Both nodes hold references to each other. Neither can be dropped.

How to Fix: Use Weak<T>:

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

#[derive(Debug)]
struct Node {
    next: Option<Rc<RefCell<Node>>>,
    prev: Option<Weak<RefCell<Node>>>,
}

Performance & Memory Insights

Reference Counting Overhead

Rc and Arc add runtime overhead:

// Regular value - no overhead
let x = 42;

// Rc - tracks reference count
let r = Rc::new(42);  // Extra memory for count

Each Rc or Arc allocates extra memory for the reference count. For small types (like integers), the overhead is significant percentage-wise.

Box is Zero-Cost

Box has no runtime overhead beyond pointer indirection:

let b = Box::new(42);  // Just a heap pointer, no extra metadata

Arc Atomicity Cost

Arc is slower than Rc because atomic operations are more expensive:

let a = Arc::new(data);  // Atomic increment/decrement
let r = Rc::new(data);   // Non-atomic increment/decrement (faster)

Use Rc in single-threaded code, Arc in multithreaded code.

Real-World Use Cases

Use Case 1: DOM-like Tree Structures

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

struct Element {
    tag: String,
    children: RefCell<Vec<Rc<Element>>>,
}

// Multiple parents can reference same element
// Mutate children through shared references

Use Case 2: Async Shared State

use std::sync::Arc;
use tokio::sync::Mutex;

let shared_state = Arc::new(Mutex::new(AppState::new()));
let state_clone = Arc::clone(&shared_state);

tokio::spawn(async move {
    let mut state = state_clone.lock().await;
    state.update();
});

Use Case 3: Caching with Interior Mutability

use std::cell::RefCell;
use std::collections::HashMap;

struct Cache<'a> {
    data: &'a HashMap<String, String>,
    results: RefCell<HashMap<String, String>>,
}

impl<'a> Cache<'a> {
    fn get(&self, key: &str) -> String {
        if let Some(cached) = self.results.borrow().get(key) {
            return cached.clone();
        }
        
        let result = self.compute(key);
        self.results.borrow_mut().insert(key.into(), result.clone());
        result
    }
}

Best Practices

1. Box for Single Ownership

// ✓ Good - clear sole ownership
fn process(data: Box<Vec<i32>>) {
    // Process owned data
}

// ✗ Avoid unless necessary
fn process(data: &Vec<i32>) {
    // Borrow if you don't need ownership
}

2. Use Rc in Single-Threaded, Arc in Multithreaded

// Single-threaded
use std::rc::Rc;
let data = Rc::new(shared_data);

// Multithreaded
use std::sync::Arc;
let data = Arc::new(shared_data);

3. Rc::clone() Over .clone()

let a = Rc::new(42);
let b = Rc::clone(&a);  // ✓ Clear intent: incrementing ref count
let c = a.clone();      // Less clear

4. Use Weak to Break Cycles

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

struct Node {
    value: i32,
    parent: Option<Weak<Node>>,
    children: Vec<Rc<Node>>,
}

Beginner Mistakes

Mistake 1: Overusing Smart Pointers

// ✗ Unnecessary Box
fn add(a: Box<i32>, b: Box<i32>) -> Box<i32> {
    Box::new(a + b)
}

// ✓ Just use regular values
fn add(a: i32, b: i32) -> i32 {
    a + b
}

Smart pointers solve specific problems. Don't use them by default.

Mistake 2: Thinking Arc Enables Mutations

let a = Arc::new(vec![1, 2, 3]);
a.push(4);  // ✗ ERROR - Arc doesn't give mutable access

Arc is for shared immutable access. For mutable sharing, use Arc<Mutex<T>>.

Mistake 3: Creating Circular References

// Creates memory leak
a.next = Some(Rc::clone(&b));
b.prev = Some(Rc::clone(&a));  // Circular - never drops

Use Weak for parent pointers in parent-child relationships.

Advanced Insights

Weak References

Weak<T> is a non-owning reference. It doesn't prevent data from being dropped.

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

let a = Rc::new(42);
let w = Rc::downgrade(&a);  // Create weak reference

if let Some(a_ref) = w.upgrade() {
    println!("{}", a_ref);  // Use if still alive
}

Custom Drop Implementations

Smart pointers can implement custom cleanup:

impl Drop for MySmartPointer {
    fn drop(&mut self) {
        // Custom cleanup logic
    }
}

Conclusion

Smart pointers solve specific ownership problems. The key is knowing which one to use:

  • Box for sole heap ownership

  • Rc for shared ownership (single-threaded)

  • Arc for shared ownership (multithreaded)

  • RefCell for interior mutability (single-threaded)

  • Cell for copy interior mutability

Key Takeaways:

  • Smart pointers enable patterns beyond basic ownership

  • Each has specific use cases and trade-offs

  • Combine them for powerful patterns (Rc<RefCell<T>>, Arc<Mutex<T>>)

  • Don't overuse them simple ownership is often best

Next Steps:

  • Build recursive data structures with Box

  • Experiment with shared ownership using Rc

  • Study thread-safe patterns with Arc

  • Review production code using these patterns

Smart pointers are where Rust transitions from "simple" to "powerful." Master them, and you'll handle complex ownership scenarios with confidence.