DEV Community

Brian Maher
Brian Maher

Posted on • Edited on

Rust Borrow Checker Explained Part 3: Function Result Lifetimes

Intro

In Part 2, we went over the basics of borrowing and how it looks out for you. In this article we will go over the rules of lifetimes which are used by the borrow checker to ensure the owned lifetime always out lasts the borrowed lifetime.

Lifetimes

Lifetimes are just a fancy name for "scope". If you recall, in Part 1, we went over how Rust uses the stack to manage memory. Any argument functions and local variables are automatically "popped" off the stack when the function returns. A Rust lifetime simply describes how long a variable is in scope.

Functions With Explicit Lifetime

Let's try to define a function to get the longest string:

fn main() {
    let str1 = "tiger";
    let str2 = "three blind mice";
    let longest = longest_str(str1, str2);
    print!("longest={longest}");
}

fn longest_str(str1: &str, str2: &str) -> &str {
    if str1.len() < str2.len() {
        return str2;
    } else {
        return str1;
    }
}
Enter fullscreen mode Exit fullscreen mode

Try it!

Which produces this error:

error[E0106]: missing lifetime specifier
 --> src/main.rs:8:43
Enter fullscreen mode Exit fullscreen mode

Rust requires lifetimes to be in the function signatures so it can avoid having to infer these lifetimes, which would be computationally expensive and small changes in implementation would alter the implied function signature in ways that are incompatible.

If we manually inspect this code, we see that the lifetime of the resulting &str must always be the same as (or longer) than the str1 and str2 parameters, since we can't know until runtime if str1 will be returned or if str2 will be returned.

So, the explicit lifetime we need is:

fn longest_str<'a>(str1: &'a str, str2: &'a str) -> &'a str
Enter fullscreen mode Exit fullscreen mode

Lifetime names always begin with a single tick, and they need to be declared in the same way as function template parameters. To use them, add the single tick and name after each &, so if one of these was a mutable borrow, you would use the syntax &'a mut.

You might be asking, why this is needed when this example uses the exact same lifetime for every parameter. Let's look at this example:

use std::collections::HashMap;

fn main() {
    let mut map: HashMap<String, String> = HashMap::new();
    map.insert("one".into(), "1st".into());
    let value;
    {   // "{" opens a new "scope":
        let one = String::from("one");
        value = lookup(&map, &one);
    }   // `one` is now out of "scope"
    print!("value={value}");
}

fn lookup<'a>(map: &'a HashMap<String, String>, key: &'a String) -> &'a str {
    return map.get(key).map(|v| v.as_str()).unwrap_or("not found");
}
Enter fullscreen mode Exit fullscreen mode

Try it!

...which when ran produces this error:

error[E0597]: `one` does not live long enough
  --> src/main.rs:9:30
Enter fullscreen mode Exit fullscreen mode

This lookup function is declaring that the map and key lifetimes must be the same or greater than the result lifetime... however, this actually is not necessary, since the lifetime of the result is only dependent on the lifetime of the map. So, we just need the map and result lifetime to be the same, the lifetime of the key can be different:

fn lookup<'a, 'b>(map: &'a HashMap<String, String>, key: &'b String) -> &'a str
Enter fullscreen mode Exit fullscreen mode

...and now this code compiles.

Implied Function Lifetime Rules

Rust will try to imply lifetimes using lifetime elision rules to avoid tediously declaring lifetimes which can often be inferred.

Rust already knows the lifetime of all function parameters at the call point, the borrow checker just needs help in matching up which function parameter lifetime(s) match with any borrowed result(s)... which is why some of these lifetimes can be implied.

The rules:

  • If a function has no borrowed result, then you don't need to worry about annotating parameters with lifetimes.
  • If a function takes in a single borrowed parameter, all borrowed results will have the same lifetime.
  • If a function is actually an object method (it has a borrowed self parameter), all borrowed results will have the same lifetime as the object's self parameter.
  • Any borrowed parameter without a lifetime declaration which does not follow a rule above will get a distinct lifetime.

When compiling functions, Rust will enforce the lifetime constraints, so there is no harm in trying to always declare your functions without explicit lifetimes.

Function Result Lifetimes Recap

To recap, we can declare lifetime "template" parameters and use them to describe the relationship between the parameters passed into your function and the result of the function. The borrow checker tries to apply some common patterns for these rules, but it is not perfect, so knowing how to explicitly declare them is necessary at times.

The next article in this series will talk about how to use borrowing inside of your data structures to avoid copying.

Top comments (0)