// #Rust
In rust, lifetime annotations are designed to give the compiler enough information about reference lifetimes to make the call if memory access is safe. With the big goal of getting all the checking done at compile time so that at runtime, the app is unleashed with no extra safety checks. But with safety still guaranteed.
This sounds very worth it to me, but leaves me wondering, can the compiler make this guarantee without me the developer having to give it any extra metadata? Can’t it just walk through every reference and check that it is safe? My guess is that the technical answer to this question is “yes, it is theoretically possible”, but similar to static vs. dynamic types, there are a lot of benefits to force the developer to be explicit about their intent.
In the real world there are examples of languages, like typescript, which perform some static type analysis at compile time on code which can be dynamically typed. And it shows that is it possible to very solid at this. But it is easy to see why the complexity can blow up fast, like a complex type whose entire usage needs to be analyzed to figure out what is it’s structure. Compare that to if the developer had just said, “btw, this is what I am thinking”.
Asking for more info from the developer is a scenario where the developer has to take one extra step, but the compiler will take the other nine (versus just doing all ten). But this one step massively simplifies the compiler’s job. And in situations where there is ambiguity and a call has to be made, explicit intent keeps things from shifting around due to minor, seemingly unrelated refactors.
So is this the same reasoning for explicit lifetime annotations?
The rust compiler asks for lifetime annotations in two spots, function signatures and struct definitions. Both of these are interfaces where references can go in as inputs and returned as outputs (input lifetimes and output lifetimes). Now, I think the compiler could theoretically analyze reference usage in a function or struct without any hints from the developer. But there are similar issues as dynamic typing. This could be a very large ask based on function or struct complexity. There could be ambiguity where multiple lifetime definitions work, and the compile will have to make a call. And that call could change based on refactors on usage.
Function signatures and struct definitions are a natural “black box” interface. It allows for the compiler to just know the metadata of a function or struct to enable it to still make a call on safety. I imagine this is really nice for the compiler when functions or structs are across crate boundaries. Instead of having to parse the whole world it can focus on the current crate and assume the metadata on the “edges” is correct.
This all makes sense to me, but what maybe tripped me up the most when first learning about lifetimes is that sometimes you don’t need to be explicit. At least when first learning about lifetimes, I’d honestly prefer that they just were always required so I know for sure I understand the concept.
The rust compiler has a lifetime elision feature where it will actually guess the lifetimes for you if three rules hold. In other words, if lifetimes work after the three rules are applied, the compiler assumes it knows what is going on without any input from the developer on intention. The three rules apply only to function signatures, not struct definitions.
- Each input reference gets its own lifetime.
- If there is exactly one input lifetime, it is assigned to all output lifetimes.
- If
&self
or&mut self
is an input lifetime, it is assigned to all output lifetimes. Helpful to keep methods simple.
It is interesting the elision rules only work on functions. Probably because the interface is a little more contained? Although this does kinda sheds some light on the “infer this” '_
anonymous lifetime shorthand. The shorthand keeps it clear that some borrowing is happening, but links it up with the builtin elision.
struct TextWrapper<'a> {
content: &'a str,
}
// Error - Can't fully elide, need to specify the struct's lifetime parameter.
fn wrap_text(text: &str) -> TextWrapper {
TextWrapper { content: text }
}
// Works - We don't need to explicitly annotate 'text' because elision rules
// handle that part and '_ means "infer this part".
fn wrap_text(text: &str) -> TextWrapper<'_> {
TextWrapper { content: text }
}
// Works - Full explicit.
fn wrap_text<'a>(text: &'a str) -> TextWrapper<'a> {
TextWrapper { content: text }
}
Elision and structs in return values.
Something to keep in mind to is how async
“functions” look like functions, but get transformed into struct state machines. Any references held across await points need to be stored in the future implementing struct. Struct definitions have no elision rules, so the async functions inherit the explicit lifetime requirement.