Rust - Ownership, Borrowing & Memory Layout

To achieve predictable performance and eliminate runtime garbage collection overhead, Rust provides explicit control over memory layout while enforcing compile-time ownership invariants.

Stack vs Heap Layout

Rust categorizes memory into stack and heap based on whether the type size is known at compile time:

+------------------------------------+      +-------------------------------+
|             STACK                  |      |             HEAP              |
|                                    |      |                               |
|  let s = String::from("hello");    |      |                               |
|  +------------------------------+  |      |   +---+---+---+---+---+       |
|  | ptr  |  len: 5  |  cap: 5    | -+------+-> | h | e | l | l | o |       |
|  | 8B   |    8B    |    8B      |  |      |   +---+---+---+---+---+       |
|  +------------------------------+  |      |                               |
|  Total: 24 bytes on stack          |      +-------------------------------+
+------------------------------------+
  • Stack Allocation: Extremely fast. Types with sizes known at compile time (integers, floats, arrays [T; N], structs containing fixed types) reside on the stack. Allocation is a single instruction: adjusting the stack pointer (RSP).
  • Heap Allocation: Used when sizes are dynamic or vary at runtime (Vec<T>, String, Box<T>). The stack holds a fixed-size descriptor (pointer, length, capacity) pointing to contiguous heap storage.

Fat Pointers & Dynamically Sized Types (DSTs)

In C and C++, pointers are uniformly the size of a machine word (8 bytes on 64-bit systems). In Rust, types whose sizes are unknown at compile time are called Dynamically Sized Types (DSTs) and are referenced via Fat Pointers (16 bytes on 64-bit).

1. Slices (&[T] and &str)

A slice fat pointer contains two 64-bit words:

  1. Data Pointer: Memory address of the first element.
  2. Length: Number of elements in the slice.
let array: [i32; 5] = [10, 20, 30, 40, 50];
let slice: &[i32] = &array[1..4]; // Pointer to array[1] + Length = 3

Because length is bundled into the reference:

  • Safe indexing (slice[i]) checks bounds in one comparison.
  • Functions accepting &[T] work over fixed arrays, Vec<T>, or heap buffers without separate pointer and size arguments.

2. Trait Objects (&dyn Trait)

When performing dynamic dispatch, a trait object fat pointer contains:

  1. Data Pointer: Memory address of the underlying concrete struct instance.
  2. Vtable Pointer: Pointer to the virtual method table generated by rustc for that specific concrete type.
Trait Object Fat Pointer (16 bytes)
+-----------------------+-----------------------+
|  Data Pointer (8B)    |  Vtable Pointer (8B)  |
+-----------+-----------+-----------+-----------+
            |                       |
            v                       v
     [ Concrete Data ]       [ Vtable Structure ]
                             | - Destructor fn pointer (drop_in_place)
                             | - Size of concrete type
                             | - Alignment of concrete type
                             | - Trait method function pointers...

Unlike C++ where the virtual table pointer (vptr) is embedded inside every object instance, Rust keeps the vtable pointer in the fat pointer itself. As a result, structs do not carry virtual table overhead unless explicitly referenced through a trait object.

Smart Pointers Under the Hood

Rust provides standard smart pointer types that encapsulate ownership patterns and memory allocation strategies:

Type Thread-Safe? Allocation Overhead Use Case
Box<T> Yes (T: Send) 8 bytes on stack; 1 heap allocation for T. Unique heap allocation; recursive types.
Rc<T> No (!Send, !Sync) 8 bytes on stack; 1 heap allocation (RcBox<T>). Shared read-only ownership in single-threaded code.
Arc<T> Yes (T: Send + Sync) 8 bytes on stack; 1 heap allocation (ArcInner<T>). Shared read-only ownership across threads (atomic ref count).
RefCell<T> No Inline integer borrow flag (isize). Dynamic interior mutability for single-threaded structs.
Cell<T> No Zero runtime overhead; relies on value copies. Interior mutability for small Copy types.

Rc<T> vs Arc<T> Memory Architecture

Both Rc<T> and Arc<T> allocate a header block on the heap alongside the value:

Heap Layout:
+-------------------------------------------------------------+
| Strong Count (usize) | Weak Count (usize) | Value (T)       |
+-------------------------------------------------------------+
  • Rc<T> updates strong/weak counts using non-atomic integer increments (*count += 1). It is fast but cannot be sent across threads.
  • Arc<T> uses atomic operations (fetch_add, fetch_sub) with acquire/release memory barriers. This guarantees thread safety across CPU cores at a slightly higher instruction cost.

RefCell<T>: Runtime Borrow Checking

RefCell<T> moves borrow checking from compile time to runtime using a single signed integer borrow flag (isize):

  • borrow(): Increments the borrow counter. If counter is negative (meaning an active mutable borrow exists), it panics.
  • borrow_mut(): Sets the counter to -1. If counter is non-zero (meaning active shared or mutable borrows exist), it panics.

This enables interior mutability (mutating data behind a shared &self reference) while still enforcing that a mutable reference is strictly exclusive.

Move Semantics at the Machine Level

When a value is moved in Rust:

let a = String::from("systems");
let b = a; // Moved

At the machine code level:

  1. The 24-byte stack descriptor of a (ptr, len, cap) is copied to b via a 3-word memcpy or register move.
  2. The compiler's borrow checker statically marks a as uninitialized.
  3. No heap memory is copied, and no destructor is called when a exits scope.

Drop Flags

If a move occurs conditionally inside an if or match block:

let x = String::from("hello");
if condition {
    consume(x); // Moved conditionally
}
// Scope exit: does x get dropped here?

The compiler allocates a hidden 1-byte Drop Flag on the stack frame to track whether x is currently initialized. At scope exit, it checks the drop flag before executing the destructor. If the move is unconditional, the compiler elides the drop flag entirely at compile time.

Lifetimes & Variance Under the Hood

Lifetimes do not alter program execution; they are compile-time generic parameters that allow the compiler to prove that references remain valid.

fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
    if x.len() > y.len() { x } else { y }
}

Here, 'a represents the intersection (overlap) of the validity regions of x and y. The returned reference is guaranteed not to outlive this overlapping region.

Lifetime Elision Rules

To avoid excessive annotations, rustc infers lifetimes in function signatures using three deterministic rules:

  1. Each elided lifetime in function arguments becomes a distinct lifetime parameter: fn parse(s: &str) desugars to fn parse<'a>(s: &'a str).
  2. If there is exactly one input lifetime parameter, that lifetime is assigned to all elided output lifetimes: fn trim(s: &str) -> &str desugars to fn trim<'a>(s: &'a str) -> &'a str.
  3. If there are multiple input lifetime parameters and one of them is &self or &mut self, the lifetime of self is assigned to all elided output lifetimes.

Variance

Variance dictates how subtyping relationships among lifetimes affect generic types:

  • Covariant (&'a T): If 'a outlives 'b, then &'a T can be used wherever &'b T is expected.
  • Invariant (&'a mut T): If T has a lifetime, &'a mut T is invariant over T. This prevents assigning a short-lived reference into a longer-lived container, which would create a dangling pointer.
Recommended Reading
Sponsored
Programming Rust
Systems Rust
Programming Rust
By Jim Blandy, Jason Orendorff, Leonora TindallView on Amazon