Is there a case where the compiler couldn't figure out lifetimes?

I'm still not sure if I understand lifetime annotations.

The common example is a simple function like

fn max(a: &i32, b: &i32) -> &i32 {
    if a < b { *c } else { a }
}

If I try to compile that without lifetime annotations, I get an "expected lifetime parameter" error for the return value.

The usual explanation is that the compiler can't know if the return value points to the memory from a or from b.

But if I change this to:

fn max<'a, 'b>(a: &'a i32, b: &'b i32) -> &'a i32 {
    if a < b { b } else { a }
}

I get:

10 | fn max<'a, 'b>(a: &'a i32, b: &'b i32) -> &'a i32 {
   |                               -------     -------
   |                               |
   |                               this parameter and the return type are declared with different lifetimes...
11 |     if a < b { b } else { a }
   |                ^ ...but data from `b` is returned here

So obviously the compiler knows where the return value can come from, so it should be able to assign them appropriate lifetime annotations automatically.

Of course if this wouldn't be an implementation but just a trait definition, then that wouldn't work.

So do we have lifetime annotations just for traits or are there actual cases where the compiler couldn't figure them out automatically?

The compiler could figure this out, but that means that if you changed your implementation, the function's signature changes, and that would break your users. Rust doesn't do type inference in function signatures for this reason, and it's the same with lifetimes. Does that make sense?

11 Likes

To illustrate this point, imagine having a library that defines:

pub
fn max (a: &'_ i32, b: &'_ i32) -> &'_ i32
{
    if a < b {
        todo!()
    } else {
        a
    }
}

with "inference", Rust could say that the max function's signature is:

pub
fn max<'ret, 'whatever> (a: &'ret i32, b: &'whatever i32) -> &'ret i32

so, someone depending on that library would be able to write:

let a = 21 + 21;
let b = a - 42;
let m = max(&a, &b);
drop(b);
println!("max = {}", *m);

without any compilation error (given the signature, only a is required to outlive the usage points of m).

And then, on the next version of the library, their author patches that todo! call they forgot to remove with an actual implementation.

pub
fn max (a: &'_ i32, b: &'_ i32) -> &'_ i32
{
    if a < b {
        b
    } else {
        a
    }
}

and then, because of inference, Rust now sees that the signature of the function is:

pub
fn max<'ret> (a: &'ret i32, b: &'ret i32) -> &'ret i32

that is, now both pointees must outlive all the usage points of the return value.

This means that a change in the body / private implementations details of a function has "leaked" into changing the API; so this change is a breaking change, meaning that the previous example now fails to compile with this new version of the library. Sneaky, and thus, scary!

Now you can see how much of a footgun this "action at a distance" is, and why Rust has chosen to avoid any type/lifetime inference whatsoever in public functions (FWIW, they may consider soothing this for private functions and / or constants).

7 Likes

This topic was automatically closed 90 days after the last reply. New replies are no longer allowed.