Smart Pointers in Rust, A Beginner’s Perspective
A beginner’s honest attempt at understanding one of Rust’s most powerful — and initially baffling — concepts.
Most resources about Rust smart pointers assume you already know what a pointer is. They assume you’ve spent time in C or C++, that you’ve felt the pain of a dangling pointer or a segfault, and that you’re now here looking for a safer alternative.
I had none of that context. Rust is my first programming language.
So when Chapter 15 of The Rust Programming Language book opened with “A pointer is a variable which stores the memory address of a chunk of memory,” I had to stop, stare at that sentence for a while, and actually think about what memory even is.
What follows is what I pieced together. Not just what smart pointers are, but why they exist the way they do — and the mental models that finally made everything feel less like magic and more like deliberate, sensible design.
First: What Even Is a Pointer?
Think of your computer’s RAM as a massive row of numbered mailboxes. Each box can hold a small piece of data. A pointer is a variable that doesn’t hold actual data — it holds the address (the box number) of where the real data lives.
A reference in Rust (&) is essentially a pointer, but with rules attached: ownership rules, borrowing rules, lifetime rules. Rust enforces that these rules are never broken, and it does so at compile time — before the program even runs.
Smart pointers are data structures that act like pointers but carry additional capabilities on top. They’re implemented as structs that implement two special traits: Deref and Drop.
Dereflets you write code that works the same way whether you're working with a regular reference or a smart pointer.Droplets you define cleanup logic — what happens when the smart pointer goes out of scope.
Here’s something that surprised me: we’ve already used smart pointers without realizing it. String and Vec<T> are smart pointers.
Box<T>: The Most Boring Smart Pointer (In the Best Way)
Box<T> is the most stripped-down smart pointer in Rust. All it does is allocate a value on the heap instead of the stack. No reference counting, no shared ownership, nothing fancy. Just a pointer to some heap memory that it owns.
You use it in three situations:
1. When you don’t know a type’s size at compile time
Rust needs to know exactly how much space every type takes at compile time so it can allocate the right amount of stack space. This becomes a problem with recursive types — types that contain themselves.
// This won't compile
enum List {
Cons(i32, List), // Rust asks: how big is List? Well, it contains a List... infinite!
Nil,
}The fix is elegant. Since a pointer always has a fixed size (8 bytes on a 64-bit system), you just put the recursive part behind a Box:
enum List {
Cons(i32, Box<List>), // Fixed size now: one i32 + one pointer
Nil,
}2. When moving large data would be expensive
When you move a value in Rust, if it lives on the stack, Rust copies all of it to a new location. For large data, that’s expensive. If instead the data lives on the heap behind a Box, moving ownership only copies the pointer — 8 bytes regardless of how big the data is.
3. When you want to store different types together
Sometimes you want a Vec that can hold different types — say, a Button struct and a Text struct — that share a common trait. Since different structs have different sizes, a plain Vec can't hold them. But a Vec<Box<dyn Draw>> can, because every element is just a pointer of the same size, regardless of what it points to.
The Deref Trait: Teaching Rust What * Means for Your Type
When you write *y in Rust, you're asking: "give me whatever y is pointing at." For a standard reference (&T), the compiler knows exactly how to do this. But for a custom struct? The compiler has no idea.
This is why the Deref trait exists. It lets you say: "when someone uses * on my type, here is how to get to the underlying data."
When you implement Deref for MyBox<T>:
use std::ops::Deref;impl<T> Deref for MyBox<T> {
type Target = T; fn deref(&self) -> &Self::Target {
&self.0 // return a reference to the inner value
}
}
Under the hood, *y becomes *(y.deref()). Rust calls your deref() method to get a reference, and then applies the standard dereference to that. The compiler handles all of this invisibly.
Deref Coercion: Rust’s Quality-of-Life Gift
Once you implement Deref, Rust gives you something extra: deref coercion. This is an automatic conversion that happens when you pass a reference of one type to a function expecting a reference of another type — as long as a chain of Deref implementations connects them.
For example, if you have a &MyBox<String> and a function that expects a &str, Rust will:
- Call
deref()on&MyBox<String>→ gets&String - Call
deref()on&String→ gets&str - Match found. Done.
Without this, you’d write:
hello(&(*m)[..]);With it, you just write:
hello(&m);The chain happens entirely at compile time, so there’s zero runtime cost.
The Drop Trait: Automating Cleanup
Drop lets you define what happens when a value goes out of scope. For smart pointers, this is where memory gets freed. For file handles, this is where the file gets closed. For network connections, this is where you tell the server you're done.
In languages like C, forgetting to close a file can corrupt it. Forgetting to free memory causes your program to gradually consume more and more RAM. The Drop trait makes all of this automatic in Rust.
impl Drop for MyFile {
fn drop(&mut self) {
println!("Closing file and saving data: {}", self.filename);
}
}The moment the value goes out of scope, this runs. You don’t call it. You can’t forget it.
One thing to know: you cannot call .drop() directly on a value. Rust won't let you, because it would cause a double free — Rust would try to clean up the same memory twice. If you want to drop something early, use the drop(value) function from std::mem::drop, which transfers ownership and drops it safely.
Get Riz Baruah’s stories in your inbox
Join Medium for free to get updates from this writer.
Values are dropped in reverse order of creation, by the way. The last thing created is the first thing cleaned up.
Rc<T>: When One Owner Isn't Enough
Rust’s default rule is single ownership: exactly one variable owns a piece of data at any time. That’s usually fine. But sometimes it genuinely isn’t.
Think of a graph data structure. A node might be pointed to by multiple edges. If you delete one edge, the node shouldn’t disappear — the other edges still need it. The node should only be cleaned up when nobody is pointing to it anymore.
This is what Rc<T> — Reference Counted — handles. It keeps a count of how many owners a piece of heap-allocated data has. Every time you create a new owner (Rc::clone(&value)), the count goes up. Every time an owner goes out of scope, the count goes down. When the count hits zero, the data is dropped.
let a = Rc::new(Node { ... }); // count: 1
let b = Rc::clone(&a); // count: 2
let c = Rc::clone(&a); // count: 3
// c goes out of scope // count: 2
// b goes out of scope // count: 1
// a goes out of scope // count: 0 → data is freedTwo things to remember about Rc<T>:
First, Rc::clone() is not like a regular .clone(). It doesn't copy any data. It just increments the counter and gives you another pointer to the same data. This is cheap.
Second, Rc<T> only allows immutable access to the data. If multiple owners could all mutate the same value, you'd have chaos. This immutability is intentional and enforced.
Rc<T> is also single-threaded only. For multiple threads, you'd use Arc<T> (Atomic Reference Counting), which handles the counter increments safely across threads.
RefCell<T>: Mutating the Immutable (Carefully)
Here’s where things get genuinely interesting — and where Rust’s philosophy shows its teeth.
RefCell<T> enables something called interior mutability: the ability to mutate data even when you only have an immutable reference to it. This sounds like it should be illegal. In most of Rust, it is. But RefCell<T> does it safely by moving the borrow-checking from compile time to runtime.
The borrowing rules still apply — you can still only have one mutable borrow at a time, or any number of immutable borrows, but not both. If you violate these rules with RefCell<T>, your program doesn't fail to compile. It panics at runtime instead.
Why would you ever want this? Consider the most common reason: traits.
Sometimes a library defines a trait method with &self — immutable access. You implement that trait, and you're locked into that signature. But what if your implementation needs to mutate some internal state to do its job? You can't change the method signature. The Drop trait is a perfect example. So is any logging or caching mechanism.
RefCell<T> solves this by letting you hide the mutability:
struct MockMessenger {
sent_messages: RefCell<Vec<String>>,
}impl Messenger for MockMessenger {
fn send(&self, message: &str) {
self.sent_messages.borrow_mut().push(String::from(message));
}
}From the outside, MockMessenger looks immutable. Internally, borrow_mut() gives you temporary mutable access to the Vec, and the borrow-checking happens at runtime.
If you try to take two mutable borrows at once, the program panics. This is the tradeoff you accept: runtime errors instead of compile-time ones, in exchange for flexibility the compiler can’t reason about on its own.
RefCell<T> is also single-threaded. The thread-safe equivalent is Mutex<T>.
Combining Rc<T> and RefCell<T>
Each of these types solves one problem:
Rc<T>: multiple owners, but only immutable accessRefCell<T>: single owner, but mutable access even from an immutable reference
What if you need both? Multiple owners and mutability?
You combine them: Rc<RefCell<T>>.
let value = Rc::new(RefCell::new(5));let a = Rc::clone(&value);
let b = Rc::clone(&value);*value.borrow_mut() += 10;// Both a and b now see the updated value: 1
This pattern comes up frequently when building data structures where multiple parts of your program need to share and modify the same piece of data.
Reference Cycles: The One Way Rust Can Leak Memory
Rust’s ownership system makes memory leaks nearly impossible. Nearly.
If you use Rc<T> and RefCell<T> together carelessly, you can create a reference cycle — where object A points to object B, and object B points back to object A. Both have a reference count of 2. When they go out of scope, the stack variables drop, bringing each count to 1. But the count never reaches 0, because each object is holding the other alive.
The data sits in the heap forever — no longer reachable from your code, never freed. That’s a memory leak.
The solution is Weak<T>. A weak reference says: "I know this data exists, but I am not claiming ownership. If everything else decides to drop it, don't wait for me."
Weak references don’t increment the strong count. They don’t keep data alive. And before you use a weak reference, you have to call .upgrade() on it, which returns an Option — because the data might have already been dropped.
The correct pattern for parent-child relationships is:
- Parent owns children →
Rc<T>(strong reference) - Child knows about parent →
Weak<T>(weak reference, no ownership)
This way, when the parent is dropped, the strong count hits zero, and everything cleans up. The child’s weak reference to the parent simply returns None when upgraded.
Putting It All Together
Here’s a quick map of when to reach for each type:
You need…Use…Heap allocation, one ownerBox<T>Multiple owners, immutable accessRc<T>Single owner, interior mutabilityRefCell<T>Multiple owners, mutable accessRc<RefCell<T>>Non-owning reference (to prevent cycles)Weak<T>
Smart pointers are Rust’s way of giving you precise control over memory without making it your full-time job. Box<T> handles the common case. Rc<T> handles shared ownership. RefCell<T> handles the cases where the compiler can't statically reason about your borrowing patterns but you know — and can prove at runtime — that they're safe.
The compiler is always conservative. It would rather reject correct code than accept dangerous code. Smart pointers are the tools Rust gives you to say: “I understand the rules. I’ll enforce them myself.”
Coming from no prior programming experience, this chapter was a lot. But the more I sat with each type and really asked why does this exist, what problem did someone have before writing this, the clearer it got. The designs are not arbitrary. Every constraint, every limitation — the immutability of Rc, the runtime panics of RefCell, the non-ownership of Weak — is a deliberate, considered tradeoff.
That’s the thing about Rust. Once you stop fighting the rules and start asking why they exist, it starts to make a lot of sense.
Written as part of learning Rust from The Rust Programming Language book. First-time programmer trying to make sense of the heap, one smart pointer at a time.










