Rust’s smart pointers aren’t just about memory management; they’re fundamentally about controlling how data is shared and mutated, often in ways that C++ programmers find surprisingly flexible.

Let’s see Box in action. Imagine you have a recursive data structure like a linked list or an expression tree. You can’t define it directly because its size would be infinite. Box solves this by putting the data on the heap and storing a pointer to it on the stack.

// A simple linked list node
struct Node {
    value: i32,
    next: Option<Box<Node>>, // Box allows recursive types
}

impl Node {
    fn new(value: i32) -> Self {
        Node { value, next: None }
    }
}

fn main() {
    let mut head = Node::new(1);
    let second = Node::new(2);
    head.next = Some(Box::new(second)); // Box allocates 'second' on the heap

    println!("Head value: {}", head.value);
    if let Some(ref next_node) = head.next {
        println!("Next value: {}", next_node.value);
    }
}

In this main function, head.next = Some(Box::new(second)); takes ownership of second, moves it to the heap, and Box stores a pointer to it. This is single ownership with heap allocation. Box is the simplest smart pointer, primarily for heap allocation and preventing infinite type sizes.

Now, what if you need to share data between multiple parts of your program, but still want to enforce single ownership at any given moment? This is where Rc (Reference Counting) comes in. Rc allows multiple owners of the same data, but the data is only deallocated when the last owner goes out of scope.

Consider a scenario where you have a configuration object that needs to be accessed by several different modules, but you don’t want to clone the entire configuration each time.

use std::rc::Rc;

#[derive(Debug)]
struct Config {
    setting: String,
}

fn process_data(config: Rc<Config>) {
    println!("Processing with config: {}", config.setting);
    // 'config' can be cloned cheaply here, incrementing the reference count
    let another_ref = Rc::clone(&config);
    println!("Another ref sees config: {}", another_ref.setting);
}

fn main() {
    let shared_config = Rc::new(Config {
        setting: "production".to_string(),
    });

    println!("Initial count: {}", Rc::strong_count(&shared_config)); // Output: 1

    let config_for_module1 = Rc::clone(&shared_config);
    println!("Count after module1: {}", Rc::strong_count(&shared_config)); // Output: 2

    process_data(Rc::clone(&shared_config)); // Pass a clone to the function
    println!("Count after process_data call: {}", Rc::strong_count(&shared_config)); // Output: 2 (one for shared_config, one for config_for_module1)

    // When config_for_module1 goes out of scope here, the count decrements.
    // When shared_config goes out of scope here, the count decrements to 0, and Config is dropped.
}

Rc::new() creates the object on the heap and returns an Rc. Rc::clone() doesn’t copy the data; it just increments an internal reference count and returns a new Rc pointer. When an Rc goes out of scope, its destructor decrements the count. When the count reaches zero, the data is deallocated. This is crucial for graph-like data structures where nodes might be referenced from multiple places.

Rc is great for single-threaded scenarios. But what about concurrent programming? That’s where Arc (Atomic Reference Counting) shines. Arc is the thread-safe equivalent of Rc. It uses atomic operations for its reference counting, which are slower than Rc’s but safe to use across threads.

Imagine you have a shared cache that multiple threads need to read from.

use std::sync::Arc;
use std::thread;
use std::time::Duration;

#[derive(Debug)]
struct Cache {
    data: Vec<String>,
}

fn main() {
    let shared_cache = Arc::new(Cache {
        data: vec!["item1".to_string(), "item2".to_string()],
    });

    let mut handles = vec![];

    for i in 0..3 {
        let cache_clone = Arc::clone(&shared_cache); // Clone Arc for each thread
        let handle = thread::spawn(move || {
            println!("Thread {} sees cache: {:?}", i, cache_clone.data);
            thread::sleep(Duration::from_millis(100)); // Simulate work
        });
        handles.push(handle);
    }

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

    println!("All threads finished.");
    // shared_cache goes out of scope, Arc decrements count.
}

Here, Arc::clone() is used to give each thread its own reference to the Cache. The atomic reference counting ensures that the Cache remains valid as long as any thread is using it, and is deallocated only when all threads have finished and their Arc clones have gone out of scope.

Finally, we have Cell and its variants (RefCell). These are for interior mutability within a single thread. They allow you to mutate data even if you only have an immutable reference (&T). Cell is for types that implement the Copy trait, while RefCell is for types that don’t.

Consider a scenario where you have a struct that you want to be able to modify a field of, even when you only have an immutable reference to the struct itself. This is often seen in GUI frameworks or event loops.

use std::cell::Cell;

struct Counter {
    value: Cell<i32>, // Use Cell for Copy types
}

impl Counter {
    fn increment(&self) {
        let current = self.value.get(); // Get the current value
        self.value.set(current + 1);    // Set the new value
    }

    fn get_value(&self) -> i32 {
        self.value.get()
    }
}

fn main() {
    let counter = Counter { value: Cell::new(0) };

    let immutable_ref_to_counter = &counter; // We only have an immutable reference

    immutable_ref_to_counter.increment(); // Mutate through immutable reference!
    immutable_ref_to_counter.increment();

    println!("Final counter value: {}", immutable_ref_to_counter.get_value()); // Output: 2
}

The magic here is that Cell::get() and Cell::set() allow mutation even through an &self reference. This is because Cell internally uses unsafe code to bypass Rust’s borrowing rules, but it does so in a way that is safe as long as there’s only one thread. If you try to use RefCell across threads, you’ll get a panic at runtime.

The key takeaway is that Box is for heap allocation, Rc for shared ownership in a single thread, Arc for shared ownership across threads, and Cell/RefCell for interior mutability within a single thread.

Want structured learning?

Take the full Rust course →