Common Misunderstandings About Lifetimes in Rust

A variable's lifetime denotes the span during which the data it refers to remains valid. This span is determined statically by the compiler. Several widespread misconceptions can lead to confusion when reasoning about lifetimes.

1) Generics Include More Than Owned Types

It’s tempting to think of a generic type Item as representing only owned types like i32 or String. In reality, Item encompasses any type, including references and nested references: &i32, &&i32, &mut &i32, etc. Thus:

  • Item is a supertype of both &Item and &mut Item.
  • &Item and &mut Item are disjoint sets.

Attempting overlapping trait implementations shows this:

#![allow(dead_code)]
trait Marker {}

impl<Item> Marker for Item {}       // covers everything
// impl<Item> Marker for &Item {}    // error: conflict with above
// impl<Item> Marker for &mut Item {} // error: conflict with above

// These are fine because they no longer overlap
impl<Item> Marker for &Item {}
impl<Item> Marker for &mut Item {}

Key takeaways:

  • Item includes references.
  • &Item and &mut Item cannot share implementations with Item itself.

2) Item: 'static Does Not Mean "Lives Forever"

Many assume val: 'static implies the data lives until program termination and is immutable. This confuses the bound (Item: 'static) with the concrete type &'static Item.

  • &'static Item is a reference guaranteed valid for the entire run.
  • Item: 'static means the item can be held indefinitely — it may be an owned type (String, Vec<u8>) or a 'static reference.

Owned types satisfy 'static regardless of their actual lifespan:

use rand;

fn discard<Item: 'static>(item: Item) {
    std::mem::drop(item);
}

fn main() {
    let mut data = Vec::new();
    for _ in 0..5 {
        if rand::random() {
            data.push(rand::random::<u64>().to_string());
        }
    }

    for mut s in data {
        s.push_str("-edited");
        discard(s); // works fine
    }
    println!("Done");
}

Key takeaways:

  • 'static bound ≠ data lives forever.
  • Owned types can be mutated, dropped earlier, and need not exist for the whole execution.

3) &'lt Val Is Not Equivalent to Val: 'lt

&'lt Val implies Val: 'lt, but not vice versa. Val: 'lt is broader:

fn accept_ref<'lt, Val: 'lt>(r: &'lt Val) {}
fn accept_any<'lt, Val: 'lt>(v: Val) {}

struct Wrapper<'lt, Val: 'lt>(&'lt Val);

fn main() {
    let s = String::from("text");
    accept_any(&s);           // ok
    accept_any(Wrapper(&s));   // ok
    accept_any(&Wrapper(&s));  // ok

    accept_ref(&s);            // ok
    // accept_ref(Wrapper(&s)); // error: expected reference
    accept_ref(&Wrapper(&s)); // ok
}

Key takeaways:

  • Val: 'lt accepts owned types, nested ref-containing types, and plain references.
  • &'lt Val accepts only direct references.

4) Lifetime Annotations Are Often Implicit

Rust frequently elides lifetimes via rules:

  1. Each input reference gets its own lifetime.
  2. With one input lifetime, it applies to all outputs.
  3. For methods, if one input is &self/&mut self, output gets self’s lifetime.

Evenif you don’t write annotations, they are present implicitly:

// Elided form
fn show(txt: &str);
// Expanded form
fn show<'a>(txt: &'a str);

Functions returning a reference without inputs need explicit annotation:

// fn bad() -> &str { "oops" } // error
fn good() -> &'static str { "ok" }

Key takeaways:

  • Most Rust code uses implicit lifetimes.
  • Even simple functions often rely on elision.

5) Passing Compilation Doesn’t Guarantee Correct Lifetimes

The compiler ensures memory safety, not semantic intent. Example bug due to overly restrictive lifetimes:

struct ByteIter<'data> {
    rest: &'data [u8],
}

impl<'data> ByteIter<'data> {
    // Buggy: each yielded element tied to 'mut_self
    fn next_buggy<'iter>(&'iter mut self) -> Option<&'iter u8> {
        if self.rest.is_empty() {
            None
        } else {
            let b = &self.rest[0];
            self.rest = &self.rest[1..];
            Some(b)
        }
    }

    // Fixed: tie output to 'data, not 'mut_self
    fn next_fixed(&mut self) -> Option<&'data u8> {
        if self.rest.is_empty() {
            None
        } else {
            let b = &self.rest[0];
            self.rest = &self.rest[1..];
            Some(b)
        }
    }
}

Key takeaways:

  • Compiler checks safety, not necessarily intended flexibility.
  • Meaningful lifetime names expose hidden constraints.

6) Trait Objects Always Have Implied Lifetimes

Trait objects follow elision rules based on context:

use std::cell::Ref;

trait Demo {}

// Box<dyn Demo>  =>  Box<dyn Demo + 'static>
type A = Box<dyn Demo>;
type B = Box<dyn Demo + 'static>;

// &'a dyn Demo  =>  &'a (dyn Demo + 'a)
type C<'a> = &'a dyn Demo;
type D<'a> = &'a (dyn Demo + 'a);

When converting between generic functions and trait-object versions, lifetime differences can appear unexpectedly.

Key takeaways:

  • Every dyn Trait carries an inferred lifetime.
  • Context determines whether it’s 'static or shorter.

7) Error Messages May Suggest Suboptimal Fixes

Compiler suggestions aim for safety, not optimality. Example:

fn first<'a>(x: &'a str, y: &str) -> &str { x }
// Help suggests: -> &'a str for both inputs, overly strict.
// Better: keep second input free:
fn first<'a>(x: &'a str, _y: &str) -> &'a str { x }

Key takeaways:

  • Suggestions are safe but not always semantically ideal.
  • Understand the actual borrowing needs before applying fixes.

8) Lifetimes Cannot Change at Runtime

Lifetimes are fully static constructs. Attempts to swap references with different lifetimes fail:

struct Holder<'lt> { ref_field: &'lt str }

fn main() {
    let long = String::from("long");
    let mut h = Holder { ref_field: &long };
    {
        let short = String::from("short");
        h.ref_field = &short; // short dies before h is used again → error
    }
}

Even unreachable blocks trigger the same analysis.

Key takeaways:

  • Lifetimes fixed at compile time.
  • Shortest possible lifetime chosen across all control paths.

9) Downgrading &mut to & via Reborrow Is Tricky

Reborrowing creates a new reference tied to the original’s lifetime:

let mut n = 5;
let shared: &i32 = &*(&mut n); // reborrow
let another: &i32 = &n;          // error: n still mutably borrowed

The mutable borrow lives until the shared borrow ends, preventing coexistence.

Key takeaways:

  • Avoid reborrowing &mut to & unless necessary.
  • Shared reference from reborrow behaves like a mutable borrow in terms of lifetime.

10) Closures Follow Different Lifetime Elision Rules

Closures don’t use the same elision as functions:

fn func(x: &i32) -> &i32 { x }      // sugar: fn<'a>(x: &'a i32) -> &'a i32
let closure = |x: &i32| x;          // sugar: for<'a,'b> |x: &'a i32| -> &'b i32 { x }

This mismatch can cause confusing errors. Explicit signatures sometimes require boxing or trait objects.

Key takeaways:

  • Closure elision differs from functions.
  • Be prepared to spell out lifetimes explicitly.

11) &'static T Cannot Always Substitute &'a T in Function Signatures

While 'static references coerce to shorter lifetimes in values, they don’t coerce through generic function boundaries:

fn gen<'a>() -> &'a str { "hello" }
fn stc() -> &'static str { "hello" }

fn pick_val<T>(a: T, b: T) -> T { b } // works with both
fn pick_fn<T, F: Fn() -> T>(a: T, f: F) -> T { f() } // fails if f returns &'static str

Key takeaways:

  • Returning &'static T is less flexible than a generic &'a T in higher-order contexts.
  • Coercion rules differ between value and type-level usage.

Tags: rust lifetimes generics memory-safety compiler

Posted on Sat, 09 May 2026 06:34:01 +0000 by Chris12345