7.7 Why Ownership Prevents Double-Free and Use-After-Free
Right, let’s get to the heart of the matter. You’ve probably heard that Rust prevents memory bugs like double-frees and use-after-frees. It’s not magic; it’s a ruthless, compile-time enforcement of a single, brilliant rule: every piece of memory has one and only one owner at a time.
Think of memory as a physical object, say, a concert ticket. If you give your ticket to a friend, you no longer have it. You can’t use it to get in, and you certainly can’t try to give it to another friend later. The concept of “ownership” in Rust is that literal. This model completely sidesteps the need for a garbage collector constantly running in the background, checking if anyone’s still using things. Instead, the rules are checked at compile time. If your code breaks them, it simply won’t compile. It’s like having a pedantic but brilliant friend looking over your shoulder, saving you from yourself.
How a Double-Free is Stopped Before It Happens
In C, a double-free is a classic catastrophe. You allocate some memory, free it, and then accidentally free it again. The memory allocator’s internal bookkeeping gets utterly confused, often leading to crashes or security vulnerabilities. Here’s how it would look in C:
#include <stdlib.h>
int main() {
int *buffer = malloc(1024 * sizeof(int)); // First allocation
// ... do some work with buffer ...
free(buffer); // First free. Okay.
// ... some more code ...
free(buffer); // Second free. Boom. Undefined Behavior.
return 0;
}
The compiler happily lets you do this. It’s a ticking bomb. Now, let’s try the equivalent in Rust. We’ll use a Box, which is a simple pointer to heap-allocated memory.
fn main() {
let data = Box::new(42); // data owns the Box on the heap
println!("The value is: {}", data);
// data goes out of scope here and is automatically freed.
// The memory is gone. Poof.
// Imagine we try to free it again manually... we can't even access it.
// The variable `data` is no longer in scope or valid.
}
But let’s try to be more deliberate about causing trouble. What if we try to create a second variable pointing to the same box and then have both go out of scope?
fn main() {
let owner = Box::new("hello");
let would_be_thief = owner; // This is the crucial line.
println!("Trying to use owner: {}", owner);
}
Try to compile this. You’ll be greeted with a beautifully descriptive error:
error[E0382]: borrow of moved value: `owner`
--> src/main.rs:5:42
|
2 | let owner = Box::new("hello");
| ----- move occurs because `owner` has type `Box<&str>`, which does not implement the `Copy` trait
3 | let would_be_thief = owner; // This is the crucial line.
| ----- value moved here
4 |
5 | println!("Trying to use owner: {}", owner);
| ^^^^^ value borrowed here after move
This is Rust’s ownership system in action. The assignment let would_be_thief = owner; isn’t a “copy”; it’s a move. Ownership of the heap data is transferred from owner to would_be_thief. The variable owner is now invalid. Using it is a compile-time error. Therefore, when would_be_thief goes out of scope at the end of the function, the memory is freed exactly once. The double-free is impossible because the compiler has statically ensured there’s only ever one owner responsible for freeing the resource.
Slaying the Use-After-Free
A use-after-free is even more insidious. You free a piece of memory, but a stale pointer to that same memory still exists. Later, you use that stale pointer. At best, you’re reading garbage data; at worst, an attacker can exploit this to run arbitrary code. Let’s see how ownership prevents this.
The previous example already demonstrated it: once ownership is moved, the original variable is invalid. You cannot use it. The compiler won’t let you. The “free” event (the variable going out of scope) is followed by a compiler-enforced period of silence for that variable. It’s gone. You can’t talk about it anymore.
But what if you need to share access? That’s where Rust’s other genius features, borrowing and lifetimes, come in (& and &mut). Borrowing lets you temporarily access data without taking ownership. The compiler tracks the duration of these borrows with excruciating precision through lifetimes to ensure they never outlive the data they point to.
fn main() {
let mut owner = Box::new(64);
// Create a mutable borrow (a reference)
let borrower: &mut i32 = &mut owner;
*borrower += 1; // Dereference and modify the value
println!("Value after borrow: {}", owner); // This is fine
// The borrow of `owner` ends after it's last use, which is here.
// Now we can use `owner` freely again.
*owner += 10;
println!("Final value: {}", owner);
}
The compiler’s lifetime checks ensure that the reference borrower cannot exist in any way once owner is moved or goes out of scope. Any attempt to use owner while it’s mutably borrowed, or to use borrower after owner becomes invalid, is caught at compile time. This rigid tracking of who can read and write what, and when, completely eliminates use-after-free errors. The data will always be alive for the entire duration of any reference to it. It’s a guarantee.