Here's an example.
Note that while Rust lifetime rules are conceptually simple they also include bazillion extensions and special warts that make more programs compileable. That's because developers begged for them: original Rust, Rust 1.0 had very simple borrow-checked and it was easy to understand how it works… but using it was very hard. Today it's the opposite: there are lots of leniency so using it is easy… but fully understand it is hard.
That's in my example I haven't used simple literals but used String::from
. Because literal like "fdsa"
or "fds"
doesn't carry any lifetime information at all!
That's why I take literal, convert it to string then immediately put result back into &str
:
let email1: &str = &String::from("foo@bar.baz");
let email2: &str = &String::from("hello@example.com");
…
let alice: &str = &String::from("Alice");
let bob: &str = &String::from("Bob");
This seemingly useless operation is core to understanding: I still have reference to the string with exact same content as before, but now that reference couldn't exist forever: it points of temporary String
– and that String
is destroyed at the closing brace, at the end of scope.
And I also create these variables in difference places: user1
and user2
come as arguments into foo
function and they leave that function – that way Rust couldn't “help me”: references to user
have to outlive the foo
function, while references email1
and email2
point to the local objects that don't outlive the function – and thus couldn't live as long as user1
and user2
. Temporary strings would be gone after return from the function, there are no way to make references live longer.
And now you see why Users
is defined like this:
struct Users<'a, 'b> {
user1: &'a str,
email1: &'b str,
user2: &'a str,
email2: &'b str,
}
We have four references but only two lifetimes. Why? Because we want to select them:
fn select_use_and_email<'a, 'b>(
Users { user1, email1, user2, email2 } : Users<'a, 'b>,
use_first: bool) -> (&'a str, &'b str) {
(
if use_first { user1 } else { user2 },
if use_first { email1 } else { email2 },
)
}
Here I would return either user1
or user2
and also email1
or email2
. Is it possible to do this with four references? Sure, like this:
fn select_use_and_email<'a, 'b, 'c: 'a, 'd: 'b>(
Users { user1, email1, user2, email2 } : Users<'a, 'b, 'c, 'd>,
use_first: bool) -> (&'a str, &'b str) {
(
if use_first { user1 } else { user2 },
if use_first { email1 } else { email2 },
)
}
struct Users<'a, 'b, 'c, 'd> {
user1: &'a str,
email1: &'b str,
user2: &'c str,
email2: &'d str,
}
Here I needed to tell the compiler that 'c
outlives 'a
and 'd
outlives 'b
. Only then compiler would accept that code. Otherwise it's invalid. In that case we probably can save few keystrokes with the rule you proposed but the whole program would become more fragile: we have to specify these lifetimes expelicitly (because we want to establish relationships between them) – and it helps to see them in struct declaration.
And here the rest of the program, for the completeness (in case if something would happen to Godbolt):
fn foo<'a>(use_first: bool, user1: &'a str, user2: &'a str) -> &'a str {
let email1: &str = &String::from("foo@bar.baz");
let email2: &str = &String::from("hello@example.com");
let (user, email) = select_use_and_email(
Users { user1, email1, user2, email2 },
use_first
);
println!("e-mail selected: {email}");
user
// Variables email1 and email 2 are destroyed here
}
pub fn main() {
for alice_or_bob in [true, false] {
let alice: &str = &String::from("Alice");
let bob: &str = &String::from("Bob");
let user = foo(alice_or_bob, alice, bob);
println!("User selected: {user}");
// Variables alice and bob are destroyed here
}
}
Lifetimes in Rust are kinda… anti-GC susbsystem.
Where in “normal” language with tracing GC objects exist as long as references to objects exist, that is, additional references extend the lifetime of objects, in Rust it's the opposite: objects exist as scopes, functions and other “big” things make them exist – and when object disappears references should disappear, too (or else program wouldn't work).
My own mental model for “tracing GC languages” are “helium balloons in the sky”: they all “naturally want” to fly away, but as long as they are tethered by at least one rope to the Earth (or to other balloon or balloons) – they stay with us.
And my mental model for Rust is more of a… “railroad with cargo”: train goes from station A to static B, where it unloads its cargo and is split into separate railwagons to move different cargo somewhere. You still can attach ropes, but… your cries and curses “but I had an important info that train, how dare you destroy it” are thoroughly ignored: train arrived at the end station and it would be dismantled… deal with that.
Rust compiler help you to “deal with that” when you use references, but if you use “references evil twins”, pointers… then it's your responsibility to track train schedule and deal with them – yet rule is still the same: cargo moves on trains, trains are dismantled when they arrive at the end station, ropes are useless if they go to the train that no longer exist.