Variance and Soundness

Variance is a fundamental concept in Rust's type system that affects how lifetime parameters interact with subtyping. Facet tracks variance at runtime to enable safe reflection APIs.

What is variance?

Variance determines whether you can substitute a type with a different lifetime:

  • Covariant: A longer lifetime can be used where a shorter one is expected. &'static str can be used as &'a str.
  • Contravariant: A shorter lifetime can be used where a longer one is expected. fn(&'a str) can accept fn(&'static str).
  • Invariant: No substitution allowed. The lifetime must match exactly.
// Covariant: &'a T
fn takes_ref<'a>(r: &'a str) {}
let s: &'static str = "hello";
takes_ref(s);  // OK: 'static -> 'a

// Contravariant: fn(&'a T)
fn takes_fn<'a>(f: fn(&'a str)) {}
fn static_fn(s: &'static str) {}
// takes_fn(static_fn);  // Would need 'a -> 'static

// Invariant: &'a mut T
fn takes_mut<'a>(r: &'a mut String) {}
// Cannot change the lifetime of a mutable reference

Why does this matter for reflection?

Facet's Peek type lets you read values at runtime. Without careful design, reflection could allow lifetime laundering — converting a value with one lifetime to a different lifetime, leading to use-after-free bugs.

The problem

Consider a type that contains a function pointer:

#[derive(Facet)]
struct FnWrapper<'a> {
    f: fn(&'a str),  // Contravariant in 'a
}

If reflection allowed lifetime changes, you could:

  1. Create a FnWrapper<'static> containing fn(&'static str)
  2. Use reflection to "cast" it to FnWrapper<'short>
  3. Call the function with a &'short str that goes out of scope
  4. The function still expects &'static struse-after-free!

The solution

Peek is invariant over its 'facet lifetime parameter. This means you cannot change the lifetime through reflection at all:

// This is enforced at compile time
fn launder<'a>(p: Peek<'_, 'static>) -> Peek<'_, 'a> {
    p  // ERROR: cannot coerce Peek<'_, 'static> to Peek<'_, 'a>
}

Variance tracking in Shape

Every Shape in facet has a variance field that records the type's variance:

pub struct Shape {
    // ... other fields ...

    /// Variance of this type with respect to its lifetime parameter.
    pub variance: Variance,
}

pub enum Variance {
    Covariant,
    Contravariant,
    Invariant,  // Default
}

Currently, all types default to Invariant (the safe choice). Future versions may compute variance automatically based on field types.

Variance rules

When combining types, variance follows these rules:

Type AType BCombined
CovariantCovariantCovariant
ContravariantContravariantContravariant
CovariantContravariantInvariant
AnyInvariantInvariant

The Variance::combine() method implements these rules:

let struct_variance = field1_variance.combine(field2_variance);

Examples of variance

TypeVarianceWhy
&'a TCovariantLonger refs can substitute for shorter
&'a mut TInvariantMutable refs can't change lifetime
fn(&'a T)ContravariantFunctions taking refs are contravariant
fn() -> &'a TCovariantReturn positions are covariant
Cell<&'a T>InvariantInterior mutability forces invariance
Box<&'a T>CovariantBox is transparent, inner ref is covariant
Vec<fn(&'a T)>ContravariantVec is transparent, fn is contravariant

Further reading