Skip to main content

Welcome to a Grasp lesson

Create a free account to keep studying and create your own study path.

Lesson 6

Understanding Lifetime Annotations

Recommended resources

Welcome back. In our journey through Rust's core concepts, we have explored ownership, which dictates who is responsible for cleaning up data, and borrowing, which allows temporary access to data without transferring ownership. You've also mastered Result and Option, ensuring that every possible outcome of an operation is handled at compile time.

This lesson addresses the final, crucial piece of Rust's memory safety puzzle. Our learning outcome is to explain lifetime annotations and their role in preventing dangling references. Drawing from your C/C++ background, you're undoubtedly familiar with the risks of dangling pointers—references to memory that has been freed or has gone out of scope. Rust eliminates this entire class of bugs at compile time through its lifetime system. We will explore what lifetimes are, why they are necessary, and how to use the syntax to describe relationships between references to the compiler.

This diagram illustrates Rust's core safety features. Lifetimes, working in tandem with Ownership and the Borrow Checker, are fundamental to preventing dangling references and ensuring memory safety.

The Problem: Dangling References

The primary purpose of lifetimes is to prevent dangling references. A dangling reference is a pointer that points to a memory location that has been deallocated or whose content is no longer valid. In many languages, this leads to undefined behavior, security vulnerabilities, and crashes. Rust's borrow checker analyzes lifetimes to catch these errors before your code even compiles.

Let's watch a short segment that clearly illustrates this problem and introduces how the borrow checker thinks about it.

Rust Lifetimes Finally Explained!

The video "Rust Lifetimes Finally Explained!" from the Let's Get Rusty channel starts with a perfect, concise demonstration of a dangling reference and how the borrow checker identifies the issue by comparing scopes.

Please watch from the beginning until the end of the example. Pay close attention to the visual annotation of lifetimes 'a and 'b and how the compiler determines that one "does not live long enough."

This simple example highlights the core issue: the reference r lives longer than the data x it points to. Rust prevents this. To formalize this, let's connect it back to your experience with C and see how Rust's philosophy evolved directly from the problems found in earlier systems languages.

Item 14: Understand lifetimes - Effective Rust

This section of Effective Rust provides excellent context by tracing the history of this problem from C, through C++, and to Rust's solution.

Please read the section titled Evolution of Lifetimes. The C code example showing a function returning a pointer to a local variable will be very familiar and perfectly frames why Rust's approach is so valuable.


The Solution: Generic Lifetime Annotations

As we've seen, in many simple cases, the borrow checker can determine if a reference is valid by comparing the lexical scopes. However, when we introduce functions that take and return references, the situation becomes more complex. The compiler can't always know the lifetimes of the data that will be passed in, so it can't verify that a returned reference will be valid.

This is where we must help the compiler by adding generic lifetime annotations. These annotations don't change how long any reference lives; instead, they describe the relationships between the lifetimes of multiple references, creating a contract that the function must uphold.

The canonical example for this is a function that finds the longer of two string slices.

Validating References with Lifetimes

Chapter 10 of "The Rust Programming Language" provides a step-by-step walkthrough of this exact problem. It clearly explains why the compiler needs help and how to provide it.

Please read the following three sections: Generic Lifetimes in Functions: This section introduces the longest function and the compile error it produces without lifetimes. Lifetime Annotation Syntax: This explains the 'a syntax. In Function Signatures: This shows how to apply the annotations to the longest function and explains what the annotation fn longest<'a>(x: &'a str, y: &'a str) -> &'a str actually tells the compiler.

The key takeaway from that reading is that the signature fn longest<'a>(...) -> &'a str tells Rust: "The returned reference will be valid for a lifetime 'a, which is guaranteed to be no longer than the shorter of the lifetimes of the inputs x and y."

This ensures that the returned reference cannot outlive either of the inputs from which it might have come.

Let's reinforce this with a video that walks through the same logic, which can help solidify the concept.

Rust Lifetimes Finally Explained!

Continuing with "Rust Lifetimes Finally Explained!", this segment mirrors the Rust book's example, explaining the relationships and the "smallest lifetime" rule.

Watch from the longest function example to see the problem and solution animated. The explanation around "what we're actually saying" is particularly helpful.

A Fundamental Rule

A critical constraint emerges from this system: a function cannot return a reference to a value created inside that function. The value would be dropped at the end of the function's scope, and the reference would be instantly dangling. Therefore, any returned reference must have a lifetime tied to one of the input parameters or to a 'static value.

The following short clip makes this point very clearly.

Rust Lifetimes Finally Explained!

This brief segment explicitly demonstrates why you cannot return a reference to a local variable.

Please watch from this quick example.


Lifetimes in Structs

The same principle applies when you want to store references inside a struct. If a struct holds a reference, that struct cannot be allowed to outlive the data it is borrowing. Therefore, the struct definition must also be annotated with a lifetime parameter.

// This struct holds a reference, so its definition needs a lifetime parameter.
struct ImportantExcerpt<'a> {
    part: &'a str,
}

fn main() {
    let novel = String::from("Call me Ishmael. Some years ago...");
    let first_sentence = novel.split('.').next().unwrap();

    // 'i' holds a reference to a part of 'novel'.
    // 'i' cannot be used after 'novel' goes out of scope.
    let i = ImportantExcerpt {
        part: first_sentence,
    };
}

This annotation 'a on ImportantExcerpt creates a contract: any instance of ImportantExcerpt is tied to the lifetime of the data in its part field. This is a common pattern in embedded Rust, for example when a driver struct holds a reference to a peripheral register block. The driver struct must not outlive the hardware registers it's configured to control.


Making Life Easier: Elision and 'static

At this point, you might be thinking this seems verbose. Fortunately, the Rust team identified several very common, deterministic patterns where lifetimes could be inferred. These are called the lifetime elision rules. The compiler automatically applies these rules, so you don't have to write explicit annotations in these common cases.

The first_word function from our earlier lessons is a perfect example. Its signature is fn first_word(s: &str) -> &str;. It compiles without any lifetime annotations because it fits the elision rules perfectly.

Validating References with Lifetimes

This section from the Rust book provides a clear, rule-based explanation of lifetime elision.

Please read the section on elision rules. Focus on the three rules the compiler uses to infer lifetimes.

The three rules are, in summary:

  1. Each reference in a function's parameters gets its own unique lifetime.
  2. If there is exactly one input lifetime, that lifetime is assigned to all output lifetimes. (This is why fn first_word(s: &str) -> &str works).
  3. If one of the input parameters is &self or &mut self, its lifetime is assigned to all output lifetimes. (This makes methods very ergonomic).

The 'static Lifetime

There is one special, named lifetime: 'static. A reference with a 'static lifetime is one that is valid for the entire duration of the program. String literals ("hello") are the most common example, as they are baked directly into the program's binary.

let s: &'static str = "I have a static lifetime.";

While compiler errors might sometimes suggest using 'static, you should use it with caution. It should only be used for data that you can truly guarantee will live forever. In most cases, a lifetime error indicates a real bug in ownership or borrowing logic that needs to be fixed, not silenced with 'static.

This image gives a visual sense of how lifetimes define scopes for different variables and references within code.

This IDE visualization shows the scopes of different variables and their borrows. The lifetime of a reference (like `y`'s reference to `x`) is the duration for which it can be safely used, which is constrained by the lifetime of the original data (`x`).

Conclusion

We've now completed our tour of Rust's compile-time memory safety guarantees. Lifetimes are the mechanism that allows the borrow checker to verify that all references are always valid, eliminating dangling pointers and a host of related bugs before the program is even run. For embedded systems development, where robustness is non-negotiable, this is a game-changing feature.

Here are our key takeaways:

  • Dangling references are prevented by the borrow checker, which ensures no reference can outlive the data it points to.
  • Lifetime annotations ('a) are used to describe the relationships between reference lifetimes to the compiler, particularly in function signatures and struct definitions where these relationships are not obvious.
  • A function returning a reference must tie its lifetime to an input reference's lifetime. The concrete lifetime used will be the intersection (the smaller) of the relevant input lifetimes.
  • Lifetime elision rules make common patterns more ergonomic by allowing the compiler to infer lifetimes automatically.
  • The 'static lifetime denotes a reference that is valid for the entire program's duration.

This lesson concludes our first module on Rust fundamentals. You now have the conceptual tools to understand Rust's core value proposition: writing safe, concurrent, and performant systems code.

In our next lesson, we will begin Module 2: Low-Level and Systems Programming in Rust. We'll start by exploring unsafe Rust. While lifetimes provide a powerful safety net, interacting directly with hardware sometimes requires us to temporarily step outside of the compiler's guarantees. We will learn when unsafe is necessary in an embedded context and how to use it responsibly.