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:
- Data Pointer: Memory address of the first element.
- 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:
- Data Pointer: Memory address of the underlying concrete struct instance.
- Vtable Pointer: Pointer to the virtual method table generated by
rustcfor 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:
- The 24-byte stack descriptor of
a(ptr,len,cap) is copied tobvia a 3-wordmemcpyor register move. - The compiler's borrow checker statically marks
aas uninitialized. - No heap memory is copied, and no destructor is called when
aexits 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:
- Each elided lifetime in function arguments becomes a distinct lifetime parameter:
fn parse(s: &str)desugars tofn parse<'a>(s: &'a str). - If there is exactly one input lifetime parameter, that lifetime is assigned to all elided output lifetimes:
fn trim(s: &str) -> &strdesugars tofn trim<'a>(s: &'a str) -> &'a str. - If there are multiple input lifetime parameters and one of them is
&selfor&mut self, the lifetime ofselfis assigned to all elided output lifetimes.
Variance
Variance dictates how subtyping relationships among lifetimes affect generic types:
- Covariant (
&'a T): If'aoutlives'b, then&'a Tcan be used wherever&'b Tis expected. - Invariant (
&'a mut T): IfThas a lifetime,&'a mut Tis invariant overT. This prevents assigning a short-lived reference into a longer-lived container, which would create a dangling pointer.

