Skip to content

Instantly share code, notes, and snippets.

@themisir
Last active September 8, 2023 08:34
Show Gist options
  • Save themisir/cbef0271ee9ff34f49cbc4adacc2ab35 to your computer and use it in GitHub Desktop.
Save themisir/cbef0271ee9ff34f49cbc4adacc2ab35 to your computer and use it in GitHub Desktop.
article draft

Lifetimes

Just mark it with '_, 'a or 'static and hope one of them works was my first go-to solutions when dealign with rust borrow checker. I had no clue what lifetimes meant to represent, or how their values are inferred since unlike type generics there's not a single point where the type can be inferred from the context.

let mut values = Vec::new();
values.push(Route { path: "/home" });

There, the second line calls Vec<T>::push(&mut self, value: T) function with a value of Route { path: "/home" } which is an instance of the type Route. There compiler can infer that the type argument T can be implicitly set to Route and fill the missing gaps for us. You might be thinking, what if you wanted to store different implementations of Route, aka how does it deal with inheritance. The answer is, we don't need to worry about that because rust doesn't have a concept of inheritance. As a matter of fact, it is completely unnecessary in this context, since we are dealing with data not the implementation (which is usually the main selling point of inheritance - you can use different implementation for the same schema or data? I am not 100% sure tho). When you push a value into vec, you are just dealing with what's on the screen: Route { path: "/home" } - a pointer to a static string is being passed. There's no method tables or dynamic invoking (expect there can be actually! It's just you have to be explicit about it). Even if the Route struct has methods, they will be resolved on the built time and invoked statically compared to doing dynamic map lookup on runtime. To put into a separate perspective, in rust, the compiler deal with types so your program on runtime doesn't have to do so. Even when you use dynamic method tables, it doesn't seem magic as calling an abstract method and hoping it's not called for a null value on runtime.

As described above, rust type system is quite powerful to infer generic arguments from the context. But what's the deal with borrow checker's lifetime annotations?

For starters those annotations just refer to scope of the value reference they are supposed to be existing. Let's have a look at a sample:

struct User<'user> {
  name: &'user str,
}

If you have come across a rust code snippet, you have probably seen method signatures with lifetime annotations like 'a, 'b', 'v, etc... A thing that helped me to better understand lifetime annotations was to naming them with more readible names than single letter identifiers.

First 3 lines of the above example declares a struct called User with a name field of string reference type. As of writing this article, all struct refernece fields should have an explicit lifetime annotation. We defined a single generic lifetime parameter on the first line <'user> and set our name field's life time to that parameter. In short this tells compiler that the references inside a User should live equal to or longer than the passed 'user lifetime.

fn set_name<'method, 'user>(user: &'method mut User<'user>, new_name: &'user str) {
  user.name = new_name;
}

On the set_name method we have 2 generic parameters:

  • 'method - we will use this to represent the lifetime of the method call itself. All the references with the annotation should be available during our method call.
  • 'user - lifetime of the User value. This parameter will be passed down to the User struct as a generic argument (User<'user>) to mark its fields lifetime

Now the interesting point here isn't the method itself or the user argument lifetime, but the second argument - new_name. As you can see we haven't marked it's lifetime as 'method . Instead we used 'user, which we also passed down as an generic argument to the User type to detonate lifetime of its reference fields. By using the same lifetime for both new_name and the User<> generic parameter, we tell compiler to make sure the reference passed to new_name argument lives equal to or longer than the lifetime of the User object (which).

let mut user = User { name: "" }; // declare a sample value we can work on

{
  let name1: &'static str = "new_name";
  set_name(&mut user, name1);
}

{
  let name2 = String::from("name2");
  set_name(&mut user, &name2);
}

println!("name: {}", user.name);

On the above snippet, the first block will work just fine, becuase the name reference we passed will live longer than the lifetime of the user object itself ('static references refer to references that will be valid through the entire program lifecycle. Constant str literals have static lifetime).

Meanwhile the second block won't compile, we will get an error saying "error[E0597]: name2 does not live long enough". That's because unlike the first block, the reference of the name2 will be limited to the block itself, and the value within the String container will be dropped when you get out of the block. So, even if compiler would let you use the reference, since the String value would be dropped, the value behind the reference would no longer be stable, possibly be overwritten by something else, resulting in undefined behavior.

It's worth mentioning that the String type in rust represents a string value stored on the process heap, it's a container for &str references. When you drop a String value, its memory will be freed.

Extending lifetimes

One other thing to note here is, as you can see we haven't passed any lifetime annotation to the User object itself. That's because the compiler infers lifetime from the context. In this case the value lifetime will be equal to the execution of the whole block since the user value does live in that scope. If you return the value, you will extend the lifetime contract of the user object to the calling scope.

fn create_user<'user>(name: &'user str) -> User<'user> {
  User { name }
}

fn update_user<'user>(mut user: User<'user>) {
  let name = String::from("temp");
  set_name(&mut user, &name);
}

fn main() {
  let mut user = create_user("");
  
  {
    let name1: &'static str = "name3";
    set_name(&mut user, name1);
  }
  
  println!("name: {}", user.name);
  
  update_user(user);
}

On the above snippet, even though the User object is created within the scope of the create_user method body, since we return its value, we extended its lifetime to the caller scope.

fn update_user<'user>(mut user: User<'user>) {
  let name = String::from("temp");
  set_name(&mut user, &name);
}

fn main() {
  let mut user = User { name: "" };
  update_user(user);
}

Similarly, by moving a value into another method, you move all the lifetime contracts to the called method. &name will have a valid lifetime for the set_name function, because the user value has the same lifetime as the name value. Both values will be dropped when the method returns, so the references will be valid during the lifetime of each other.

To be honest, despite me skipping another piano practice session to explain borrowed value lifetimes I don't expect you to understand it just with the above explanations. I would suggest just getting there and playing out with it until you figure it out for yourself. Or I don't know, maybe read from someone more suitable for teaching CS concepts.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment