Rust smart pointers: when to use Box, Rc, Arc and RefCell
Why does Rust need Box, Rc, Arc and RefCell, and when do you use each one?
In Java or Python you never think about who owns an object. You create it, you pass it around, and the runtime keeps it alive as long as somebody points to it. Rust has no garbage collector, so that question has an answer at compile time, and smart pointers are how you change the answer when the default doesn’t fit.
This post is for people who know a garbage-collected language and little Rust. The examples are small programs written for this post, and every error and output in it comes from the compiler. Two pieces of real code come from my side projects: a small search engine with a B-tree index, and a CRDT experiment with a TCP server.
One owner, and nothing else keeps a value alive
The rule is short. Every value has exactly one owner, a variable or a field. When the owner goes out of scope, the value is dropped, right there, with no collector running later. You can lend the value with a reference, &T to read or &mut T to change it, and the compiler checks that no reference outlives the owner, and that nobody reads while somebody else writes.
In Java, every object variable is a reference to something on the heap, and the garbage collector decides when it dies. CPython counts references on every object and frees it when the count reaches zero, with a separate collector for cycles. Rust does neither by default. Most of the time the one-owner rule is enough, and it costs nothing at runtime. A smart pointer is a type that owns a value and behaves like a reference to it, for the cases where the rule is not enough. You already use two of them: String and Vec own their data on the heap and free it when they are dropped.
Box: one owner, on the heap
Box<T> is the simplest one, almost banal: it puts a value on the heap and owns it. There is still exactly one owner, the box. What changes is the size of the thing you store: the box itself has a fixed size, whatever is inside it, and that matters in two situations.
The first is a type that contains itself. Here is a binary tree where a node holds its children directly:
struct Node { value: i32, left: Option<Node>, right: Option<Node>,}To know the size of a Node, the compiler needs the size of a Node. It says so, and it suggests the fix:
error[E0072]: recursive type `Node` has infinite size --> tree.rs:1:1 |1 | struct Node { | ^^^^^^^^^^^2 | value: i32,3 | left: Option<Node>, | ---- recursive without indirection |help: insert some indirection (e.g., a `Box`, `Rc`, or `&`) to break the cycle |3 | left: Option<Box<Node>>, | ++++ +With Option<Box<Node>> a node holds a pointer to its children, the size is known, and the tree works. My search engine’s B-tree is built the same way:
pub struct BTreeMap<K, V>where K: Ord + Clone, V: Clone,{ root: Option<Box<Node<K, V>>>,}
struct Node<K, V>where K: Ord + Clone, V: Clone,{ keys: Vec<K>, values: Vec<V>, children: Vec<Box<Node<K, V>>>, leaf: bool,}Writing this post, I noticed that two of those boxes don’t do anything. Vec is already a smart pointer: on the stack it is three words, the pointer, the length and the capacity, and its elements live on the heap. So Vec<Node> has a known size too, and the compiler accepts it:
struct Node { keys: Vec<i32>, children: Vec<Node>,}
fn main() { let tree = Node { keys: vec![10], children: vec![Node { keys: vec![1, 5], children: vec![] }] }; println!("{} children, size of Node: {} bytes", tree.children.len(), std::mem::size_of::<Node>());}1 children, size of Node: 48 bytesThe extra Box around each child costs one more allocation and one more pointer to follow on every step down the tree. So Box is needed where a type would contain itself directly, not wherever there is recursion.
Why you write Box<dyn Error>
The second situation is the one you meet in the first week. A function that reads a file and parses a number can fail in two ways, with two different error types: std::io::Error and std::num::ParseIntError. In Java you would write throws Exception and return either. In Python you just raise. In Rust the return type has to say what comes back.
dyn Error means “any type that implements the Error trait”. The catch is that those types have different sizes, so the compiler cannot reserve space for “any” of them on the stack. Try to return one by value:
use std::error::Error;
fn parse(text: &str) -> Result<u16, dyn Error> { Ok(text.parse::<u16>()?)}error[E0277]: the size for values of type `(dyn std::error::Error + 'static)` cannot be known at compilation time --> dyn_error_by_value.rs:3:25 |3 | fn parse(text: &str) -> Result<u16, dyn Error> { | ^^^^^^^^^^^^^^^^^^^^^^ doesn't have a size known at compile-time | = help: the trait `Sized` is not implemented for `(dyn std::error::Error + 'static)`Put it in a box and the size is known again: a Box<dyn Error> is two pointers, one to the error and one to the table of its methods. The ? operator does the rest. On an error it converts it into the function’s error type with the From trait, and the standard library implements that conversion from any error into Box<dyn Error>:
use std::error::Error;use std::fs;
fn read_port(path: &str) -> Result<u16, Box<dyn Error>> { let text = fs::read_to_string(path)?; // std::io::Error let port = text.trim().parse::<u16>()?; // std::num::ParseIntError Ok(port)}With a file containing 80800 and then with a missing file, the same function returns two different errors:
error: number too large to fit in target typeerror: No such file or directory (os error 2)The price is the same as Java’s throws Exception: the caller no longer knows which error it got without asking. For a main or a small tool that only prints the error, that’s fine. A library usually defines its own error type instead.
Rc: several owners, one thread
Sometimes a value has no single owner. Two parts of a program both need the same configuration, and neither outlives the other in a way the compiler can see. Rc<T>, reference counted, is CPython’s model, opt-in and for one value: Rc::clone doesn’t copy the data, it adds an owner and increments a counter, and the value is dropped when the last owner goes.
use std::rc::Rc;
struct Config { name: String,}
fn main() { let config = Rc::new(Config { name: String::from("prod") }); let api = Rc::clone(&config); let worker = Rc::clone(&config); println!("{} owners of {}", Rc::strong_count(&config), worker.name); drop(api); println!("{} owners left", Rc::strong_count(&config));}3 owners of prod2 owners leftRc and Arc were what confused me at first, because they look like they break the one-owner rule. They don’t, really. The Rc is the owner, and it owns the counter along with the value. What you get from it is shared, read-only access, so the other half of the rules still holds: if several places can see a value, none of them can change it.
use std::rc::Rc;
fn main() { let names = Rc::new(vec![String::from("a")]); let other = Rc::clone(&names); other.push(String::from("b"));}error[E0596]: cannot borrow data in an `Rc` as mutable --> rc_mutate.rs:6:5 |6 | other.push(String::from("b")); | ^^^^^ cannot borrow as mutable | = help: trait `DerefMut` is required to modify through a dereference, but it is not implemented for `Rc<Vec<String>>`RefCell: the borrow check moves to runtime
RefCell<T> is the way out, and the rules don’t go away with it: it checks them while the program runs instead of when it compiles. borrow() hands out a read borrow, borrow_mut() a write borrow, and the cell keeps count. Rc<RefCell<T>> is the usual pair: several owners, and changes through any of them.
use std::cell::RefCell;use std::rc::Rc;
fn main() { let names = Rc::new(RefCell::new(vec![String::from("a")])); let other = Rc::clone(&names); other.borrow_mut().push(String::from("b")); println!("{:?}", names.borrow());
let first = names.borrow_mut(); let second = names.borrow_mut(); // compiles, panics at runtime println!("{} {}", first.len(), second.len());}The first half works. The second compiles fine and stops the program, because two writers at once is exactly what the compiler would have refused:
["a", "b"]
thread 'main' (87) panicked at refcell.rs:11:24:RefCell already borrowedThat is the cost of RefCell: a small counter on every borrow, and a mistake that shows up in production instead of in the editor. If you have seen Java throw a ConcurrentModificationException when a list changes during a loop, it’s the same idea: a check at runtime for something the code should never do.
Arc and Mutex: the same, across threads
Rc updates its counter without any synchronization, which is why it’s cheap. Send one to another thread and the compiler stops you:
error[E0277]: `Rc<i32>` cannot be sent between threads safely --> rc_thread.rs:6:32 |6 | let handle = thread::spawn(move || println!("{counter}")); | ------------- -------^^^^^^^^^^^^^^^^^^^^^^ | | | | | `Rc<i32>` cannot be sent between threads safelyArc<T> is the atomic version: same idea, a counter updated with atomic instructions, so it’s safe to share between threads and a little more expensive. Like Rc, it only gives read access. To change the value from several threads you add a lock, Mutex<T>, and you can only reach the data by taking it. Java’s synchronized works on the honour system; here, code that forgets the lock doesn’t compile.
The CRDT server keeps the shared state in one Arc<Mutex<…>>. Each new client gets its own clone of the Arc, which moves into the task that serves it. The same function returns Box<dyn Error>, because binding the socket and accepting a connection fail with errors of their own:
pub async fn run(port: u16) -> Result<(), Box<dyn std::error::Error>> { let listener = TcpListener::bind(format!("127.0.0.1:{port}")).await?; println!("CRDT server listening on 127.0.0.1:{port}");
let state = Arc::new(Mutex::new(ServerState { next_client_id: 1, counter: GCounter::new(), rga_ops: Vec::new(), clients: HashMap::new(), }));
loop { let (socket, addr) = listener.accept().await?; println!("New connection from {addr}");
let state = Arc::clone(&state); tokio::spawn(async move { if let Err(e) = handle_client(socket, state).await { eprintln!("Client error: {e}"); } }); }}The Mutex here is tokio’s, which you lock with .await: a task waiting for it gives the thread back to the other tasks instead of blocking it.
The strange parts
A few behaviours surprise everybody once:
-
A
RefCellborrow lasts until the end of the scope, not until its last use. The compiler is smart about plain references and ends a borrow where you stop using it. ARefCellborrow is a value, a guard, and it is dropped at the closing brace:guard.rs let count = names.borrow().len(); // the guard is dropped at the end of this linenames.borrow_mut().push(format!("{count}"));let view = names.borrow(); // this guard lives until the end of mainprintln!("first: {}", view[0]);names.borrow_mut().push(String::from("c")); // view is not used again, still panicsOutput thread 'main' (113) panicked at guard.rs:11:11:RefCell already borrowedThe fix is to keep a borrow inside one expression, or to close it with
drop(view)before borrowing again. -
A
Mutexdoesn’t let the same thread lock it twice. It is not reentrant: the secondlock()in the thread that already holds it never returns; it might deadlock or panic. Java’ssynchronizedis reentrant, so this one catches people who come from there. -
Every
lock()returns aResult, and that is why you see.lock().unwrap()everywhere. If a thread panics while it holds the lock, the mutex is marked as poisoned, and the nextlock()returns an error instead of the data, because the data might be half-updated. It’s still reachable on purpose:into_inner()on the error hands it over. -
Box<dyn Error>cannot cross threads. It says nothing about whether the error inside is safe to send, sothread::spawnrefuses it:Output error[E0277]: `dyn std::error::Error` cannot be sent between threads safely--> error_send.rs:9:18|9 | let handle = thread::spawn(|| work());| ^^^^^^^^^^^^^^^^^^^^^^^^ `dyn std::error::Error` cannot be sent between threads safelyThe answer is to promise it in the type,
Box<dyn Error + Send + Sync>. The CRDT server does exactly that for the function that runs inside each spawned task,handle_client, whilerunkeeps the plainBox<dyn Error>. -
Two
Rcthat point to each other are never freed. The counters never reach zero, and nothing in Rust comes to collect the cycle the way CPython’s collector would. For the compiler this is still safe: a leak is wasted memory, not a dangling pointer. The tool against it isWeak<T>, a pointer that doesn’t count as an owner. In a tree the parents own the children withRc, and a child points back to its parent withWeak.upgrade()gives the parent back if it still exists, andNoneonce it is gone. -
Rc::clone(&x)andx.clone()do the same thing. The first form is the convention because it reads as what it is, a counter increment, while.clone()on other types usually means a deep copy.
Which one, and what it costs
| Type | Owners | Threads | Changes the value | Rules checked | Cost |
|---|---|---|---|---|---|
Box<T> | one | like T | yes, through the owner | at compile time | one allocation |
Rc<T> | many | one | no | at compile time | a counter update on clone and drop |
Arc<T> | many | many | no | at compile time | an atomic counter update |
RefCell<T> | n/a | one | yes, through a shared reference | at runtime, it panics | a borrow counter |
Mutex<T> | n/a | many | yes, while locked | at runtime, it blocks | a lock |
Let the compiler tell you
You don’t have to pick one in advance. Start with plain ownership and references, and the compiler tells you when that’s not enough, in a few recognisable ways:
E0072, a type with infinite size: put aBoxwhere the type contains itself.the size … cannot be known at compilation timeon adyn Trait: put it in aBox, likeBox<dyn Error>.- A value that two parts of the program need to own:
Rc, orArcif threads are involved. E0596, cannot borrow data in anRcas mutable: addRefCell, orMutexfor threads.E0277, cannot be sent between threads safely: replaceRcwithArc, andBox<dyn Error>withBox<dyn Error + Send + Sync>.