You may not know this, but it’s possible to give names to your lifetimes which are longer than a single character! Effectively naming your lifetimes can help improve code clarity in several scenarios, which this post describes in detail.
If you’ve looked at example Rust code, you’ve likely noticed that lifetimes are usually named with a single letter. In your first introduction to lifetimes, where they’re revealed explicitly with a transition from an inferred-lifetime code sample to one with explicitly named lifetimes, you might have seen something like the following.
struct Person {
name: String
}
impl Person {
pub fn name<'a>(&'a self) -> &'a str {
&self.name
}
}
A common type of first lifetime code sample.
↺This code example isn’t wrong, and there are certainly a lot of single-letter lifetime parameter names in real-world Rust codebases, but the tendency to use these short names in example code can leave some Rustaceans with the impression that only single-letter names are allowed, but in fact they can be any length at all! Here’s another version of the above.
struct Person {
name: String
}
impl Person {
pub fn name<'me>(&'me self) -> &'me str {
&self.name
}
}
A rewrite of the prior example, with a lifetime variable name that’s possibly more helpful.
↺Info 1 A contrived example.
Skip this content.This example is a bit contrived and may not be considered sufficiently useful in terms of the additional explanatory value of the longer name, but helps to illustrate the idea that names can be longer.
There are specific scenarios where effective naming can be helpful, including in the presence of long-lived common owners whose values are borrowed throughout the code (like an allocation arena or configuration struct), or in the presence of multiple borrow sources you want to clearly disambiguate.
Case 1: Long-Lived Common Owner
It’s common in many programs to have a single struct which contains a variety of data used throughout a program. It may be a configuration struct holding information on how the program was initialized or set to run. It may be an arena holding data pulled from outside sources. Whatever the case may be, the pattern of centralizing data and then handing out borrows to that data is common, and for good reason. It can reduce data duplication and lead to clearer-to-navigate code.
use once_cell::unsync::OnceCell;
struct Provider {
data_cache: OnceCell<Data>,
metadata_cache: OnceCell<Metadata>,
}
// ...
fn process_data(data: &Data) -> Result<&str> {
// ...
}
An example of a central data provider with a function processing data from it.
↺This example shows an imaginary “data provider,” containing some caches of information used throughout a
program. It also shows a process_data
function which is operating on data borrowed from the provider. Now,
in this case the process_data
function can be written as-is, without specifying lifetimes explicitly; the
fact that there’s only one input lifetime means the output reference is automatically inferred to have the
same lifetime as the input reference. However, this process_data
function doesn’t make clear that data
is coming from the Provider
, and an unwitting developer may attempt to use the data in a way which outlives
the Provider
. This may be made less likely with appropriate naming.
fn process_data<'prov>(data: &'prov Data) -> Result<&'prov str> {
// ...
}
A rewrite with explicit lifetimes.
↺This example works the same as the prior one, but the use of the 'prov
name for the lifetime helps hint
to future developers that this data is coming from the Provider
.
This kind of situation also commonly arises with arena allocators. Inside the Rust compiler, for instance,
there’s the extremely common 'tcx
lifetime, which is the lifetime of the arena containing the typing
context for the program. This lifetime appears all over the Rust compiler codebase, and whenever you see 'tcx
,
you’re provided with information about where the reference is coming from, and how long it’ll live.
Case 2: Multiple Borrow Sources
Sometimes, you may alternatively have a structure which contains borrows of multiple sources. In the following example, we use a “view” structure to combine references to multiple places into a single structure.
struct Article {
title: String,
author: Author,
}
#[derive(PartialEq, Eq)]
struct Author {
name: String,
}
struct ArticleProvider {
articles: Vec<Article>,
}
struct AuthorProvider {
authors: Vec<Author>,
}
struct AuthorView<'art, 'auth> {
author: &'auth Author,
articles: Vec<&'art Article>,
}
fn authors_with_articles<'art, 'auth>(
article_provider: &'art ArticleProvider,
author_provider: &'auth AuthorProvider,
) -> Vec<AuthorView<'art, 'auth>> {
author_provider
.authors
.iter()
.map(|author| {
let articles = article_provider
.articles
.iter()
.filter(|article| &article.author == author)
.collect();
AuthorView { author, articles }
})
.collect()
}
A view structure bundling multiple references together.
↺In this example, the “view” structure has two lifetimes to permit the borrows of the respective fields to come from different places, and the naming with longer names helps to disambiguate which borrow is which, and where it’s coming from.
This example also wouldn’t work without explicitly specifying the lifetimes with some sort of name, as the lifetime inference rules do not infer lifetimes for output references when more than one input reference exists. This is because there’d be no principled way to infer which input lifetime the output lifetime is derived from without inspecting the body of the function, and for performance and clarity reasons, the Rust compiler declines to do that sort of in-depth function body analysis.
Conclusion
There are more cases than these where you might want to use longer lifetime names. They’re not always appropriate, but when they are, it’s good to know that they’re possible. Questions of readability are always trade-offs between length and clarity, and there’s no hard and fast rule for what’s “right.” Hopefully this post has at least helped clarify the situations when you might want to apply this practice.