Lifetimes in depth

Rust · Advanced · 18 min

Library signatures, thread and task spawning, and callback APIs all hinge on these less obvious lifetime rules.

Lifetimes form a hierarchy: 'b: 'a ("'b outlives 'a") means anything valid for 'b is valid for 'a, so a &'static str can be used where a &'a str is expected. The same syntax applies to types: `T: 'a` means every reference inside T lives at least as long as 'a.

`T: 'static` is the most misread bound. It does not mean the value lives forever. It means T contains no borrowed data shorter than 'static, so the holder may keep it as long as it likes. String, Vec<u8> and Arc<Mutex<X>> are all 'static types even though they are dropped normally. thread::spawn and tokio::spawn require F: 'static because the task may outlive the caller's stack frame; that is why you move owned data in rather than borrowing locals.

Higher-ranked trait bounds say "for every lifetime": F: for<'x> Fn(&'x str) -> &'x str accepts a closure that works on input of any lifetime, rather than one chosen by the caller. You mostly see them implicitly, since Fn(&str) -> &str is sugar for exactly that.

Reborrowing is why passing a &mut T to a function does not move it: the compiler inserts &mut *r, a shorter borrow, and you can use r again afterwards.

In the 2024 edition, a return-position impl Trait automatically captures every generic parameter and lifetime in scope. When a returned iterator should not be tied to some input, opt out with precise capturing: fn ids(&self) -> impl Iterator<Item = u32> + use<> (or list exactly which parameters it uses, e.g. use<'a, T>).

Finally, trait object lifetimes have defaults: Box<dyn Trait> means Box<dyn Trait + 'static>, while &'a dyn Trait means &'a (dyn Trait + 'a). Write Box<dyn Trait + 'a> to store borrowing objects.

Example

use std::fmt::Debug;

// T: 'static means T owns its data (no short borrows), so it may move to another thread
fn spawn_print<T: Debug + Send + 'static>(value: T) -> std::thread::JoinHandle<()> {
    std::thread::spawn(move || println!("{value:?}"))
}

// higher-ranked: f must work for whatever lifetime each call gets
fn apply_all<F>(items: &[String], f: F) -> Vec<&str>
where
    F: for<'x> Fn(&'x str) -> &'x str,
{
    items.iter().map(|s| f(s)).collect()
}
// 2024 edition: the hidden type would capture the borrow of `v`; use<> says it borrows nothing
fn snapshot(v: &[u32]) -> impl Iterator<Item = u32> + use<> { v.to_vec().into_iter() }

fn main() {
    spawn_print(String::from("owned is 'static")).join().unwrap();
    println!("{:?}", apply_all(&["  a ".into(), " b".into()], str::trim)); // ["a", "b"]
    let mut ids = vec![1, 2];
    let snap = snapshot(&ids);
    ids.push(3);                                 // allowed: snap does not borrow ids
    println!("{:?}", snap.collect::<Vec<_>>());  // [1, 2]
}

Remember

  • T: 'static means 'owns its data', not 'lives forever'
  • for<'x> bounds accept callbacks that work for any input lifetime
  • Rust 2024 impl Trait returns capture all lifetimes; narrow with use<..>
  • Box<dyn Trait> defaults to + 'static

Watch out for

  • Trying to satisfy 'static by leaking or making data global, when cloning or Arc would do.

Check yourself

  1. Which of these types satisfies `T: 'static`?
  2. In Rust 2024, what does `-> impl Iterator<Item = u8> + use<>` on a `&self` method say?

Sign in to answer, see the explanations, and get this lesson back as a quick review later.

Official docs: doc.rust-lang.org/reference/trait-bounds.html