rust-job-prep / phase-01 / day-02.rs
PHASE 01  |  DAY 02 / 15
rust-job-prep / self-eval / index.html
PHASE 01 · 15 DAYS 0 / 100
// rust job ready series — phase 01: core rust

Lifetimes

$ rustc day02.rs  →  how_long_can_a_reference_live
Day 02 'a 'static elision rules struct lifetimes dangling refs

A lifetime is the scope for which a reference is guaranteed to be valid. Every reference in Rust has a lifetime — most of the time it's invisible because the compiler infers it. Lifetime annotations ('a) don't change how long anything lives — they just describe the relationships between the lifetimes of multiple references so the borrow checker can verify none of them outlive the data they point to.

Think of it like this: ownership (Day 01) answers "who owns this data and when does it get dropped?". Lifetimes answer "how long is this reference allowed to be used for, given when the data it points to gets dropped?". Lifetimes are a compile-time-only concept — they add zero runtime cost and disappear completely from the compiled binary.

Most functions don't need explicit 'a annotations because the compiler applies these 3 elision rules automatically. If after applying all three the compiler still can't figure out the lifetimes, that's when you must annotate manually.

RULE 01

Each Reference Param Gets Its Own Lifetime

Every parameter that's a reference gets its own lifetime parameter. fn foo(x: &str, y: &str) becomes fn foo<'a,'b>(x: &'a str, y: &'b str) internally.

RULE 02

One Input → Output Gets That Lifetime

If there's exactly one input lifetime, it's assigned to all output lifetimes. fn foo(x: &str) -> &str becomes fn foo<'a>(x: &'a str) -> &'a str.

RULE 03

&self / &mut self → Output Gets Its Lifetime

If one of the params is &self or &mut self (a method), the lifetime of self is assigned to all output lifetimes — common in builder/getter methods.

USE CASE The classic "why do lifetimes exist" example — a function tries to return a reference to data it created internally. That data is dropped when the function ends, so the reference would dangle. This is exactly what lifetimes prevent.
day02_dangling_reference.rs
COMPILE ERROR
fn dangle() -> &String {
    let s = String::from("hello"); // s created here, owned by dangle()
    &s // ❌ ERROR: we return a reference to s...
} // ...but s is dropped HERE, at the end of the function!
  // The reference we tried to return would point to freed memory.
  // error[E0106]: missing lifetime specifier
  // help: this function's return type contains a borrowed value
  //       with no value for it to be borrowed from

fn main() {
    // let s = dangle(); // would be a dangling reference if this compiled
}
UNDER THE HOOD
  • s is a local String — its owner is the dangle function's stack frame.
  • When dangle() returns, its stack frame is popped and s's Drop::drop() runs — the heap memory holding "hello" is freed.
  • If Rust allowed &s to be returned, the caller would hold a pointer to freed memory — a use-after-free bug, the exact thing C/C++ programs suffer from.
  • The fix: either return the owned String (move it out, no reference) — or accept a reference as input and return a reference tied to that input's lifetime (next example).
USE CASE The simplest fix to the dangling reference problem: return the owned value instead of a reference. Ownership of the String moves out to the caller — nothing dangles.
day02_fix_return_owned.rs
FIXED
fn no_dangle() -> String {       // return type is OWNED String, not &String
    let s = String::from("hello");
    s // ✅ ownership MOVES out to the caller — s is not dropped
}

fn main() {
    let result = no_dangle();
    println!("{}", result); // ✅ "hello" — result owns the data now
}
USE CASE The textbook example for why 'a exists: longest takes two string slices and returns whichever is longer. The compiler cannot know which one without help — Rule 02 (one input → output) doesn't apply because there are TWO input lifetimes.
day02_longest_lifetime.rs
EXPLICIT 'a
// 'a says: the returned reference will live AT MOST as long as
// the SHORTER of x's and y's lifetimes.
fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
    if x.len() > y.len() {
        x
    } else {
        y
    }
}

fn main() {
    let s1 = String::from("long string is long");
    let s2 = String::from("short");

    let result = longest(s1.as_str(), s2.as_str());
    println!("Longest string is: {}", result); // "long string is long"
}
UNDER THE HOOD
  • <'a> declares a generic lifetime parameter — just like <T> declares a generic type parameter. It does NOT set a specific duration.
  • x: &'a str, y: &'a str tells the compiler: "treat both inputs as if they live for the same region 'a". In practice, Rust picks 'a = the smaller (more restrictive) of the two actual lifetimes.
  • -> &'a str means: "the returned reference's validity is tied to 'a" — so the caller can't use the result longer than the shorter-lived of s1/s2 would allow.
  • This is purely a compile-time contract. At runtime, longest just returns a pointer — no extra checks, no overhead.
USE CASE Now break it on purpose — call longest where one argument's owner is dropped before the result is used. This shows the borrow checker actually enforcing the 'a contract from the previous example.
day02_lifetime_violation.rs
COMPILE ERROR
fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
    if x.len() > y.len() { x } else { y }
}

fn main() {
    let s1 = String::from("long string is long");
    let result;
    {
        let s2 = String::from("short"); // s2 lives only in this inner scope
        result = longest(s1.as_str(), s2.as_str());
    } // s2 dropped here
    // 'a was forced to shrink to s2's lifetime (the shorter one)
    // so `result` is only valid INSIDE the block above.
    println!("Longest string is: {}", result);
    // ❌ ERROR: `s2` does not live long enough
    // `result` may still borrow from `s2`, but `s2` is already dropped
}
UNDER THE HOOD
  • Because both params share lifetime 'a, the compiler must pick a single 'a that's valid for both s1 and s2 — that's the smaller scope, i.e. the inner block where s2 lives.
  • result therefore inherits that shrunk 'a — it's only valid inside the inner { } block, regardless of which branch (x or y) actually gets returned at runtime.
  • The compiler doesn't know at compile time which branch wins (that depends on .len() at runtime) — so it conservatively assumes result could be borrowing from s2, and rejects the println! outside the block.
  • This is the borrow checker trading a little flexibility for a 100% guarantee: no dangling references, ever, in safe Rust.
// structs that hold references
USE CASE A struct that borrows data instead of owning it — common for "view" types (parsers, tokenizers) that slice into a larger string without copying. The struct's lifetime 'a says: "I cannot outlive the data I'm pointing into."
day02_struct_with_lifetime.rs
STRUCT 'a
// ExcerptHolder borrows a &str — it does NOT own the string data.
// 'a ties the struct's lifetime to the lifetime of that borrowed &str.
struct ExcerptHolder<'a> {
    part: &'a str,
}

impl<'a> ExcerptHolder<'a> {
    // Rule 3 (elision): &self's lifetime is assigned to the output
    fn announce_and_return_part(&self, announcement: &str) -> &str {
        println!("Attention please: {}", announcement);
        self.part
    }
}

fn main() {
    let novel = String::from("Call me Ishmael. Some years ago...");
    let first_sentence = novel.split('.').next().expect("no '.' found");

    let excerpt = ExcerptHolder { part: first_sentence };
    // `excerpt` cannot outlive `novel` — enforced at compile time

    println!("{}", excerpt.part); // "Call me Ishmael"
    let _ = excerpt.announce_and_return_part("New excerpt!");
}
UNDER THE HOOD
  • part: &'a str means the struct doesn't own its string data — it's a "view" into novel's heap allocation, just like the &str from Day 01's slices.
  • 'a on the struct means: any instance of ExcerptHolder cannot live longer than the data part points to. If novel is dropped while excerpt still exists, that's a compile error.
  • impl<'a> ExcerptHolder<'a> — you must declare 'a on the impl block too, matching the struct's definition.
  • Rule 3 (elision) applies in announce_and_return_part: because &self is a parameter, its lifetime is assigned to the output &str — no explicit annotation needed.
  • This pattern is everywhere in real code: JSON parsers, tokenizers, and config readers that avoid allocating new strings by borrowing slices of the original input.
// the 'static lifetime
USE CASE 'static is a special lifetime meaning "this reference can live for the entire duration of the program". String literals are 'static by default because they're baked directly into the compiled binary.
day02_static_lifetime.rs
'static
fn main() {
    // String literals are &'static str — baked into the binary's read-only data segment
    let s: &'static str = "I live for the whole program";
    println!("{}", s);

    // A function that returns a 'static reference
    fn get_app_name() -> &'static str {
        "rust-job-prep" // literal — always valid, no owner to drop
    }
    println!("{}", get_app_name());
}

// ⚠️  COMMON MISTAKE: 'static does NOT mean "leak memory" or "live forever
// no matter what". It means "CAN live that long if needed" — for owned data
// like Box, you'd need Box::leak() to actually get a 'static reference,
// which intentionally leaks memory. Don't reach for 'static to "fix" a
// lifetime error — it usually means restructuring ownership instead.
UNDER THE HOOD
  • String literals ("...") are embedded in the binary's read-only data section — they exist before main() runs and after it ends, so a reference to them is trivially valid for 'static.
  • 'static is the longest possible lifetime — any 'a can be a subset of 'static, but not the reverse.
  • Beginners often add 'static to "fix" a borrow-checker error — this usually just moves the error elsewhere or forces unnecessary heap leaks via Box::leak / Box::new(...).into() tricks. Treat a 'static requirement as a signal to re-check ownership design.
// multiple lifetimes & bounds
USE CASE When two references genuinely have different lifetimes that shouldn't be tied together, use separate lifetime parameters ('a, 'b) instead of forcing them to be equal — this gives the caller more flexibility.
day02_multiple_lifetimes.rs
'a + 'b
// 'a and 'b are INDEPENDENT — x and y can have totally different lifetimes.
// The output only depends on 'a (x's lifetime), so 'b has no constraint
// on the bound 'a: 'b means "'a must outlive 'b" — used when needed.
fn first_word_with_logger<'a, 'b>(text: &'a str, logger_tag: &'b str) -> &'a str {
    println!("[{}] processing text...", logger_tag); // 'b only used here

    match text.find(' ') {
        Some(idx) => &text[..idx],
        None => text,
    }
}

fn main() {
    let sentence = String::from("lifetimes are not that scary");
    let word;
    {
        let tag = String::from("PARSER"); // short-lived 'b
        word = first_word_with_logger(&sentence, &tag);
    } // tag dropped here — but `word` is fine, it only borrows from `sentence` ('a)

    println!("first word: {}", word); // ✅ "lifetimes" — still valid!
}
UNDER THE HOOD
  • Compare this to the earlier longest example — there, forcing x and y to share 'a caused the output to be artificially restricted by the shorter-lived argument.
  • Here, logger_tag: &'b str has its own independent lifetime. The return type &'a str only depends on 'a (from text) — so 'b can end early (when tag is dropped) without affecting word.
  • This is the practical lesson: only tie lifetimes together if the output genuinely depends on both. Over-constraining with a single 'a for everything is a common beginner mistake that causes unnecessary borrow errors.
🚫

Zero-Copy Parsing

Parsers (JSON, HTTP, config files) hold &'a str slices into the original input buffer instead of allocating new Strings for every token — massive performance win on hot paths.

Compile-Time Memory Safety

Lifetimes are how Rust guarantees no dangling pointers without a garbage collector or runtime checks — the safety analysis happens entirely before the binary is built.

🔒

Safe Borrowing Across Function Boundaries

Lifetime annotations let APIs express "this returned reference is only valid as long as you keep this input alive" — callers get compile errors instead of production crashes.

🔧

Self-Referential Async State Machines

async fn bodies desugar into structs that often borrow their own fields across .await points — the compiler's lifetime + Pin machinery makes this safe.

REAL-WORLD USAGE
serde_json / simd-json — deserializers can borrow string slices directly from the input buffer via &'a str fields, avoiding allocation per field
nom (parser combinators) — entire parsing libraries are built around &'a [u8] / &'a str input slices threaded through every combinator
tokio / hyper — HTTP header parsing borrows from the raw request buffer with carefully bounded lifetimes for zero-copy networking
ripgrep — searches file contents using borrowed byte slices end-to-end; lifetimes ensure matches never outlive the mapped file
Q1 What is a lifetime, and does it change how long a value lives? +
A lifetime is a compile-time-only annotation describing the scope for which a reference is valid. It does not change how long any value actually lives in memory — actual lifetimes are determined by ownership and scope (Day 01). Lifetime annotations just give the borrow checker enough information to verify that references never outlive their data. They are erased entirely before/during compilation and add zero runtime cost.
Q2 What are the three lifetime elision rules? +
Rule 1: each reference parameter gets its own implicit lifetime.

Rule 2: if there's exactly one input lifetime, it's assigned to all output lifetimes.

Rule 3: if one parameter is &self or &mut self, its lifetime is assigned to all outputs.

If these three rules don't fully determine the lifetimes (e.g. two reference params and no self, with a reference return type), the compiler requires explicit 'a annotations.
Q3 What does fn longest<'a>(x: &'a str, y: &'a str) -> &'a str actually guarantee? +
It guarantees that the returned reference's lifetime is no longer than the smaller (more restrictive) of x's and y's lifetimes. It does NOT mean x and y must have identical actual lifetimes — the compiler just picks 'a as the intersection (smaller) of the two when checking the call site. This is why the result can become invalid as soon as the shorter-lived argument is dropped, even if the function actually returned the longer-lived one.
Q4 What does 'static mean, and is it ever a "fix" for a lifetime error? +
'static means a reference is valid for the entire remaining duration of the program — the longest possible lifetime. String literals are &'static str because they're embedded in the binary's read-only data.

It is rarely the correct "fix" for a lifetime error. Slapping 'static on a type often just moves the problem (now you need a 'static value to satisfy it) or forces a memory leak via Box::leak. The right fix is almost always to restructure ownership — e.g. clone the data, use an owned type, or adjust the struct's lifetime parameters.
Q5 Why would a struct need a lifetime parameter, e.g. struct Excerpt<'a> { part: &'a str }? +
Because the struct holds a reference (&'a str) instead of an owned value (String). The lifetime parameter 'a ties the struct's own validity to the validity of the data it borrows — any instance of Excerpt<'a> cannot outlive the string it points into. This is the foundation of zero-copy data structures: parsers, tokenizers, and views into buffers that avoid allocating owned copies.
Q6 When would you use two separate lifetimes ('a, 'b) instead of one? +
Use separate lifetimes when two references are logically independent and the output only depends on one of them. Forcing both parameters to share a single 'a over-constrains the function — the output's lifetime gets unnecessarily tied to whichever input has the shorter lifetime, even if the returned value never actually borrows from it. Splitting into 'a and 'b (and optionally adding a bound like 'a: 'b if one must outlive the other) gives callers more flexibility and avoids spurious borrow-checker errors.
Pattern What It Solves Used In
Higher-Ranked Trait Bounds (HRTB)
for<'a> Fn(&'a T)
Express "this closure must work for any lifetime 'a", not just one fixed lifetime — needed when a trait method's input lifetime varies per call. Iterator combinators, callback-based APIs, fn trait bounds
Lifetime subtyping
'a: 'b
"'a outlives 'b" — used in where clauses when one reference must remain valid for at least as long as another. Self-referential builder patterns, generic struct constraints
Cow<'a, str> Combines lifetimes with ownership: borrow (&'a str) when possible, clone to String only when mutation is needed. Avoids unnecessary allocation. Config/template processing, string normalization in parsers
Lifetime in trait objects
Box<dyn Trait + 'a>
Trait objects normally default to 'static — annotate explicitly when a dyn Trait needs to borrow data with a shorter lifetime. Plugin systems, callback registries holding borrowed closures
Self-referential structs via Pin Async fn bodies generate structs whose fields can reference other fields in the same struct — normal lifetimes can't express this; Pin + the generated state machine handle it. tokio async tasks, futures combinators
day02_advanced.rs — Cow<'a, str> (borrow or own)
ADVANCED
use std::borrow::Cow;

// Returns a borrowed slice if no change is needed, or an owned String
// only when mutation is actually required — avoids unnecessary allocation.
fn normalize<'a>(input: &'a str) -> Cow<'a, str> {
    if input.contains(' ') {
        Cow::Owned(input.replace(' ', "_")) // allocates a new String
    } else {
        Cow::Borrowed(input) // zero allocation — just a reference
    }
}

fn main() {
    let a = normalize("already_clean");  // Cow::Borrowed — no allocation
    let b = normalize("needs spaces");  // Cow::Owned — one allocation

    println!("{} | {}", a, b); // "already_clean | needs_spaces"

    // HRTB example: a closure that must accept &T for ANY lifetime 'a
    // — common in iterator/visitor-style APIs
    fn apply_to_all(items: &[String], f: F)
    where
        F: for<'a> Fn(&'a String) -> usize,
    {
        for item in items {
            println!("len = {}", f(item));
        }
    }
    apply_to_all(&[String::from("a"), String::from("bb")], |s| s.len());
}