Lifetimes: Validating References with Lifetimes
One detail we didn't discuss in Chapter 4 is that every reference in Oxide has a lifetime, which is the scope for which that reference is valid. Most of the time, lifetimes are implicit and inferred, just like most of the time, types are inferred. But just as we sometimes must annotate types when multiple types are possible, we must annotate lifetimes when the lifetimes of references could be related in a few different ways.
Oxide requires us to annotate the relationships using generic lifetime parameters to ensure the actual references used at runtime will definitely be valid.
Preventing Dangling References with Lifetimes
The main aim of lifetimes is to prevent dangling references, which cause a program to reference data other than the data it's intended to reference. Consider this pseudocode, where we explicitly show the lifetime of a reference:
{
let r: &Int // lifetime must satisfy: 'a
{
let x = 5
r = &x // x has lifetime 'b
} // x is dropped here, ending 'b
println!("{}", r) // r still tries to use x here, but x no longer exists!
}
In this example, r tries to reference an Int (x) that goes out of scope before we try to use the reference. The variable x doesn't live long enough. The reason is that x will be dropped when the inner scope ends, but r will still be referring to that location in memory.
Oxide's borrow checker prevents this problem from compiling. Let's look at how lifetimes help us write valid code.
The Borrow Checker
Oxide's compiler has a borrow checker that compares scopes to determine whether all borrows are valid. Here's the logic:
- Each reference has a lifetime that corresponds to the scope of the code it's being used in
- You cannot borrow a value for a lifetime that outlives the value
- The compiler will reject your code if it violates these rules
Let's look at an example where the borrow checker catches a dangling reference:
fn main() {
let r: &Int
{
let x = 5
r = &x
}
println!("r: {}", r) // error: `x` does not live long enough
}
When we compile this code, Oxide will give us an error:
error[E0597]: `x` does not live long enough
--> src/main.rs:8:13
|
7 | r = &x;
| ^^ borrowed value does not live long enough
8 | }
| - `x` dropped here while still borrowed
9 |
10 | println!("r: {}", r);
| - borrow later used here
The variable r has a reference to x, but x goes out of scope right away. So r will be referencing memory that no longer contains valid data. This is a classic memory safety issue that Oxide prevents at compile time.
Lifetime Annotations in Function Signatures
In most cases, lifetimes are implicit. However, when a function returns a reference, we need to specify which input parameter's lifetime the returned reference is tied to. We do this with lifetime annotations.
Lifetime annotations use the syntax 'a (pronounced "lifetime a"). Let's look at an example:
fn longest<'a>(x: &'a String, y: &'a String): &'a String {
if x.len() > y.len() {
x
} else {
y
}
}
This function compares two string slices and returns the longer one. The lifetime annotation 'a tells the compiler:
- The function takes two parameters that are references with lifetime
'a - The function returns a reference with the same lifetime
'a - The returned reference will be valid as long as both input references are valid
Let's examine what happens without the lifetime annotations:
fn longest(x: &String, y: &String): &String {
if x.len() > y.len() {
x
} else {
y
}
}
The compiler can't tell which input parameter the return reference relates to. It could be x or y. So we get an error:
error[E0106]: missing lifetime specifier
--> src/main.rs:1:33
|
1 | fn longest(x: &String, y: &String): &String {
| ------- ------- ^ expected lifetime parameter
|
Let's use our annotated version correctly:
fn longest<'a>(x: &'a String, y: &'a String): &'a String {
if x.len() > y.len() {
x
} else {
y
}
}
fn main() {
let string1 = "abracadabra".toString()
let string2 = "xyz".toString()
let result = longest(&string1, &string2)
println!("The longest string is '{}'", result)
}
This code compiles because:
- Both
string1andstring2are in themainfunction's scope - We return a reference to one of them
- That reference will be valid as long as both input references are valid
- When we print
result, bothstring1andstring2still exist
But this wouldn't work:
fn longest<'a>(x: &'a String, y: &'a String): &'a String {
if x.len() > y.len() {
x
} else {
y
}
}
fn main() {
let string1 = "long string".toString()
let result: &String
{
let string2 = "xyz".toString()
result = longest(&string1, &string2)
}
println!("The longest string is '{}'", result)
}
The problem is:
string2goes out of scope at the end of the inner block- But
resultmight referencestring2 - The lifetime annotation says the return reference must be valid as long as both inputs
- Since
string2becomes invalid before we useresult, this is an error
Lifetime Annotations in Struct Definitions
We can also use lifetime annotations in struct definitions when a struct holds references:
struct ImportantExcerpt<'a> {
part: &'a String,
}
fn main() {
let novel = "Call me Ishmael. Some years ago...".toString()
let firstSentence = novel.split('.').next() ?? ""
let excerpt = ImportantExcerpt { part: &firstSentence }
}
The lifetime annotation 'a means that the ImportantExcerpt struct can hold a reference to a String, and that reference must live at least as long as the ImportantExcerpt instance.
This struct can't outlive the reference it holds:
struct ImportantExcerpt<'a> {
part: &'a String,
}
fn main() {
let excerpt: ImportantExcerpt
{
let novel = "Call me Ishmael. Some years ago...".toString()
excerpt = ImportantExcerpt { part: &novel }
}
println!("{}", excerpt.part) // error: `novel` does not live long enough
}
The Three Lifetime Rules
The borrow checker uses three rules to determine whether borrows are valid.
Rule 1: Each reference has its own lifetime
Each time you have a reference, it has an associated lifetime. For example:
fn foo<'a>(x: &'a Int) { }
fn bar<'a, 'b>(x: &'a Int, y: &'b Int) { }
Each parameter gets its own lifetime variable.
Rule 2: Return lifetimes must relate to input lifetimes
If a function takes references as parameters and returns a reference, the return type's lifetime must relate to the lifetimes of the input parameters:
fn foo<'a>(x: &'a Int, y: &Int): &'a Int { }
Here, the return value's lifetime is tied to x's lifetime, not y's. This means the returned reference is valid as long as x is valid.
Rule 3: Methods use &self's lifetime
For methods (in extension blocks), the returned reference's lifetime is implicitly tied to &self:
extension ImportantExcerpt<'a> {
fn announceAuthor() {
println!("Attention please: {}", self.part)
}
}
This is equivalent to:
extension ImportantExcerpt<'a> {
fn announceAuthor() {
println!("Attention please: {}", self.part)
}
}
Lifetime Elision
Because the rules are common, Oxide (like Rust) allows you to omit lifetime annotations in many cases. This is called lifetime elision. The compiler will infer the lifetimes for you automatically in these situations:
Elision works for input references
If there's only one input reference, its lifetime is automatically used for all outputs:
// You can write this...
fn firstWord(s: &String): &String {
let bytes = s.asBytes()
for (i, &item) in bytes.iter().enumerate() {
if item == b' ' {
return &s[0..i]
}
}
&s[..]
}
// Without specifying: fn firstWord<'a>(s: &'a String): &'a String { }
// The compiler figures it out!
Elision works for methods
Methods always have an implicit &self lifetime:
struct Excerpt {
text: String,
}
extension Excerpt {
fn returnText(): &String {
&self.text
}
}
// Implicit: fn returnText<'a>(&'a self): &'a String { }
Combining Lifetimes with Generics and Traits
You can combine lifetimes with generic type parameters and trait bounds:
fn longest<'a, T: PartialOrd + Display>(x: &'a T, y: &'a T): &'a T {
if x > y {
println!("x is larger: {}", x)
x
} else {
println!("y is larger: {}", y)
y
}
}
This function takes two generic parameters with the same lifetime, compares them, and returns a reference with that lifetime.
Here's a more complex example with trait bounds and lifetimes:
struct Pair<'a, T> {
items: &'a [T],
}
extension<'a, T> Pair<'a, T> {
fn first(): T? {
self.items.first().cloned()
}
}
Static Lifetime
The 'static lifetime is a special lifetime that means the reference is valid for the entire duration of the program. All string literals have the 'static lifetime:
let s: &'static String = "Hello, world!"
String literals are baked directly into the program's binary, so they're always available.
You'll often see 'static bounds on types when you want to ensure they own their data:
fn printIt<T: std.fmt.Display + 'static>(t: T) {
println!("{}", t)
}
This says T must implement Display and must not contain any borrowed references.
Advanced Lifetime Patterns
Higher-Ranked Trait Bounds
Sometimes you need to specify that a function accepts references with any lifetime. This uses for<'a> syntax:
fn acceptAnyLifetime<F>(f: F)
where
F: for<'a> Fn(&'a Int) -> &'a Int,
{
// f works with references of any lifetime
}
Lifetime Subtyping
A longer lifetime is a subtype of a shorter lifetime:
fn demo<'a, 'b>(x: &'a Int): &'b Int
where
'a: 'b, // 'a outlives 'b
{
x
}
The constraint 'a: 'b means 'a outlives 'b, so we can treat a &'a Int as a &'b Int.
Common Lifetime Mistakes
Mistake 1: Returning a reference to a local variable
fn badFunction(): &String {
let s = "hello".toString()
&s // Error: `s` does not live long enough
}
Solution: Return owned data instead:
fn goodFunction(): String {
let s = "hello".toString()
s
}
Mistake 2: Mismatching input and output lifetimes
fn problem<'a, 'b>(x: &'a String): &'b String {
x // Error: lifetime mismatch
}
Solution: Make sure the output lifetime matches an input:
fn fixed<'a>(x: &'a String): &'a String {
x
}
Summary
Lifetimes are Oxide's way of ensuring that references are always valid. While the syntax can seem intimidating at first, the rules are logical:
- Every reference has a lifetime
- Function signatures must make the relationship between input and output lifetimes explicit
- The compiler will reject code where references might outlive the data they point to
In practice, you'll rarely need to write lifetime annotations because:
- Single input references automatically determine output lifetime
- Methods automatically use
&self's lifetime - The compiler will tell you exactly where you need them
With lifetimes, Oxide ensures memory safety without garbage collection or runtime overhead. This is one of the most powerful features of the language!
Comparison with Rust
Oxide's lifetime syntax is identical to Rust's because lifetimes are a fundamental part of the compiler's borrow checking algorithm. The only difference is in how method syntax works, but the lifetime rules remain exactly the same.
Both Oxide and Rust use the same three rules for lifetime elision and the same mechanisms for explicit annotation. If you understand lifetimes in Oxide, you understand them in Rust as well!