TL;DR
Understand Box, Rc, RefCell, and interior mutability patterns in Rust
Key concepts
- Rust smart pointers
- Rust Box Rc Arc
- Rust RefCell
- Rust interior mutability
- Rust heap allocation
Smart Pointers in Rust
Smart pointers are data structures that act like pointers but also carry additional metadata and capabilities. Unlike regular references (which only borrow data), smart pointers often own the data they point to. You have already used some smart pointers without knowing it: String and Vec<T> are both smart pointers that own heap-allocated data. In this lesson, we explore the key smart pointer types in Rust's standard library: Box, Rc, and RefCell.
What You'll Learn
By the end of this lesson you will build a shared board that several owners can hold at once and any of them can write to — the Rc<RefCell<T>> shape — and you will get there by debugging the two failures that shape produces. The part that catches people is that one of those failures is not a compile error. RefCell takes the borrow rule you already know and moves the check from compile time to run time, so the same mistake that used to be a red squiggle in your editor becomes a panic in a running program.
This lesson is not on the capstone's path — the taskwork CLI you build at the end of the track has a single owner for its task list and never needs shared mutation. What it is for is the boundary of the ownership model: the point where a single owner is genuinely the wrong shape, what Rust charges you to step outside it, and how to tell which situation you are in. Every graph, tree with parent links, observer list and shared cache you meet in real Rust code is built out of what is here. You arrive here able to say why only one owner may exist and why a borrow may not outlive it (Ownership, and Borrowing in Depth); what is new is a set of types that buy their way around each of those rules, and a clear price list for doing so.
Box<T>: Heap Allocation
Box<T> is the simplest smart pointer. It allocates data on the heap and stores a pointer to it on the stack. When the Box goes out of scope, both the pointer and the heap data are cleaned up. Use Box when you need a value with a known size at compile time but the type itself is dynamically sized, or when you want to transfer ownership without copying large amounts of data:
fn main() {
// Simple heap allocation
let boxed_number = Box::new(42);
println!("Boxed number: {}", boxed_number);
// Box is useful for large data -- moves the pointer, not the data
let large_array = Box::new([0u8; 1000]);
println!("Large array first element: {}", large_array[0]);
// Recursive types require Box because the size would be infinite otherwise
#[derive(Debug)]
enum List {
Cons(i32, Box<List>),
Nil,
}
let list = List::Cons(1,
Box::new(List::Cons(2,
Box::new(List::Cons(3,
Box::new(List::Nil))))));
println!("Linked list: {:?}", list);
}
That last case is the one worth slowing down on. A Cons variant holding a plain List would need to be big enough to hold a Cons holding a List holding a Cons, and so on forever — the compiler cannot pick a size, and it rejects the type with error[E0072]: recursive type has infinite size. Box<List> breaks the cycle because a Box is just a pointer, and a pointer has a fixed size no matter how large the thing it points to is. The program below makes that claim measurable with std::mem::size_of. Predict the three sizes before you run it.
Predict
Chain is a recursive enum whose Link variant holds an i32 and a Box<Chain>. An i32 is 4 bytes and a Box is 8. The program prints the size of Chain once before building a 3-link chain and once after. Predict the last two numbers: the size of Chain, and whether it changes after three links exist.
use std::mem::size_of;
enum Chain {
Link(i32, Box<Chain>),
End,
}
fn main() {
println!("size of i32: {}", size_of::<i32>());
println!("size of Box<Chain>: {}", size_of::<Box<Chain>>());
println!("size of Chain: {}", size_of::<Chain>());
let long = Chain::Link(1, Box::new(Chain::Link(2, Box::new(Chain::Link(3, Box::new(Chain::End))))));
let mut sum = 0;
let mut cur = &long;
while let Chain::Link(value, next) = cur {
sum += value;
cur = next;
}
println!("sum of 3 links: {}", sum);
println!("size of Chain: {}", size_of::<Chain>());
}Two things to carry forward. First, size_of::<Chain>() is a fact about the type, fixed at compile time, so it prints 16 before and after the chain is built — the links themselves live on the heap behind the Box pointers. Second, that fixed size is the whole reason Box rescues a recursive type: the compiler only ever needs room for one pointer, not for an infinite regress of nested values. Any indirection would do — the compiler's own error message suggests Box, Rc, or & — and Box is the one you reach for when the child has a single owner.
The Deref Trait
The Deref trait lets a smart pointer behave like a regular reference. When you implement Deref, the * operator and automatic deref coercion work with your type. This is what makes Box<T> transparent to use -- you can call methods on the inner value without explicit dereferencing in most cases:
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) -> &T {
&self.0
}
}
fn greet(name: &str) {
println!("Hello, {}!", name);
}
fn main() {
let x = 5;
let y = MyBox::new(x);
assert_eq!(5, *y); // Deref lets us use *
println!("Value in MyBox: {}", *y);
// Deref coercion: MyBox<String> -> &String -> &str
let name = MyBox::new(String::from("Rust"));
greet(&name); // Automatic deref coercion chain
}
The Drop Trait
The Drop trait lets you customize what happens when a value goes out of scope. This is Rust's equivalent of a destructor. Smart pointers use Drop to clean up heap memory, close files, release locks, and more:
struct DatabaseConnection {
name: String,
}
impl DatabaseConnection {
fn new(name: &str) -> Self {
println!("[{}] Connection opened", name);
DatabaseConnection {
name: String::from(name),
}
}
fn query(&self, sql: &str) {
println!("[{}] Executing: {}", self.name, sql);
}
}
impl Drop for DatabaseConnection {
fn drop(&mut self) {
println!("[{}] Connection closed and resources freed", self.name);
}
}
fn main() {
let conn1 = DatabaseConnection::new("primary");
let conn2 = DatabaseConnection::new("replica");
conn1.query("SELECT * FROM users");
conn2.query("SELECT * FROM orders");
// You can drop a value early with std::mem::drop
drop(conn2);
println!("conn2 has been dropped early");
conn1.query("SELECT count(*) FROM users");
println!("End of main -- conn1 will be dropped now");
}
Rc<T>: Reference Counted Pointers
Sometimes a value needs to have multiple owners -- for example, in a graph where multiple edges point to the same node. Rc<T> (Reference Counted) enables shared ownership by tracking the number of references. When the last Rc is dropped, the value is cleaned up. Note that Rc<T> is for single-threaded scenarios only:
use std::rc::Rc;
fn main() {
// Shared ownership with Rc
let shared_data = Rc::new(vec![1, 2, 3, 4, 5]);
let reference1 = Rc::clone(&shared_data);
let reference2 = Rc::clone(&shared_data);
println!("Original: {:?}", shared_data);
println!("Ref 1: {:?}", reference1);
println!("Ref 2: {:?}", reference2);
println!("Reference count: {}", Rc::strong_count(&shared_data));
// Drop one reference
drop(reference1);
println!("After dropping ref1, count: {}", Rc::strong_count(&shared_data));
// Practical example: shared configuration
let config = Rc::new(String::from("production"));
let service_a = Rc::clone(&config);
let service_b = Rc::clone(&config);
println!("Service A config: {}", service_a);
println!("Service B config: {}", service_b);
println!("Config ref count: {}", Rc::strong_count(&config));
}
The count is not a private implementation detail — Rc::strong_count reads it out, which makes shared ownership one of the few Rust concepts you can watch happen. Two things move it: every Rc::clone adds one, and every owner that goes away subtracts one. That second half is the interesting one, because an owner can go away without you writing drop — it also happens when a binding simply reaches the end of its scope. Trace the five printed counts before you run this.
Predict
Five counts are printed from the same Rc. One clone is made in an inner block that ends before line D, and one is dropped explicitly before line E. Predict all five numbers in order (A, B, C, D, E).
use std::rc::Rc;
fn main() {
let a = Rc::new(String::from("config"));
println!("A: {}", Rc::strong_count(&a));
let b = Rc::clone(&a);
println!("B: {}", Rc::strong_count(&a));
{
let c = Rc::clone(&a);
println!("C: {}", Rc::strong_count(&c));
}
println!("D: {}", Rc::strong_count(&a));
drop(b);
println!("E: {}", Rc::strong_count(&a));
}The counts run 1, 2, 3, 2, 1. There is one count, stored alongside the data, not one per handle — that is what lets the value know when its last owner disappears. It rose to 3 while c was alive and fell back to 2 at the closing brace of the inner block, with no drop written anywhere: Rc decrements in its own Drop implementation, which Rust runs automatically at scope exit. So drop(b) is not doing anything scope exit would not have done later; it just does it sooner. When the count reaches zero, the String behind it is freed.
RefCell<T>: Interior Mutability
Rust normally enforces borrowing rules at compile time. RefCell<T> moves these checks to runtime, allowing you to mutate data even when there are immutable references to the RefCell. This pattern is called interior mutability. If you violate the borrowing rules at runtime, the program will panic instead of producing undefined behavior:
use std::cell::RefCell;
fn main() {
let data = RefCell::new(vec![1, 2, 3]);
// Immutable borrow with .borrow()
println!("Data: {:?}", data.borrow());
// Mutable borrow with .borrow_mut()
data.borrow_mut().push(4);
data.borrow_mut().push(5);
println!("After push: {:?}", data.borrow());
// Multiple immutable borrows are fine
let borrow1 = data.borrow();
let borrow2 = data.borrow();
println!("Two borrows: {:?} and {:?}", borrow1, borrow2);
// Must drop these before a mutable borrow
drop(borrow1);
drop(borrow2);
data.borrow_mut().retain(|&x| x > 2);
println!("After filter: {:?}", data.borrow());
}
Notice what RefCell did not change. The two drop calls before the mutable borrow are there because the rule being enforced is the same rule you already know — only the moment of enforcement moved. Pin that down before going further.
Recall
Without scrolling up: Borrowing in Depth gave you a rule about how many references to one value may be live at once, and the compiler rejected any program that broke it. RefCell lets you call borrow_mut() on a value you only hold an immutable reference to. Which row correctly states what RefCell changes about that rule, and what it costs?
So RefCell moves the borrow check; it does not weaken it. Many shared borrows or one exclusive borrow, never both — the rule from Borrowing in Depth survives intact, but a counter inside the RefCell enforces it while the program runs instead of the compiler enforcing it before the program exists. That buys you mutation through a shared reference, which you will need the moment Rc appears, since Rc only ever hands out &T. It costs you the compiler's proof: break the rule and you get a panic reading RefCell already borrowed at the offending line, not a build error. (You may also see the name BorrowMutError in this context — that is the Debug name of the error type try_borrow_mut() hands back in its Err, not the text of the panic.)
Reading a borrow panic
That trade is only worth taking if you can read the bill when it arrives, and a RefCell panic tells you much less than a compiler error would. Here is one, produced on the toolchain behind this page's Run button (rustc 1.97.1):
thread 'main' (576) panicked at /tmp/main.rs:8:18:
RefCell already borrowed
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
Compare that with what the borrow checker gives you for the same mistake caught statically: an error[E0502] naming three spans — where the first borrow starts, where the conflicting one happens, and where the first is still in use — plus a help: suggestion. Here you get one location and four words. So the routine is different, and it is mostly about what the message does not say:
- The location is where the borrow was REFUSED, not where the conflicting borrow was taken.
/tmp/main.rs:8:18points at theborrow_mut()call. The guard that blocked it was created somewhere above and is still alive — and that line, the one you actually have to change, is not in the message at all. Reading a borrow panic always means looking upward from the reported line for aborrow()orborrow_mut()whose guard has not been dropped yet. RefCell already borrowedmeans a shared guard blocked an exclusive request. The mirror-image failure — asking for a shared borrow while aborrow_mut()guard is alive — readsRefCell already mutably borrowed. The one word of difference tells you which of the two guards to go looking for.- A guard's life ends at the end of its enclosing statement, or when the binding holding it goes out of scope, or at an explicit
drop. That is where the "somewhere above" usually hides: a guard bound to aletlives to the end of the block, while a guard produced in the middle of an expression dies at the semicolon.let v = cell.borrow();andcell.borrow().len();behave very differently, and only one of them is still holding the cell on the next line. RUST_BACKTRACE=1is the fourth step, and it does more here than it does for a compile error, because the reported location may be inside a helper that many callers reach. The backtrace names the caller. It does not run on this page — the Run button sets no environment variables — but it is the first thing to reach for at a terminal.
One naming trap while you are here, because both names are real and they are not the same thing: RefCell already borrowed is the panic text; BorrowMutError is the Debug name of the error type that try_borrow_mut() returns in its Err. If you see BorrowMutError in output, something called the non-panicking try_ variant and printed the error — which is precisely the escape hatch to reach for when a conflict is a case you want to handle rather than a bug you want to hear about.
That failure is worth meeting deliberately, because the code that causes it looks entirely reasonable. The program below compiles without a single warning and then dies on its first pass through the loop. Work out which two borrows overlap before you change anything.
Debug
This archiver walks a list of entries and appends an archived copy of every entry older than the cutoff. It compiles cleanly, then panics at runtime with 'RefCell already borrowed'. Work out which borrow is still alive when the second one is requested, then restructure it so all three entries print.
use std::cell::RefCell;
// Archive every entry older than cutoff by appending a marked copy.
fn archive_old(entries: &RefCell<Vec<(String, u32)>>, cutoff: u32) {
for (name, age) in entries.borrow().iter() {
if *age > cutoff {
entries
.borrow_mut()
.push((format!("{}-archived", name), *age));
}
}
}
fn main() {
let entries = RefCell::new(vec![
(String::from("alpha"), 3),
(String::from("beta"), 12),
]);
archive_old(&entries, 10);
let final_len = entries.borrow().len();
assert_eq!(final_len, 3, "expected 3 entries after archiving, got {}", final_len);
println!("Entries after archiving: {}", final_len);
for (name, age) in entries.borrow().iter() {
println!(" {} ({})", name, age);
}
}Expected output: Entries after archiving: 3
alpha (3)
beta (12)
beta-archived (12)
The bug is that the for loop's iterator borrows from the Ref guard that entries.borrow() created, so that shared borrow stays live for every iteration — including the one that asks for borrow_mut(). RefCell sees a shared and an exclusive borrow overlapping, refuses, and panics with RefCell already borrowed. The fix is to split the phases rather than to shorten the write: build a plain Vec of the entries to append while you are only reading, which lets the read guard drop at the end of that statement, then take borrow_mut() once and extend with what you collected. Read fully, then write — the same discipline the compiler enforces statically when you try to push to a Vec you are iterating, except here nothing warns you until the program runs.
The read phase and the write phase, in order
That fix is worth building rather than reading, because the shape of it is the transferable part: decide everything you are going to change while you are only reading, let the read borrow end, then write once.
Here is the frame the five lines drop into, so entries and cutoff are values you can see rather than parameters you have to remember:
use std::cell::RefCell;
fn main() {
let entries = RefCell::new(vec![
(String::from("ada"), 41u32),
(String::from("grace"), 45u32),
(String::from("alan"), 33u32),
]);
let cutoff = 40u32;
// the five shuffled lines go here
drop(writer);
println!("archived to {} entries", entries.borrow().len());
}
Arrange the code
These five lines archive the entries older than a cutoff, the way the fixed version of the program above does: read the collection, work out what to append, release the read, then append. The pieces are shuffled. Put them in the order that runs — then answer what the order is really testing: three of the four adjacent swaps are refused by the compiler, and one is not. Which one, and what happens instead?
let to_add: Vec<(String, u32)> = snapshot.iter().filter(|(_, age)| *age > cutoff).map(|(n, a)| (format!("{}-archived", n), *a)).collect();let snapshot = entries.borrow();drop(snapshot);writer.extend(to_add);let mut writer = entries.borrow_mut();
Notice what makes this block different from most ordering puzzles: the wrong arrangement is not caught by the compiler, so the evidence that it is wrong is a panic rather than an error. That is the whole RefCell bargain in five lines.
Combining Rc and RefCell
The most powerful pattern is Rc<RefCell<T>>, which gives you shared ownership with interior mutability. Multiple parts of your code can own the same data and mutate it:
use std::cell::RefCell;
use std::rc::Rc;
#[derive(Debug)]
struct Node {
value: i32,
children: Vec<Rc<RefCell<Node>>>,
}
impl Node {
fn new(value: i32) -> Rc<RefCell<Node>> {
Rc::new(RefCell::new(Node {
value,
children: vec![],
}))
}
fn add_child(parent: &Rc<RefCell<Node>>, child: Rc<RefCell<Node>>) {
parent.borrow_mut().children.push(child);
}
}
fn print_tree(node: &Rc<RefCell<Node>>, depth: usize) {
let node = node.borrow();
let indent = " ".repeat(depth);
println!("{}Node({})", indent, node.value);
for child in &node.children {
print_tree(child, depth + 1);
}
}
fn main() {
let root = Node::new(1);
let child_a = Node::new(2);
let child_b = Node::new(3);
let grandchild = Node::new(4);
Node::add_child(&root, Rc::clone(&child_a));
Node::add_child(&root, Rc::clone(&child_b));
Node::add_child(&child_a, Rc::clone(&grandchild));
// We can still mutate nodes through any reference
child_b.borrow_mut().value = 30;
grandchild.borrow_mut().value = 40;
println!("Tree structure:");
print_tree(&root, 0);
// The grandchild is owned by both child_a's children vec and our local variable
println!(
"\nGrandchild ref count: {}",
Rc::strong_count(&grandchild)
);
}
Choosing the Right Smart Pointer
The four types are usually presented as a menu. They are better read as an escalation: one problem, and each stage reached for only because the stage above it could not do the job. The comments say why each step was taken, not what the lines do.
use std::cell::RefCell;
use std::rc::Rc;
#[derive(Debug)]
struct Settings {
retries: u32,
}
fn main() {
// Stage 1 - no smart pointer at all. One owner, on the stack, mutable
// because the BINDING is mutable. Reach for this until something forces
// you not to; every stage below costs something this one does not.
let mut plain = Settings { retries: 3 };
plain.retries += 1;
println!("1 plain: {:?}", plain);
// Stage 2 - Box. Still exactly one owner; the only thing that changed is
// WHERE the value lives. Chosen when the size is not known at compile time
// (a trait object, a recursive type) or the value is large enough that
// moving it is worth avoiding. Note it does NOT buy sharing or mutation:
// boxed is still mut for the same reason plain was.
let mut boxed = Box::new(Settings { retries: 3 });
boxed.retries += 1;
println!("2 box: {:?}", boxed);
// Stage 3 - Rc. Now there are two owners and neither is "the" owner, so
// the value lives until BOTH are gone. The price is written into the API:
// Rc hands out only shared references, so nothing below can mutate.
// Uncommenting the next line is error[E0594] - cannot assign to data in
// an Rc, because DerefMut is not implemented for it.
let shared = Rc::new(Settings { retries: 3 });
let second = Rc::clone(&shared);
// shared.retries += 1;
println!("3 rc: {:?} owners={}", second, Rc::strong_count(&shared));
// Stage 4 - Rc<RefCell<T>>. RefCell buys back the mutation stage 3 gave
// up, by moving the borrow check to run time. Only reach here when you
// genuinely need BOTH properties: more than one owner AND mutation.
let cell = Rc::new(RefCell::new(Settings { retries: 3 }));
let other = Rc::clone(&cell);
other.borrow_mut().retries += 1;
println!("4 rc+refcell:{:?} owners={}", cell.borrow(), Rc::strong_count(&cell));
}
Read the printed lines against the comments. Stages 1 and 2 print the same thing, because Box changed where the value lives and nothing else — it bought no sharing and no extra mutability. Stage 3 prints retries: 3 and owners=2: two owners now exist, and the price is visible in the output, because Rc hands out only shared references and the increment is commented out. Stage 4 is the only one that has both — two owners and a successful mutation — and it is the only one paying for a runtime borrow check.
The rule that falls out of it: take the highest stage you have a reason for, and no higher. Every stage down from Rc<RefCell<T>> removes a runtime cost and moves a class of bug from run time back to compile time. Most code that reaches for stage 4 needs stage 1, and the giveaway is a borrow_mut() whose guard is created and dropped on the same line, which means nothing was ever really shared.
Now place a fifth stage yourself, without writing any code. Where in that escalation does Arc<Mutex<T>> belong, and which of the four stages is it the direct replacement for? Answer both halves before you read on — the second half is the one that pins it, and Concurrency gave you what you need for it.
Try It Yourself
Reading about shared mutable state is not the same as wiring it up. This is a build task: a small program that reports its own pass/fail. You are given a metrics board — an Rc<RefCell<Vec<(String, u32)>>> that several parts of a program share and update in place — and three stubbed functions. Run it as-is and it fails immediately, naming the first stub that is still empty. Implement each until every check passes and it prints All checks passed.
The three stubs use exactly what this lesson taught: Rc::clone to hand out a second owner of one board rather than a copy of it, borrow_mut() to mutate through a shared reference, and a read borrow that is taken, used and released. The checks watch Rc::strong_count on both sides of a drop, so a share that quietly builds a fresh board instead of cloning the Rc is caught rather than silently passing. The starter has the type, the stubs and the checks; you write only the three bodies.
Build
Finish the build. Three functions are stubbed out and the checks below them fail until each behaves. Run it as-is to see which check fails first, decide what that stub is missing, then implement all three until it prints 'All checks passed.' The checks run top to bottom, so the first failure you see is TODO 1 — implement it first, then work down.
use std::cell::RefCell;
use std::rc::Rc;
/// A metrics board several parts of a program share and update in place.
type Board = Rc<RefCell<Vec<(String, u32)>>>;
// TODO 1: hand back a SECOND owner of the same board.
// The clone must share the board, not copy it — a change made through the
// returned handle must be visible through the original.
// share(&board) -> a Board whose strong_count is one higher
fn share(board: &Board) -> Board {
// your code here
Rc::new(RefCell::new(Vec::new()))
}
// TODO 2: add amount to the counter called name, creating it at amount
// if it is not on the board yet. Mutate through the shared board.
// record(&b, "hits", 2) twice -> ("hits", 4)
fn record(board: &Board, name: &str, amount: u32) {
// your code here
}
// TODO 3: return the total of every counter's value.
// total(&b) -> the sum of all counter values
fn total(board: &Board) -> u32 {
// your code here
0
}
// --- Build checks: these must all pass. Do not edit below this line. ---
fn main() {
let board: Board = Rc::new(RefCell::new(Vec::new()));
assert_eq!(Rc::strong_count(&board), 1, "a fresh board has exactly one owner");
let worker = share(&board);
assert_eq!(
Rc::strong_count(&board),
2,
"share() must clone the Rc so the board has two owners, not build a new board"
);
record(&worker, "hits", 2);
record(&board, "hits", 2);
record(&worker, "errors", 1);
assert_eq!(
board.borrow().len(),
2,
"two distinct counters should exist: hits and errors"
);
assert_eq!(
board.borrow()[0],
(String::from("hits"), 4),
"recording 2 twice through DIFFERENT owners must accumulate to 4 on the SAME board"
);
assert_eq!(total(&board), 5, "total() should sum every counter: 4 + 1");
drop(worker);
assert_eq!(
Rc::strong_count(&board),
1,
"dropping the second owner brings the count back to 1"
);
println!("All checks passed.");
println!("counters: {}", board.borrow().len());
println!("total: {}", total(&board));
println!("owners: {}", Rc::strong_count(&board));
}Expected output: All checks passed.
counters: 2
total: 5
owners: 1
Once it passes, try two variations and predict each before running:
- Break the sharing on purpose. Change
shareback toRc::new(RefCell::new(Vec::new()))and predict which check fails first before running. It is thestrong_countassertion immediately aftershare, not any of the counter checks: the board still has one owner because you built a second, unrelated board rather than a second handle to the first. This is the assertion earning its place — without it, the two boards would each quietly hold their ownhitscounter and the bug would surface much later. - Hold a read borrow across a write. In
record, replace the first line withlet existing = board.borrow().len();and then keep theboard.borrow_mut()call below it on the sameexistingvalue. Predict whether this fails at compile time or at run time. Binding the read guard toexisting's statement lets it drop at the semicolon, so this one still runs; but bind the guard itself withlet counters = board.borrow();and leave it live across theborrow_mut(), and you get a runtime panic readingRefCell already borrowed— the same overlap as the archiver above, and never a compile error.
Shared Mutation Elsewhere
Rc<RefCell<T>> looks like a lot of ceremony for something most languages hand you without a word. It is worth being exact about what the ceremony is buying, because the answer is not "safety in general":
Transfer
In JavaScript, two names for one object are two owners that can both mutate it, and nothing checks anything: const a = board; const b = board; and both may write. Rust makes you spell the same arrangement Rc<RefCell<T>> and charges you a runtime check that can panic. Which statement names what genuinely transfers, rather than a surface difference?
Key Takeaways
Box<T>provides simple heap allocation with single ownership -- it costs one allocation and matching free plus one indirection, and nothing extra on topRc<T>enables multiple ownership via reference counting, for single-threaded use onlyRefCell<T>provides interior mutability by moving borrow checks from compile time to runtime- The
Dereftrait enables smart pointers to be used like regular references via automatic coercion - The
Droptrait lets you customize cleanup behavior when a value goes out of scope Rc<RefCell<T>>combines shared ownership with mutation -- useful for graphs and shared state- For multi-threaded scenarios, use
Arc<Mutex<T>>instead ofRc<RefCell<T>>
Pro Tip: Reach for
Box<T>first -- it is the simplest and most performant smart pointer. Only move toRc<T>when you truly need shared ownership, and only addRefCell<T>when you need interior mutability. Each layer of indirection adds a small cost, so keep your pointer types as simple as your use case allows.
Next Steps
You now have a solid grasp of Rust's core concepts. Next, you'll learn how to write tests to verify your code works correctly using Rust's built-in testing framework.
Two-tier handoff: this document is the complete reading surface. Continue learning for stateful practice, progress, and real sandbox execution.