DEV Community

Cover image for Ultimate Guide To Rust Lifetimes For Newbies
Confidence Okoghenun
Confidence Okoghenun

Posted on • Originally published at confidence.sh

Ultimate Guide To Rust Lifetimes For Newbies

Welcome to the last article of the Rust Memory Management series. The saga is finally coming to an end. We started by learning how program memory works, then saw how to share data through borrowing and references. You’ll need to catch up on these if you haven’t, as it will help you understand the new concept introduced in this article.

In this article, we’ll complete the trifecta of Rust’s memory management system. We’ll learn about lifetimes and how to use them. Let’s go!

What Are Lifetimes?

Imagine you work in a farm and your boss needs you to build a program to categorize crops. Armed with the knowledge of borrowing and references, you set out to design a performant solution. For memory efficiency, you use borrowing to ensure that categorized crops point to their original allocation. Your boss is going to be so proud when he sees your program!

#[derive(Debug)]               //an attribute allowing us to print this struct
struct CropsByType<'a> {       //ignore the 'a for now...
    vegetables: &'a [String],
    fruits: &'a [String],
}

fn group_crops(crops: &[String]) -> CropsByType {
    CropsByType {
        vegetables: &crops[0..1],
        fruits: &crops[2..3],
    }
}

fn main() {
    let final_crops = {
        let crops = vec![
            "lettuce".to_string(),
            "spinach".to_string(),
            "apple".to_string(),
            "orange".to_string(),
        ];
        group_crops(&crops)
    };
    println!("{:?}", final_crops);
}
Enter fullscreen mode Exit fullscreen mode

Code block 1

But there’s a problem. Your code doesn’t compile.

Not so fast gif

Unfortunately, your program wasn’t as invincible as you thought. Rust prevents the code from compiling because you’ve introduced a use-after-freed bug. Let’s take a closer look at this program together and figure out what’s really going on.

error[E0597]: `crops` does not live long enough
  --> src/main.rs:22:21
   |
15 |     let final_crops = {
   |         ----------- borrow later stored here
16 |         let crops = vec![
   |             ----- binding `crops` declared here
...
22 |         group_crops(&crops)
   |                     ^^^^^^ borrowed value does not live long enough
23 |     };
   |     - `crops` dropped here while still borrowed

For more information about this error, try `rustc --explain E0597`.
Enter fullscreen mode Exit fullscreen mode

final_crops defines a new scope (i.e with { and }) which computes its value. Within this scope a vector of crops is declared, and passed as a reference to group_crops. group_crops returns a struct CropsByType, containing slice references to the borrowed crops vector. The code block below (Code block 2) has been annotated to illustrate this flow.

A slice is a reference to a range of elements in an array or vector. For example, &crops[0..1] is a reference to elements between index 0 and 1 in crops, i.e. ["lettuce", "spinach"]

fn group_crops(crops: &[String]) -> CropsByType {
    CropsByType {
        vegetables: &crops[0..1], //slice of crops, referencing ["lettuce", "spinach"]
        fruits: &crops[2..3],     //slice of crops, referencing ["apple", "orange"]
    }
}

fn main() {
                                  //start here 👇
    let final_crops = {           //new scope created

        let crops = vec![         //crops vec is allocated
            "lettuce".to_string(),
            "spinach".to_string(),
            "apple".to_string(),
            "orange".to_string(),
        ];
        group_crops(&crops)      //CropsByType struct is returned
                                 //but contains references to crops

    };  //scope exits, crops is deallocated
        //final_crops receives CropsByType which points to deallocated crops
        //Rust prevents the program from compiling

    println!("{:?}", final_crops);
}
Enter fullscreen mode Exit fullscreen mode

Code block 2

Following the flow, something interesting happens next. CropsByType is returned to final_crops when the scope exits, and crops is deallocated. But CropsByType points to crops since it doesn’t copy any of its values, so what happens next? Rust stops the program because it is trying to access deallocated memory. Proceeding any further can cause serious run-time bugs.

You get a better perspective of what’s happening using the construct of lifetimes. Here is what I mean (Code block 3 below):

fn main() {
    let final_crops  = {    //lifetime of final_crops start here <─────┐
                                                                       
        let crops = vec![   //lifetime of crops start here <──────┐    │
            "lettuce".to_string(),                                │    │
            "spinach".to_string(),                                │    │
            "apple".to_string(),                                  │    │
            "orange".to_string(),                                 │    │
        ];                                                            
        group_crops(&crops)                                           
    };                      //ends here <─────────────────────────┘    │
    println!("{:?}", final_crops);                                     
}                           //ends here <──────────────────────────────┘
Enter fullscreen mode Exit fullscreen mode

Code block 3

Viewing this through the lens of lifetimes, the compile error is Rust’s way of telling us that crops has a shorter lifetime than final_crops, yet final_crops borrows the values of crops. Thus, lifetimes is a feature of the borrow checker used to ensure all borrows are valid. Lifetimes helps the compiler to prevent access to values that are deallocated.

One doesn't simply meme

To fix the compile error, we need to extend the lifetime of crops to be at least of equal length to final_crops. To do this, move crops into the outer scope. Now the program compiles smoothly, and is finally invincible!

fn main() {
    let crops = vec![
        "lettuce".to_string(),
        "spinach".to_string(),
        "apple".to_string(),
        "orange".to_string(),
    ];
    let final_crops = { group_crops(&crops) };
    println!("{:?}", final_crops);
}
Enter fullscreen mode Exit fullscreen mode

Code block 4

Lifetime Annotations

In most cases, the Rust compiler is smart enough to infer the lifetimes of borrowed values, but may need a hint or two in complex scenarios. Such hints are given through lifetime annotations, another concept unique to Rust. Annotations are pretty straightforward. They help the compiler figure out the boundaries for which borrowed values must be alive.

Here’s an example of a lifetime annotation from the crops program (Code block 5). The CropsByType struct has a lifetime parameter named 'a (pronounced tick a). Basically, it says instances of this struct must live as long as the vector of crops that its fields (i.e. vegetables & fruits) borrows from. Here, we are explicitly instructing the compiler to ensure that both CropsByType and crops have the same lifetime. Thus, CropsByType cannot outlive crops to which it borrows from.

struct CropsByType<'a> {      //defines a lifetime param for this struct called 'a
    vegetables: &'a [String], //the param 'a, is used here to tie CropsByType's lifetime to the crops slice 
    fruits: &'a [String],     //same story here. 'a can be used as many times as needed within this struct
}
Enter fullscreen mode Exit fullscreen mode

Code block 5

Lifetime parameters can be given any lowercase names i.e 'myawesomelifetimeparam, but short letters like 'a or 'b are commonly used for brevity. Also, shorter letters are common because lifetime annotations are designed to be markers.

Lifetime annotations are not limited to structs, but are used on functions, methods and traits. Here’s a rewrite of the group_crops function to show the lifetime annotations. Note that they were inferred by the compiler when omitted earlier. Occasionally, the compiler may require you specify the lifetimes in more complex scenarios.

//BEFORE 😐
fn group_crops(crops: &[String]) -> CropsByType {
    CropsByType {
        vegetables: &crops[0..1],
        fruits: &crops[2..3],
    }
}

//AFTER 🤩
fn group_crops<'x>(crops: &'x [String]) -> CropsByType {
    let vegetables: &'x [String] = &crops[0..1];
    let fruits: &'x [String] = &crops[2..3];
    CropsByType { vegetables, fruits }
}
Enter fullscreen mode Exit fullscreen mode

Code block 6

If you could just meme

Lifetime annotations are quite straight forward, although their syntax may seem weird. But they work like regular function parameters. With that settled, let’s explore one final detail about lifetimes.

Static Lifetimes

The lifetime name 'static is reserved in Rust to refer to values that live for the remaining lifetime of the program. Like global variables, they always exist throughout the program. The word static is used because it refers to values that are hard coded within the binary of a compiled program.

For example, the variable person in the block below (Code block 7), references a series of bytes "Confidence" that is hard-coded into the binary of our program. Thus, this value isn’t loaded into memory, but is directly read from the program’s binary for efficiency. This is why the name 'static is reserved. Sometimes, when then the 'static lifetime name is omitted, it is automatically inferred by the compiler:

let person: &'static str = "Confidence";  //static string slice
let person: &str = "Confidence";          //same as above
let person = "Confidence";                //same as above
Enter fullscreen mode Exit fullscreen mode

Code block 7

Lifetimes The Smart Way

Thinking about your program in terms of lifetimes may be unusual, but it isn't complicated. If you're just getting started, you wouldn’t come across it much because the compiler is good at figuring it out. Sometimes, however, you may get a friendly error message telling you to add them.

If you enjoy all things Rust, follow me on Twitter. Cheers, have a good one!

Top comments (7)

Collapse
 
zahash profile image
zahash

I don’t understand why people use single letter names for lifetimes. They put so much effort into coming up with good names for variables and functions and classes but why not lifetimes?

I was also struggling with lifetimes when I first started out with rust but after giving them proper descriptive names, it became incredibly easy and trivial.

I highly recommend everyone to try it

Collapse
 
megaconfidence profile image
Confidence Okoghenun

Hey @zahash I completely agree with you. Tick a's were really confusing at first, but the reason most devs use single letters is that lifetimes are meant to be markers.

That said, it's a good idea to use proper names to help people figure stuff out like. I'm with you on this!

Collapse
 
michalfita profile image
Michał Fita

If it doesn't compile, it's not a bug, but error.

Collapse
 
megaconfidence profile image
Confidence Okoghenun

It's why Rust is so cool. If your code compiles, you're guaranteed there are no weird bugs

Collapse
 
michalfita profile image
Michał Fita

Actually the guarantee is about certain types of bugs only usually related to typical programming mistakes in other languages. That's why it's called memory safety. In fact it practically removes all heisenbugs so hard to debug.

No programming language protects from logic bugs and they may be weird as you call them as well.

Collapse
 
rdarrylr profile image
Darryl Ruggles

Great article - thanks for sharing!

Collapse
 
megaconfidence profile image
Confidence Okoghenun

Thanks Darrly, I'm glad it was helpful.

Is there something you'd like to see next?