Systems Programming Mastery with Rust
Ownership and Memory
Memory Without a Garbage Collector
Most programming languages manage memory in one of two ways. Some use a garbage collector (GC) that periodically scans for unused memory and cleans it up. Others require the programmer to manually allocate and free memory. Both approaches have trade-offs. Garbage collection adds runtime overhead, while manual management can lead to bugs like memory leaks or dangling pointers.
Rust takes a third path. It manages memory through a system of ownership with a set of rules that the compiler checks at compile time. This system provides the memory safety of a garbage collector without the performance cost. To understand ownership, we first need to look at how a program stores data in memory.
The Stack and the Heap
All computer programs use two main areas of memory to store data: the stack and the heap. They have different structures and purposes.
The stack is highly organized and fast. Data is added in a last-in, first-out (LIFO) order, like a stack of plates. You can only add or remove items from the top. All data stored on the stack must have a known, fixed size. This makes it incredibly efficient for the processor to access.
The heap is less organized. When you need to store data but don't know its exact size at compile time, or if you need it to live longer than a specific function, you can store it on the heap. The operating system finds an empty spot in memory, marks it as in use, and returns a pointer—the address of that location. Accessing data on the heap is slower than the stack because it involves following that pointer to find the data.
Managing heap data is what ownership is all about. Keeping track of what parts of the code are using which data on the heap, minimizing duplicate data, and cleaning up unused data are the problems that Rust's ownership system solves.
The Rules of Ownership
The ownership system is governed by a few simple rules, which are checked by the compiler. If any of these rules are violated, the program won't compile.
- 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.
Let's break this down with an example. A scope is the range within a program for which an item is valid. For a variable, this is typically from where it is declared to the end of the current block (curly braces).
{
// s is not valid here, it’s not yet declared
let s = String::from("hello"); // s is valid from this point forward
// do stuff with s
println!("{}", s);
} // this scope is now over, and s is no longer valid
When the variable s comes into scope, it requests memory from the heap for the string data. When it goes out of scope at the closing brace }, Rust automatically calls a special function called drop. This function returns the memory that s was using back to the operating system. This happens deterministically at the end of the scope, ensuring memory is freed as soon as it's no longer needed.
Moving and Cloning
Things get more interesting when we assign one variable to another. For simple, fixed-size types stored entirely on the stack, the behavior is what you'd expect.
let x = 5;
let y = x; // A copy of the value 5 is made
println!("x = {}, y = {}", x, y); // Both x and y are valid
Since integers have a known size, both x and y are pushed onto the stack as two separate values. But what about a String, which stores its data on the heap?
let s1 = String::from("hello");
let s2 = s1;
// println!("s1 is {}", s1); // This line would cause a compile error!
This looks similar, but it works differently. A String is made of three parts stored on the stack: a pointer to the actual data on the heap, a length, and a capacity. When we assign s1 to s2, Rust copies this stack data. It does not copy the heap data. If it did, both s1 and s2 would point to the same memory location. When they both went out of scope, they would both try to free the same memory—a bug called a double free.
To ensure memory safety, Rust considers s1 to no longer be valid after the assignment. We say that ownership of the string has been moved from s1 to s2.
If you truly need a full, independent copy of heap data, you can use the clone() method. This will duplicate the data on the heap, which can be a more expensive operation.
let s1 = String::from("hello");
let s2 = s1.clone();
println!("s1 = {}, s2 = {}", s1, s2); // Both are valid
This system of ownership, moving, and dropping is the foundation of Rust's memory safety guarantees. It prevents a whole class of common bugs at compile time, allowing you to write fast, safe code without a garbage collector. In the next sections, we'll explore how Rust allows you to use data without taking ownership through a concept called borrowing.
What is a key advantage of Rust's ownership system compared to a traditional garbage collector (GC)?
In a Rust program, where is data with a size that is unknown at compile time, like a String, typically stored?