Reinventing From Scratch — Rc<T>
Chapter 0 — Preface: Why Rebuild Rc<T>?
Shared ownership looks polite on the surface; under the hood it’s a negotiation of lifetimes, counts, and carefully staged destruction.
This book-length chapter set is a ground-up reconstruction of Rust’s single-threaded reference-counted pointer: Rc<T>. The goal is not to replace the standard library but to internalize the invariants that make Rc<T> sound. By building our own miniature Rc, you’ll see the engineering tradeoffs behind:
- The distinction between strong and weak references.
- Why
Rc<T>is deliberately !Send and !Sync (non-thread-safe). - How implicit weak ownership ties the allocation’s lifetime to the strong regime.
- Why cycles leak and how
Weak<T>is the standard escape hatch. - Where tail allocation and header+value layouts yield cache-friendly designs.
How to read this series
Each chapter mixes narrative, diagrams, code, and proof sketches that state what the unsafe blocks rely on. We keep invariants front-and-center and introduce interior mutability via Cell<usize> for counts. You’ll also get:
- Progressive implementations (
MyRc<T>,MyWeak<T>). - Deallocation paths that separate value drop from block free.
- Testing guidance with Miri (to catch UB) and checklists you can keep next to your editor.
Why care if Rc<T> already exists?
Because containers and smart pointers are patterns, and you will one day invent one. Understanding Rc empowers you to design safe intrusive lists, arenas, interning tables, and cache layers. You’ll know when to use Rc, when to pick Arc, and when to build bespoke ownership forms.
What we won’t cover
- Multi-threaded atomics (
Arc<T>). Our focus is single-threadedCell-based counts. - Garbage collection and tracing. RC is a deterministic, local heuristic—GC is global.
- Cycle collectors. We’ll show leak-avoidance patterns using
Weak, not full collectors.
A running mental model
Rc<T> (strong) Weak<T> (weak)
│ │
└──► [header: strong, weak | value: T]
▲ ▲
value kept alive │ └─ allocation kept alive (implicit weak)
``
When **strong > 0**, `T` is alive. When **strong == 0**, `T` is dropped, but the allocation remains as long as **weak > 0** (so `Weak` can still upgrade and fail gracefully). Finally, when **strong == 0 && weak == 0**, the allocation is deallocated.
### Safety mindset
- The counts live in a **header** (`RcBox<T>`) preceding `T`. We mutate them via `Cell<usize>`.
- `Clone` increments **strong**; `Drop` decrements **strong** and maybe destroys `T`.
- `Weak::upgrade` observes **strong** and, if nonzero, increments it and returns a new strong handle.
- `Weak::drop` decrements **weak** and perhaps frees the allocation once both counts are zero.
> ⚠️ We never touch atomics here. That is the single-threaded contract that keeps `Rc` fast but confined.
### Who is this for?
Systems programmers, Rust learners who want to go beyond “just use it,” and anyone designing data structures that need explicit, deterministic memory management under the borrow checker.
---
## Deep Dive: Formal Invariants, Proof Sketches, and Edge Cases
### A. Formal Invariants
We maintain the following invariants for every allocation of `RcBox<T>`:
- **I1 (Header Validity):** The fields `strong` and `weak` are valid `usize` counters that never underflow or overflow.
- **I2 (Value Liveness):** `value: T` is initialized iff `strong > 0`. Equivalently, `strong == 0` implies `value` has been dropped.
- **I3 (Allocation Liveness):** The allocation is considered live iff `strong + weak > 0`.
- **I4 (Implicit Weak):** While `strong > 0`, the `weak` counter includes an implicit unit that represents the allocation’s ownership by the strong regime. When `strong` becomes 0, we consume exactly one unit from `weak`.
- **I5 (Deallocation Condition):** The allocation is deallocated iff `strong == 0 && weak == 0`.
- **I6 (Alias Soundness):** `get_mut` only returns `&mut T` when `strong == 1`. No two `&mut T` aliases may exist simultaneously.
- **I7 (Upgrade Correctness):** `Weak::upgrade()` returns `Some` iff it can soundly increment `strong` (i.e., `strong > 0` at the time of the increment). Otherwise it returns `None` without touching memory after deallocation.
These invariants are checked conceptually at every mutating operation: `clone`, `drop`, `downgrade`, `upgrade`, `get_mut`, and `try_unwrap`.
### B. Proof Sketches
**B.1 Single Drop of `value`**
- Let `S` be the number of strong owners. Only when `S` transitions from 1 to 0 do we call `drop_in_place(value)`.
- After the transition, `value` is logically absent (I2). No future path calls `drop_in_place(value)` again because all other `Drop` paths observe `S > 0` or `S == 0` and skip value drop.
- Therefore the destructor of `T` runs exactly once.
**B.2 No Use-After-Free in `upgrade`**
- The allocation is freed only when `S == 0 && W == 0` (I5).
- `upgrade` first reads `S`. If `S == 0`, it returns `None` and does not dereference `value` nor mutate `S`.
- If `S > 0`, it increments `S` before producing a `MyRc<T>`. Since writes to `Cell` are instantaneous w.r.t. this single-threaded model, either the increment happens first (then we are safe) or a concurrent decrement is impossible (no multi-threading). Hence no racing free occurs.
**B.3 Safety of `get_mut`**
- `get_mut` requires unique strong ownership (`S == 1`) to produce `&mut T`. If a second `Rc` existed, `S` would be greater than 1 and the method would refuse, preserving aliasing guarantees (I6).
### C. Panic Safety Considerations
- Destructors should not panic. If they do, Rust aborts during unwinding-from-unwinding; our model does not attempt to recover from double-panics.
- Construction is panic-safe because allocation and initialization happen in a single `ptr::write` of `RcBox<T>`; if panic occurs before storing, the program has not published the pointer.
- Reduction operations (`drop`) use explicit branches and avoid underflow; we do not use saturating arithmetic to prevent masking logic errors.
### D. Edge Cases
- **Degenerate types:** For `T = ()` or other ZSTs, headers still exist and counts still function; the value drop is a no-op, but the ordering invariants remain required.
- **Self-referential graphs:** `Rc<T>` does not move `T` in memory; address stability is preserved, but cycles still leak. Use `Weak` for back-edges.
- **`ptr_eq` and multiple allocations:** `ptr_eq` only answers whether two `Rc`s refer to the same allocation, not whether `T` values are equal by `PartialEq`.
### E. Micro-bench Hints
- Cloning is a single `Cell` increment; microbench with `criterion` across hot loops.
- Avoid creating/dropping `Weak` in tight loops unless necessary; it touches `weak` each time.
- Prefer `get_mut` or `try_unwrap` when ownership is unique; it skips reference-cell borrow machinery.
---
## Worked Example: A Tiny Tree with Parent Weak Links
```rust
use std::cell::{RefCell};
#[derive(Debug)]
struct Node {
name: String,
parent: RefCell<MyWeak<Node>>,
children: RefCell<Vec<MyRc<Node>>>,
}
fn make_node(name: &str) -> MyRc<Node> {
MyRc::new(Node {
name: name.into(),
parent: RefCell::new(MyRc::downgrade(&MyRc::new(Node{name:String::new(), parent:RefCell::new(MyWeak{ptr: unsafe{std::mem::transmute(1usize)}, _marker: std::marker::PhantomData}}, children: RefCell::new(vec![])}))), // placeholder, replaced on adopt
children: RefCell::new(vec![]),
})
}
// Helper: adopt `child` into `parent` (strong), set child's parent (weak)
fn adopt(parent: &MyRc<Node>, child: &MyRc<Node>) {
*child.parent.borrow_mut() = MyRc::downgrade(parent);
parent.children.borrow_mut().push(child.clone());
}
The above uses a placeholder trick to satisfy the compiler in an example-only context; in production you would construct nodes in two phases or use
Defaultand then set links.
Walkthrough
- Create
root,child1,child2asMyRc<Node>. - Call
adopt(&root, &child1)andadopt(&root, &child2). - Dropping
rooteventually drops both children once nothing else points at them strongly; theirparentfields areWeakand do not retain the tree.
Debugging Playbook
-
Symptom: Allocation never freed.
Check: Did you forget to consume the implicit weak when last strong dropped? Are there outstandingWeak? -
Symptom: Double free on last
Weak.
Check: Did you decrementweakboth on the last-strong path and again inWeak::dropwithout the implicit-weak protocol? -
Symptom: Use-after-free in
upgrade.
Check: Are you freeing the allocation whileweak > 0? Did you deallocate before decrementing the very lastweak? -
Symptom: UB in
get_mut.
Check: Ensure you only return&mut Twhenstrong == 1at the exact time of check. Avoid creating&mutacross code that might clone anotherRc.
FAQ (Extended)
Q: Why not store counts adjacent to each strong handle, like a decentralized RC?
A: It complicates synchronization even in single-threaded contexts and makes upgrade semantics unclear. Centralized counts in a header give one source of truth.
Q: Can I make Rc<T> Send if T: Send?
A: Not without atomics. The counters themselves would race; Arc<T> exists for this purpose.
Q: What about memory ordering on a single thread?
A: In single-threaded Rust, the memory model reduces to program order for Cell. We rely on the lack of concurrency, not on fences.
Q: Why does weak_count subtract 1 when strong > 0?
A: To hide the implicit weak from public observers and match std’s API expectations.
Q: Are overflow checks necessary?
A: Practically, you won’t reach usize::MAX strong references. We still use checked_add in educational code to make logic errors obvious.
Exercises (With Hints)
-
Design a
make_cyclic_pair()that intentionally creates a leaking 2-node cycle.
Hint: Store strong references in each node’snextfield; verify withdroplogs that destructors never run. -
Prove that
try_unwrapis linear in the size ofT.
Hint: It performs a singleptr::readofTand conditional deallocation; no per-field work beyondDropofTis forced. -
Implement
Rc::make_mutthat clonesTon write ifstrong > 1.
Hint: This requiresT: Cloneand either temporary ownership orRefCell<T>. -
Add debug assertions: ensure that on last-strong drop,
strong == 1andweak >= 1.
Hint: These can bedebug_assert!s to avoid release-mode overhead. -
Benchmark cloning vs upgrading using Criterion.
Hint: Hot loop withb.iter(|| { let _ = a.clone(); })vslet _ = w.upgrade();from a pre-madeWeak.
Suggested Further Reading
- Rustonomicon: Reference Counting and Ownership Models
- The
std::rcsource code (annotated with comments on drop order) - Swift’s ARC vs Rust’s RC: similarities and differences in move semantics
- CPython’s refcounting: GIL-enabled safety vs Rust’s type-level guarantees
Deep Dive: Formal Invariants, Proof Sketches, and Edge Cases
A. Formal Invariants
We maintain the following invariants for every allocation of RcBox<T>:
- I1 (Header Validity): The fields
strongandweakare validusizecounters that never underflow or overflow. - I2 (Value Liveness):
value: Tis initialized iffstrong > 0. Equivalently,strong == 0impliesvaluehas been dropped. - I3 (Allocation Liveness): The allocation is considered live iff
strong + weak > 0. - I4 (Implicit Weak): While
strong > 0, theweakcounter includes an implicit unit that represents the allocation’s ownership by the strong regime. Whenstrongbecomes 0, we consume exactly one unit fromweak. - I5 (Deallocation Condition): The allocation is deallocated iff
strong == 0 && weak == 0. - I6 (Alias Soundness):
get_mutonly returns&mut Twhenstrong == 1. No two&mut Taliases may exist simultaneously. - I7 (Upgrade Correctness):
Weak::upgrade()returnsSomeiff it can soundly incrementstrong(i.e.,strong > 0at the time of the increment). Otherwise it returnsNonewithout touching memory after deallocation.
These invariants are checked conceptually at every mutating operation: clone, drop, downgrade, upgrade, get_mut, and try_unwrap.
B. Proof Sketches
B.1 Single Drop of value
- Let
Sbe the number of strong owners. Only whenStransitions from 1 to 0 do we calldrop_in_place(value). - After the transition,
valueis logically absent (I2). No future path callsdrop_in_place(value)again because all otherDroppaths observeS > 0orS == 0and skip value drop. - Therefore the destructor of
Truns exactly once.
B.2 No Use-After-Free in upgrade
- The allocation is freed only when
S == 0 && W == 0(I5). upgradefirst readsS. IfS == 0, it returnsNoneand does not dereferencevaluenor mutateS.- If
S > 0, it incrementsSbefore producing aMyRc<T>. Since writes toCellare instantaneous w.r.t. this single-threaded model, either the increment happens first (then we are safe) or a concurrent decrement is impossible (no multi-threading). Hence no racing free occurs.
B.3 Safety of get_mut
get_mutrequires unique strong ownership (S == 1) to produce&mut T. If a secondRcexisted,Swould be greater than 1 and the method would refuse, preserving aliasing guarantees (I6).
C. Panic Safety Considerations
- Destructors should not panic. If they do, Rust aborts during unwinding-from-unwinding; our model does not attempt to recover from double-panics.
- Construction is panic-safe because allocation and initialization happen in a single
ptr::writeofRcBox<T>; if panic occurs before storing, the program has not published the pointer. - Reduction operations (
drop) use explicit branches and avoid underflow; we do not use saturating arithmetic to prevent masking logic errors.
D. Edge Cases
- Degenerate types: For
T = ()or other ZSTs, headers still exist and counts still function; the value drop is a no-op, but the ordering invariants remain required. - Self-referential graphs:
Rc<T>does not moveTin memory; address stability is preserved, but cycles still leak. UseWeakfor back-edges. ptr_eqand multiple allocations:ptr_eqonly answers whether twoRcs refer to the same allocation, not whetherTvalues are equal byPartialEq.
E. Micro-bench Hints
- Cloning is a single
Cellincrement; microbench withcriterionacross hot loops. - Avoid creating/dropping
Weakin tight loops unless necessary; it touchesweakeach time. - Prefer
get_mutortry_unwrapwhen ownership is unique; it skips reference-cell borrow machinery.
Worked Example: A Tiny Tree with Parent Weak Links
use std::cell::{RefCell};
#[derive(Debug)]
struct Node {
name: String,
parent: RefCell<MyWeak<Node>>,
children: RefCell<Vec<MyRc<Node>>>,
}
fn make_node(name: &str) -> MyRc<Node> {
MyRc::new(Node {
name: name.into(),
parent: RefCell::new(MyRc::downgrade(&MyRc::new(Node{name:String::new(), parent:RefCell::new(MyWeak{ptr: unsafe{std::mem::transmute(1usize)}, _marker: std::marker::PhantomData}}, children: RefCell::new(vec![])}))), // placeholder, replaced on adopt
children: RefCell::new(vec![]),
})
}
// Helper: adopt `child` into `parent` (strong), set child's parent (weak)
fn adopt(parent: &MyRc<Node>, child: &MyRc<Node>) {
*child.parent.borrow_mut() = MyRc::downgrade(parent);
parent.children.borrow_mut().push(child.clone());
}
The above uses a placeholder trick to satisfy the compiler in an example-only context; in production you would construct nodes in two phases or use
Defaultand then set links.
Walkthrough
- Create
root,child1,child2asMyRc<Node>. - Call
adopt(&root, &child1)andadopt(&root, &child2). - Dropping
rooteventually drops both children once nothing else points at them strongly; theirparentfields areWeakand do not retain the tree.
Debugging Playbook
-
Symptom: Allocation never freed.
Check: Did you forget to consume the implicit weak when last strong dropped? Are there outstandingWeak? -
Symptom: Double free on last
Weak.
Check: Did you decrementweakboth on the last-strong path and again inWeak::dropwithout the implicit-weak protocol? -
Symptom: Use-after-free in
upgrade.
Check: Are you freeing the allocation whileweak > 0? Did you deallocate before decrementing the very lastweak? -
Symptom: UB in
get_mut.
Check: Ensure you only return&mut Twhenstrong == 1at the exact time of check. Avoid creating&mutacross code that might clone anotherRc.
FAQ (Extended)
Q: Why not store counts adjacent to each strong handle, like a decentralized RC?
A: It complicates synchronization even in single-threaded contexts and makes upgrade semantics unclear. Centralized counts in a header give one source of truth.
Q: Can I make Rc<T> Send if T: Send?
A: Not without atomics. The counters themselves would race; Arc<T> exists for this purpose.
Q: What about memory ordering on a single thread?
A: In single-threaded Rust, the memory model reduces to program order for Cell. We rely on the lack of concurrency, not on fences.
Q: Why does weak_count subtract 1 when strong > 0?
A: To hide the implicit weak from public observers and match std’s API expectations.
Q: Are overflow checks necessary?
A: Practically, you won’t reach usize::MAX strong references. We still use checked_add in educational code to make logic errors obvious.
Exercises (With Hints)
-
Design a
make_cyclic_pair()that intentionally creates a leaking 2-node cycle.
Hint: Store strong references in each node’snextfield; verify withdroplogs that destructors never run. -
Prove that
try_unwrapis linear in the size ofT.
Hint: It performs a singleptr::readofTand conditional deallocation; no per-field work beyondDropofTis forced. -
Implement
Rc::make_mutthat clonesTon write ifstrong > 1.
Hint: This requiresT: Cloneand either temporary ownership orRefCell<T>. -
Add debug assertions: ensure that on last-strong drop,
strong == 1andweak >= 1.
Hint: These can bedebug_assert!s to avoid release-mode overhead. -
Benchmark cloning vs upgrading using Criterion.
Hint: Hot loop withb.iter(|| { let _ = a.clone(); })vslet _ = w.upgrade();from a pre-madeWeak.
Suggested Further Reading
- Rustonomicon: Reference Counting and Ownership Models
- The
std::rcsource code (annotated with comments on drop order) - Swift’s ARC vs Rust’s RC: similarities and differences in move semantics
- CPython’s refcounting: GIL-enabled safety vs Rust’s type-level guarantees