Skip to content

Chapter 17

Smart Pointers & Interior Mutability

When ownership, borrowing, and plain references aren't enough. Smart pointers give you heap allocation, shared ownership, runtime borrow checking, and copy-on-write semantics.

Box<T>, Rc<T>, Arc<T>

Box<T> - Heap Allocation

Box<T> puts a value on the heap and gives you an owned pointer to it. The value is dropped when the Box is dropped - same ownership semantics as a stack value, just stored elsewhere.

// Allocate a large struct on the heap instead of the stack
let config = Box::new(ServerConfig {
    port: 8080,
    workers: 4,
    static_root: "/var/www".to_string(),
    // ... many fields
});

// Access through the box - Deref makes it transparent
println!("Port: {}", config.port);

// Box is required for recursive types
enum Route {
    Static(String),
    Nested {
        prefix: String,
        children: Vec<Route>,  // ✓ Vec already heap-allocates
    },
    Fallback(Box<Route>),      // ✓ Box breaks the infinite size
}

You need Box in three main situations:

  • Recursive types - without Box, the compiler can't calculate the size of a type that contains itself.
  • Trait objects - Box<dyn Handler> stores any type implementing Handler. We've used this in our router since Chapter 10.
  • Large data - moving 10 KB on the stack is expensive. Boxing it means moves are just pointer copies.

Rc<T> - Shared Ownership (Single-Threaded)

Rc<T> (Reference Counted) allows multiple owners of the same data. Each Rc::clone increments a counter. When the last Rc is dropped, the data is freed:

use std::rc::Rc;

let shared_config = Rc::new(ServerConfig {
    port: 8080,
    workers: 4,
    static_root: "/var/www".to_string(),
});

let config_for_router = Rc::clone(&shared_config);   // refcount: 2
let config_for_logger = Rc::clone(&shared_config);    // refcount: 3

println!("References: {}", Rc::strong_count(&shared_config)); // 3

drop(config_for_logger);  // refcount: 2
// Data is freed when the last Rc is dropped
Warning
Rc is not thread-safe. Its reference count is a plain integer, not an atomic. The compiler enforces this - Rc does not implement Send, so you can't pass it to another thread. For multi-threaded sharing, use Arc.

Arc<T> - Shared Ownership (Multi-Threaded)

Arc<T> (Atomic Reference Counted) is the thread-safe version of Rc. It uses atomic operations for the reference count, making it safe to share across threads:

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

let config = Arc::new(ServerConfig { port: 8080, /* ... */ });

let handles: Vec<_> = (0..4).map(|i| {
    let config = Arc::clone(&config);
    thread::spawn(move || {
        println!("Worker {} using port {}", i, config.port);
    })
}).collect();

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

Choosing Between Them

TypeOwnersThread-safeCost
Box<T>OneYes (if T: Send)Heap allocation only
Rc<T>ManyNoHeap + refcount increment
Arc<T>ManyYesHeap + atomic refcount

RefCell<T> and Interior Mutability

Rust's borrowing rules are checked at compile time: either many &T or one &mut T. But sometimes you need to mutate data behind an immutable reference.RefCell<T> moves the borrow checking to runtime.

use std::cell::RefCell;

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

// Immutable borrow - like &T
{
    let view = data.borrow();
    println!("Length: {}", view.len());
} // borrow ends here

// Mutable borrow - like &mut T
{
    let mut writer = data.borrow_mut();
    writer.push(4);
}

// These rules are checked at RUNTIME:
// - Multiple borrow() calls: OK
// - One borrow_mut() call: OK
// - borrow() and borrow_mut() at the same time: PANICS
Warning
RefCell panics at runtime if you violate borrowing rules. It doesn't prevent bugs - it just defers the check. Use it only when you can't satisfy the compile-time rules. If your code panics on a borrow_mut(), the fix is usually to restructure the code so borrows don't overlap.

RefCell is single-threaded (like Rc). For multi-threaded interior mutability, use Mutex or RwLock - which we covered in Chapter 12.

The Rc + RefCell Pattern

Rc<RefCell<T>> gives you shared, mutable data in single-threaded code. It's the closest Rust gets to a garbage-collected mutable object:

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

// Shared mutable request log (single-threaded)
let log: Rc<RefCell<Vec<String>>> = Rc::new(RefCell::new(vec![]));

let log_for_middleware = Rc::clone(&log);
let log_for_handler = Rc::clone(&log);

// Middleware writes to the log
log_for_middleware.borrow_mut().push("request received".to_string());

// Handler also writes to the log
log_for_handler.borrow_mut().push("response sent".to_string());

// Read the final log
println!("{:?}", log.borrow());
// ["request received", "response sent"]
PatternThreadsBorrow check
Rc<RefCell<T>>SingleRuntime (panics)
Arc<Mutex<T>>MultiRuntime (blocks)
Arc<RwLock<T>>MultiRuntime (blocks, many readers OK)

Cow<T> - Clone on Write

Cow<'a, T> (Clone on Write) is an enum that holds either a borrowed reference or an owned value. It defers cloning until mutation is needed - and if no mutation happens, the clone never occurs:

use std::borrow::Cow;

// Cow is defined as:
// enum Cow<'a, B: ToOwned + ?Sized> {
//     Borrowed(&'a B),
//     Owned(B::Owned),
// }

/// Normalize a path - only allocates if the path needs changing.
fn normalize_path(path: &str) -> Cow<'_, str> {
    if path.ends_with('/') && path.len() > 1 {
        // Need to modify - create an owned String
        Cow::Owned(path.trim_end_matches('/').to_string())
    } else {
        // No modification needed - just borrow the original
        Cow::Borrowed(path)
    }
}

let a = normalize_path("/api/users");   // Cow::Borrowed - no allocation
let b = normalize_path("/api/users/");  // Cow::Owned - allocated a new String

// Both can be used as &str
println!("{} {}", a, b);

Cow is perfect for functions that usually return the input unchanged but sometimes need to transform it. In a webserver, this applies to:

  • Path normalization - most paths don't need changing.
  • URL decoding - most URLs don't have encoded characters.
  • Header canonicalization - most headers are already lowercase.
/// URL-decode a string. Only allocates if there's something to decode.
fn url_decode(input: &str) -> Cow<'_, str> {
    if !input.contains('%') {
        return Cow::Borrowed(input); // fast path - nothing to decode
    }

    let mut result = String::with_capacity(input.len());
    let mut chars = input.chars();

    while let Some(c) = chars.next() {
        if c == '%' {
            let hex: String = chars.by_ref().take(2).collect();
            if let Ok(byte) = u8::from_str_radix(&hex, 16) {
                result.push(byte as char);
            } else {
                result.push('%');
                result.push_str(&hex);
            }
        } else if c == '+' {
            result.push(' ');
        } else {
            result.push(c);
        }
    }

    Cow::Owned(result)
}

let a = url_decode("hello");          // Borrowed - no %
let b = url_decode("hello%20world");  // Owned - decoded to "hello world"
Tip
Cow implements Deref, so you can use it anywhere a &str or &[u8] is expected. It also implements Into<String> and Display, making it interchangeable with both borrowed and owned strings.

Applying Smart Pointers: Shared Server Configuration

Let's bring it together with a practical pattern: server-wide configuration that's built once and shared immutably across all connections, plus runtime statistics that require interior mutability:

src/server.rs
use std::sync::{Arc, RwLock, atomic::{AtomicU64, Ordering}};
use std::collections::HashMap;

/// Immutable server configuration - read by all threads, never changes.
pub struct Config {
    pub port: u16,
    pub static_root: String,
    pub max_body_size: usize,
    pub default_headers: Vec<(String, String)>,
}

/// Runtime statistics - updated by many threads concurrently.
pub struct Stats {
    pub requests_total: AtomicU64,
    pub requests_by_path: RwLock<HashMap<String, u64>>,
}

impl Stats {
    pub fn new() -> Self {
        Stats {
            requests_total: AtomicU64::new(0),
            requests_by_path: RwLock::new(HashMap::new()),
        }
    }

    pub fn record_request(&self, path: &str) {
        // Atomic - no lock needed
        self.requests_total.fetch_add(1, Ordering::Relaxed);

        // RwLock - many readers, one writer
        let mut map = self.requests_by_path.write().unwrap();
        *map.entry(path.to_string()).or_insert(0) += 1;
    }

    pub fn total(&self) -> u64 {
        self.requests_total.load(Ordering::Relaxed)
    }

    pub fn snapshot(&self) -> HashMap<String, u64> {
        self.requests_by_path.read().unwrap().clone()
    }
}

/// Everything a connection handler needs, bundled in one Arc.
pub struct ServerState {
    pub config: Config,
    pub stats: Stats,
    pub router: crate::router::Router,
}

// In main():
// let state = Arc::new(ServerState {
//     config: Config { port: 8080, ... },
//     stats: Stats::new(),
//     router: build_router(),
// });
//
// Each connection gets Arc::clone(&state) - one pointer,
// access to everything.

The design uses each smart pointer for its strength:

  • Arc<ServerState> - shared ownership across threads. Each connection task clones the Arc.
  • AtomicU64 - lock-free counter for total requests. No mutex needed for simple incrementing.
  • RwLock<HashMap> - many readers (stats page) or one writer (recording a request). Better than Mutex when reads vastly outnumber writes.
  • Config has no wrapper - it's immutable after construction. Arc alone provides shared read access.
Note
RwLock vs Mutex: use RwLock when you have many concurrent readers and infrequent writers. Use Mutex when writes are frequent or the critical section is very short (where RwLock's overhead isn't worth it).

Reference Cycles and Weak<T>

Rc and Arc have a flaw: if two values reference each other, their reference counts never reach zero and the memory is never freed. This is a reference cycle- a memory leak:

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

struct Node {
    value: String,
    next: RefCell<Option<Rc<Node>>>,
}

let a = Rc::new(Node { value: "A".into(), next: RefCell::new(None) });
let b = Rc::new(Node { value: "B".into(), next: RefCell::new(None) });

// Create a cycle: A → B → A
*a.next.borrow_mut() = Some(Rc::clone(&b));
*b.next.borrow_mut() = Some(Rc::clone(&a));

// When a and b go out of scope:
// a's refcount: 2 (variable 'a' + b.next) → drops to 1 → NOT freed
// b's refcount: 2 (variable 'b' + a.next) → drops to 1 → NOT freed
// Memory leak!

Weak<T> solves this. A Weak reference doesn't count toward the reference count. It doesn't prevent the value from being dropped. You have to upgrade() it to an Rc/Arc to use it - and upgrade() returns None if the value has been dropped:

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

struct Node {
    value: String,
    children: RefCell<Vec<Rc<Node>>>,
    parent: RefCell<Option<Weak<Node>>>,  // Weak - doesn't prevent cleanup
}

let parent = Rc::new(Node {
    value: "parent".into(),
    children: RefCell::new(vec![]),
    parent: RefCell::new(None),
});

let child = Rc::new(Node {
    value: "child".into(),
    children: RefCell::new(vec![]),
    parent: RefCell::new(Some(Rc::downgrade(&parent))),
    //                        ^^^^^^^^^^^^^^ creates a Weak reference
});

parent.children.borrow_mut().push(Rc::clone(&child));

// Access parent from child:
if let Some(parent_ref) = child.parent.borrow().as_ref() {
    if let Some(parent) = parent_ref.upgrade() {
        println!("Parent: {}", parent.value);
    } else {
        println!("Parent has been dropped");
    }
}

The rule: strong references for ownership (parent → child), weak references for back-links (child → parent). This prevents cycles while still allowing navigation in both directions.

Tip
In practice, reference cycles are rare in Rust because ownership naturally forms a tree. You mostly encounter them with graph structures, observer patterns, or caches. For our webserver, the simple Arc pattern (no cycles) is all we need.

Exercise

  1. Implement the ServerState pattern from this chapter in your server. Bundle config, stats, and the router into one Arc<ServerState>. Add a GET /stats endpoint that returns the request counts.
  2. Write a normalize_header_name function using Cow: if the name is already lowercase, return a borrow; if it contains uppercase letters, return an owned lowercase string. Benchmark it against an always-allocating version.
  3. Replace the Mutex<HashMap> in your Store (from Chapter 14) with a RwLock<HashMap>. Why is this better for the todo API? When would Mutex still be the right choice?
  4. Create a simple cache using Arc<RwLock<HashMap<String, Weak<String>>>>. Store Arc<String> values elsewhere, and Weak references in the cache. When the Arc is dropped, the cache entry's upgrade() returns None. This is a basic eviction strategy.
  5. Use Cow for response bodies: if a handler returns a static string, use Cow::Borrowed; if it builds a dynamic response, use Cow::Owned. Update Response to accept Cow<'static, str>.

What's Next

You now have the full toolkit for managing ownership beyond the basics: Box for heap allocation, Rc/ Arc for shared ownership, RefCell/ Mutex/RwLock for interior mutability, Cow for deferred cloning, and Weak for breaking cycles.

In the next chapter, we learn macros - Rust's metaprogramming system. We'll write a route! macro to make our router configuration cleaner and a json_response! macro to reduce response-building boilerplate.