Why Rust Lifetime Elision cannot inference the proper lifetime annotations on functions?

In the newest version of Rust Compiler, the Lifetime Elision can only inference the lifetime annotations in specific circumstances. For example, if I have a function:

fn get_match_sub_vec(tar_vec: &Vec<&Vec<i32>>, given_vec: &Vec<i32>) -> Option<&Vec<i32>> {
    for v in tar_vec.iter() {
        if v.len() == given_vec.len() {
            return Some(v);
        }
    }
    return None;
}

The compiler will throw the error: missing lifetime specifier.

However, if I inproperly annotate the lifetime of parameters:

fn get_match_sub_vec<'a,'b>(tar_vec: &'a Vec<&Vec<i32>>, given_vec: &'b Vec<i32>) -> Option<&'b Vec<i32>> {
    for v in tar_vec.iter() {
        if v.len() == given_vec.len() {
            return Some(v);
        }
    }
    return None;
}

The compiler will throw the error: explicit lifetime required in the type of tar_vec, which means the lifetime of the returned value is mismatch the annotation in the function signature.

My question is: If the Rust compiler is able to detect usage of reference and check the correctness of lifetime annotation, why do we still need to explicitly add the lifetime annotation on the function signature?

The only reason I guess is to avoid confusion of references lifetimes during the usage of traits. If my guess is right, why don't the Rust compiler only check the lifetime annotation on traits instead of on functions?

Could anyone answer my question? Thanks so much!

Your tar_vec actually has two lifetimes, &'a Vec<&'c Vec<i32>>, but the 'c hasn't been explicitly named.

If you add that other lifetime, the error message is much clearer:

error[E0623]: lifetime mismatch
 --> src/lib.rs:4:20
  |
1 | fn get_match_sub_vec<'a,'b, 'c>(tar_vec: &'a Vec<&'c Vec<i32>>, given_vec: &'b Vec<i32>) -> Option<&'b Vec<i32>> {
  |                                                  ------------                               --------------------
  |                                                  |
  |                                                  this parameter and the return type are declared with different lifetimes...
...
4 |             return Some(v);
  |                    ^^^^^^^ ...but data from `tar_vec` is returned here

To answer your question, the compiler was able to see something is wrong or ambiguous, but it tried not to guess too much from how you write code because it can then become quite "magical".

That's why you will often see error messages mention hints, but the compiler will leave you to make the changes instead of silently doing what it thinks you intended.

5 Likes

The signature of your function is your contract with not only the compiler, but also users of your function. Users need to know the contract. It would also be too easy to accidentally make a backwards-incompatible change if the signature was inferred from the body. Additionally, using the signature as the contract allows the compiler to borrow check callers of your function without evaluating the body.

(Edit: corrected auto-correct)

9 Likes

If a digital signature system is able to automatically verify the validity of an encrypted message given a secret key, why can't it just infer the secret key from the encrypted message?

It is a lot easier to verify than it is to infer.

3 Likes

I should follow up and say that you can elide the lifetime in certain unambiguous situations. This could technically be extended (to, say, the most restrictive well-formed interpretation), but doing so would add complexity to the language and not necessarily be intuitive.

1 Like

Note that there's a meaningful terminology difference you're skipping over here. The reason we call it "lifetime elision" -- a different word from when we say "type inference" -- is that there's a very intentional choice that was made to not be smart here.

The philosophical reason here is that the signature is a firewall of sorts between the callers and the body. That wouldn't matter if everyone could write perfect code immediately, but I'm certainly not that good. So having that firewall is critical for having nice error messages, as it keeps a mistake inside a function from causing problems in the callers too.

Suppose, for example, that the compiler did infer exactly which lifetime goes with the return value, and you wrote this:

fn get_match_sub_vec(tar_vec: &Vec<&Vec<i32>>, given_vec: &Vec<i32>) -> Option<&Vec<i32>> {
    todo!("I'll get to this later")
}

The hypothetical lifetime inference would go "oh, well, there's no constraints on the output lifetime at all" and basically make it -> Option<&'static Vec<i32>>. But that's really not what you wanted, and will mean that any code you write calling the function is likely to pass borrowck because of that 'static, but would plausibly then stop compiling once you implement it properly.

Whereas with the elision rules, the body doesn't matter, and thus you have to say what you expect to happen, but in return that's what will happen. And all the callers can be type- and borrow-checked even if there's a mistake inside that particular function.

Once you know this general principal, you'll see it in more places.

For example, if Rust wanted to it could certainly make the following legal:

fn mul_add(a: i32, b: i32, c: i32) -> _ {
    a * b + c
}

After all, type inference has no trouble figuring out that the body returns i32, and thus it could know that the function should too.

But it doesn't, because that would let mistakes inside the function leak to callers. For example, imagine you'd typed this instead:

fn mul_add(a: i32, b: i32, c: i32) -> _ {
    a * b + c;
}

That's certainly an easy typographic mistake to make. And if the return type were inferred, it'd be completely valid (albeit silly) code for a function that's -> (). But because you need to specify -> i32 in the signature, it provides the firewall: the callers know that it's i32 despite the mistake in the body, and the body gets a "you probably want to remove this semicolon" suggestion on the type error.

13 Likes

I think one of the most important considerations for why it doesn't do that is that it would prevent the documentation from fully describing the code. As things are now, the generated documentation will use the function header pretty much exactly as written to show you how the function can be called. If the body were used to decide what lifetimes needed to be there, then the header would be insufficient, so the documentation would be insufficient. We'd all be in a world of pain if we needed to read the body of each function to figure out how it can be used.

2 Likes