Ruby

Understanding Lifetimes in Rust

With the convenience of higher level languages that handle the scope of how long each object in our code base exists, many of us have not needed to learn nor understand lifetimes of objects beyond understanding scopes themselves. Rust likewise manages lifetimes for us, where we have more control of simple situations via ownership and borrowing. In more complex situations, however, we need to give identifying marks in the code to allow the compiler to understand a greater scope of a lifetime.

In simple terms, this all comes down to freeing up memory once the code base is done with it. Higher level languages, such as Go, Ruby, or Python, use garbage collectors that mark and sweep over the objects throughout the code base to see if they’re ready to be freed from memory and will do so for all objects that indicate they are. Lower level languages such as C or Assembly required you to manually free memory after you were done with it.

Rust avoids the cost of both garbage collecting and handwritten code to free memory by maintaining when to free memory according to the lifetimes given to each thing in the code base. Lifetime is primarily determined by the ownership system (a system that says which part of the code is responsible for owning the item in memory) and in complex situations, the manual lifetime descriptions given to help the compiler. The memory is freed immediately once the scope of the code item’s lifetime comes to an end.

Simple Lifetimes

Rust type const lives for the entire lifetime of the program and its value will be inlined into any place the code refers to it.

const MAJOR_VERSION: i32 = 1;

The type static also lives for the entire lifetime of the program. It isn’t inlined in your code though, as it remains in one fixed location in memory.

static MINOR_VERSION: i32 = 0;

Most other lifetimes simply happen within the scope of a block or until the ownership is consumed by a method call. If an item is to exist beyond its usage within a method call, then the method needs to borrow it instead or take a copy of it to allow its lifetime to continue in the outer scope after the method is finished.

let money: String = "42".to_string();

fn borrow_it(qty: &String) {
  println!("Your money total is ${}", qty)
}

borrow_it(&money); // borrowed ownership
// the ownership of money has been returned to this higher scope

fn consume_it(qty: String) {
  println!("My money total is ${}", qty)
}

consume_it(money);
// money memory is freed as the ownership has been
// taken into the methods scope and that scope has ended.

println!("No money: ${}", money);
// This fails

Rust kindly tells us what we did wrong when we run the above code with:

error[E0382]: use of moved value: `money`
  --> src/main.rs:19:29
   |
15 |   consume_it(money);
   |              ----- value moved here
...
19 |   println!("No money: ${}", money);
   |                             ^^^^^ value used here after move
   |
   = note: move occurs because `money` has type `std::string::String`, which does not implement the `Copy` trait

Notice that it tells us our code would work if std::string::String had the Copy trait implemented. If you change the above code to use the i32 type rather than a String, it will work without an error; i32 does implement the Copy trait and the compiler will deduce from the usage within the block that the value is used later so a copy will be performed at the borrow in that case. Even when copied, the lifetime for money ends at its last usage within this code block as it’s consumed by the last println! command.

!Sign up for a free Codeship Account

Complex Lifetimes

There are times when the Rust compiler will demand lifetimes to be described/annotated when there isn’t enough information in the code to determine what the lifetimes should be.

#[derive(Debug)]
struct Earth {
  location: String,
}

#[derive(Debug)]
struct Dinosaur<'a> {
  location: &'a Earth,
  name: String,
}

fn main() {
  let new_york = Earth {
    location: "New York, NY".to_string(),
  };
  let t_rex = Dinosaur {
    location: ≠w_york,
    name: "T Rex".to_string(),
  };
  println!("{:?}", t_rex);
}

In the above example, you can see a lifetime annotation of 'a. When our struct borrows an instance of Earth, it needs the added lifetime marker. This helps the compiler to know that a Dinosaur should not outlive Earth, which it holds a reference to.

One trick that helped me when I first tackled implementing and learning lifetimes in my own project when the program suggested it needed them was to put more lifetime references in places where they perhaps didn’t need to go. I found the compiler’s error messages understood my intention better when the lifetime annotations were there and wrong, than when they weren’t there and wrong. Of course, I advise looking at code that has lifetimes implemented in it to help get you started. A little trial and error will help you learn this quickly.

An example impl implementation for the Dinosaur above would look like:

impl<'a> From<Dinosaur<'a>> for String {
  fn from(d: Dinosaur) -> String {
    format!("{:?}", d)
  }
}

// replace the above println! with
println!("{}", String::from(t_rex));

Scope Lifetimes

When getting values from lower/inner scopes, it is best to assign the desired result value to a variable in the higher scope.

{
  // outer scope
  let result: i32;

  {
    // inner scope
    result = 42 + 42;
  }

  println!("{}", result);
}

If we were to try to assign result in the inner scope without first declaring it in the outer scope, we would get the following error.

error[E0425]: cannot find value `result` in this scope
  --> src/main.rs:10:18
   |
10 |   println!("{}", result);
   |                  ^^^^^^ not found in this scope

This is because the lifetime of things declared in inner scopes is limited to their scope unless they are given to something that already has a longer lifetime from elsewhere. Return values from scopes are considered outer scope values. Let’s look at the above example rewritten that way.

{
  let result: i32 = {
    42 + 42
  };

  println!("{}", result);
}

This will correctly give us 84.

The compiler will be very clear when we don’t use our scopes correctly. So we can write the code we think will work and simply follow the feedback the compiler gives us to fix them. Here’s a failing example:

{
  let result: &i32;
  
  {
    let x = 42 + 42;
    result = &x;
  }

  println!("{}", result);
}

And its output:

error[E0597]: `x` does not live long enough
  --> src/main.rs:7:3
   |
6  |     result = &x;
   |               - borrow occurs here
7  |   }
   |   ^ `x` dropped here while still borrowed
...
10 | }
   | - borrowed value needs to live until here

This paints a pretty clear picture of the scope in which a lifetime lives. There are options such as cloning and copying to allow values to be returned to a higher scope. But that will most often have a minor effect on performance as it takes more time to copy things in memory than it does to pass a reference.

Summary

Of all the things involving lifetimes in Rust, the annotations seem like they’d be a big hurdle as the syntax looks a bit archaic. But the important thing to know is that you aren’t changing the behavior of lifetimes when you use them — they’re simply declaring them. That is, lifetimes with annotations work the same way as when there aren’t written annotations. They are simply markers to help clarify for the compiler the contexts for which the lifetimes are involved.

Beyond that, lifetimes are pretty simple. Just write code and see what happens. The language’s compiler will be a tutor for you and your understanding will grow as you learn from its very intelligent error messages. You can be bold and just code as Rust will help you navigate the unknown seas.

Reference: Understanding Lifetimes in Rust from our WCG partner Daniel P. Clark at the Codeship Blog blog.

Daniel P. Clark

Daniel P. Clark is a freelance developer, as well as a Ruby and Rust enthusiast. He writes about Ruby on his personal site.
Subscribe
Notify of
guest

This site uses Akismet to reduce spam. Learn how your comment data is processed.

0 Comments
Inline Feedbacks
View all comments
Back to top button