Lifetime for fn select(param: &str) -> &str

I think I am starting to understand lifetime, in a large part thanks to the answers in this thread.

I also think I bumped into a case when the elision rules ruin the party a bit. I am writing not to complain, but to ask you to verify I understand this correctly or help me understand it better.

This code works:

fn main() {
    let mut a = String::new();
    println!("{a}");
    a = String::from("before");
    println!("{a}");

    let c = select(&a);
    //a = String::from("after");

    println!("{}", c);
    println!("{}", a);
}

fn select(name: &str) -> &str {
    if name > "abc" {
        "first"
    } else {
        "second"
    }
}

However, if I enable the a = String::from("after"); line then I get a compilation error, despite the
fact that there is no real problem in this code.

error[E0506]: cannot assign to `a` because it is borrowed
  --> src/main.rs:8:5
   |
7  |     let c = select(&a);
   |                    -- `a` is borrowed here
8  |     a = String::from("after");
   |     ^ `a` is assigned to here but it was already borrowed
9  |
10 |     println!("{}", c);
   |                    - borrow later used here

For more information about this error, try `rustc --explain E0506`.

As I understand this is due to the fact that the elision rules as explained here assume the following lifetime annotation:

fn select<'a>(name: &'a str) -> &'a str {

We can correct this, in this specific and rather contrived example by setting the lifetime manually:

fn select<'a, 'b>(name: &'a str) -> &'b str {

What you're probably looking for is:

fn select(name: &str) -> &'static str {
    if name > "abc" {
        "first"
    } else {
        "second"
    }
}

Because the lifetime of the string slice returned by select is not related to the name argument at all; they're just static strings which live for the whole life of your program. So if its return value is annotated properly, it will compile once a = String::from("after"); is un-commented.

4 Likes

You want:

fn select(name: &str) -> &'static str {

The defaults are what they are because they represent the most common use cases. Normally if you take in a single borrow and return a single borrow, the borrows are related. When you have a use case that isn't as common, yes, you need to use annotations. As you progress, you'll probably find that you use elision more often than not.

Assuming you don't want to have to annotate every lifetime, how would you prefer elision work? I can assure you that choosing a fresh lifetime for types in return position would almost never be the correct result. Same with 'static -- and moreover, 'static is not always a valid lifetime (a &'static Ty<'non_static> is an invalid type).

Some would argue that the compiler should choose the lifetimes based on the function body, but this would lead to very brittle and breakage-prone code.

Function APIs are a contract between caller and callee. With the elided lifetimes in the OP, the contract states to the caller: So long as you use the returned value, the argument remains borrowed. That contract allows the callee (function writer) to change the body of the function to return name or some substring of name if they so desire, without breaking downstream.

If the function writer is okay with the contract "you can only return literal or leaked &strs", then the version returning the 'static[1] is the better contract.

It may seem trivial in this case, but it's rather important when you have more complicated function bodies, generics, and traits and trait implementations.


  1. or an unrelated lifetime ↩ī¸Ž

5 Likes

This topic was automatically closed 90 days after the last reply. We invite you to open a new topic if you have further questions or comments.