Lower bound and upper bound (lifetimes)

Hello, I have seen people talk about lifetimes on functions for inputs and output separately, in terms of lower bound (output) and upper bound (inputs). Can someone please explain what does this means with an example and also what mental model one should have while dealing with lifetimes in functions. Thank you!

Hard to guess what they were talking about. Happen to have a link?

Might be they were talking about variance. (Even then the variance of a lifetime depends on the overall type it appears in, not just if it's in input or output position. But they might have been presenting a simplified gist of variance.)

Some important points include:

  • Rust lifetimes (like 'a) correspond to borrow durations.

  • Lifetimes that come from outside the function always have a duration longer than the function body, and you can never borrow a local variable for that long. All lifetimes with names come from outside the function. So this will always fail:

    fn example_1<'a>() {
        let local = 0;
        let borrow: &'a i32 = &local;
    }
    
  • Know what lifetime elision in function signatures corresponds to.

  • The meaning of the following signature:

    fn example_2<'a>(a: &'a str, b: &'a str) -> &'a str { ... }
    

    Is that uses of the return value keep both of the borrows that you passed in active.

    Similarly, the meaning of this signature is that uses of the return value keep the (only) borrow in the inputs active.

    fn example_3(a: &str) -> &str { ... }
    // Without lifetime elision:
    fn example_3<'a>(a: &'a str) -> &'a str { ... }
    
  • More generally, the function signature determines what stays borrowed, and not the function body. The function signature is a contract which both the function body and the function caller must honor.

    Among other benefits, this means that (for example) the author of example_2 could change when which of either a or b is returned, and it won't cause borrow check errors for any of the exiting callers.


I got all that way and didn't touch on variance. Variance is a topic beyond just functions and I didn't think of a succinct way to sum it up in a bullet point. I've written some introduction to the topic, mainly in terms of references, here. Feel free to ask some more targeted questions if you have any.

Hello, thanks a lot for answering!

sadly no, I saw someone talking about it in the rust lang server.

Is it something we as the caller of the function must up hold? that at the point of calling the function both the borrows should be active (otherwise, we would have a compiler error)?
but if I change the function signature as follows:

fn example_4<'a, 'b>(a: &'a str, b: &'b str) -> &'b str { ... }

what does this mean? also could you please give me an example where calling this could fail and one where it passes?

I'll check it out, thanks a lot for sharing this! :slight_smile:

and how does this differ from something returning 'a and 'b

fn example_5<'a, 'b>(_: &'a str, _: &'b str) -> &'static str {
    "manganhedenbergite"
}

You usually create the borrows to call the function. Then how long the borrows stay active after that depends on how you use the returned value. That's how the borrow checker works, and if you do something that conflicts with the borrows being active, you get a compiler error.

Uses of the returned value keep the 'b borrow (*b) active, but not the 'a borrow. Practically speaking, the 'a borrow ends when the function returns.

The returned value is valid forever, and does not keep the input borrows active at all. It's independent of the inputs.

2 Likes

Hello, I was going through the guide that you've written on "building intuition around borrow errors" and came across this:

The exact definition of "lifetime" is surprisingly complicated and beyond the scope of this guide

Could you please provide a brief overview of this? I feel like I’m getting the hang of lifetimes but I always feel like something is still missing.

It's difficult because lifetimes play many different roles (borrow checking, outlive bounds, trait parameters, generally part of the type system).

If we pick out just borrow checking, the simplest answer I can give is "a Rust lifetime is the duration of a borrow". Even this is a simplification, but it can get you quite far.

I don't know how useful it will be, but the only way I've thought of to maybe give a better reply is to give some examples of the multiple roles lifetimes play. This is just an overview, nothing in depth on any particular subtopic.

Borrow Durations

When borrow checking a function body, most lifetimes are, approximately, the duration of borrows local to the function. These cannot be given names. There are also lifetimes that come from somewhere outside the function ('static if nothing else), and these can be given names. Nameable lifetimes are valid throughout the function body and at least just beyond.

// `'a` is some borrow longer than the call to `example`.
//
// The lifetime in the type of `b` is also some borrow longer than the call
// to `example`.  (We could have given it a name, but didn't.)
fn example<'a>(a: &'a str, b: &str) {
    let s = String::new();
    // The lifetime in the type of `r` is some local, unnameable lifetime.
    let r = &s;
    println!("{a} {b} {r}");
}

You can also think of durations as the set of places in the code where the borrow is active/valid/alive. Named lifetimes are valid everywhere in the function body, and beyond.

Lifetime outlives bounds

When analyzing where lifetimes are valid or "alive", it's sometimes necessary for the use of one lifetime to keep another lifetime active. We write this as 'a: 'b and read it as "'a outlives 'b". You can think of it as: the places where 'a is valid is a superset of the places where 'b is valid. So if there's some use of 'b that requires it to be valid/alive, 'a must also be valid/alive.

fn example() {
    let x = 0;
    let r1 = &x; // Say `r1` has type `&'a i32`
    let r2 = r1; // Say `r2` has type `&'b i32`

    // This is a use of `'b` but not of `'a`.  But the compiler needs some
    // way to know that `x` is still borrowed here.  The way it does that
    // is to require `'a: 'b` when we assign `r2 = r1`.
    //
    // `'b` has to be valid here, so `'a` has to be valid here, so `x`
    // is still borrowed.
    println!("{r2}");
}

These types of bounds are also checked as parts of annotations and function signatures.

fn require_static(_: &'static ()) {}

fn example() {
    let local = ();
    // This would force the borrow of local to be `'static`, but that
    // means `local` is borrowed when it goes out of scope, so you get an error.
    let _ = require_static(&());

    // Same idea.
    let _: &'static () = &local;
}

That includes making sure function bodies can only rely on the bounds in the signature.

fn example<'a, 'b>(a: &'a str, b: &'b str) -> &'a str {
    // `&'b str` can coerce to `&'a str` only if `'b: 'a`.
    // But that bound isn't in the signature so we're not allowed to assume it
    // holds, so we get a compiler error here.  If you add `'b: 'a`, then it
    // will compile.
    b
}

Subtyping, variance, type constructor parameters

The fact that lifetimes are part of the type system was sort of snuck into that last example. Lifetime based coercion is performed via a type upcast. &'static str is a subtype of every other &'x str, so &'static str can coerce to &'x str and so on.

Lifetimes parameterize type constructors in the type system. There is no single Vec type, but there is a Vec<String> and Vec<()> type. Similarly there is no single &str type, but there is a &'static str and &'some_other_lifetime str, etc.

Type outlives bound, dyn lifetimes

T: 'a means that for any lifetime 'x which appears in T, 'x: 'a is satisfied.

If you have a dyn SomeTrait + 'a, it means the type T of the value which was coerced to dyn SomeTrait satisfied T: 'a.

Trait parameters, trait bounds

If you parameterize a trait with a lifetime, you can use that lifetime parameter in a variety of ways within the trait: in some bound, or as the input or output of some method. Or just leave it up to the implementor to use the lifetime or not, such as in the definition of an associated type.

In most cases, however, I end up thinking of a lifetime in a trait parameter as indicating some capability that the implementing type has. For example a Searcher<'a> implementor has the capability to search haystacks with duration 'a.

A function that needs a searcher capable of searching haystacks of particular lifetime 'a might have a bound like where T: Searcher<'a>.

Higher ranked trait bounds

Sometimes you need a generic type to have the capability of working with every possible lifetime. This often comes up when you need the type to work with unnameable lifetimes --
lifetimes shorter than a function body. The only way to write a bound that says "make sure you can work with a borrow of something local to my function" is to say "make sure you can work with all lifetimes".

We call those HRTBs (higher-ranked trait bounds) and they look like this:

where T: for<'a> Trait<'a>

'a is acting like an infinite set of lifetimes here, in some sense.

The Fn* trait bounds have a special sugar for HRTBs where lifetime elision of inputs means it goes in the for<..>, and the normal output elision rules apply.

where F: Fn(&str),
      F: for<'a> Fn(&'a str), // Same thing

Higher ranked types

Types which meet a HRTB can be coerced into a higher-ranked dyn type:

fn example() {
    let bx: Box<dyn Fn(&str) -> &str> = Box::new(str::trim);
    // Same thing
    let bx: Box<dyn for<'a> Fn(&'a str) -> &'a str> = Box::new(str::trim);
}

You can do this with any trait, not just the Fn* traits. (But only the Fn* traits have the elision sugar.)

If you replace any of the for<..> lifetimes with a specific lifetime, you get a different type which you can coerce to.

fn example() {
    let bx: Box<dyn Fn(&str) -> &str> = Box::new(str::trim);
    let bx: Box<dyn Fn(&'static str) -> &'static str> = bx;
}

This is another part of subtyping in Rust. In terms of capabilities, we might say the first bx can handle any lifetime, but the second bx can only handle the 'static lifetime.

There are also higher-ranked function pointer types.

fn example() {
    let fp: fn(&str) -> &str = str::trim;
    // Same thing
    let fp: for<'a> fn(&'a str) -> &'a str = str::trim;
    // Same kind of type coercions are possible
    let fp: fn(&'static str) -> &'static str = fp;
}

Type system guarantees

You can use HRTBs and the fact that lifetimes are part of the type system to create environments where crafted invariants are known to hold. Instead of trying to explain what I meant by that, read this paper up through at least the branded types section.

Type branding is an example of where lifetimes are playing a role based on being part of the type system even though there isn't necessarily a corresponding borrow duration.

2 Likes

what a detailed write up, thank you!