Skip to content

← Ownership III: Lifetimes, Explicitly step 17 of 22

Hard End-to-End

`T: 'static` in practice — a type-erased handler registry

Two registries that hold boxed closures. They differ by five characters, and those five characters are the difference between an API that composes and one that fights every caller.

pub type Handler = Box<dyn Fn(i64) -> i64>;
pub type BorrowedHandler<'a> = Box<dyn Fn(i64) -> i64 + 'a>;

pub struct Registry { handlers: Vec<Handler> }
pub struct BorrowingRegistry<'a> { handlers: Vec<BorrowedHandler<'a>> }

Each gets register and call_all(&self, x) -> Vec<i64>, which runs every handler on x in registration order. Then:

pub fn run(offsets: Vec<i64>, x: i64) -> (Vec<i64>, Vec<i64>)

builds one of each. For every offset it registers a handler computing x.wrapping_add(offset) — but the owning registry’s closures capture an owned i64 copy, and the borrowing registry’s closures capture a &i64 pointing into offsets. Both tuples come out identical. The arithmetic is not the exercise.

The two meanings of 'static

This is the single most misread thing in Rust, so get it straight before you write a line:

  • &'static T — a reference whose referent is valid for the whole program. String literals, consts, statics, Box::leak.
  • T: 'static — a bound on a type, meaning “this type contains no references that could become invalid”. Every owned type satisfies it. String: 'static. Vec<u8>: 'static. i64: 'static. A String you allocate at noon, mutate at 12:01 and drop at 12:02 satisfies T: 'static the entire time.

It does not mean “lives forever”. It means “does not borrow anything short-lived”. Learners who read F: Fn(i64) -> i64 + 'static as “F lives forever” conclude they can never spawn a thread over their own data, and lose an afternoon.

Where the trap is hidden

Look again at Vec<Box<dyn Fn(i64) -> i64>>. There is no lifetime in it. There is also no warning about it — and it silently means:

Vec<Box<dyn Fn(i64) -> i64 + 'static>>

Trait objects have default lifetime bounds, and because Box<T> puts no lifetime bound on T, the default is 'static. That default is exactly right for Registry, whose whole point is to hold handlers that outlive whatever built them. It is exactly wrong for BorrowingRegistry, and nothing tells you. Get the field type wrong there and the implementation is what breaks:

error[E0310]: the parameter type `F` may not live long enough
              the parameter type `F` must be valid for the static lifetime

The alias BorrowedHandler<'a> is given to you already correct. The bound on BorrowingRegistry::register is not. The starter says + 'static on both registries; only one of them should.

The pin

const _: () = {
    fn _pin<'a>(r: &mut BorrowingRegistry<'a>, table: &'a [i64]) {
        r.register(move |x| x.wrapping_add(table[0]));
    }
};

A registry parameterised by 'a, a table borrowed for 'a, and a closure that captures the table. That must work — it is the entire reason BorrowingRegistry exists. With the starter’s + 'static bound you get:

error[E0521]: borrowed data escapes outside of function
              argument requires that `'a` must outlive `'static`

Read that last line slowly. You asked for a closure that borrows nothing short-lived; the caller handed you one that borrows table; the only way to reconcile them is for 'a to be 'static, which it is not. The fix is register<F: Fn(i64) -> i64 + 'a> — “F may borrow, as long as it borrows for at least as long as this registry does.”

Do not edit or delete the pin.

Small things that matter

  • type_complexity is a warn-by-default clippy lint and Vec<Box<dyn Fn(i64) -> i64>> is close to its threshold. The two type aliases are there so you never have to find out; use them.
  • The site compiles with -O, so overflow checks are off and debug_assertions is false. A plain + would wrap silently rather than panic. Use wrapping_add so the behaviour is a decision instead of an accident — two of the tests sit exactly on i64::MIN / i64::MAX.
  • Registry and BorrowingRegistry both derive Default; build them with ::default() rather than writing a new (clippy::new_without_default would object to an argument-less new with no Default impl).
  • To make the borrowing closures actually borrow, iterate &offsets and let each closure move a &i64 into itself. move on a reference copies the reference, which is still a borrow — that is the whole point.

    Loading visualization…