Mastering Systems Programming with Rust
Ownership and Memory
Memory and Ownership
Every program needs to manage how it uses a computer's memory. Some languages use garbage collection, which runs in the background to clean up memory that's no longer in use. Others require the programmer to manually allocate and free memory. Both approaches have trade-offs.
Rust uses a third way: an ownership system with a set of rules that the compiler checks at compile time. This system manages memory automatically without the runtime cost of a garbage collector. If you can understand ownership, you can understand a huge part of what makes Rust unique.
Rust’s key innovation lies in its ownership model, which enforces strict rules on how memory is accessed and managed.
To grasp ownership, we first need to look at how a program stores data. All data is stored either on the stack or on the heap.
| Stack | Heap |
|---|---|
| Stores data with a known, fixed size. | Stores data that can grow or change size. |
| Very fast access (LIFO - Last In, First Out). | Slower access; involves finding free space. |
| Pushing and popping values is quick. | The system must find a block of memory and return a pointer. |
| All data must be fixed-size at compile time. | Perfect for data created at runtime, like a String from user input. |
Integers, booleans, and characters are simple values with a known size, so they go on the stack. A String, which can be resized, lives on the heap. Understanding this distinction is the key to understanding ownership.
The Rules of Ownership
Rust's ownership model follows three simple rules, which the compiler enforces strictly:
- Each value in Rust has a variable that’s called its owner.
- There can only be one owner at a time.
- When the owner goes out of scope, the value is dropped.
scope
noun
The region of a program where a variable is valid. It typically starts where the variable is declared and ends with the closing curly brace } of its block.
Let's see this in action. A variable is valid from the point it's declared until the end of its current scope.
fn main() {
// s is not valid here, it’s not yet declared
{
let s = "hello"; // s is valid from this point forward
// do stuff with s
} // this scope is now over, and s is no longer valid
}
When a variable like s that owns heap data goes out of scope, Rust automatically calls a special function for us called drop. This function returns the memory back to the operating system. It's called at the closing curly brace }. This predictable cleanup is why Rust doesn't need a garbage collector.
Move vs. Copy
Things get more interesting when we assign a variable to another. Let's start with a simple stack-only type like an integer.
let x = 5;
let y = x;
println!("x = {}, y = {}", x, y); // This works fine!
This code is straightforward. Two variables, x and y, both hold the value 5. Because integers have a known, fixed size, they are pushed onto the stack. When we assign x to y, Rust makes a copy of the value. Both variables are independent and perfectly valid.
Now let's try this with a String, which manages data on the heap.
let s1 = String::from("hello");
let s2 = s1;
println!("{}", s1); // This will cause a compile error!
This fails because of Rust's second ownership rule: there can only be one owner at a time. A String is made of three parts stored on the stack: a pointer to the memory that holds its contents, a length, and a capacity. The actual content ("hello") is on the heap.
When we assign s1 to s2, Rust copies the stack data (pointer, length, capacity). It does not copy the heap data. If it did, and both s1 and s2 went out of scope, they would both try to free the same memory. This is called a double free error, a notorious memory safety bug.
To ensure memory safety, Rust considers s1 to no longer be valid after let s2 = s1;. We say that s1 was moved into s2.
This process of invalidating the original owner is called a move. It prevents the double free error and is a core part of Rust's safety guarantees.
So why did the integer example work? Types like integers, floats, booleans, and characters don't have any heap data. They are stored entirely on the stack. Copying them is cheap and there's no risk of double-freeing memory. These types implement a special trait called Copy. If a type implements the Copy trait, an older variable is still usable after assignment.
In Rust, assignment of heap-based data is a 'move'. Assignment of stack-only data is a 'copy'.
This behavior isn't limited to variable assignments. The same ownership rules apply when passing values to functions. Passing a String to a function moves ownership to the function's parameters. Once the function is over, its parameters go out of scope, and the String's memory is dropped. This prevents you from accidentally using the value after you've given it away.
Let's test your understanding of these core concepts.
Which of the following is a core rule of Rust's ownership system?
In Rust, data with a known, fixed size at compile time (like an i32 integer) is typically stored on the stack, while data with an unknown size or that can change size (like a String) is stored on the heap.
Ownership is one of Rust's most distinct features. It might feel restrictive at first, but it's the foundation for the memory safety and concurrency that make the language so powerful. In the next section, we'll explore how to let code use a value without taking ownership of it, a concept called borrowing.