Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Chapter 19: Smart Pointers

Memory management is a critical aspect of systems programming. C programmers are accustomed to managing memory manually using raw pointers (*T) and functions like malloc() and free(). This approach offers fine-grained control but is notoriously prone to errors like memory leaks, double frees, and use-after-free bugs.

Rust takes a different approach. It strongly encourages stack allocation and employs compile-time-checked references (&T, &mut T) for borrowing data. These references ensure memory safety for many common patterns without requiring manual deallocation. However, certain scenarios require more explicit control over memory allocation, ownership strategies, and lifetime management, particularly when dealing with heap data or shared access. This is where Rust’s smart pointers come into play. In fact, even familiar types like String and Vec<T> are internally implemented as smart pointers, managing heap-allocated memory for dynamic arrays and strings respectively, and automatically deallocating them when they go out of scope.

Smart pointers in Rust are typically structs that wrap some form of pointer (often a raw pointer internally) but provide enhanced behavior and guarantees. They own the data they point to and manage its lifecycle, most notably by automatically handling deallocation when the smart pointer goes out of scope (via the Drop trait). They integrate seamlessly with Rust’s ownership and borrowing rules, providing memory safety guarantees.

This chapter introduces the most common smart pointers in the Rust standard library, explores their use cases, and contrasts them with memory management techniques in C and C++. We will see how they help prevent the memory safety issues endemic to manual memory management while providing necessary flexibility.


19.1 The Concept of Smart Pointers

At its core, a pointer is simply a variable holding a memory address. C relies heavily on raw pointers, requiring meticulous manual management. Rust, in contrast, primarily uses references (&T for shared access, &mut T for exclusive mutable access). References borrow data temporarily without owning it and do not manage memory allocation or deallocation. The Rust compiler statically verifies references to prevent common issues like dangling pointers by ensuring they never outlive the data they refer to.

A smart pointer differs fundamentally because it owns the data it points to (usually on the heap). This ownership implies several key characteristics:

  1. Resource Management: The smart pointer is responsible for cleaning up the resource it manages (typically freeing memory) when it is no longer needed. In Rust, this cleanup happens automatically when the smart pointer goes out of scope, thanks to the Drop trait.
  2. Abstraction: They abstract away the need for manual deallocation calls (like free()). In safe Rust, you generally cannot manually free memory managed by standard smart pointers.
  3. Enhanced Behavior: Many smart pointers add capabilities beyond basic pointing, such as reference counting (Rc<T>, Arc<T>) or enforcing borrowing rules at runtime (RefCell<T>).
  4. Pointer-Like Behavior: They typically implement the Deref and DerefMut traits, allowing instances of smart pointers to be treated like regular references (&T or &mut T) in many contexts (e.g., using the * operator or method calls via automatic dereferencing).

While safe Rust discourages direct manipulation of raw pointers (*const T, *mut T), smart pointers provide high-level, safe abstractions that offer the flexibility needed for heap allocation, shared ownership, and other advanced patterns, all while upholding Rust’s memory safety principles.

19.1.1 The Deref Trait for Pointer Behavior

Smart pointers in Rust, despite being structs, often behave like regular references. This “pointer-like” behavior is enabled by the Deref trait, found in std::ops. By implementing Deref for a custom type, you define how the dereference operator (*) behaves on instances of that type, allowing them to be treated as if they were references to their inner value.

The Deref trait requires a single method: deref. This method takes an immutable reference to self (&self) and returns an immutable reference to the inner data (&Self::Target).

#![allow(unused)]
fn main() {
use std::ops::Deref;
struct MyBox<T>(T); // A simple tuple struct acting as a minimal Box equivalent

impl<T> MyBox<T> {
    fn new(x: T) -> MyBox<T> {
        MyBox(x)
    }
}

impl<T> Deref for MyBox<T> {
    type Target = T; // Associated type: what we dereference to

    fn deref(&self) -> &Self::Target {
        &self.0 // Return a reference to the inner value
    }
}
}

In the example above, MyBox<T> is a custom smart pointer that simply wraps a value. By implementing Deref, we enable the * operator on MyBox instances. For example, if let y = MyBox::new(5);, then *y would yield 5, just as it would for a regular reference let y_ref = &5;.

Rust’s compiler transparently applies Deref::deref when the dereference operator * is used on a type that implements Deref. This means *my_box is desugared to *(my_box.deref()). This seamless integration is why smart pointers like Box<T> can be used almost interchangeably with references in many contexts.

Deref Coercions

A powerful consequence of the Deref trait is deref coercion. This feature allows Rust to automatically convert a reference to a type that implements Deref into a reference to the type it dereferences to. This occurs implicitly in function and method calls where the expected parameter type does not exactly match the provided argument type, but the argument’s type implements Deref to the expected type.

For instance, &String can be coerced to &str because String implements Deref<Target = str>. Similarly, &Box<i32> can be coerced to &i32. This reduces the need for explicit dereferencing or type conversions, making Rust code more ergonomic.

use std::ops::Deref;
struct MyBox<T>(T);
impl<T> MyBox<T> { fn new(x: T) -> MyBox<T> { MyBox(x) } }
impl<T> Deref for MyBox<T> { type Target = T; fn deref(&self) -> &Self::Target { &self.0 } }
fn hello(name: &str) {
    println!("Hello, {name}!");
}

fn main() {
let m = MyBox::new(String::from("Rust"));
hello(&m); // &MyBox<String> is coerced to &String, then to &str
}

In this example, &m is of type &MyBox<String>. Due to MyBox implementing Deref to String, and String implementing Deref to str, Rust can automatically chain these deref coercions to convert &MyBox<String> to &String, and then to &str, matching the hello function’s parameter.

For mutable contexts, the DerefMut trait provides similar functionality for mutable dereferencing, allowing &mut T to be coerced to &mut U if T implements DerefMut<Target=U>. A mutable reference &mut T can also coerce to an immutable &U if T implements Deref<Target=U>, but the reverse (immutable to mutable) is not permitted due to Rust’s borrowing rules.

19.1.2 The Drop Trait for Resource Cleanup

While Deref handles how smart pointers behave during their lifetime, the Drop trait defines what happens when a value is no longer needed. The Drop trait allows you to customize the cleanup logic that executes automatically when a value goes out of scope. This is Rust’s implementation of the RAII (Resource Acquisition Is Initialization) pattern, crucial for memory safety and resource management.

The Drop trait requires implementing a single method: drop. This method takes a mutable reference to self (&mut self).

struct CustomSmartPointer {
    data: String,
}

impl Drop for CustomSmartPointer {
    fn drop(&mut self) {
        println!("Dropping CustomSmartPointer with data `{}`!", self.data);
    }
}

fn main() {
let c = CustomSmartPointer { data: String::from("my stuff") };
let d = CustomSmartPointer { data: String::from("other stuff") };
println!("CustomSmartPointers created.");
// c and d will be dropped automatically when they go out of scope.
// d is dropped before c, due to reverse order of creation.
}

In this example, when c and d go out of scope at the end of main, their respective drop methods are automatically called by the Rust compiler. This mechanism is how Box<T> deallocates heap memory, how File handles close file descriptors, and how other smart pointers manage their specific resources without explicit manual calls.

A key aspect of Drop is that you cannot explicitly call the drop method yourself. Doing so would lead to a compile-time error, as Rust’s ownership system ensures drop is called exactly once when a value is no longer used. If you need to force a value to be cleaned up earlier than its natural scope end, you must use the std::mem::drop function (note: drop is a function in std::mem, not the trait method). This function takes ownership of the value, causing it to be dropped immediately.

struct CustomSmartPointer { data: String }
impl Drop for CustomSmartPointer { fn drop(&mut self) { println!("Dropping CustomSmartPointer with data `{}`!", self.data); } }
fn main() {
let c = CustomSmartPointer { data: String::from("some data") };
println!("CustomSmartPointer created.");
std::mem::drop(c); // Force c to be dropped now
println!("CustomSmartPointer dropped before the end of main.");
}

The Deref and Drop traits together form the bedrock of Rust’s smart pointer design, enabling safe, automatic resource management and ergonomic pointer-like interactions, without the pitfalls of manual memory handling or the overhead of a garbage collector.

19.1.3 When Are Smart Pointers Necessary?

Many Rust programs operate effectively using stack-allocated data, references, and standard library collections like Vec<T> or String (which manage their own heap memory internally). However, explicit use of smart pointers becomes necessary in scenarios like:

  1. Explicit Heap Allocation: When you need direct control over placing data on the heap, perhaps for large objects or types whose size cannot be known at compile time.
  2. Shared Ownership: When a single piece of data needs to be owned or accessed by multiple independent parts of your program simultaneously (Rc<T> for single-threaded, Arc<T> for multi-threaded).
  3. Interior Mutability: When you need to modify data through a shared (immutable) reference, using controlled mechanisms that ensure safety (often involving runtime checks).
  4. Recursive or Complex Data Structures: Implementing types like linked lists, trees, or graphs where nodes might refer to other nodes, often requiring pointer indirection (Box<T>, Rc<T>) to define the structure and manage ownership.
  5. Breaking Ownership Rules Safely: Situations where the strict compile-time ownership rules are too restrictive, but safety can still be guaranteed through runtime checks or specific pointer semantics (e.g., reference counting).
  6. FFI (Foreign Function Interface): Interacting with C libraries often involves managing raw pointers, and smart pointers (especially Box<T>) can help manage the lifetime of Rust data passed to or received from C code.

If your program doesn’t face these specific requirements, Rust’s default mechanisms for memory and data access might suffice.


19.2 Smart Pointers vs. References

Distinguishing between references and smart pointers is fundamental:

References (&T and &mut T):

  • Borrow: Provide temporary, non-owning access to data owned by someone else.
  • No Memory Management: Do not allocate or deallocate memory.
  • Compile-Time Checked: Validity (lifetime) is checked entirely at compile time.
  • Zero-Cost (Typically): Usually have no runtime overhead compared to using the data directly.

Smart Pointers (e.g., Box<T>, Rc<T>, Arc<T>):

  • Own: Own the data they point to.
  • Manage Lifecycle: Responsible for resource cleanup (e.g., deallocation) via the Drop trait when they go out of scope.
  • May Allocate: Often, but not always, involve heap allocation (Box::new, Rc::new).
  • Add Behavior: Can incorporate features like reference counting, interior mutability checks, etc.
  • Safety Guaranteed: Integrate with Rust’s ownership system, ensuring safety through compile-time or runtime checks.
  • Indirection: Always involve a level of pointer indirection to access the underlying data.
  • Location: The smart pointer struct itself (e.g., the Box<T> instance) typically resides on the stack or within another data structure. The data it points to is often allocated on the heap.

In essence, references are like temporary lenses for viewing data, while smart pointers are wrappers that own and manage data. Both are crucial tools in Rust for writing safe and efficient code.


19.3 Comparison with C and C++ Memory Management

Understanding how Rust’s smart pointers fit into the evolution of memory management helps appreciate their design:

19.3.1 C: Manual Management

  • Mechanism: Raw pointers (*T), malloc(), calloc(), realloc(), free().
  • Control: Maximum control over memory layout and lifetime.
  • Safety: Entirely manual. Highly susceptible to memory leaks, double frees, use-after-free errors, dangling pointers, and buffer overflows. Requires disciplined coding conventions (e.g., documenting pointer ownership).

19.3.2 C++: RAII and Standard Smart Pointers

  • Mechanism: Introduced Resource Acquisition Is Initialization (RAII), where resource lifetimes (like memory) are bound to object lifetimes (stack variables, class members). Standard library provides std::unique_ptr (exclusive ownership), std::shared_ptr (reference-counted shared ownership), std::weak_ptr (non-owning reference for breaking cycles). Move semantics improve ownership transfer.
  • Control: High level of control, automated cleanup via RAII.
  • Safety: Significantly safer than C. unique_ptr prevents many errors. However, shared_ptr can still suffer from reference cycles (leading to leaks), and misuse (e.g., dangling raw pointers obtained from smart pointers) is possible.

19.3.3 Rust: Ownership, Borrowing, and Smart Pointers

  • Mechanism: Builds on RAII (via the Drop trait) but enforces ownership and borrowing rules rigorously at compile time. Smart pointers (Box, Rc, Arc) provide different ownership strategies tightly integrated with the borrow checker. Where compile-time checks are insufficient (e.g., interior mutability), Rust uses types like RefCell that perform runtime checks, panicking on violation rather than allowing undefined behavior.
  • Control: Offers control similar to C++ but with stronger safety guarantees enforced by the compiler. Direct manipulation of raw pointers requires explicit unsafe blocks.
  • Safety: Aims for memory safety comparable to garbage-collected languages but without the typical GC overhead. Prevents most memory errors at compile time. Runtime checks provide a safety net for more complex patterns.

Rust’s approach leverages the type system and compiler to prevent errors that require manual diligence or runtime overhead (like garbage collection) in other languages.


19.4 Box<T>: Simple Heap Allocation

Box<T> is the most basic smart pointer, providing ownership of data allocated on the heap. Conceptually, Box<T> is a simple struct that holds a raw pointer to heap-allocated data of type T.

  • Creation: Box::new(value) allocates memory on the heap, moves value into that memory, and returns a Box<T> instance (which itself usually lives on the stack or in another structure).
  • Ownership: The Box<T> exclusively owns the heap-allocated data. Only one Box<T> points to a given allocation at a time (though ownership can be transferred via moves).
  • Deallocation: When the Box<T> goes out of scope, its Drop implementation is called, which deallocates the heap memory and drops the contained value T.

19.4.1 Key Features of Box<T>

  1. Exclusive Ownership: Ensures only one owner exists, aligning with Rust’s default ownership rules but for heap data.
  2. Heap Allocation: The primary way to explicitly put data on the heap in Rust.
  3. Known Size Pointer: A Box<T> always has the size of a pointer, regardless of the size of T. This is crucial for types whose size isn’t known at compile time (like trait objects) or for recursive types.
  4. Indirection: Provides a level of pointer indirection to access the data.
  5. Deref and DerefMut: Implements these traits. Deref allows a Box<T> to be treated like &T (e.g., using * for immutable access or calling methods via automatic deref coercion: my_box.some_method()). If the Box<T> binding itself is mutable (let mut my_box), DerefMut allows treating it like &mut T, enabling mutation of the heap-allocated value (e.g., *my_box = new_value; or my_box.some_mut_method()). It’s worth noting that the single mut keyword on the binding enables both the standard reassignment of the Box itself (my_box = Box::new(...)) and, thanks to DerefMut, the mutation of the value it points to.
  6. Minimal Overhead: Because Box<T> is essentially just a wrapper around a raw pointer, accessing the data via Box<T> involves the same level of indirection as a C pointer. There’s no additional overhead for the pointer access itself compared to a raw pointer, beyond the initial heap allocation cost.

19.4.2 Use Cases and Trade-Offs

Common Use Cases:

  1. Recursive Data Structures: To define types that need to contain pointers to themselves (e.g., nodes in a list or tree), Box<T> breaks the infinite size calculation at compile time by providing indirection with a known pointer size.
    #![allow(unused)]
    fn main() {
    enum List {
        Cons(i32, Box<List>),
        Nil,
    }
    }
  2. Trait Objects: To store an object implementing a specific trait when the concrete type isn’t known at compile time (dyn Trait). Box<dyn Trait> provides the necessary indirection and owns the unknown-sized object on the heap.
  3. Transferring Large Data: Moving a Box<T> is efficient because it only involves copying the pointer itself (which is small and typically resides on the stack or in a register), not the potentially large data structure located on the heap. This is much faster than moving the entire data structure if it were stack-allocated.
  4. Explicit Heap Placement: To avoid placing large data structures on the stack, preventing potential stack overflows, especially in constrained environments or deep recursion.

Trade-Offs:

  • Indirection Cost: Accessing heap data via a pointer involves an extra memory lookup compared to direct stack access, potentially leading to cache misses and a small performance penalty.
  • Allocation Cost: Heap allocation and deallocation operations are generally slower than stack allocation.

Example:

fn main() {
    let stack_val = 5; // On the stack

    // Allocate an integer on the heap, owned by a mutable Box binding
    let mut boxed_val: Box<i32> = Box::new(stack_val);

    // The 'mut' allows the binding itself to be reassigned:
    // boxed_val = Box::new(7);
    // This would drop the original Box(5) and point to a new Box(7).

    // Access the value using immutable dereferencing
    println!("Initial value on heap: {}", *boxed_val); // Output: 5

    // Mutate the value on the heap via mutable dereferencing
    // This requires `boxed_val` to be declared with `let mut`
    *boxed_val += 10;
    println!("Mutated value on heap: {}", *boxed_val); // Output: 15

    // You can still work with the mutated value directly
    let added_val = *boxed_val + 10;
    println!("Heap value + 10: {}", added_val); // Output: 25

    // Methods defined on i32 (taking &self) can often be called directly
    // on the Box<i32> due to automatic deref coercion.
    println!("Absolute value on heap: {}", boxed_val.abs()); // Output: 15
    // Note: .abs() takes &self, so deref coercion works seamlessly.
    // Methods taking `self` like checked_add would need
    // explicit deref: (*boxed_val).checked_add(10)

    // `boxed_val` goes out of scope here. Its Drop implementation runs,
    // freeing the heap memory.
}

Note: For specific advanced scenarios, particularly involving async code or FFI where data must not be moved in memory after allocation, Pin<Box<T>> is used. This provides guarantees about memory location stability.


19.5 Rc<T>: Single-Threaded Reference Counting

Rust’s default ownership model mandates a single owner. What if you need multiple parts of your program to share ownership of the same piece of data, without copying it, and where lifetimes aren’t easily provable by the borrow checker? Rc<T> (Reference Counted pointer) addresses this for single-threaded scenarios.

Rc<T> manages data allocated on the heap and keeps track of how many Rc<T> pointers actively refer to that data. The data remains allocated as long as the strong reference count is greater than zero.

19.5.1 Why Rc<T>?

  • Enables multiple owners of the same heap-allocated data within a single thread.
  • Useful when the lifetime of shared data cannot be determined statically by the borrow checker.
  • Avoids costly deep copies of data when sharing is needed.

19.5.2 How It Works

  • Allocation: Rc::new(value) allocates memory on the heap large enough to hold both the value (T) and two reference counts (a “strong” count and a “weak” count, see Section 19.8). It initializes the strong count to 1 and the weak count to 1 (representing the allocation itself), moves value into the allocation, and returns the Rc<T> pointer.
  • Cloning: Calling Rc::clone(&rc_ptr) does not clone the underlying data T. Instead, it creates a new Rc<T> pointer pointing to the same heap allocation and increments the strong reference count. This is a cheap operation (typically just updating the count).
  • Dropping: When an Rc<T> pointer goes out of scope, its destructor decrements the strong reference count.
  • Deallocation: If the strong reference count reaches zero, the heap-allocated data (T) is dropped. If the weak count is also zero at this point (or later becomes zero), the memory for the allocation (which held T and the counts) is deallocated.

Important Constraints:

  • Single-Threaded Only: Rc<T> uses non-atomic reference counting. Sharing or cloning it across threads is not safe and will result in a compile-time error (it does not implement the Send or Sync traits). Use Arc<T> for multi-threaded scenarios.
  • Immutability: Rc<T> only provides shared access, meaning you can only get immutable references (&T) to the contained data. To mutate data shared via Rc<T>, you must combine it with an interior mutability type like RefCell<T> (resulting in Rc<RefCell<T>>).

Example:

use std::rc::Rc;

#[derive(Debug)]
struct SharedData { value: i32 }

fn main() {
    // Rc manages SharedData on the heap, along with its reference counts
    let data = Rc::new(SharedData { value: 100 });

    // Rc::strong_count is useful for demonstration/debugging
    println!("Initial strong count: {}", Rc::strong_count(&data)); // Output: 1

    // Create two more pointers sharing ownership by cloning the Rc pointer
    let owner1 = Rc::clone(&data); // Increments strong count
    let owner2 = Rc::clone(&data); // Increments strong count

    println!("Count after two clones: {}", Rc::strong_count(&data)); // Output: 3

    // Access data through any owner (dereferences to &SharedData)
    println!("Data via owner1: {:?}", owner1);
    println!("Data via owner2: {:?}", owner2);
    println!("Data via original: {:?}", data);

    drop(owner1); // owner1 goes out of scope, decrements strong count
    println!("Count after dropping owner1: {}", Rc::strong_count(&data)); // Output: 2

    drop(owner2); // owner2 goes out of scope, decrements strong count
    println!("Count after dropping owner2: {}", Rc::strong_count(&data)); // Output: 1

    // The original `data` goes out of scope here. Strong count becomes 0.
    // SharedData is dropped, weak count is decremented.
    // Since weak count also becomes 0, the heap memory is freed.
}

The function Rc::strong_count(&pointer) provides the current strong reference count. This is primarily useful for debugging, demonstration, or specific resource management checks, but less common in typical application logic.

19.5.3 Limitations and Trade-Offs

  • Runtime Overhead: Incrementing and decrementing the reference count involves a small runtime cost with every clone and drop.
  • No Thread Safety: Restricted to single-threaded use.
  • Reference Cycles: If Rc<T> pointers form a cycle (e.g., A points to B, and B points back to A via Rc), the strong reference count will never reach zero, leading to a memory leak. Weak<T> is needed to break such cycles.

19.6 Interior Mutability: Cell<T>, RefCell<T>, OnceCell<T>

Rust’s borrowing rules are strict: you cannot have mutable access (&mut T) at the same time as any other reference (&T or &mut T) to the same data. This is checked at compile time and prevents data races. However, sometimes this is too restrictive. The interior mutability pattern allows mutation through a shared reference (&T), moving the borrowing rule checks from compile time to runtime or using specific mechanisms for simple types.

These types reside in the std::cell module and are generally intended for single-threaded use cases.

19.6.1 Cell<T>: Simple Value Swapping (for Copy types)

Cell<T> offers interior mutability for types T that implement the Copy trait (primitive types like i32, f64, bool, tuples/arrays of Copy types, and simple structs composed of Copy types).

  • Operations: Provides get() which copies the current value out, and set(value) which replaces the internal value. It also offers replace() and swap().
  • Safety Mechanism: No runtime borrowing checks occur. Safety relies on the Copy nature of T. Since you only ever get copies or replace the value wholesale, you can’t create dangling references to the interior data through the Cell’s API.
  • Overhead: Very low overhead, typically compiles down to simple load/store instructions.

Example:

use std::cell::Cell;

fn main() {
    // `i32` implements Copy
    let shared_counter = Cell::new(0);

    // Can mutate through the shared reference `&shared_counter`
    let current = shared_counter.get();
    shared_counter.set(current + 1);

    shared_counter.set(shared_counter.get() + 1); // Increment again

    println!("Counter value: {}", shared_counter.get()); // Output: 2
}

19.6.2 RefCell<T>: Runtime Borrow Checking

For types that are not Copy, or when you need actual references (&T or &mut T) to the internal data rather than just copying/replacing it, RefCell<T> is the appropriate choice.

  • Mechanism: Enforces Rust’s borrowing rules (one mutable borrow XOR multiple immutable borrows) at runtime. It keeps track of the current borrow state internally.
  • Operations:
    • borrow(): Returns a smart pointer wrapper (Ref<T>) providing immutable access (&T). Increments an internal immutable borrow count. Panics if there’s an active mutable borrow.
    • borrow_mut(): Returns a smart pointer wrapper (RefMut<T>) providing mutable access (&mut T). Marks an internal flag indicating a mutable borrow. Panics if there are any other active borrows (mutable or immutable).
  • Safety Mechanism: Runtime checks. If borrowing rules are violated, the program panics immediately, preventing data corruption or undefined behavior.
  • Overhead: Higher than Cell<T> due to runtime tracking of borrow state (counts/flags).

Example:

use std::cell::RefCell;

fn main() {
    // Vec<i32> is not Copy
    let shared_list = RefCell::new(vec![1, 2, 3]);

    // Get an immutable borrow
    {
        // `borrow()` returns Ref<Vec<i32>>, which derefs to &Vec<i32>
        let list_ref = shared_list.borrow();
        println!("First element: {}", list_ref[0]);
        // list_ref goes out of scope here, releasing the immutable borrow
    }

    // Get a mutable borrow
    {
        // `borrow_mut()` returns RefMut<Vec<i32>>, which derefs to &mut Vec<i32>
        let mut list_mut_ref = shared_list.borrow_mut();
        list_mut_ref.push(4);
        // list_mut_ref goes out of scope here, releasing the mutable borrow
    }

    println!("Current list: {:?}", shared_list.borrow());

    // Example of runtime panic: Uncommenting the lines below would cause a panic
    // let _first_borrow = shared_list.borrow();
    // let _second_borrow_mut = shared_list.borrow_mut();
    // PANIC! Cannot mutably borrow while immutably borrowed.
}

19.6.3 Combining Rc<T> and RefCell<T>

A very common pattern is Rc<RefCell<T>>. This allows multiple owners (Rc) to share access to data that can also be mutated (RefCell) within a single thread, deferring borrow checks to runtime.

Example: Simulating a graph node that can be shared and whose children can be modified.

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

#[derive(Debug)]
struct Node {
    value: i32,
    // Children owned via Rc, but Vec is mutable via RefCell
    children: RefCell<Vec<Rc<Node>>>,
}

fn main() {
    let root = Rc::new(Node {
        value: 10,
        children: RefCell::new(vec![]),
    });

    let child1 = Rc::new(Node { value: 11, children: RefCell::new(vec![]) });
    let child2 = Rc::new(Node { value: 12, children: RefCell::new(vec![]) });

    // Mutate the children Vec through the RefCell, even though `root` is shared via Rc
    // Obtain a mutable borrow of the Vec inside the RefCell
    root.children.borrow_mut().push(Rc::clone(&child1));
    root.children.borrow_mut().push(Rc::clone(&child2));
    // Mutable borrows are released here

    println!("Root node: {:?}", root);
    println!("Child1 strong count: {}", Rc::strong_count(&child1));
    // Output: 2 (root.children + child1 var)
}

std::cell::OnceCell<T> provides a cell that can be written to exactly once. It’s useful for lazy initialization or setting global configuration within a single thread. After the first successful write, subsequent attempts fail silently. get() returns an Option<&T>.

Related types like std::sync::OnceLock (thread-safe) or types in crates like once_cell provide convenient wrappers for computing a value on first access (lazy initialization).

Example (OnceCell):

use std::cell::OnceCell;

fn main() {
    let config: OnceCell<String> = OnceCell::new();

    // Try to get the value before setting - returns None
    assert!(config.get().is_none());

    // Initialize the config
    let result = config.set("Initial Value".to_string());
    assert!(result.is_ok());

    // Try to get the value now - returns Some(&String)
    println!("Config value: {}", config.get().unwrap());

    // Attempting to set again fails (returns Err containing the value we tried to set)
    let result2 = config.set("Second Value".to_string());
    assert!(result2.is_err());
    println!("Config value is still: {}", config.get().unwrap());
    // Remains "Initial Value"
}

Summary of Single-Threaded Interior Mutability:

  • Cell<T>: For Copy types, minimal overhead, use when simple get/set/swap is sufficient.
  • RefCell<T>: For non-Copy types or when references (&T/&mut T) are needed. Enforces borrow rules at runtime (panics on violation). Use when mutation is needed via a shared reference.
  • OnceCell<T>: For write-once, read-many scenarios like lazy initialization.
  • These are not thread-safe. For concurrent scenarios, use their std::sync counterparts (Mutex, RwLock, OnceLock).

19.7 Arc<T>: Thread-Safe Reference Counting

Rc<T> is unsuitable for multi-threaded environments because its reference count updates are not atomic (not protected against race conditions). When you need to share ownership of data across multiple threads, Rust provides Arc<T> (Atomically Reference Counted).

Arc<T> behaves very similarly to Rc<T> but uses atomic operations for incrementing and decrementing the reference count. These operations guarantee correctness even when performed concurrently by multiple threads, albeit with a higher performance cost than Rc’s non-atomic updates.

19.7.1 Arc<T> Basics

  • Provides shared ownership of heap-allocated data usable across threads.
  • Like Rc<T>, Arc::new(value) allocates the value T and its reference counts (strong and weak, both atomic) together on the heap.
  • Arc::clone(&arc_ptr) increments the atomic strong reference count and creates a new pointer to the same data. The cloned Arc can be moved (Send) to another thread.
  • Dropping an Arc<T> atomically decreases the strong count. The data T is dropped and memory deallocated when the strong count reaches zero (and the weak count also reaches zero, see Section 19.8).
  • Requires T to be Send + Sync to allow the Arc<T> itself to be sent between threads and shared immutably. If mutable access across threads is needed, T must be wrapped in a Mutex or RwLock (see below), and T itself inside the lock must be Send.
  • Like Rc<T>, Arc<T> only provides immutable access (&T) to the underlying data via dereferencing.

Example: Sharing immutable data across threads.

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

fn main() {
    // Data wrapped in Arc for thread-safe sharing
    let numbers = Arc::new(vec![10, 20, 30, 40, 50]); // Vec<i32> is Send + Sync
    let mut handles = vec![];

    // Arc::strong_count is useful for demonstration/debugging
    println!("Initial Arc strong count: {}", Arc::strong_count(&numbers)); // Output: 1

    // Spawn multiple threads, each cloning the Arc
    for i in 0..3 {
        let numbers_clone = Arc::clone(&numbers); // Clone Arc, increments atomic count
        let handle = thread::spawn(move || { // `move` takes ownership of numbers_clone
            // Access the shared data immutably from the thread
            println!("Thread {}: Element at index {}: {}", i, i, numbers_clone[i]);
            // numbers_clone dropped here, count decreases atomically
        });
        handles.push(handle);
    }

    // `numbers` still exists in the main thread
    // Count might fluctuate as threads start/finish cloning/dropping
    println!("Count after spawning threads (approx): {}", Arc::strong_count(&numbers));

    // Wait for all threads to complete
    for handle in handles {
        handle.join().unwrap();
    }

    // After all threads finish, only the original `numbers` Arc remains
    println!("Final Arc strong count: {}", Arc::strong_count(&numbers)); // Output: 1
    // `numbers` dropped here, count becomes 0, Vec is dropped, memory freed.
}

19.7.2 Combining Arc<T> with Mutexes/RwLocks for Shared Mutability

Since Arc<T> only grants immutable access, how do you mutate data shared across threads? You combine Arc<T> with a thread-safe interior mutability primitive, typically std::sync::Mutex<T> or std::sync::RwLock<T>.

  • Arc<Mutex<T>>: Allows multiple threads to share ownership (Arc) of a mutex (Mutex) which guards the actual data (T). To access T, a thread must first lock the mutex using the lock() method. This blocks until the lock is acquired, ensuring exclusive access. The lock returns a “lock guard” (e.g., MutexGuard). When the guard goes out of scope, the lock is automatically released.
  • Arc<RwLock<T>>: Similar, but allows multiple concurrent readers (read()) or one exclusive writer (write()). Better performance than Mutex if reads are much more frequent than writes, but potentially more complex regarding lock acquisition fairness or starvation.

Example: Shared counter using Arc<Mutex<T>>

use std::sync::{Arc, Mutex};
use std::thread;
use std::time::Duration; // Not needed for core logic

fn main() {
    // Shared counter: Arc for shared ownership, Mutex for exclusive access for mutation
    // 0u32 is Send because u32 is Send
    let counter = Arc::new(Mutex::new(0u32));
    let mut handles = vec![];

    for i in 0..5 {
        let counter_clone = Arc::clone(&counter);
        let handle = thread::spawn(move || {
            // Lock the mutex to gain exclusive access.
            // .lock() returns Result<MutexGuard<..>, PoisonError<..>>
            // .unwrap() panics if the lock was "poisoned" (a thread panicked
            // while holding it).
            let mut num = counter_clone.lock().unwrap(); // `num` is a MutexGuard<u32>

            // Mutate the data safely (dereferences guard to &mut u32)
            *num += 1;
            println!("Thread {} incremented counter to {}", i, *num);

            // Mutex is automatically unlocked when `num` (the lock guard) goes
            // out of scope here.
        });
        handles.push(handle);
    }

    // Wait for all threads to finish
    for handle in handles {
        handle.join().unwrap();
    }

    // Lock the mutex in the main thread to read the final value
    println!("Final counter value: {}", *counter.lock().unwrap()); // Output: 5
}

While Mutex provides simple exclusive access, RwLock can be more efficient if the data is read much more often than it’s written, because it allows any number of readers to access the data concurrently. Only write access requires exclusivity.

Example: Shared data using Arc<RwLock<T>>

This example simulates multiple threads reading shared data concurrently, while fewer threads occasionally acquire exclusive access to modify it.

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

fn main() {
    // Data shared via Arc, protected by RwLock for read/write access
    // Let's store a simple value, initially 100
    let shared_data = Arc::new(RwLock::new(100));
    let mut handles = vec![];

    println!("Initial data: {}", *shared_data.read().unwrap());

    // --- Spawn multiple reader threads ---
    // These threads can access the data concurrently if no writer holds the lock.
    for i in 0..5 {
        let data_clone = Arc::clone(&shared_data);
        let handle = thread::spawn(move || {
            // Acquire read lock using .read().unwrap()
            // Blocks only if a writer currently holds the lock.
            let data_guard = data_clone.read().unwrap(); // Returns RwLockReadGuard
            println!("Reader {} sees data: {}", i, *data_guard);

            // Simulate some work while holding the read lock
            thread::sleep(Duration::from_millis(50 + (i * 10) as u64));
            // Stagger sleeps

            // Read lock is automatically released when data_guard goes out of scope
            println!("Reader {} finished.", i);
        });
        handles.push(handle);
    }

    // Allow readers to start
    thread::sleep(Duration::from_millis(10));

    // --- Spawn fewer writer threads ---
    // These threads need exclusive access to modify the data.
    for i in 0..2 {
        let data_clone = Arc::clone(&shared_data);
        let handle = thread::spawn(move || {
            // Acquire write lock using .write().unwrap()
            // Blocks if any readers OR another writer holds the lock.
            let mut data_guard = data_clone.write().unwrap();
            // Returns RwLockWriteGuard
            *data_guard += (i + 1) * 10; // Writer 0 adds 10, Writer 1 adds 20
            println!("Writer {} modified data to: {}", i, *data_guard);

            // Simulate some work while holding the write lock
            thread::sleep(Duration::from_millis(100));

            // Write lock is automatically released when data_guard goes out of scope
            println!("Writer {} finished.", i);
        });
        handles.push(handle);
    }

    // Wait for all threads to complete
    for handle in handles {
        handle.join().unwrap();
    }

    // Read the final value (acquires a read lock)
    println!("Final data: {}", *shared_data.read().unwrap());
    // Expected: 100 + 10 (from writer 0) + 20 (from writer 1) = 130
}

This example demonstrates:

  • Using Arc::clone to share the Arc<RwLock<T>>.
  • Acquiring a read lock with read(), allowing multiple threads to potentially hold it concurrently.
  • Acquiring an exclusive write lock with write().
  • The automatic release of locks when the guards (RwLockReadGuard, RwLockWriteGuard) go out of scope.

Arc<T> (often combined with Mutex or RwLock) is fundamental for managing shared state safely and effectively in concurrent Rust programs. It comes with the overhead of atomic operations for reference counting and the potential blocking overhead of acquiring locks.


19.8 Weak<T>: Breaking Reference Cycles

Reference-counted pointers (Rc<T>, Arc<T>) track ownership via a strong reference count. The data stays alive as long as the strong count > 0. This works well unless objects form a reference cycle: Object A holds a strong reference (Rc or Arc) to Object B, and Object B holds a strong reference back to Object A.

In such a cycle, even if all external references to A and B are dropped, A and B still hold strong references to each other. Their strong counts will never reach zero, and their memory will leak – it’s never deallocated because their Drop implementations are never called.

Weak<T> is a companion smart pointer for both Rc<T> and Arc<T> designed specifically to break these cycles. A Weak<T> provides a non-owning reference to data managed by an Rc or Arc.

19.8.1 Strong vs. Weak References

  • Strong Reference (Rc<T> / Arc<T>): Represents ownership. Increments the strong reference count stored alongside the data on the heap. Keeps the data alive.
  • Weak Reference (Weak<T>): Represents a non-owning, temporary reference. Created from an Rc or Arc using Rc::downgrade(&rc_ptr) or Arc::downgrade(&arc_ptr). It increments a separate weak reference count (also stored alongside the data and the strong count on the heap) but does not affect the strong count. Does not keep the data alive by itself.

When an Rc/Arc is dropped, it decrements the strong count. If the strong count becomes zero, the inner value T is dropped. Then, the weak count is decremented (corresponding to the allocation itself). If the weak count also becomes zero, the heap allocation (holding the counts and formerly T) is freed. Weak<T> pointers only keep the allocation alive (containing the counts) until the last Weak<T> is dropped, even if T itself has already been dropped.

By using Weak<T> for references that would otherwise complete a cycle (e.g., a child referencing its parent in a tree where parents strongly own children), you allow the strong counts to drop to zero when external references disappear, enabling proper deallocation of the contained value T.

19.8.2 Accessing Data via Weak<T>

Since a Weak<T> doesn’t own the data, the data might have been deallocated (if the strong count reached zero) while the Weak<T> still exists. Therefore, you cannot access the data directly through a Weak<T>.

To access the data, you must attempt to upgrade the Weak<T> back into a strong reference (Rc<T> or Arc<T>) using the upgrade() method:

  • weak_ptr.upgrade() returns Option<Rc<T>> (or Option<Arc<T>>).
  • If the data is still alive (strong count > 0 when upgrade is called), it atomically increments the strong count and returns Some(strong_ptr). You hold a temporary strong reference.
  • If the data has already been dropped (strong count was 0), it returns None.

This mechanism ensures you only access the data if it’s still valid.

Consider a tree where nodes own their children (Rc), but children need a reference back to their parent. Using Rc for the parent link would create cycles (parent -> child and child -> parent both strong). Weak solves this:

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

#[derive(Debug)]
struct Node {
    value: i32,
    // Parent link uses Weak to avoid cycles
    parent: RefCell<Weak<Node>>, // Weak pointer doesn't own the parent
    // Children links use Rc for ownership
    children: RefCell<Vec<Rc<Node>>>, // Rc owns the children
}

fn main() {
    let leaf = Rc::new(Node {
        value: 3,
        parent: RefCell::new(Weak::new()), // Start with no parent (empty Weak)
        children: RefCell::new(vec![]),
    });

    println!(
        "Leaf initial: strong={}, weak={}",
        Rc::strong_count(&leaf), // Count for `leaf` variable
        Rc::weak_count(&leaf) // Count related to allocation itself + any Weak pointers
    ); // Output: strong=1, weak=1

    let branch = Rc::new(Node {
        value: 5,
        parent: RefCell::new(Weak::new()),
        children: RefCell::new(vec![Rc::clone(&leaf)]), //Branch owns leaf (strong ref)
    });

    println!(
        "Branch initial: strong={}, weak={}",
        Rc::strong_count(&branch),
        Rc::weak_count(&branch)
    ); // Output: strong=1, weak=1
    println!(
        "Leaf counts after branch owns it: strong={}, weak={}",
        Rc::strong_count(&leaf), // Now 2: `leaf` var + `branch.children`
        Rc::weak_count(&leaf)
    ); // Output: strong=2, weak=1

    // Set leaf's parent to point to branch using a weak reference
    // Rc::downgrade creates a Weak<Node> from the Rc<Node>
    *leaf.parent.borrow_mut() = Rc::downgrade(&branch);
    // Increments branch's weak count

    println!(
        "Branch after parent link: strong={}, weak={}",
        Rc::strong_count(&branch), // Strong count unchanged
        Rc::weak_count(&branch)
        // Weak count increments (now 2: allocation + leaf.parent)
    ); // Output: strong=1, weak=2
    println!(
        "Leaf after parent link: strong={}, weak={}",
        Rc::strong_count(&leaf), // Unchanged
        Rc::weak_count(&leaf)
    ); // Output: strong=2, weak=1

    // Access leaf's parent using upgrade()
    if let Some(parent_node) = leaf.parent.borrow().upgrade() {
        // Successfully got a temporary Rc<Node> to the parent
        println!("Leaf's parent value: {}", parent_node.value); // Output: 5
        // `parent_node` (the temporary Rc) drops here, decr. branch's strong count
    } else {
        println!("Leaf's parent has been dropped.");
    }

    // Check counts before dropping branch variable
    println!("Counts before dropping branch var: branch(strong={}, weak={}),
        leaf(strong={}, weak={})",
        Rc::strong_count(&branch), Rc::weak_count(&branch), // branch(1, 2)
        Rc::strong_count(&leaf), Rc::weak_count(&leaf));    // leaf(2, 1)

    drop(branch); // Drop the `branch` variable's strong reference

    println!(
        "Counts after dropping branch var: leaf(strong={}, weak={})",
        Rc::strong_count(&leaf),
        // Leaf strong count drops to 1 (only `leaf` var remains)
        Rc::weak_count(&leaf)    // Leaf weak count remains 1
    ); // Output: leaf(strong=1, weak=1)
    // Note: Branch's strong count became 0, so Node(5) was dropped.
    // Branch's weak count became 1 (due to leaf.parent). Allocation still exists.

    // Try accessing the parent again; Node(5) data should be gone.
    if leaf.parent.borrow().upgrade().is_none() {
        // upgrade() fails because branch's strong count is 0
        println!("Leaf's parent has been dropped (upgrade failed)."); // Should print
    } else {
        println!("Leaf's parent still exists?"); // Should not print
    }

    // leaf drops here, its strong count becomes 0, Node(3) is dropped.
    // Its weak count becomes 0, allocation is freed.
    // When Node(3) drops, its RefCell<Weak<Node>> drops.
    // The Weak pointer to branch drops.
    // Branch's weak count becomes 0, its allocation is freed.
}

By using Weak<Node> for the parent field, the reference cycle is broken, allowing both branch and leaf nodes (and their allocations) to be deallocated correctly when their strong counts reach zero.


19.9 Summary

Rust’s standard library provides a versatile set of smart pointers that extend its core ownership and borrowing system to handle more complex memory management scenarios safely and efficiently:

  • Box<T>: Simple heap allocation with exclusive ownership. Essentially a wrapper around a raw pointer with automatic deallocation (Drop). Minimal overhead for access. Essential for recursive types, trait objects, and controlling data placement.
  • Rc<T>: Single-threaded reference counting for shared ownership. Stores the value and counts on the heap. Rc::clone is cheap (increments count). Provides immutable access only. Not thread-safe.
  • Arc<T>: Thread-safe (atomic) reference counting for shared ownership. Stores the value and atomic counts on the heap. Use Arc::clone to share across threads. Provides immutable access only. Use with Mutex or RwLock for shared mutable state.
  • Interior Mutability (Cell<T>, RefCell<T>, OnceCell<T>): Allow mutating data through shared references (&T) within a single thread. Cell is for Copy types (no runtime checks, simple get/set). RefCell uses runtime borrow checks (panics on violation) for non-Copy types or when references are needed. OnceCell handles write-once initialization. Often combined with Rc<T> (e.g., Rc<RefCell<T>>).
  • Thread-Safe Mutability (Mutex<T>, RwLock<T>): Used with Arc<T> (e.g., Arc<Mutex<T>>) to allow safe mutation of shared data across multiple threads by ensuring exclusive (Mutex) or shared-read/exclusive-write (RwLock) access via locking.
  • Weak<T>: Non-owning pointer derived from Rc<T> or Arc<T>. Does not keep data alive (doesn’t affect strong count). Used to observe data or, critically, to break reference cycles and prevent memory leaks. Access requires upgrade() which returns an Option<Rc<T>> or Option<Arc<T>>.

These tools enable developers to implement complex data structures, manage shared state, and build concurrent applications without sacrificing Rust’s core promise of memory safety. They replace the need for manual memory management found in C and mitigate issues sometimes encountered with C++ smart pointers (like dangling raw pointers or undetected cycles) by integrating deeply with the borrow checker and employing runtime checks or atomic operations where necessary. Choosing the right smart pointer (or combination) for the specific ownership, mutability, and concurrency requirements is key to writing idiomatic and robust Rust code.