The Rust Lang Book: Understanding Ownership in Rust
This chapter explores ownership, Rust's most unique and defining feature. Ownership is the mechanism that allows Rust to make strict memory safety guarantees at compile time without the overhead of a garbage collector. Understanding how ownership works—alongside references, borrowing, and slices—is fundamental to writing efficient and safe Rust code.
1. The Memory Management Paradigm Shift
To appreciate why Rust uses an ownership model, it is helpful to compare it to the two dominant memory management strategies used in modern programming languages: Garbage Collection (GC) and Manual Memory Management.
Memory Management Strategies Comparison
The Rust Trade-off
Rust is a systems programming language that prioritizes runtime performance, minimal binary size, and absolute memory safety. To achieve this without a garbage collector, Rust shifts the burden of memory management from runtime execution to compile-time analysis.
While this introduces a steeper learning curve and slower initial write times, it eliminates entire classes of runtime bugs, saving hours of debugging in production.
2. Deep Dive: Stack vs. Heap Memory Layout
Because Rust is a systems language, its design decisions are heavily influenced by whether data is stored on the Stack or the Heap at runtime.
The Stack
The stack is a highly organized, fast-access memory structure that operates on a Last-In, First-Out (LIFO) basis.
- Fixed Size: All data placed on the stack must have a known, fixed size at compile time.
- Stack Frames: When a function executes, Rust allocates a stack frame containing the function's local variables.
- Automatic Lifetime: When the function finishes executing, its stack frame is popped off the stack, and all local variables inside it are automatically dropped.
The Heap
The heap is an unorganized pool of memory used for data whose size is dynamic or unknown at compile time.
- Dynamic Allocation: When you request space on the heap, the operating system finds an empty spot large enough, marks it as in-use, and returns a pointer (the address of that location).
- Pointer Storage: The pointer itself has a fixed size, meaning the pointer is stored on the stack, while the actual data it references resides on the heap.
- Manual/System Control: Accessing heap data is slower than stack data because you must follow the pointer to find the data.
Visualizing Stack and Heap Allocation
Consider a program where function a calls function b. Function a has a local integer y and a string literal x (stored in the binary). Function b allocates a dynamic String on the heap:
- Allocation Speed: Pushing to the stack is incredibly fast because the CPU simply moves the stack pointer. Allocating on the heap is slower because the OS must search for an available block of memory and perform bookkeeping.
- Access Speed: Accessing heap data is slower because the CPU must dereference the pointer to jump to the physical heap address.
3. The Three Golden Rules of Ownership
All memory safety guarantees in Rust are governed by three simple, strict rules.
💡 The Three Rules of Ownership
- Each value in Rust has a variable that is called its owner.
- There can only be one owner at a time.
- When the owner goes out of scope, the value is dropped.
Scope and the drop Function
A variable's scope is the range within a program for which an item is valid. A scope is typically defined by curly braces {}.
fn main() {
{ // s is not valid here; it is not yet declared
let s = "hello"; // s is valid from this point forward
// do stuff with s
} // this scope is now over, s is no longer valid
}
When a variable goes out of scope, Rust automatically calls a special function called drop. For heap-allocated data, the drop function deallocates the heap memory immediately, preventing memory leaks without manual intervention.
4. Memory Dynamics: Moves, Copies, and Clones
How variables interact with each other depends on whether their types are stored on the stack or the heap.
The Naive Approach: Stack Copying
For simple, fixed-size types stored entirely on the stack, assigning one variable to another duplicates the value.
let x = 5;
let y = x; // x is copied into y
Both x and y are valid and contain the value 5. This is fast and cheap because the data is small.
The Heap Problem: Shallow Copies and Double-Free Bugs
If we try the same operation with a heap-allocated String, a naive shallow copy would create a major security vulnerability.
let s1 = String::from("hello");
let s2 = s1;
Under the hood, s1 consists of a pointer, a length, and a capacity stored on the stack, pointing to the actual text on the heap:
If Rust performed a simple shallow copy, both s1 and s2 would point to the exact same heap memory:
When s1 and s2 go out of scope, Rust would attempt to call drop on both variables. This leads to a double-free error, a severe security vulnerability where memory is deallocated twice, potentially corrupting memory or allowing arbitrary code execution.
The Rust Solution: The Move
To ensure memory safety, Rust invalidates the original variable when you assign it to another. This operation is called a Move.
let s1 = String::from("hello");
let s2 = s1; // s1 is MOVED to s2. s1 is now invalid!
// println!("{}", s1); // Compile-time Error: borrow of moved value: `s1`
Because s1 is invalidated, only s2 owns the heap memory. When s2 goes out of scope, the memory is freed exactly once.
Deep Copying with Clone
If you explicitly want to copy the heap data (which is an expensive operation), you must use the clone method:
let s1 = String::from("hello");
let s2 = s1.clone(); // Deep copy of heap data
println!("s1 = {}, s2 = {}", s1, s2); // This works!
The Copy Trait
Types that are stored entirely on the stack (like integers, booleans, floats, and characters) implement the Copy trait. If a type implements Copy, an older variable is still usable after assignment because the value is trivially duplicated on the stack.
5. Ownership and Functions
Passing a value to a function uses the same semantics as assigning a value to a variable: it will either move or copy the value.
Code Example: Moving and Copying in Functions
fn main() {
let s = String::from("hello"); // s comes into scope
takes_ownership(s); // s's value moves into the function...
// ... and so is no longer valid here
// println!("{}", s); // Compile Error! s has been moved.
let x = 5; // x comes into scope
makes_copy(x); // x would move into the function,
// but i32 is Copy, so it's okay to
// still use x afterward
println!("x is still usable: {}", x);
}
fn takes_ownership(some_string: String) { // some_string comes into scope
println!("{}", some_string);
} // some_string goes out of scope and `drop` is called. Heap memory is freed.
fn makes_copy(some_integer: i32) { // some_integer comes into scope
println!("{}", some_integer);
} // some_integer goes out of scope. Nothing special happens.
Returning Values and Transferring Ownership
Ownership can also be returned from a function:
fn main() {
let s1 = gives_ownership(); // gives_ownership moves its return
// value into s1
let s2 = String::from("hello"); // s2 comes into scope
let s3 = takes_and_gives_back(s2); // s2 is moved into takes_and_gives_back,
// which also moves its return value into s3
}
fn gives_ownership() -> String {
let some_string = String::from("yours");
some_string // some_string is returned and moves out
}
fn takes_and_gives_back(a_string: String) -> String {
a_string // a_string is returned and moves out
}
The Tediousness of Ownership Transfers
If we want to use a variable inside a function but keep ownership of it, we would have to return it back in a tuple:
fn main() {
let s1 = String::from("hello");
let (s2, len) = calculate_length(s1);
println!("The length of '{}' is {}.", s2, len);
}
fn calculate_length(s: String) -> (String, usize) {
let length = s.len();
(s, length) // Must return the string back to transfer ownership
}
This pattern is highly tedious and unergonomic. To solve this, Rust provides References.
6. References and Borrowing
A reference is like a pointer in that it is an address we can follow to access data stored at that address; however, that data is owned by some other variable. Unlike pointers, references are guaranteed to point to a valid value of a particular type for the life of that reference.
Borrowing
We use the ampersand (&) to represent references. Passing a reference to a function instead of the actual value is called borrowing.
fn main() {
let s1 = String::from("hello");
// We pass &s1, which creates a reference pointing to s1
let len = calculate_length(&s1);
println!("The length of '{}' is {}.", s1, len); // s1 is still valid!
}
fn calculate_length(s: &String) -> usize { // s is a reference to a String
s.len()
} // s goes out of scope, but because it does not own the value, nothing is dropped.
Mental Model: Reference Layout
The reference s on the stack points to the owner s1 on the stack, which in turn points to the heap memory:
Immutability of References
By default, references are immutable. You cannot modify something you are borrowing:
fn main() {
let s = String::from("hello");
change(&s);
}
fn change(some_string: &String) {
// some_string.push_str(", world"); // Compile Error: cannot mutate immutable reference
}
7. The Rules of Borrowing and Preventing Data Races
To modify a borrowed value, we must use a mutable reference (&mut). However, Rust enforces strict rules around mutable references to prevent runtime bugs.
Implementing Mutable References
To make a reference mutable:
- The underlying variable must be declared mutable (
let mut). - The reference passed must be mutable (
&mut s). - The function signature must accept a mutable reference (
&mut String).
fn main() {
let mut s = String::from("hello");
change(&mut s);
println!("{}", s); // Prints: "hello, world"
}
fn change(some_string: &mut String) {
some_string.push_str(", world");
}
⚠️ The Golden Rule of Borrowing
To prevent data corruption and unpredictable behavior, Rust enforces this strict rule:
At any given time, for a particular piece of data in a particular scope, you can have:
- Any number of immutable references (
&T), OR- Exactly one mutable reference (
&mut T).
Why This Rule Exists: Preventing Data Races
A data race is a specific type of race condition that occurs when these three behaviors happen concurrently:
- Two or more pointers access the same memory location at the same time.
- At least one of the pointers is being used to write to the data.
- There is no mechanism being used to synchronize access to the memory.
Data races cause undefined behavior and are incredibly difficult to track down at runtime. Rust prevents them entirely at compile time by disallowing multiple active mutable references:
let mut s = String::from("hello");
let r1 = &mut s;
let r2 = &mut s; // Compile Error: cannot borrow `s` as mutable more than once
println!("{}, {}", r1, r2);
Mixing Immutable and Mutable References
You also cannot have a mutable reference if an immutable reference to the same data already exists:
let mut s = String::from("hello");
let r1 = &s; // No problem
let r2 = &s; // No problem
let r3 = &mut s; // Compile Error: cannot borrow `s` as mutable because it is also borrowed as immutable
println!("{}, {}, and {}", r1, r2, r3);
Rationale: Users of an immutable reference (r1 and r2) do not expect the underlying value to suddenly change out from under them.
Reference Scopes (Non-Lexical Lifetimes)
A reference's scope starts when it is introduced and ends when it is used for the last time. This compiler feature (called Non-Lexical Lifetimes) allows for clean, safe code patterns:
fn main() {
let mut s = String::from("hello");
let r1 = &s;
let r2 = &s;
println!("{} and {}", r1, r2); // r1 and r2 are used for the last time here
let r3 = &mut s; // This is allowed! r1 and r2 are no longer active in this scope.
r3.push_str(", world");
}
8. Dangling References and Compile-Time Safety
In languages with pointers (like C and C++), it is easy to accidentally create a dangling pointer—a pointer that references a location in memory that has been freed or allocated to something else.
Rust guarantees that references will never be dangling references. If you have a reference to some data, the compiler will ensure that the data will not go out of scope before the reference does.
The Dangling Reference Bug (Compile-Time Error)
fn main() {
let reference_to_nothing = dangle();
}
fn dangle() -> &String { // returns a reference to a String
let s = String::from("hello"); // s is a new String
&s // we return a reference to the String, s
} // Here, s goes out of scope and is dropped. Its memory is gone!
// Returning &s would point to invalid memory.
If you try to compile this, Rust throws a clear error:
The compiler prevents you from returning a reference to a value that will be destroyed at the end of the function.
The Correct Approach: Transferring Ownership
To resolve this, return the owned String directly, transferring ownership out of the function:
fn no_dangle() -> String {
let s = String::from("hello");
s // Ownership is moved out; memory is not dropped.
}
9. Slices: Referencing Contiguous Sequences
Slices let you reference a contiguous sequence of elements in a collection rather than the entire collection. Like references, slices do not take ownership of the underlying data.
The Problem: Out-of-Sync Indices
Imagine writing a function that takes a string of words separated by spaces and returns the first word it finds.
If we do not use slices, we might return the index of the end of the first word:
fn first_word(s: &String) -> usize {
let bytes = s.as_bytes();
for (i, &item) in bytes.iter().enumerate() {
if item == b' ' {
return i; // Return the index of the space
}
}
s.len() // If no space, return the entire length
}
This compiles, but it introduces a major logical bug:
fn main() {
let mut s = String::from("hello world");
let word = first_word(&s); // word gets the value 5
s.clear(); // This empties the String, making it ""
// word still has the value 5, but there is no longer a string to access index 5!
// Any attempt to use 'word' to extract data from 's' will cause a runtime crash.
}
Because the index word is a plain integer, it is not tied to the state of s. If s changes, word becomes stale and invalid.
The Solution: String Slices
A string slice is a reference to part of a String. It is written as &str.
let s = String::from("hello world");
let hello = &s[0..5]; // Range from index 0 to 4 (5 is exclusive)
let world = &s[6..11]; // Range from index 6 to 10 (11 is exclusive)
Slice Range Syntaxes
Rust offers several shorthand syntaxes for ranges:
- Omit starting index:
&s[..5]is equivalent to&s[0..5] - Omit ending index:
&s[6..]is equivalent to&s[6..s.len()] - Entire string:
&s[..]references the whole string.
Refactoring first_word with Slices
We can rewrite the function to return a slice (&str):
fn first_word(s: &String) -> &str {
let bytes = s.as_bytes();
for (i, &item) in bytes.iter().enumerate() {
if item == b' ' {
return &s[0..i]; // Return a slice of the first word
}
}
&s[..] // Return a slice of the entire string
}
Now, if we try to clear the string while holding a reference to the first word, the compiler catches the bug:
fn main() {
let mut s = String::from("hello world");
let word = first_word(&s); // Immutable borrow occurs here
// s.clear(); // Compile Error: cannot borrow `s` as mutable because it is also borrowed as immutable
println!("the first word is: {}", word);
}
Because word is an immutable slice (&str), it acts as an active immutable borrow. Calling s.clear() requires a mutable borrow, which is blocked by the compiler, preventing the out-of-sync bug entirely.
String Literals are Slices
String literals are stored directly inside the compiled binary. The type of a string literal is &str:
let s = "Hello, world!"; // s is a string slice pointing to a specific location in the binary
Improving APIs with Slice Coercion
If you write a function that takes a string slice instead of a reference to a String, the API becomes more versatile because of deref coercion:
// This function works with both String references and string slices!
fn first_word(s: &str) -> &str {
// ...
}
Other Slices
Slices are not limited to strings. You can take slices of arrays as well:
let a = [1, 2, 3, 4, 5];
let slice = &a[1..3]; // Type is &[i32]
assert_eq!(slice, &[2, 3]);
Summary and Key Takeaways
- The Ownership Model is Rust's mechanism for managing memory safely and efficiently without a garbage collector or manual allocation/deallocation.
- The Stack stores fixed-size data with automatic lifetimes, while the Heap stores dynamic-size data accessed via pointers.
- The Three Rules of Ownership dictate that every value has exactly one owner at a time, and when that owner goes out of scope, the value is automatically dropped.
- Moves vs. Copies: Assigning a heap-allocated variable to another moves ownership and invalidates the original variable. Stack-only types that implement the
Copytrait are duplicated instead. - Borrowing allows you to access data via references (
&) without taking ownership. References are immutable by default. - The Borrowing Rules restrict you to either one mutable reference (
&mut) or any number of immutable references (&) in a given scope to prevent data races. - Dangling References are prevented at compile time by ensuring that no reference can outlive the data it points to.
- Slices (
&strand&[T]) provide safe, read-only views into contiguous sequences of collections, preventing out-of-sync bugs by tying the slice directly to the owner's lifetime.