Other Resources: Lifetimes
References are a bit like playing catch.
There is always going to be two people and are dependent on one catching and one throwing.
Using a reference is like throwing a ball. it has a destination and origin, and reading/writing from a reference value is like catching & throwing.
lifetimes help make sure that when you look up to throw it back to your partner, your partner is still there...
How the book describes it
Lending out a reference to a resource that someone else owns can be complicated. For example, imagine this set of operations:
- I acquire a binding to some kind of resource/value.
- I lend you a reference to the resource.
- I decide I’m done with the resource, and deallocate it, while you still have your reference.
- You decide to use the resource.
lifetimes help make sure that step 4 never happens after step 3.
- instead of having to infer how long a reference is going to be in scope for...
- you can use lifetimes to make sure to label lifetime relationships
example
fn skip_prefix(line: &str, prefix: &str) -> &str {
// ...
line
}
let line = "lang:en=Hello World!";
let lang = "en";
let v;
{
let p = format!("lang:{}=", lang); // -+ `p` comes into scope.
v = skip_prefix(line, p.as_str()); // |
} // -+ `p` goes out of scope.
println!("{}", v);
in the code example above: the compiler doesn't know if the value of v
(based off the fn definition of skip_prefix
) is going to be in scope for the life of line
or prefix
.
we can use lifetimes (in this case) to explicitly express how long the references should live (their "lifetime")
fn skip_prefix<'a, 'b>(line: &'a str, prefix: &'b str) -> &'a str {
// ...
}
'a
and 'b
tell the compiler how the lifetimes between references are related.
this tells the compiler that the returned reference will have the same lifetime as the line
parameter even though p
goes out of scope in the original example.
syntax
'a
reads lifetime a
in function declaration we put our function's lifetimes between <>
after the function name.
fn bar<'a, 'b>(...)
and then in the parameter list, we use the lifetimes we have named.
...(x: &'a mut i32)
this reads a mutable reference to an i32 with a lifetime of 'a
you can need to use lifetimes with structs as well.
struct Foo<'a> {
x: &'a i32,
}
fn main() {
let y = &5;
let f = Foo { x: y};
println!("{}", f.x);
}
this ensures that any reference to a "Foo" cannot outlive the reference to the i32 it contains.
code that will not work because of lifetimes:
struct Foo<'a> {
x: &'a i32,
}
fn main() {
let x;
{
let y = &5;
let f = Foo {x: y};
x = &f.x;
} // ------------------+ `f` out of scope.
println!("{}", x); //---+ trying to access a value of a struct that was dropped from memory.
}
implementing a method on Foo:
struct Foo<'a> {
x: &'a i32,
}
impl<'a> Foo<'a> {
fn x(&self) -> &'a i32 { self.x }
}
fn main() {
let y = &5;
let f = Foo { x: y};
println!("{}", f.x);
}
in this case we are not repeating ourselves technically
impl
defines a new lifetime named <'a> and Foo uses it before the fn statement.
I saw a couple youtube videos that use lifetimes this way:
fn x_or_y<'a>(x: &'a str, y: &'a str) -> &'a str {}
this says that the parameters and the return value all have the same lifetimes and they all exist in the same scope together.
in the example of differing lifetimes in a fn statement.
fn x_or_y<'a, 'b>(x: &'a str, y: &'b str) -> &'a str {}
this changes the message to the compiler to say that the arguments have different scopes but the return value is going to have the same scope as parameter x
.
'static
using this named lifetime bakes the data segment that has this lifetime into the final binary of the code
AKA: it will be in memory (stack or heap) for the entirety of the programs execution.
let x: &'static str = "Hello. World!";
you can declare globals with:
static FOO: i32 = 5;
Lifetime Elision
Here’s a simplified explanation of lifetime elision in Rust:
Lifetimes are a way for Rust to make sure references are always valid, preventing things like dangling pointers. Sometimes, you have to explicitly write lifetimes in function signatures, but in many cases, Rust can infer them for you—that's called lifetime elision.
Think of lifetime elision as a set of rules Rust follows to automatically figure out lifetimes, so you don’t have to write them all the time. It makes your code shorter and easier to read.
There are a few simple rules Rust uses for lifetime elision:
-
Single Input Reference: If a function takes one reference as a parameter and returns a reference, Rust assumes the output lifetime is the same as the input.
// Example: fn example(s: &str) -> &str { s }
Here, Rust automatically infers that the returned reference has the same lifetime as the input reference
s
. -
Multiple Input References: If there are multiple input references, Rust can’t assume which one the output comes from, so you need to specify it manually.
The goal of lifetime elision is to save you from writing lifetimes in most common cases, making it easier to write code without worrying too much about the exact lifetimes of your references.
So, in many simple functions, you don't need to write lifetimes at all—Rust figures them out for you!
Multiple References: Lifetime Elision
Let's look at how lifetime elision works when there are multiple reference parameters. With multiple references, there are more rules about when you need to explicitly state lifetimes. Here are a couple of examples:
Example 1: Function with Multiple Input References, No Output Reference
If a function has multiple reference parameters and does not return a reference, lifetime elision is not needed. Here's an example:
fn compare(a: &str, b: &str) -> bool {
a == b
}
In this function:
a
andb
are both references.- Since the function does not return a reference, there is no need to specify lifetimes, and Rust can easily manage these references without ambiguity.
Example 2: Function with Multiple Input References and an Output Reference
If a function returns a reference and takes multiple references as parameters, Rust can't infer the output lifetime automatically. In such cases, you have to specify which of the input references the return value is tied to.
For instance:
// Without lifetime annotations, this won't compile.
fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
if x.len() > y.len() {
x
} else {
y
}
}
Here:
- There are two input references,
x
andy
, and the function returns one of them. - Rust can't know which input reference (
x
ory
) the output is associated with, so you need to explicitly define a lifetime ('a
) to indicate that the output reference will have the same lifetime as both input references.
Example 3: Multiple References with Different Lifetimes
Consider the following function where the returned reference could be from one of the input references:
// Here, we use two different lifetimes: `'a` and `'b`.
fn select_first<'a, 'b>(x: &'a str, y: &'b str) -> &'a str {
x
}
In this example:
- There are two input references,
x
with lifetime'a
andy
with lifetime'b
. - The function returns
x
, so we explicitly annotate that the output has the same lifetime asx
.
This shows that when dealing with multiple references, Rust often needs more information about how long each reference will live to make sure everything is safe.
Summary of Elision Rules with Multiple References
- If there is one input reference, Rust automatically assigns the same lifetime to the output reference.
- If there are multiple input references, Rust cannot infer which one is related to the output, and you must provide explicit lifetime annotations to make it clear.
Using lifetime annotations helps ensure that the returned reference is valid as long as any of the input references it’s tied to, avoiding potential memory safety issues.
These rules help Rust manage references safely while still allowing for flexible and readable code.