Rust's Memory Story
Type this right now
// save as ownership.rs — compile: rustc ownership.rs fn main() { let s1 = String::from("hello"); let s2 = s1; // s1 is MOVED to s2 // println!("{}", s1); // Uncomment this — the compiler will refuse. println!("{}", s2); // Only s2 is valid now. }
$ rustc ownership.rs
$ ./ownership
hello
Now uncomment the s1 line:
error[E0382]: borrow of moved value: `s1`
--> ownership.rs:5:20
|
2 | let s1 = String::from("hello");
| -- move occurs because `s1` has type `String`
3 | let s2 = s1;
| -- value moved here
4 |
5 | println!("{}", s1);
| ^^ value borrowed here after move
That's the borrow checker. It just prevented a use-after-free at compile time. In C, you'd get undefined behavior. In Rust, you get a compiler error with an explanation of exactly what went wrong.
Ownership: one owner, one lifetime
Every value in Rust has exactly one owner. When the owner goes out of scope, the value is dropped (freed). No garbage collector. No manual free. The compiler inserts drop calls at exactly the right places.
fn main() { { let s = String::from("hello"); // s owns the String println!("{}", s); } // s goes out of scope → String::drop() called → heap memory freed // s does not exist here. No dangling pointer possible. }
Compare with C:
#include <stdlib.h>
#include <string.h>
int main() {
{
char *s = malloc(6);
strcpy(s, "hello");
printf("%s\n", s);
// Forgot free(s)? → memory leak
// Remembered free(s), then used s later? → use-after-free
free(s);
}
// s still exists as a dangling pointer. C doesn't care.
return 0;
}
Rust ownership model:
┌─────────────┐ owns ┌──────────────────┐
│ Variable s │─────────────►│ Heap: "hello" │
│ (on stack) │ │ (5 bytes + null) │
└─────────────┘ └──────────────────┘
│ │
│ s goes out of scope │
▼ ▼
s is gone memory is freed (drop)
No dangling ref No leak
Move semantics
When you assign a String to another variable, ownership moves. The original variable
becomes invalid. This prevents double-free.
fn main() { let s1 = String::from("hello"); let s2 = s1; // MOVE — s1 is invalidated // In memory: // s1's stack data (ptr, len, cap) was COPIED to s2 // But s1 is now considered uninitialized by the compiler // The heap data was NOT copied — same pointer, one owner println!("{}", s2); }
Before move:
┌──── s1 ────┐ ┌──────────────────┐
│ ptr ───────────────────────►│ 'h' 'e' 'l' 'l' │
│ len: 5 │ │ 'o' │
│ cap: 5 │ └──────────────────┘
└────────────┘
After move (let s2 = s1):
┌──── s1 ────┐ ┌──────────────────┐
│ INVALID │ │ 'h' 'e' 'l' 'l' │
│ (compiler │ ┌──────────►│ 'o' │
│ forbids) │ │ └──────────────────┘
└────────────┘ │
│
┌──── s2 ────┐ │
│ ptr ────────────┘
│ len: 5 │
│ cap: 5 │
└────────────┘
For types that are cheap to copy (i32, f64, bool, char), Rust uses Copy instead
of move. The value is duplicated, and both variables remain valid:
#![allow(unused)] fn main() { let x = 42; let y = x; // COPY — both x and y are valid println!("{} {}", x, y); // Fine! Integers implement Copy. }
💡 Fun Fact: The distinction between Copy and Move is a zero-cost abstraction. At the machine code level, both are a
memcpyof the stack data. The difference is purely in what the compiler permits afterward. Move adds no runtime cost — it's just a compile-time rule.
Borrowing: references without ownership
Sometimes you want to use a value without taking ownership. That's borrowing.
fn print_length(s: &String) { // borrows s — does not own it println!("Length: {}", s.len()); } // s (the reference) goes out of scope, but the String is NOT dropped fn main() { let s = String::from("hello"); print_length(&s); // lend s to the function println!("{}", s); // s is still valid! }
Two kinds of references:
&T — shared reference (read-only)
• Multiple &T can exist simultaneously
• Cannot modify the data
• Like a "read lock"
&mut T — exclusive reference (read-write)
• Only ONE &mut T can exist at a time
• No &T can coexist with &mut T
• Like a "write lock"
fn main() { let mut s = String::from("hello"); let r1 = &s; // OK — shared reference let r2 = &s; // OK — multiple shared refs allowed println!("{} {}", r1, r2); let r3 = &mut s; // OK — r1 and r2 are no longer used after this point r3.push_str(" world"); println!("{}", r3); }
The compiler enforces these rules at compile time. No runtime overhead. No data races possible in safe code.
🧠 What do you think happens?
#![allow(unused)] fn main() { let mut v = vec![1, 2, 3]; let first = &v[0]; // Borrow an element v.push(4); // Modify the vector println!("{}", first); }Does this compile? Why or why not? (Hint: what does
pushdo if the vector needs to grow?)
Lifetimes: how long references are valid
The compiler tracks the lifetime of every reference — how long the borrowed data is valid.
#![allow(unused)] fn main() { fn longest<'a>(s1: &'a str, s2: &'a str) -> &'a str { if s1.len() > s2.len() { s1 } else { s2 } } }
The 'a says: "the returned reference lives as long as the shorter of s1 and s2."
#![allow(unused)] fn main() { fn broken() -> &str { let s = String::from("hello"); &s // ERROR: returning reference to local variable } // s is dropped here — reference would dangle }
error[E0106]: missing lifetime specifier
error[E0515]: cannot return reference to local variable `s`
In C, this compiles silently and causes a use-after-free:
char *broken() {
char s[] = "hello"; // Stack-allocated
return s; // Returns pointer to stack frame that's about to be freed
} // s is gone. Caller has a dangling pointer.
Smart pointers: Box, Vec, String
Box<T>: single heap allocation
fn main() { let x = Box::new(42); // Allocates 4 bytes on the heap println!("x = {} at {:p}", x, &*x); } // x dropped → heap memory freed
Stack Heap
┌──────────┐ ┌──────┐
│ x: *ptr ─┼───────────►│ 42 │
│ (8 bytes)│ │(4 B) │
└──────────┘ └──────┘
Box<T> is exactly one pointer wide. It's Rust's equivalent of malloc + free, but with
automatic cleanup.
Vec<T>: growable array
fn main() { let mut v: Vec<i32> = Vec::new(); println!("Empty: len={}, cap={}", v.len(), v.capacity()); // 0, 0 v.push(1); // Allocates v.push(2); v.push(3); v.push(4); println!("After 4: len={}, cap={}", v.len(), v.capacity()); // 4, 4 v.push(5); // Capacity exceeded → reallocate (double) println!("After 5: len={}, cap={}", v.len(), v.capacity()); // 5, 8 }
Vec<i32> on the stack: Heap buffer:
┌───────────────────┐ ┌───┬───┬───┬───┬───┬───┬───┬───┐
│ ptr ──────────────┼──────►│ 1 │ 2 │ 3 │ 4 │ 5 │ │ │ │
│ len: 5 │ └───┴───┴───┴───┴───┴───┴───┴───┘
│ cap: 8 │ used (5) unused (3)
└───────────────────┘
24 bytes on stack
When Vec grows past capacity, it allocates a new buffer (typically 2x), copies elements,
and frees the old buffer. This is exactly what C's realloc does, but Rust's borrow checker
ensures no references to the old buffer survive the reallocation.
String: it's a Vec<u8>
fn main() { let s = String::from("hello"); // String is literally: struct String { vec: Vec<u8> } println!("len={}, cap={}, size_of={}", s.len(), s.capacity(), std::mem::size_of::<String>()); // len=5, cap=5, size_of=24 (same as Vec: ptr + len + cap) let slice: &str = &s; // &str is just (pointer, length) — no allocation println!("size_of &str = {}", std::mem::size_of::<&str>()); // 16 }
&str is a fat pointer: a pointer to UTF-8 bytes plus a length. It doesn't own anything.
It can point into a String, a string literal (in .rodata), or a slice of any UTF-8 bytes.
Reference counting: Rc and Arc
When you need multiple owners, Rust provides reference-counted pointers:
use std::rc::Rc; fn main() { let a = Rc::new(String::from("shared data")); let b = Rc::clone(&a); // Increments reference count let c = Rc::clone(&a); // Increments again println!("References: {}", Rc::strong_count(&a)); // 3 println!("a = {}", a); println!("b = {}", b); println!("c = {}", c); } // c dropped (count→2), b dropped (count→1), a dropped (count→0) → data freed
Stack Heap
┌─────┐ ┌────────────────────────┐
│ a ─┼──────────────────────►│ refcount: 3 │
│ │ ┌───►│ String: "shared data" │
│ b ─┼──────────────────┘ ┌─►│ │
│ │ │ └────────────────────────┘
│ c ─┼────────────────────┘
└─────┘
Rc<T> is single-threaded only. For thread-safe reference counting, use Arc<T> (Atomic Rc):
use std::sync::Arc; use std::thread; fn main() { let data = Arc::new(vec![1, 2, 3]); let handles: Vec<_> = (0..3).map(|i| { let data = Arc::clone(&data); thread::spawn(move || { println!("Thread {}: {:?}", i, data); }) }).collect(); for h in handles { h.join().unwrap(); } }
Arc uses atomic operations for the reference count, making it safe to share across threads.
The cost: atomic increments/decrements are slower than normal increments (~5-20 ns vs ~1 ns).
The global allocator
Under the hood, Box::new, Vec::push, and String::from all call the global allocator.
By default, this is your system's malloc/free.
// You can verify this: Rust's alloc calls end up as malloc use std::alloc::{GlobalAlloc, Layout, System}; fn main() { unsafe { let layout = Layout::new::<[u8; 64]>(); let ptr = System.alloc(layout); // This calls malloc(64) println!("Allocated at: {:p}", ptr); System.dealloc(ptr, layout); // This calls free(ptr) } }
You can replace the allocator entirely:
#![allow(unused)] fn main() { // Using jemalloc (add jemallocator = "0.5" to Cargo.toml) // use jemallocator::Jemalloc; // #[global_allocator] // static GLOBAL: Jemalloc = Jemalloc; }
No-alloc: embedded Rust
For embedded systems (microcontrollers, kernels), you can't use a heap at all. Rust supports
this with #![no_std]:
#![allow(unused)] #![no_std] #![no_main] fn main() { // No Vec, no String, no Box — nothing that allocates // Use fixed-size arrays, stack allocation, and the heapless crate // use heapless::Vec; // Fixed-capacity Vec, stored on the stack // let mut v: Vec<i32, 16> = Vec::new(); // Max 16 elements, no heap use core::panic::PanicInfo; #[panic_handler] fn panic(_info: &PanicInfo) -> ! { loop {} } }
This connects directly to embedded programming — when you're writing firmware for an STM32 or an ESP32, you have no OS, no heap, and every byte is precious. Rust's ownership model still works perfectly: stack allocation, static references, and compile-time guarantees.
💡 Fun Fact: The Linux kernel is starting to accept Rust code. Kernel code uses no heap allocator in the traditional sense — memory is managed through slab allocators and page allocators. Rust's
#![no_std]+ custom allocator support makes this possible.
🔧 Task: Trigger every borrow checker error
Write a Rust program (or multiple small programs) that intentionally triggers each of these compile errors. Read each error message carefully — they're some of the best error messages in any compiler.
// 1. Use after move fn ex1() { let s = String::from("hello"); let t = s; println!("{}", s); // E0382: borrow of moved value } // 2. Multiple mutable references fn ex2() { let mut s = String::from("hello"); let r1 = &mut s; let r2 = &mut s; // E0499: cannot borrow `s` as mutable more than once println!("{} {}", r1, r2); } // 3. Mutable + immutable reference fn ex3() { let mut s = String::from("hello"); let r1 = &s; let r2 = &mut s; // E0502: cannot borrow as mutable because also borrowed as immutable println!("{} {}", r1, r2); } // 4. Dangling reference fn ex4() -> &'static str { let s = String::from("hello"); &s // E0515: cannot return reference to local variable } // 5. Move out of borrowed content fn ex5() { let v = vec![String::from("hello")]; let s = v[0]; // E0507: cannot move out of index of `Vec<String>` } fn main() { // Uncomment each one at a time, try to compile, read the error. // ex1(); // ex2(); // ex3(); // println!("{}", ex4()); // ex5(); }
For each error:
- Read the error code (e.g., E0382)
- Run
rustc --explain E0382for a detailed explanation - Fix the error using the compiler's suggestion
- Understand why the rule exists — what bug would it cause in C?