Skip to content

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::clone allocates nothing and moves the strong count from 1 to 2. That is why Rc::clone is the cheapest .clone() in Rust — and also why it is not free: a count is written on every clone and every drop.
  • Rc::downgrade allocates nothing and moves the weak count to 1.
  • Dropping the second Rc frees nothing. Dropping the last Rc drops the value and still frees nothing — weak.upgrade() is None, yet the 24 bytes stay, because the weak count lives in them.
  • Dropping the last Weak is the one free.

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 is Rc::new's single 24-byte allocation; std::shared_ptr<T>(new T) is two allocations, and Rust has no spelling for that. shared_ptr counts atomically always, so it is Arc; Rc is 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 Rc cost on every value, and a sys.getsizeof(0) of 28 bytes on a 64-bit build includes it. Rust charges the count only to the values you put in an Rc.
  • Java, C#, Go. A tracing garbage collector has no count to update on copy, and pays instead in collection pauses and memory headroom; Box has neither cost and Rc has the count but no collector — which is why an Rc cycle leaks, as the Rc page shows.

See also

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