Rc<T> is laid out in memory: a single heap allocation holding your value next to two counts;Rc::clone is cheap, and how it differs from cloning the value inside;Rc with RefCell to get shared, mutable data, as in a tree of nodes;Rc methods: strong_count, ptr_eq, get_mut, and try_unwrap;Rcs can leak memory, and why Rc is strictly single-threaded.Learn Rust Series):You have been using Rc since episode 12 as "a value with several owners", and at the end of the last episode we leaned on it again, wrapping a RefCell inside an Rc to get shared, mutable, single-threaded data. Today we stop treating Rc as a black box and open the lid. Once you can picture the allocation it manages, and watch the counts move as handles come and go, the whole family of reference-counted types clicks into place at once -- and, just as important, you understand both what Rc can do and what it deliberately cannot ;-)
As always, let me clear last episode's homework first, and then we go internals.
Episode 32 was interior mutability, Cell and RefCell. There were three tasks, and here is full runnable code for each.
Exercise 1 asked for a Config struct with a Cell<bool> "dirty" flag, a mark_dirty(&self) method that sets it to true, and an is_dirty(&self) -> bool reader -- confirming you can flip the flag while only ever holding a shared &Config:
use std::cell::Cell;
struct Config {
dirty: Cell<bool>,
}
impl Config {
fn new() -> Self {
Config { dirty: Cell::new(false) }
}
fn mark_dirty(&self) { // note: &self, not &mut self
self.dirty.set(true);
}
fn is_dirty(&self) -> bool {
self.dirty.get()
}
}
fn main() {
let cfg = Config::new();
println!("dirty? {}", cfg.is_dirty()); // false
cfg.mark_dirty(); // mutated through a shared &Config
println!("dirty? {}", cfg.is_dirty()); // true
}
The whole point sits in fn mark_dirty(&self): a bool gets flipped through a shared reference, no &mut in sight. Because bool is Copy, Cell is the perfect, panic-free tool here -- get reads a copy out, set writes a whole new value in.
Exercise 2 wanted a Cache wrapping a RefCell<HashMap<u32, u64>> with a single fib(&self, n: u32) -> u64 method that memoises Fibonacci results in the map, all through &self, taking care that any borrow_mut() guard is dropped before you recurse:
use std::cell::RefCell;
use std::collections::HashMap;
struct Cache {
memo: RefCell<HashMap<u32, u64>>,
}
impl Cache {
fn new() -> Self {
Cache { memo: RefCell::new(HashMap::new()) }
}
fn fib(&self, n: u32) -> u64 {
if n < 2 {
return n as u64;
}
// The borrow() here produces a temporary Ref guard that is dropped
// at the end of this if-let, BEFORE the recursive calls below.
if let Some(&cached) = self.memo.borrow().get(&n) {
return cached;
}
let value = self.fib(n - 1) + self.fib(n - 2);
self.memo.borrow_mut().insert(n, value); // guard drops at the semicolon
value
}
}
fn main() {
let cache = Cache::new();
println!("fib(10) = {}", cache.fib(10)); // 55
println!("fib(50) = {}", cache.fib(50)); // 12586269025
}
The trap this exercise trains you to avoid is holding a borrow across the recursion. The borrow() inside the if let yields a temporary Ref that lives only for that statement, so it is already gone by the time self.fib(n - 1) runs and takes its own borrows. If you had instead written let map = self.memo.borrow(); at the top of the method and kept it alive, the first recursive call's borrow_mut() would have blown up with a BorrowMutError. Short borrows, exactly as we drilled last time.
Exercise 3 asked you to deliberately cause a BorrowMutError by holding a borrow() while calling something that does borrow_mut() on the same RefCell, observe the panic, and then fix it by scoping the first borrow in its own { ... } block:
use std::cell::RefCell;
fn main() {
let data = RefCell::new(vec![1, 2, 3]);
// BROKEN (panics): the shared borrow in `guard` is still alive on the
// next line, so borrow_mut() sees a conflicting borrow and panics with
// "already borrowed: BorrowMutError".
//
// let guard = data.borrow();
// data.borrow_mut().push(4);
// println!("{guard:?}");
// FIXED: scope the shared borrow so its guard drops before we mutate.
let first = {
let guard = data.borrow();
guard[0] // copy the value out; `guard` drops at this closing brace
};
data.borrow_mut().push(4); // fine now -- no live borrow remains
println!("first was {first}, data now {:?}", data.borrow()); // 1, [1, 2, 3, 4]
}
The broken version compiles perfectly and then panics at runtime, which is RefCell's whole bargain: the borrow rules are still enforced, just a beat too late to be a compile error. The fix is the little { ... } block -- it forces the Ref guard to drop at the closing brace, so by the time borrow_mut() runs there is no conflicting borrow alive. Right, homework cleared -- now let us pry Rc open ;-)
When you write Rc::new(value), Rc performs a single heap allocation and lays out three things inside it, back to back: a strong count, a weak count, and your value. Internally this block is often called the RcBox. The Rc<T> handle you hold on the stack is nothing more than a pointer to that block -- it is exactly one machine word wide, the size of a pointer, regardless of how big T is.
That layout explains everything that follows. Cloning an Rc does not touch your value at all; it copies the pointer and bumps the strong count by one. Dropping an Rc lowers the strong count by one. And the value itself -- your T -- is dropped and its part of the allocation freed only when the strong count reaches zero, meaning the last owner has gone away:
use std::rc::Rc;
fn main() {
let a = Rc::new(String::from("shared"));
println!("strong = {}", Rc::strong_count(&a)); // 1
let b = Rc::clone(&a); // pointer copied, count now 2
{
let c = Rc::clone(&a);
println!("strong = {}", Rc::strong_count(&a)); // 3
let _ = &c;
} // c goes out of scope here -- its Drop lowers the count back to 2
println!("strong = {}", Rc::strong_count(&a)); // 2
let _ = &b;
}
The string "shared" exists once on the heap, and a, b, and c are three pointers aimed at it. That is the entire mechanism in a sentence: a count living next to the data, raised on clone and lowered on drop. Nothing more magical than that.
Notice the idiom Rc::clone(&a) rather than a.clone(). Both do exactly the same thing, but the community convention is to write Rc::clone for reference-count bumps, precisely because it reads as "cheap pointer copy, increment a counter" and does not look like a potentially expensive deep copy of the underlying data. It is a signal to the reader, and it is worth adopting.
The count-hits-zero rule is easiest to believe when you can see it happen. Give a type a Drop implementation (episode 20) and it will announce the exact instant it is freed:
use std::rc::Rc;
struct Tracked;
impl Drop for Tracked {
fn drop(&mut self) {
println!("value freed");
}
}
fn main() {
let a = Rc::new(Tracked);
let b = Rc::clone(&a);
drop(a); // strong count 2 -> 1, nothing is freed yet
println!("still alive");
drop(b); // strong count 1 -> 0, NOW "value freed" prints
}
The output is still alive followed by value freed, in that order. Dropping a only decrements the count; the Tracked value survives because b still owns it. It is the drop of the last handle that runs the destructor and releases the memory. This is deterministic, by the way -- there is no garbage collector deciding when to run later. The freeing happens synchronously, at the precise point the final Rc is dropped.
Because an Rc::clone only copies a pointer and touches a counter, it is an O(1) operation no matter how large the shared data is. That is the reason to reach for Rc in the first place: sharing a big structure between many owners without paying to copy it. Contrast that with cloning the value inside the Rc, which really does duplicate it:
use std::rc::Rc;
fn main() {
let original = Rc::new(vec![10, 20, 30, 40, 50]);
// Rc::clone: copy a pointer, bump the count. The Vec is NOT copied.
let handle = Rc::clone(&original);
println!("shared view: {:?}", handle); // [10, 20, 30, 40, 50]
println!("strong = {}", Rc::strong_count(&original)); // 2
// Cloning the INNER value makes a real, separate Vec on its own.
let independent: Vec<i32> = (*original).clone();
println!("independent copy: {:?}", independent);
println!("strong still = {}", Rc::strong_count(&original)); // still 2
}
The first clone gives you a second owner of the same vector and pushes the strong count to two. The second one dereferences through the Rc to reach the Vec and clones that, producing a brand-new, independent vector that has nothing to do with the reference count. Knowing which clone you are calling is the difference between a pointer bump and a full heap copy.
Rc hands out only shared access -- Rc<T> derefs to &T, never to &mut T. It has to, because with several owners in play there is no single one entitled to mutate. That is the same wall we hit at the end of episode 32, and the same solution applies: to get shared mutable data, you put a RefCell inside the Rc, giving Rc<RefCell<T>>. A tree of nodes is the classic case that needs exactly this:
use std::rc::Rc;
use std::cell::RefCell;
#[derive(Debug)]
struct Node {
value: i32,
children: RefCell<Vec<Rc<Node>>>,
}
fn main() {
let leaf = Rc::new(Node {
value: 3,
children: RefCell::new(vec![]),
});
let branch = Rc::new(Node {
value: 5,
children: RefCell::new(vec![Rc::clone(&leaf)]),
});
println!("branch value: {}", branch.value); // 5
println!("leaf owners: {}", Rc::strong_count(&leaf)); // 2: leaf + branch's child
// The RefCell lets us push a new child through a shared Rc handle.
branch.children.borrow_mut().push(Rc::new(Node {
value: 4,
children: RefCell::new(vec![]),
}));
println!("branch children: {}", branch.children.borrow().len()); // 2
}
The leaf node is owned twice -- once by the leaf binding, once as a child inside branch -- so its strong count reads two. The RefCell around children is what lets us push a new child even though we only ever hold shared Rcs. This Rc<RefCell<T>> shape is the single-threaded workhorse for graphs, trees, and any structure where several parts of a program point at the same node.
Sometimes you need to know whether two handles point at the same allocation, as opposed to at two separate allocations that merely hold equal values. Rc::ptr_eq answers exactly that -- it compares the pointers, not the values behind them:
use std::rc::Rc;
fn main() {
let a = Rc::new(5);
let b = Rc::clone(&a);
let c = Rc::new(5); // same number, but a different allocation
println!("a and b same allocation? {}", Rc::ptr_eq(&a, &b)); // true
println!("a and c same allocation? {}", Rc::ptr_eq(&a, &c)); // false
}
a and b are two handles into one allocation, so ptr_eq is true. c holds a 5 as well, but it lives in its own separate RcBox, so ptr_eq(&a, &c) is false even though *a == *c. This identity check is invaluable when you walk a graph and need to recognise "have I already visited this exact node?".
You cannot normally get a &mut out of an Rc, but there is a safe escape hatch for the special case where you happen to be the only owner. Rc::get_mut hands you a mutable reference to the inner value, but only when the strong count is one; otherwise it returns None:
use std::rc::Rc;
fn main() {
let mut a = Rc::new(5);
if let Some(v) = Rc::get_mut(&mut a) {
*v += 1; // allowed: a is currently the only owner
}
println!("{a}"); // 6
let _b = Rc::clone(&a); // now there are two owners
println!("can still mutate? {}", Rc::get_mut(&mut a).is_some()); // false
}
While a is the sole handle, get_mut grants mutable access. The moment we clone it, a second owner exists, and get_mut starts returning None -- because handing out a &mut while another owner is watching would break the aliasing rule. That None is a compiler-backed guarantee that you will never accidentally mutate data someone else is relying on.
A close cousin is Rc::try_unwrap, which tries to move the inner value out of the Rc entirely, consuming the handle. It succeeds only if this is the last remaining owner (strong count one); otherwise it hands the Rc back to you unharmed inside an Err:
use std::rc::Rc;
fn main() {
let sole = Rc::new(String::from("mine alone"));
match Rc::try_unwrap(sole) {
Ok(inner) => println!("reclaimed: {inner}"), // reclaimed: mine alone
Err(_still_shared) => println!("could not reclaim"),
}
let shared = Rc::new(String::from("ours"));
let _other = Rc::clone(&shared);
match Rc::try_unwrap(shared) {
Ok(inner) => println!("reclaimed: {inner}"),
Err(rc) => println!("still {} owners, kept it", Rc::strong_count(&rc)), // 2 owners
}
}
The first call works because sole is the only handle, so the String is moved straight out and the allocation is freed. The second call fails, and rather than losing your data it returns the Rc in the Err variant, so nothing is dropped or leaked. This is how you gracefully "graduate" a value out of shared ownership once you know you hold the last reference.
Remember that the allocation holds two counts: a strong count and a weak count. Everything so far has been about the strong count, which governs whether the value stays alive. The weak count exists for a different reason, and to see why it is needed, look at what goes wrong when two Rcs point at each other:
use std::rc::Rc;
use std::cell::RefCell;
struct Node {
next: RefCell<Option<Rc<Node>>>,
}
fn main() {
let a = Rc::new(Node { next: RefCell::new(None) });
let b = Rc::new(Node { next: RefCell::new(None) });
// Link them into a cycle: a points at b, and b points back at a.
*a.next.borrow_mut() = Some(Rc::clone(&b));
*b.next.borrow_mut() = Some(Rc::clone(&a));
println!("a strong = {}", Rc::strong_count(&a)); // 2
println!("b strong = {}", Rc::strong_count(&b)); // 2
// When main ends, the `a` and `b` bindings drop, taking each count from
// 2 down to 1 -- but never to 0, because each node still holds an Rc to
// the other. The two allocations keep each other alive and are LEAKED.
}
This compiles, runs, prints, and quietly leaks memory. Each node's strong count never reaches zero because the two nodes hold Rcs to each other, so their destructors never run. Reference counting has this one well-known blind spot: it cannot reclaim a cycle. That is precisely the problem the weak count is built to solve -- a way to point at a node without keeping it alive -- and it is where we go in an upcoming episode. For now, just file away the warning: a graph built purely out of strong Rcs can leak if it forms a loop.
Since quite some of you arrived here from the Learn Python Series, a sideways glance is illuminating, because Python does something remarkably similar under the hood. CPython, the reference implementation, manages nearly all objects with -- of all things -- reference counting. Every object carries a count, every new name pointing at it bumps that count, and every name going away lowers it; when it hits zero the object is freed. That is Rc, built invisibly into the runtime for every object:
import sys
data = ["shared"]
alias = data # a second name for the same list object
print(sys.getrefcount(data)) # e.g. 3 -- data, alias, plus the temporary arg
del alias # one name gone, refcount drops
# When the last name referring to the list disappears, CPython frees it,
# exactly like an Rc reaching a strong count of zero.
The parallels run deep, right down to the flaw: CPython's reference counting cannot reclaim a reference cycle on its own either, which is exactly why CPython bolts a separate cyclic garbage collector on top to sweep those up. Rust makes a different choice. It gives you reference counting as an explicit, opt-in tool (Rc) rather than the default for everything, it makes the counts visible through strong_count, and instead of a background collector to catch cycles it hands you a weak-pointer mechanism to break them by design. You pay a little more attention up front; you get no surprise pauses and no hidden runtime. I think that is a very reasonable trade.
Rc::new(value) makes one heap allocation holding a strong count, a weak count, and your value. The Rc handle is just a pointer to that block, one word wide.Rc copies the pointer and bumps the strong count -- an O(1) operation, no matter how big the data. Cloning the inner value instead makes a full, separate copy.strong_count and a Drop impl let you observe directly.Rc only lends &T, so for shared mutable data you use Rc<RefCell<T>>, the canonical single-threaded building block for trees and graphs.ptr_eq tests same-allocation identity; get_mut gives a &mut only when you are the sole owner; try_unwrap moves the value out only when this is the last handle.Rcs leaks, because no count in the loop ever reaches zero -- the reason the weak count exists, which we will put to work soon.Rc uses plain, non-atomic counts, which is why it is fast but strictly single-threaded.That last point deserves a closing word, because it is the bridge to the next stretch of the series. The counts inside Rc are ordinary integers, incremented and decremented with plain, non-atomic machine instructions. That is what makes them cheap. But it also means that if two threads tried to bump the same count at once, their operations could interleave and corrupt it -- you might drop an increment and free a value that is still in use, the classic use-after-free. Rust refuses to let that happen by making Rc neither Send nor Sync (the marker traits from episode 25), so the compiler flatly rejects any attempt to move or share an Rc across a thread boundary.
When you genuinely need shared ownership across threads, you pay for atomic counts and reach for Arc -- identical in shape to Rc, same layout, same methods, but thread-safe. What that atomicity costs, and when it is worth paying, is exactly where we pick up next time.
Three exercises, from gentle to chewier. Type them yourself before the next episode -- that is where the understanding sticks.
Rc<String>, then clone it once at the top level and once more inside an inner { } block. Print Rc::strong_count at four points -- before any clone, after the first clone, inside the block after the second clone, and after the block ends -- and confirm the counts read 1, 2, 3, 2.Rc<Node> where Node { value: i32, children: RefCell<Vec<Rc<Node>>> }, then write a recursive function that sums every value in the tree. Verify the total against the numbers you put in.Rc<i32> and mutate it in place with Rc::get_mut. Then Rc::clone it and confirm that Rc::get_mut now returns None, and that Rc::ptr_eq reports the original and the clone as the same allocation.Right, that is Rc opened right up -- bedankt voor het meelezen, en tot de volgende keer! ;-)