What a smart pointer costs¶
Smart pointers › What it costs
Level: 201 → 301 · working knowledge
One line: The handle is free — Box<u64>, Rc<u64> and Arc<u64> are each one word, and Option of any of them is still one word — but the work is not: Rc::new(7u64) allocates 24 bytes, every clone changes a count at run time, and a RefCell carries a flag it checks on every borrow.
use std::mem::size_of;
use std::rc::Rc;
fn main() {
println!("{}", size_of::<Rc<u64>>()); // 8 — one pointer
let shared = Rc::new(7u64); // one allocation: 24 bytes
let second = Rc::clone(&shared); // no allocation; a count goes 1 -> 2
println!("{}", Rc::strong_count(&second)); // 2
}
Zero-cost means "no worse than by hand", not "gone"¶
A zero-cost abstraction costs nothing beyond the code you would have written yourself. For Box that holds exactly: section 1 of the run shows Box<u64> is 8 bytes, the size of the *const u64 you would have kept, and Option<Box<u64>> is still 8 because Box is built on a non-null pointer (Nullable pointers). Rc, Arc and Weak are the same one word.
What is not zero is the job each type does. The run counts it with a #[global_allocator] that passes every request to System and tallies it on the way — the technique from The global allocator.
Making one¶
| Expression | Allocations | Bytes | Why that many |
|---|---|---|---|
Box::new(7u64) |
1 | 8 | the u64 |
Rc::new(7u64) |
1 | 24 | a strong count, a weak count, then the u64 |
Arc::new(7u64) |
1 | 24 | the same three words, with atomic counts |
String::from("hello") |
1 | 5 | the bytes; the length and capacity live in the handle |
Vec::<u32>::with_capacity(4) |
1 | 16 | room for four u32 |
Cow::Borrowed("hello") |
0 | 0 | it points at the literal |
RefCell::new(5i32) |
0 | 0 | not a pointer at all — the flag sits beside the value |
Rc's 24 bytes are its private RcInner<T>: in the 1.98.0 source (alloc/src/rc.rs), a strong: Cell<usize>, a weak: Cell<usize> and the value: T, and an Rc<T> holds a NonNull to that.
Sharing one, and letting go¶
Sections 3 and 4 of the run:
Rc::cloneallocates nothing and moves the strong count from 1 to 2. That is whyRc::cloneis the cheapest.clone()in Rust — and also why it is not free: a count is written on every clone and every drop.Rc::downgradeallocates nothing and moves the weak count to 1.- Dropping the second
Rcfrees nothing. Dropping the lastRcdrops the value and still frees nothing —weak.upgrade()isNone, yet the 24 bytes stay, because the weak count lives in them. - Dropping the last
Weakis the onefree.
Arc does the same with atomic operations, which is what lets it cross threads and what makes each count change more expensive than Rc's. The run does not time it; a count change is too small to time with a single measurement, which Timing a block explains.
RefCell pays in a flag and a check¶
size_of::<RefCell<i32>>() is 16 where i32 is 4: the RefCell keeps a borrow counter beside the value, padded to 8 bytes. Every borrow() and borrow_mut() reads and writes it, and a conflicting borrow is found at run time — try_borrow_mut returns Err(BorrowMutError) while a Ref is alive, and borrow_mut would panic. Cell<i32> is 4 bytes: it never hands out references, so it has nothing to count. Interior mutability is the page for choosing between them.
Can the optimizer remove the allocation?¶
It is allowed to. std's GlobalAlloc ↗ docs say you must not rely on allocations actually happening: the optimizer may remove an unused one or move it to the stack. In this program it did not — built with rustc -C opt-level=3 on rustc 1.98.0, every line of the output was identical to the unoptimized run. That is a measurement of this program, not a promise about yours.
The verified output¶
Verified output of what_a_smart_pointer_costs.rs — regenerated by tools/run_examples.py, never hand-typed.
1. The pointer itself: one word, and the niche is kept
*const u64 8 bytes
Box<u64> 8 bytes
Rc<u64> 8 bytes
Arc<u64> 8 bytes
Weak<u64> 8 bytes
Option<Rc<u64>> 8 bytes
This is the zero-cost part: the handle is no bigger than the raw
pointer you would have kept by hand.
2. Making one: an allocation, sized by what is stored beside the value
Box::new(7u64) alloc 1 free 0 bytes 8
Rc::new(7u64) alloc 1 free 0 bytes 24
Arc::new(7u64) alloc 1 free 0 bytes 24
String::from("hello") alloc 1 free 0 bytes 5
Vec::<u32>::with_capacity(4) alloc 1 free 0 bytes 16
Cow::Borrowed("hello") alloc 0 free 0 bytes 0
RefCell::new(5i32) alloc 0 free 0 bytes 0
Box asks for the u64 alone. Rc and Arc ask for 24: a strong count,
a weak count, then the u64. The two that allocate nothing are the
two that store nothing on the heap.
3. Sharing one: no allocation, but a count changes at run time
Rc::clone(&shared) alloc 0 free 0 bytes 0
Rc::downgrade(&shared) alloc 0 free 0 bytes 0
strong_count 2 weak_count 1
4. Letting go: only the last strong owner drops the value
drop(second) alloc 0 free 0 bytes 0
strong_count 1
drop(shared), the last Rc alloc 0 free 0 bytes 0
weak.upgrade() None
drop(weak), the last Weak alloc 0 free 1 bytes 0
The value was dropped with the last Rc, but its 24 bytes were freed
only with the last Weak, because the weak count lives there too.
5. RefCell: a flag in the value, and a check on every borrow
size_of i32 4 Cell<i32> 4 RefCell<i32> 16
borrow_mut while a borrow is live: Err(BorrowMutError)
after the borrow ends: Ok(())
Cell hands out no references, so it needs no flag. RefCell counts
its borrows in a word beside the value and checks it at run time.
Miri reports no undefined behaviour in this program.
If you are coming from another language¶
- C++.
std::make_shared<T>allocates the control block and the value together, which isRc::new's single 24-byte allocation;std::shared_ptr<T>(new T)is two allocations, and Rust has no spelling for that.shared_ptrcounts atomically always, so it isArc;Rcis the non-atomic count C++ does not offer in the standard library. - Python. Every object carries its reference count in its header, so CPython pays the
Rccost on every value, and asys.getsizeof(0)of 28 bytes on a 64-bit build includes it. Rust charges the count only to the values you put in anRc. - Java, C#, Go. A tracing garbage collector has no count to update on copy, and pays instead in collection pauses and memory headroom;
Boxhas neither cost andRchas the count but no collector — which is why anRccycle leaks, as theRcpage shows.
See also¶
- What makes a pointer smart —
DerefandDrop, the two traits these costs sit behind - Smart pointer claims, run — "a compile-time construct the compiler boils away", checked
Box— the one allocation, and when to pay itRc: the clone that copies a pointer — the count in full- The third owned form:
Box<str>,Rc<str>,Arc<str>— the same costs for text - The global allocator — the counting technique used here
Sources¶
std's GlobalAlloc ↗, Rc ↗ and RefCell ↗; the RcInner definition in alloc/src/rc.rs of the 1.98.0 rust-src component. The optimized comparison is rustc -C opt-level=3 on x86-64 macOS.
Po polsku¶
„Zerowy koszt” (zero-cost abstraction) znaczy „nie drożej niż ręcznie”, a nie „nic”. Sam uchwyt jest darmowy: Box<u64>, Rc<u64> i Arc<u64> mają po 8 bajtów, a Option z każdego z nich też 8. Kosztuje praca, którą typ wykonuje w czasie działania programu.
Licznik alokacji pokazuje to wprost: Box::new(7u64) prosi o 8 bajtów, Rc::new(7u64) o 24 (licznik silnych odwołań, licznik słabych i wartość), Rc::clone nie alokuje, ale zmienia licznik z 1 na 2. Ostatni Rc niszczy wartość, lecz pamięć zwalnia dopiero ostatni Weak, bo licznik słabych odwołań mieszka w tej samej alokacji. RefCell<i32> zajmuje 16 bajtów zamiast 4 — obok wartości trzyma licznik pożyczek i sprawdza go przy każdym borrow().
Optymalizator może usunąć nieużywaną alokację i dokumentacja GlobalAlloc zabrania na niej polegać; w tym programie z -C opt-level=3 wynik był identyczny.
Szukaj po polsku: koszt inteligentnych wskaźników · zliczanie referencji · abstrakcja o zerowym koszcie · rust Rc allocation size · rust RefCell overhead