Understanding lifetimes in Rust is crucial to mastering memory safety without garbage collection. In this post, we break down dangling references, lifetime annotations, and how Rust's borrow checker ensures your code is always safe and sound. Let's dive in.
Dangling References
Let’s take a look at the code snippet below.
fn main() {
let x : &i32;
{
let y : i32 = 10;
x = &y; // This line will cause a compile-time error
}
println!("x: {}", x);
}
This causes a classic compile-time error in rust - “borrowed value does not live long enough”. Let’s understand.
y
is a variable which is stack allocated and as soon as the scope ends, it is dropped.
{
let y : i32 = 10;
x = &y; // This line will cause a compile-time error
}
//y gets dropped
-
y
is stack-allocated and only lives inside the inner block. - As soon as the block ends,
**y
is dropped**. - But
x
is trying to hold a reference toy
outside its valid lifetime. - That would make
x
a dangling reference.
For this to compile correctly, y
must simply outlive x
.
fn main() {
let y: i32 = 10;
let x: &i32 = &y;
println!("x: {}", x); // Works fine
}
TL;DR
- Rust won’t allow refs to outlive the data.
- Borrow checker enforces that.
Generical Lifetime Annotations
They simply describe the relation between the lifetimes of multiple references.
fn longest (x : &str, y: &str) -> &str {
if x.len() > y.len() {
x
} else {
y
}
}
Here, we get a compile time error :
missing lifetime specifier
this function's return type contains a borrowed value, but the signature does not say whether it is borrowed from x
or y
So we will change our function signature like this :
fn longest <'a> (x : & 'a str, y: & 'a str) -> & 'a str
We specify a generic lifetime annotation (starting from an ‘). Here it is ‘a
.
x
, y
and the return value, all use our lifetime annotation. But what does this mean?
There is a relationship between the lifetimes of x
, y
and the return value. The relationship is : The lifetime of the return value will be same as the smallest of all the arguments’ lifetimes. The return value must not outlive any of the arguments (can be checked by checking if it outlives the argument with the smallest lifetime).
fn main() {
let name = String::from("pixperk");
let name2 = String::from("pixperk2");
let res = longest(&name, &name2);
println!("The longest name is: {}", res);
}
Here name
and name2
do not outlive each other (equal lifetimes). When res
is used, the smallest lifetime is still valid and hence res is not a dangling reference.
fn main() {
let name = String::from("pixperk");
{
let name2 = String::from("pixperk2");
let res = longest(&name, &name2);
println!("The longest name is: {}", res);
}
}
Here name2
has the smaller lifetime. But when res
is used, the smallest lifetime is still valid and hence res
is not a dangling reference.
Let’s change it up a bit.
fn main() {
let name = String::from("pixperk");
let res;
{
let name2 = String::from("pixperk2");
res = longest(&name, &name2);
}
println!("The longest name is: {}", res);
}
We get - “name2
does not live long enough”. And yes, the reference having the smaller lifetime is not valid, when res
is used and hence here it is a dangling reference.
Let’s see what happens when we try returning reference to a local variable.
fn broken<'a>() -> &'a String {
let s = String::from("oops");
&s // `s` is dropped, can't return reference
}
This is illegal because you're returning a reference to memory that’s already gone. We can fix this by returning an owned value.
Lifetimes Annotations in Struct Definitions
If we want to use references with structs, we need to specify lifetime annotations.
struct Excerpt<'a> {
part: &'a str,
}
Our struct must not outlive the reference passed to part. Let’s see this through different cases :
fn main() {
let novel = String::from("Call me Ishmael.");
let quote = &novel[..4]; // "Call"
let excerpt = Excerpt { part: quote };
println!("{}", excerpt.part);
}
Here, novel
owns the string. quote
borrows a slice from novel
. The struct instance is defined later and novel
lives long enough, and hence excerpt
is not a dangling reference.
fn main() {
let excerpt;
{
let novel = String::from("Call me Ishmael.");
let quote = &novel[..4];
excerpt = Excerpt { part: quote };
}
// novel is dropped here
println!("{}", excerpt.part); // Dangling reference!!
}
Here, excerpt
, the instance of Excerpt
outlives “novel
”, and when we try to use excerpt
, “novel
” is invalid. Therefore, excerpt
becomes a dangling reference here and we get a compiler error.
Lifetime Elision
Sometimes, the compiler can deterministically infer the lifetime annotations. It is done by checking three elision rules :
1. Each parameter that is a reference gets its own lifetime parameter
If a function takes multiple references as input, and you don’t explicitly annotate them, Rust assigns a separate lifetime to each.
fn foo(x: &str, y: &str) -> &str {
if x.len() > y.len() { x } else { y }
This won't compile. Why?
Because Rust sees this like:
fn foo<'a, 'b>(x: &'a str, y: &'b str) -> &str { ... }
But it doesn’t know whether to return 'a
or 'b
. Hence, the error:
"lifetime may not live long enough."
You must explicitly annotate the output lifetime:
fn foo<'a>(x: &'a str, y: &'a str) -> &'a str {
if x.len() > y.len() { x } else { y }
}
Now Rust knows that the returned reference is valid for the shortest of both inputs.
2. If there is exactly one input lifetime, that lifetime is assigned to all output lifetime parameters
When your function has one reference input, and you return a reference, Rust assumes the returned reference lives as long as the input.
Example:
fn identity(s: &str) -> &str {
s
This works because Rust infers:
fn identity<'a>(s: &'a str) -> &'a str {
s
}
There’s only one input lifetime 'a
, so it’s safe to assign it to the output.
3. If there are multiple input lifetimes, but one of them is &self
or &mut self
, the lifetime of self
is assigned to all output lifetime parameters
This rule exists primarily for methods, and it enables a natural feel when writing object-oriented-style code.
Example:
struct Book {
title: String,
}
impl Book {
fn get_title(&self) -> &str {
&self.title
}
}
Rust automatically infers:
fn get_title<'a>(&'a self) -> &'a str {
&self.title
}
Even if the method returns a reference to a field, Rust understands that the output reference must not outlive self
.
This makes method-writing ergonomic without sacrificing safety.
‘static
A reference with a 'static
lifetime means the data it points to will never be deallocated during the program’s execution. It's essentially "immortal" from the compiler’s perspective.
let s: &'static str = "hello";
In this case, "hello"
is a string literal stored in the binary’s read-only data segment. It lives for the entire duration of the program, so the reference to it is safely 'static
.
Common Sources of 'static
Data
-
String Literals
Always have
'static
lifetimes because they are embedded in the binary and never go out of scope. -
Heap-Leaked Data
You can deliberately "leak" heap data so that it lives for the program's entire runtime:
let leaked: &'static str = Box::leak(Box::new("persistent string".to_string()));
This is useful in situations where global, non-dropping data is required, such as plugin systems or global caches.
'static
in Practice
Rust often requires 'static
in multithreaded or asynchronous contexts where the compiler can't guarantee how long the data will be needed. A classic example is spawning threads:
std::thread::spawn(|| {
println!("Runs independently");
});
Here, the closure must be 'static
because the thread might outlive any borrowed data from the parent scope.
When Not to Use 'static
A common mistake is to annotate a function like this:
fn longest<'static>(x: &'static str, y: &'static str) -> &'static str
Unless you're certain the data truly lives for the full program duration, this is incorrect. You're promising more than the code can guarantee, which may cause subtle bugs or force you into unnecessary constraints. 'static
should not be used to silence the borrow checker.
When It’s Justified
- Global constants or config
- Singleton patterns
- Plugin registries
- Global caches
- Thread-safe lazy initialization (
once_cell
,lazy_static
)
Lifetimes might feel tricky at first, but they’re what make Rust so powerful. Once you understand the flow of data and references, writing safe and efficient code becomes second nature. Keep experimenting, and lifetimes will click.
Top comments (0)