Pointers & Smart Pointers

Rust gives you full control over memory through references and pointers with extra features.

For beginners: you'll use &T and &mut T 90% of the time. Ignore Cell, RefCell, Cow, and raw pointers until you actually need them. Box comes up with recursive types. Rc/Arc come up with shared ownership. Don't memorize everything — just know where to look.

Quick Reference

Type Syntax Safe? Use Case
Shared Ref &T S Read-only, many readers
Mutable Ref &mut T S Write access, only one at a time
Box Box<T> S Single owner, heap storage
Rc Rc<T> S Multiple owners, single thread
Arc Arc<T> S Multiple owners, many threads
Cell Cell<T> S Change copyable values through read-only ref
RefCell RefCell<T> S Check borrowing at runtime, single thread
Cow Cow<'a, T> S Starts borrowed, copies if changed
Raw Ptr *const T / *mut T Unsafe Talking to C code, manual memory

References (&T, &mut T)

Borrow a value without owning it. The compiler checks that it's safe when you build.

A reference is like a sticky note pointing to something. You can have 50 sticky notes pointing to the same thing as long as nobody touches it. The moment someone needs to change it, all sticky notes must be gone. The compiler checks this — not when running, but when building.
1 │ fn len(s: &String) -> usize { s.len() }  // borrow, don't take
2 │
3 │ let s = String::from("hello");
4 │ let r = &s;           // read-only reference
5 │ len(r);
6 │
7 │ let mut s = String::from("hello");
8 │ let r = &mut s;       // read-write reference (only one)
9 │ r.push_str(" world");
Legend: 1 &String = borrow, don't take ownership   4 &s = create a read-only reference   8 &mut s = mutable reference (exclusive)

Box<T> — Heap Storage

Store data on the heap (storage closet). Use when the size isn't known when you build, or to move big data without copying it.

Box says "put this on the heap, keep a pointer on the stack." Use it when the size isn't known when building (like types that contain themselves) or when moving big data cheaply. The Box itself is small (just a pointer) and follows normal ownership rules.
1 │ let b = Box::new(5);  // i32 on heap
2 │ println!("{}", b);    // Rust follows the pointer for you
3 │
4 │ // Recursive type (must use Box)
5 │ enum List {
6 │     Node(i32, Box),
7 │     Nil,
8 │ }
Legend: 1 Box::new() allocates on heap   6 Box<List> makes recursive type possible

Rc<T> — Shared Ownership

Multiple owners allowed. Single-thread only. Simple counter (fast).

A book club: multiple people read the same physical book. You don't make copies — you share. Rc counts how many people are reading. When the last person finishes, the book gets recycled. This only works in one room (single-thread) because the count is a whiteboard tally, not a lockbox.
1 │ use std::rc::Rc;
2 │
3 │ let a = Rc::new(String::from("hello"));
4 │ let b = Rc::clone(&a);  // increments ref count
5 │ let c = Rc::clone(&a);  // 3 owners now
6 │ println!("{}", Rc::strong_count(&a));  // 3
7 │
8 │ // Rc::clone is cheap — just increments counter, no deep copy
Legend: 3 Rc::new creates shared data   4-5 Rc::clone adds an owner (cheap!)   6 how many owners are alive   8 no deep copy, just a counter bump

Drops when count reaches 0. Use Rc::downgrade for Weak<T> (weak ref, no ownership).

Arc<T> — Thread-safe Shared Ownership

Same as Rc but works across threads. Uses a thread-safe counter (slower but safe).

Same book club, but now people are in different rooms (threads). The tally must be thread-safe — multiple people can't update the count at the same time. Slightly slower than Rc, but safe across threads. For data that can change, pair with Mutex: Arc<Mutex<T>> — the mutex makes sure only one person writes at a time.
use std::sync::Arc;
use std::thread;

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

let mut handles = vec![];
for _ in 0..3 {
    let data = Arc::clone(&data);
    handles.push(thread::spawn(move || {
        println!("{:?}", data);
    }));
}
// Combine with Mutex for shared data that can change:
// Arc<Mutex<T>>

Cell<T> & RefCell<T> — Changing Through Read-only Refs

Change data through a read-only reference. Skips normal borrowing rules, but there's a cost.

Sometimes you need to change something through a read-only reference. Cell is for small values that can be copied — it swaps values in and out like a post-it note. RefCell is for bigger values — it checks the "one writer or many readers" rule when the program runs instead of when it builds. It can crash if you break the rules. Use these only when the borrow checker won't let you do what you need.

Cell<T>

For Copy types. No borrowing — values are moved in/out.

use std::cell::Cell;

let c = Cell::new(42);
c.set(100);
println!("{}", c.get());  // 100 (copy out)

RefCell<T>

For non-Copy types. Checks borrow rules at runtime (panics on violation).

1 │ use std::cell::RefCell;
2 │
3 │ let v = RefCell::new(vec![1, 2, 3]);
4 │ v.borrow_mut().push(4);
5 │ println!("{:?}", v.borrow());  // [1, 2, 3, 4]
6 │
7 │ // Runtime check: borrow() and borrow_mut() can't coexist
8 │ // Will panic! if rules violated
Legend: 3 wraps data in a runtime-checked borrow   4 borrow_mut() gives write access (checked at run time)   5 borrow() gives read access   7-8 breaks rules? program panics

Common pattern: Rc<RefCell<T>> for shared data that can change (single thread only).

Cow<'a, T> — Copy Only When Needed

Starts as borrowed (&T), copies to owned (T) only if you change it.

"Don't copy until you have to." Cow starts as a borrowed reference (no extra memory). Only if something tries to change it does it copy the data into an owned version. This is useful when most of the time you just read data, but sometimes you need to change it.
use std::borrow::Cow;

fn process(input: &str) -> Cow<str> {
    if input.contains(' ') {
        // Needs modification → owned
        input.replace(' ', "_").into()
    } else {
        // No changes needed → borrowed
        Cow::Borrowed(input)
    }
}

let s = process("hello world");  // allocates
let s = process("hello");        // no allocation

Raw Pointers (*const T, *mut T)

C-like pointers. No safety promises. Can only be followed inside unsafe block.

Raw pointers are Rust's version of C pointers — they can be null, point to nothing useful, or point to freed memory. You're telling the compiler "I know what I'm doing, trust me." Only use them for talking to C code or building safe wrappers that the borrow checker can't handle. Every unsafe block is a promise you must check yourself.
let mut x = 10;
let r1 = &x as *const i32;    // raw const pointer
let r2 = &mut x as *mut i32;  // raw mut pointer

unsafe {
    println!("{}", *r1);       // follow the pointer in unsafe block
    *r2 = 20;
}

Raw pointers can:

  • Be null (null pointer)
  • Dangle (point to freed memory)
  • Ignore sharing rules
  • Be sent between threads safely

Use only for talking to C code or when building low-level wrappers.

Combined Patterns

 1 │ // Shared data that can change (single-thread)
 2 │ use std::{rc::Rc, cell::RefCell};
 3 │ let shared = Rc::new(RefCell::new(42));
 4 │ {
 5 │     let mut val = shared.borrow_mut();
 6 │     *val += 1;
 7 │ }
 8 │
 9 │ // Shared data that can change (multi-thread)
10 │ use std::sync::{Arc, Mutex};
11 │ let shared = Arc::new(Mutex::new(42));
12 │ {
13 │     let mut val = shared.lock().unwrap();
14 │     *val += 1;
15 │ }
Legend: 3 Rc + RefCell = shared + mutable (1 thread)   11 Arc + Mutex = shared + mutable (many threads)   5-6 borrow_mut() for write inside a scope   13 lock() for thread-safe write

Try It Yourself

Task 1: Linked List with Box

Define a recursive List enum where each node holds an i32 and optionally points to the next node. Use Box — without it the compiler can't know the size.

enum List {
    Node(i32, Box),
    Nil,
}

// Create: 1 -> 2 -> 3 -> Nil
Show solution
use List::*;

let list = Node(1, Box::new(Node(2, Box::new(Node(3, Box::new(Nil))))));

// You could also add a method to print the list
impl List {
    fn print(&self) {
        match self {
            Node(val, next) => {
                print!("{val} -> ");
                next.print();
            }
            Nil => println!("Nil"),
        }
    }
}

list.print();  // 1 -> 2 -> 3 -> Nil

Task 2: Shared Ownership with Rc

Create a Graph where multiple nodes can share the same child. Use Rc so the child is not dropped while any parent references it.

use std::rc::Rc;

struct Node {
    value: i32,
    children: Vec>,
}

// Create: root -> child_a, root -> child_b
//         child_a -> shared_child
//         child_b -> shared_child (same node!)
Show solution
let shared = Rc::new(Node {
    value: 3,
    children: vec![],
});

let child_a = Node {
    value: 1,
    children: vec![Rc::clone(&shared)],
};

let child_b = Node {
    value: 2,
    children: vec![Rc::clone(&shared)],
};

let root = Node {
    value: 0,
    children: vec![Rc::new(child_a), Rc::new(child_b)],
};

println!("shared ref count: {}", Rc::strong_count(&shared));  // 2

Task 3: Interior Mutability

Use RefCell to create a "mock" object that counts how many times a method is called, even through an immutable reference.

use std::cell::RefCell;

struct Logger {
    log_count: RefCell,
}

impl Logger {
    fn new() -> Self {
        Logger { log_count: RefCell::new(0) }
    }

    fn log(&self, msg: &str) {
        // increment count, print msg
    }

    fn count(&self) -> u32 {
        // return current count
    }
}
Show solution
impl Logger {
    fn log(&self, msg: &str) {
        *self.log_count.borrow_mut() += 1;
        println!("[{:?}] {msg}", self.log_count.borrow());
    }

    fn count(&self) -> u32 {
        *self.log_count.borrow()
    }
}

fn main() {
    let logger = Logger::new();
    logger.log("hello");
    logger.log("world");
    println!("Total: {}", logger.count());  // 2
}

Task 4: Thread-Safe Counter

Spawn 10 threads, each increments a shared counter 100 times. Use Arc<Mutex<i32>> for thread-safe sharing.

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

let counter = Arc::new(Mutex::new(0));
let mut handles = vec![];

for _ in 0..10 {
    let c = Arc::clone(&counter);
    handles.push(thread::spawn(move || {
        // increment c 100 times
    }));
}

for h in handles { h.join().unwrap(); }
println!("Result: {}", *counter.lock().unwrap());  // should be 1000
Show solution
for _ in 0..10 {
    let c = Arc::clone(&counter);
    handles.push(thread::spawn(move || {
        for _ in 0..100 {
            let mut val = c.lock().unwrap();
            *val += 1;
        }
    }));
}

for h in handles { h.join().unwrap(); }
println!("Result: {}", *counter.lock().unwrap());  // 1000

When to Use What