RustProgrammingRust Developer

How does the variance of the &mut T type prevent soundly assigning a &mut &'long str to a &mut &'short str, and what memory safety issue would this allow if permitted?

Pass interviews with Hintsage AI assistant

Answer to the question

History of the question

Variance in type systems determines how subtyping relationships between generic parameters affect the overall type. Rust's approach was heavily influenced by research into region-based memory management and the need to prevent use-after-free vulnerabilities. When Rust introduced mutable references (&mut T), the designers had to decide whether they should be covariant (like &T), contravariant, or invariant. The choice of invariance for &mut T over T was critical to maintaining memory safety without requiring runtime checks.

The Problem

If &mut T were covariant over T, you could substitute &mut U where &mut V is expected if U is a subtype of V. In lifetime terms, since 'long is a subtype of 'short (because 'long outlives 'short), this would mean you could assign &mut &'long str to &mut &'short str. This seems harmless but creates a soundness hole.

The Solution

&mut T is invariant over T. This means that &mut &'a str and &mut &'b str are unrelated types unless 'a exactly equals 'b, regardless of the subtyping relationship between the lifetimes. The compiler rejects code that attempts to coerce between them, preventing the assignment of short-lived data to locations expecting longer-lived references through a mutable indirection.

Code Example:

fn demonstrate_invariance() { let mut long_lived: &'static str = "static string"; // This would compile if &mut T were covariant: // let short_ref: &mut &'short str = &mut long_lived; // But because &mut T is invariant, this fails: // error: lifetime mismatch // let short_ref: &mut &'_ str = &mut long_lived; let local = String::from("temporary"); // If the above were allowed, we could do: // *short_ref = &local; // Now long_lived points to dropped data (UAF!) } // local dropped here

Situation from life

A team was building a configuration manager for a high-performance networking stack. The core structure needed to hold a mutable reference to a protocol configuration that could be swapped at runtime without taking ownership.

The Problem: The initial API design used &mut &'a Config where 'a was the lifetime of the network session. Developers attempted to initialize this with &mut &'static Config (for global default configs) and then pass it to functions expecting &mut &'session Config. The compiler rejected this, causing confusion because immutable references (& &'static Config) worked fine.

Solutions Considered:

1. Unsafe Transmute to Force the Conversion The team considered using std::mem::transmute to convert &mut &'static Config to &mut &'session Config. This would bypass the compiler's variance checks. However, this would allow writing a short-lived config reference into a location that might outlive the current scope, leading to immediate undefined behavior if the config was accessed after being dropped. The risk of use-after-free in production code made this unacceptable.

2. Changing to Immutable References They considered changing the API to use & &'a Config instead of &mut &'a Config. Since shared references are covariant, & &'static Config could coerce to & &'session Config. However, this removed the ability to atomically swap configurations during runtime updates, which was a core requirement for hot-reloading settings without restarting connections.

3. Using Cell<&'a Config> for Interior Mutability This option would allow mutation through a shared reference. However, Cell<T> is also invariant over T for the same safety reasons, so it didn't solve the variance issue. Additionally, Cell doesn't provide synchronization for multi-threaded access, and the overhead of runtime borrow checking with RefCell was deemed too expensive for the hot path.

4. Redesigning with Owned Types and Indirection The chosen solution eliminated the reference-to-reference pattern entirely. Instead of storing &mut &'a Config, the struct stored &'a mut ConfigHolder, where ConfigHolder was an owning wrapper. This moved the mutability to the holder level rather than the reference level, avoiding the variance trap while maintaining the ability to swap configurations. The API became more ergonomic because users no longer had to manage double references.

The Result: The redesign produced a safer API that compiled without unsafe code. The invariant nature of &mut T had forced the team to recognize a potential architectural flaw where lifetime assumptions could be violated. The final system prevented a category of bugs where stale configuration pointers could persist beyond their validity period.

What candidates often miss

Why is Cell<T> invariant over T, and how does this relate to the variance of &mut T?

Cell<T> provides interior mutability, allowing mutation through shared references. If Cell<T> were covariant over T, you could upcast Cell<&'short str> to Cell<&'static str>. Then, you could store a short-lived string reference inside and later read it through the Cell<&'static str> type, treating temporary data as static. This would be a use-after-free vulnerability. Therefore, like &mut T, Cell<T> (and UnsafeCell<T>) must be invariant over T to prevent writing short-lived data into a slot that claims to hold longer-lived data. This invariance propagates to RefCell, Mutex, and other interior mutability types.

How does PhantomData<T> affect the variance of a struct that contains no actual T, and why would you use PhantomData<fn(T)> to achieve contravariance?

PhantomData<T> tells the compiler to treat the struct as if it owns a T for the purposes of variance and drop checking. By default, PhantomData<T> gives the struct the same variance as T. However, function pointers have special variance: fn(A) -> B is contravariant in A (the argument) and covariant in B (the return). If you need a struct to be contravariant over a lifetime (meaning Struct<'long> is a subtype of Struct<'short> when 'long outlives 'short), you use PhantomData<fn(T)>. This is crucial for building type-safe callbacks or comparators where the relationship between lifetimes must be reversed.

In unsafe code, when implementing a self-referential struct using raw pointers, why must the struct be marked as invariant over its lifetime parameters?

When a struct contains a raw pointer that points to other data within the same struct (self-referential), the lifetime of that struct determines the validity of the pointer. If the struct were covariant over its lifetime 'a, you could shrink 'a to a shorter lifetime 'b, effectively claiming the struct lives only for 'b. However, the raw pointer inside was created when the struct lived longer, and might point to data that is no longer valid in the shorter scope. Invariance ensures that the struct cannot be coerced to a shorter lifetime, preserving the safety invariant that the self-reference remains valid for the entire lifetime encoded in the type system. This is why Pin is often combined with explicit variance markers in unsafe self-referential implementations.