I don't have a simple answer, as lifetimes fulfill a few different roles.
In terms of the type system, lifetimes are a kind of generic parameter for type constructors. Other generic parameter kinds include types and const
values. Effects can also be modeled as a kind of generic parameter.
As a part of the type system, lifetime parameters can be used for all sorts of type-level things. I'll come back to that later.
The lifetime parameter on a reference can be thought of as the duration of a borrow. Borrow checking is a large topic on its own, and one could fill chapters exploring exactly what a lifetime is in that context. From a type system perspective, the soundness of being able to "approximate" borrows -- coerce a &'static str
to &'non_static str
, say -- aligns with the soundness of subtyping and variance. For example it's not sound to coerce a &mut Vec<&'static str>
to a &mut Vec<&'non_static str>
, so T
in &mut T
must be invariant. This is analogous to the IList
interface example in the Wikipedia article.
Outside the context of this thread, if you asked a typical Rustacean what the essense of a lifetime is, they'd probably say it's about borrowing. If I was forced to give one concise answer, that's the answer I would give.
Lifetimes can also be used in bounds, such as where 'a: 'b
. This is also called an "outlives" relationship. When thinking of lifetimes as durations, you would say that 'a
is longer than 'b
. When thinking more in depth, it turns out that you can have two lifetimes 'x
and 'y
such that neither 'x: 'y
nor 'y: 'x
. So you may also think of 'a: 'b
as meaning "'b
is a subset of 'a
". If this is your primary memory model, it's intuitive to say "'x
is contravariant in &'x str
" so that lifetime subsets and subtypes "line up". And that used to be the terminology.
In addition to the arguments made in that thread, there's another point of view where lifetimes are a set of loans. Under that mental model, subsets and subtypes line up with our current terminology: 'x
is covariant in &'x str
.
Either mental model is compatible with the subtyping of references, and the variance in the language, which keep everything sound.
Outlives requirements can also be applied to types or generic type parameters (T: 'b
), in which case it is a type-level constraint which can be "destructured" into a set of 'a: 'b
bounds (for lifetimes 'a
found within T
's resolved type).
Traits can also be parameterized by lifetimes. The trait can then use the lifetime in its items however it wants, such as in types or in outlives bounds. This usually corresponds to having the capability to work with a lifetime parameterized type in some way.
trait Parse<'a> {
type Error;
fn parse(&'a str) -> Result<Self, Self::Error>;
}
Implementors of traits can also be type-erased by coercion. With the trait above, for example, one could have a dyn Parse<'a, Error = String>
. Now the lifetime is in a type again, but it represents more of a capability to work with the lifetime.
And it's useful to have types which can work with all lifetimes, so we have higher-ranked types:
dyn for<'any> Parse<'any, Error = String>
This is a type capable of working with any lifetime.
It's also useful to be able to require the capability of working with all lifetimes in a generic context, so we also have higher-ranked trait bounds (HRTBs):
fn foo<P>(p: P) where P: for<'any> Parse<'any> { /* ... */ }
So in addition to representing durations of borrows, lifetimes can also be used to represent capabilities (usually capabilities of being able to work with borrows of a certain duration).
But because they participate in the type system, you can use lifetimes in ways not directly related to borrowing in order to do type level "gymnastics" in the language. This is along the lines of what @kpreid was talking about with the use of "marker" types. Sometimes this can exercise emergent properties of the language which are pretty far away from the concept of borrows that we started with.
For example, you can create branded types using "phantom lifetimes" that don't correspond to borrows at all; they are a type-level mechanism. Or you can emulate a GAT with a non-generic associated type, or by using a lifetime parameterized supertrait. That article goes on to demonstrate a way to emulate conditional higher-ranked trait bounds using some emergent properties of implicit lifetime bounds (which I haven't even talked about here). That was also used in the design of scoped threads.
These examples are using the properties of lifetimes as generic parameters (and how they combine with other parts of the language like trait solving). It's easier to understand them from a type system POV than a "lifetimes are the duration of borrows" POV.
(They're also practical, but Rust's type system is turing complete, so you can do all sorts of impractical things with it as well, if you try hard enough.)
That's mainly what I had to say; this final piece is a "me too" regarding the last couple comments.
Another way to see that there are "infinite" lifetimes, or that you never meet a concrete non-'static
lifetime, is to consider this function.
fn example(vec: &mut Vec<u64>, current: u64) {
if current == 1 {
} else if current % 2 == 0 {
example(vec, current / 2)
} else {
example(vec, current * 3 + 1)
}
vec.push(current);
}
We don't give away vec
in the recursive calls, so we must be reborrowing *vec
for some shorter duration than the input lifetime each time. But we do this an unknown number of times (depth).
There's no way to compile this with "concrete lifetimes".