Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Lifetime Smells

This is the smell most specific to Rust, and the compiler won’t flag it: code with this smell compiles.

As a rule of thumb, a lifetime in a signature should reflect a design decision. If nobody made that decision, treat the lifetime as a smell.

Tier 1: a lifetime appears at all

Smell. Explicit <'a> in application-level code:

pub struct Summary<'a> {
    hottest_id: &'a str,       // borrowed from what, exactly?
    window: &'a [Frame<'a>],   // everything above must now outlive this
}

Why generation produces it. The agent started with a borrow-based design, hit lifetime errors, and added annotations until the code compiled, instead of restructuring. This is the same behavior that produces stray clones, taking the other available exit. Neither exit answers the design question: who should own this data?

Principle. In application code, owned data is the default, and borrows are short and local. A named lifetime constrains when one value may be dropped relative to another. Taking on that constraint makes sense when zero-copy has been measured to matter, and it’s a burden everywhere else.

Tier 2: a lifetime crosses a module or API boundary

Smell. A pub type or pub fn in one module exposing a lifetime parameter that other modules must carry.

Why it’s worse. A boundary-crossing lifetime propagates: every containing struct and trait implementation must now thread <'a>, and callers inherit the constraint. It also fixes the internal representation into the public contract, so moving to owned data later is a breaking change. Borrows should live and die within a function or module, and public types should own their data, unless the type is one of the deliberate exceptions below.

Three lines are enough to watch the propagation:

struct Dashboard<'a> {          // must now carry 'a...
    latest: Summary<'a>,
}

struct App<'a> {                // ...and so must everything above it
    dashboard: Dashboard<'a>,
    log: Vec<Frame>,            // and what does 'a even borrow from?
}

The legitimate exceptions

  • View types with an owned counterpart: &str and String, Path and PathBuf, BorrowedFd and OwnedFd. The borrow is the purpose of the type, and the standard library gives you the owned twin for each.
  • Iterators and RAII guards: created from a container or a lock, and short-lived by contract.
  • Zero-copy parsing, where a measured performance need exists, with an owned escape hatch (to_owned(), Cow).

The exceptions share a property: someone made the decision, the decision is visible (documentation, an owned twin, a benchmark), and the borrow’s scope is part of the contract.

Prompt. “Remove the lifetime parameters from this module’s public API. Exported types should own their data, with borrows kept internal and short-lived. If the hot path needs zero-copy, provide a borrowed view type alongside an owned type, following the Path and PathBuf pattern.”