9.5 References Must Be Valid: No Dangling References
Alright, let’s talk about one of Rust’s most brilliant and yet most obvious rules: you can’t just leave references pointing to nothing. It’s the memory safety equivalent of “don’t run with scissors.” Seems simple, right? But this is where Rust’s compiler shifts from being a helpful friend to a stubborn, albeit brilliant, lifeguard who won’t let you back in the pool until you’ve properly wrapped up that dangling rope.
The core principle is this: a reference must always point to valid data. The lifetime of the reference (the scope where it’s usable) cannot outlive the lifetime of the data it points to. If it did, you’d have a “dangling reference”—a pointer to a memory location that might contain something completely different, or nothing at all. This is a classic foot-gun in languages like C++, and Rust simply says, “Nope, not on my watch.”
Let’s break it down.
The Compiler is Your Very Paranoid Friend
The Rust compiler performs borrow checking, a static analysis phase that happens at compile time. It tracks the lifetimes of all values and references to ensure this rule is never broken. It’s not guessing; it’s using a rigorous set of rules to prove your code is sound. When it rejects your code, it’s not being difficult; it’s saving you from a problem that would have happened.
Let’s look at the textbook example of what not to do. We’ll try to create a dangling reference.
fn main() {
let reference_to_nothing = dangle();
}
fn dangle() -> &String {
let s = String::from("hello");
&s
}
Go on, try to compile that. I’ll wait.
…
Told you so. The compiler error is wonderfully descriptive:
error[E0106]: missing lifetime specifier
--> src/main.rs:5:16
|
5 | fn dangle() -> &String {
| ^ expected named lifetime parameter
|
= help: this function's return type contains a borrowed value, but there is no value for it to be borrowed from
Look at that help text. It’s not just saying “you messed up”; it’s diagnosing the exact problem: “there is no value for it to be borrowed from.” The function dangle creates a String s. When dangle ends, s goes out of scope and is dropped. Its memory is freed. We then try to return a reference &s to this now-deallocated memory. This is the dangling reference we talked about. Rust refuses to compile this because it’s inherently unsafe.
The compiler spotted that the output reference is derived from a value inside the function, and it knows that value will be gone before the reference can even be used. It’s not just checking types; it’s checking time.
The Fix: Return Owned Data or Use Proper Lifetimes
So how do we fix it? The simplest way is to just return the owned String itself. No reference, no problem. Ownership is transferred out of the function, so there’s no dangling value.
fn no_dangle() -> String {
let s = String::from("hello");
s
}
This is often the right answer. But what if you really need a reference? This is where explicit lifetimes come in (a topic for the next section), which allow you to tell the compiler how the lifetime of the reference relates to the lifetime of the input parameters. You’re essentially drawing a map for the borrow checker to prove that your reference will always be valid.
It’s Not Just Functions: Scopes Matter Everywhere
This rule isn’t confined to function returns. It applies everywhere. The borrow checker is always watching. Consider this:
fn main() {
let mut data = vec![1, 2, 3];
let first = &data[0]; // Immutable borrow occurs here
data.push(4); // Mutable borrow occurs here: OH NO!
println!("{first}");
}
Another compilation failure. Why? push might need to allocate new memory and move the existing elements, invalidating the first reference. It’s not guaranteed to happen every time, but it could. The borrow checker operates on possibility, not certainty. It sees that first (an immutable borrow) is still in scope when we try to mutably borrow data for push, and it stops us. This prevents a potential use-after-free or reading garbage memory if the reallocation occurred. It’s preventing a problem you might not even encounter on your machine, but which could absolutely crater your program on someone else’s.
Why This Feels Annoying (And Why It’s Genius)
Yes, this can feel restrictive. You’ll bang your head against the borrow checker. Everyone does. It’s a rite of passage. You’ll think, “But I know the value is still there!” The compiler doesn’t care about what you know; it only cares about what it can prove.
This is the fundamental trade-off: a little upfront friction for guaranteed memory safety. You’re not dealing with a runtime cost or a garbage collector; you’re having a conversation with the compiler at build time. Once your code compiles, you have a rock-solid guarantee: no null or dangling pointers. That’s an incredible payoff. You’re not just writing code; you’re writing provably correct code, at least in the realm of memory management. And that, my friend, is worth a few compiler errors.