This is the final part of the Advanced Rust: Ownership, Borrowing and Lifetimes series. Parts 1 through 9 covered the mechanics – how the rules work, how to read errors, and how to apply ownership patterns in production. Part 10 goes deeper into the type system itself: variance, subtyping, phantom data, and the techniques that library authors use to write APIs that are both expressive and zero-cost.
Variance: How Lifetimes Flow Through Generic Types
When a generic type Foo<'a> contains a lifetime parameter, the compiler needs to know how changes to 'a affect the usability of Foo<'a>. This is called variance.
There are three kinds:
- Covariant: if
'long: 'short, thenFoo<'long>can be used whereFoo<'short>is expected. Longer lifetime is a subtype. This is the intuitive case. - Contravariant: the opposite –
Foo<'short>can be used whereFoo<'long>is expected. This appears with function pointers that consume references. - Invariant: no substitution is allowed.
Foo<'a>can only be used where exactly'ais expected. This is the strictest case.
// Covariant: &'a T is covariant over 'a
// A &'long T can be used where &'short T is needed
fn covariant_example<'long: 'short, 'short>(
long_ref: &'long str,
) -> &'short str {
long_ref // fine: 'long is a subtype of 'short, covariant
}
// Invariant: &'a mut T is invariant over 'a
// You cannot substitute a longer lifetime for a shorter one with &mut
fn invariant_example() {
let mut long_lived = String::from("hello");
let mut short_lived = String::from("world");
let r: &mut &mut String = &mut (&mut long_lived);
// *r = &mut short_lived; // FORBIDDEN by invariance
// If this were allowed, long_lived would end up pointing to short_lived
// which could be dropped while long_lived still holds it
}
// Contravariant: fn(T) is contravariant over T
// A function accepting a shorter-lived reference can be used
// where a function accepting a longer-lived reference is needed
fn contravariant_example() {
let func: fn(&'static str) = |s| println!("{}", s);
// fn(&'static str) can be used as fn(&'short str)
// because if a function handles 'static it certainly handles any shorter lifetime
}flowchart LR
subgraph Covariant ["Covariant (e.g. &'a T)"]
A["'long (longer lifetime)"] -->|can substitute for| B["'short (shorter lifetime)"]
end
subgraph Contravariant ["Contravariant (e.g. fn(&'a T))"]
C["'short"] -->|can substitute for| D["'long"]
end
subgraph Invariant ["Invariant (e.g. &'a mut T)"]
E["'a"] -->|only exactly| F["'a"]
end
The variance of a type is derived automatically from how it uses its parameters. You do not annotate variance – the compiler computes it. But understanding it helps you reason about why certain substitutions are allowed or rejected.
PhantomData: Influencing Variance and Drop Check
PhantomData<T> is a zero-size marker type that tells the compiler “this type logically owns or uses T even though it does not physically contain it.” It has two main uses: controlling variance and satisfying the drop checker.
use std::marker::PhantomData;
// A raw pointer wrapper that logically owns T
// Without PhantomData, the compiler would not know T is owned
struct MyBox {
ptr: *const T,
_owns: PhantomData, // tells compiler: MyBox owns a T
}
impl MyBox {
fn new(val: T) -> Self {
MyBox {
ptr: Box::into_raw(Box::new(val)),
_owns: PhantomData,
}
}
}
impl Drop for MyBox {
fn drop(&mut self) {
// Safety: ptr was created from Box::into_raw
unsafe { drop(Box::from_raw(self.ptr as *mut T)); }
}
}
// PhantomData controlling variance:
// Covariant over 'a - default for immutable access
struct CovariantRef<'a, T> {
ptr: *const T,
_marker: PhantomData<&'a T>, // makes it covariant over 'a
}
// Invariant over 'a - needed for mutable access
struct InvariantRef<'a, T> {
ptr: *mut T,
_marker: PhantomData<&'a mut T>, // makes it invariant over 'a
}
// Contravariant over T - for function-like types that consume T
struct Consumer {
func: *const (),
_marker: PhantomData, // contravariant over T
} The Drop Check and Why It Matters
When a generic type implements Drop, the compiler applies the drop check: it verifies that any data the type accesses in its drop method is still valid when drop runs. PhantomData is how you communicate to the drop check what your type actually accesses:
use std::marker::PhantomData;
// Without PhantomData, the compiler does not know Inspector accesses 'a data
// and might allow 'a data to be dropped before Inspector
struct Inspector<'a> {
ptr: *const String,
_marker: PhantomData<&'a String>, // tells compiler: we access 'a data
}
impl<'a> Drop for Inspector<'a> {
fn drop(&mut self) {
// Safety: _marker tells the drop checker this data lives for 'a
// The compiler ensures 'a data is still valid when we drop
println!("Inspecting: {:?}", unsafe { &*self.ptr });
}
}
fn main() {
let s = String::from("hello");
let inspector = Inspector {
ptr: &s as *const String,
_marker: PhantomData,
};
// drop order: inspector first (declared last), then s
// PhantomData ensures the compiler enforces this
}Writing Zero-Cost Abstractions
Zero-cost abstractions are the ideal in Rust: you pay only for what you use, and abstractions compile away to the same code as the manual equivalent. Here are the techniques that make this possible.
Newtype Pattern: Type Safety Without Runtime Cost
// Without newtype: easy to mix up arguments
fn transfer(from_account: u64, to_account: u64, amount: u64) {
// which u64 is which? easy to pass wrong order
}
// With newtype: compile-time type safety, zero runtime overhead
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
struct AccountId(u64);
#[derive(Debug, Clone, Copy, PartialEq, PartialOrd)]
struct Cents(u64);
fn transfer_safe(from: AccountId, to: AccountId, amount: Cents) {
// compiler prevents mixing up from/to or confusing amount with account
println!("Transferring {} cents from {:?} to {:?}", amount.0, from, to);
}
fn main() {
let alice = AccountId(1001);
let bob = AccountId(1002);
let amount = Cents(500);
transfer_safe(alice, bob, amount);
// transfer_safe(alice, amount, bob); // compile error: type mismatch
}Type-State Pattern: Encoding State in the Type System
use std::marker::PhantomData;
// States as zero-size types
struct Locked;
struct Unlocked;
struct Safe {
contents: String,
_state: PhantomData,
}
impl Safe {
fn new(contents: &str) -> Self {
Safe {
contents: contents.to_string(),
_state: PhantomData,
}
}
fn unlock(self, _code: &str) -> Safe {
Safe {
contents: self.contents,
_state: PhantomData,
}
}
}
impl Safe {
fn read(&self) -> &str {
&self.contents
}
fn lock(self) -> Safe {
Safe {
contents: self.contents,
_state: PhantomData,
}
}
}
fn main() {
let safe = Safe::::new("secret documents");
// safe.read(); // compile error: read() only exists on Safe
let open = safe.unlock("1234");
println!("{}", open.read()); // "secret documents"
let locked = open.lock();
// locked.read(); // compile error again
} The type-state pattern uses PhantomData and zero-size types to encode state transitions in the type system. Invalid transitions become compile errors. At runtime, the state types compile away to nothing – there is zero overhead.
Builder Pattern With Lifetime Constraints
struct QueryBuilder<'a> {
table: &'a str,
conditions: Vec,
limit: Option,
}
impl<'a> QueryBuilder<'a> {
fn new(table: &'a str) -> Self {
QueryBuilder { table, conditions: vec![], limit: None }
}
fn where_clause(mut self, condition: impl Into) -> Self {
self.conditions.push(condition.into());
self // return self for chaining
}
fn limit(mut self, n: usize) -> Self {
self.limit = Some(n);
self
}
fn build(self) -> String {
let mut query = format!("SELECT * FROM {}", self.table);
if !self.conditions.is_empty() {
query.push_str(" WHERE ");
query.push_str(&self.conditions.join(" AND "));
}
if let Some(limit) = self.limit {
query.push_str(&format!(" LIMIT {}", limit));
}
query
}
}
fn main() {
let table = String::from("users");
let query = QueryBuilder::new(&table)
.where_clause("age > 18")
.where_clause("active = true")
.limit(10)
.build();
println!("{}", query);
// SELECT * FROM users WHERE age > 18 AND active = true LIMIT 10
} Putting It All Together: A Production-Grade Type
use std::marker::PhantomData;
use std::sync::Arc;
// A typed handle to a resource, parameterized by both resource type and state
// Zero runtime overhead: all type info compiled away
struct Handle {
id: u64,
inner: Arc,
_state: PhantomData,
}
struct Open;
struct Closed;
impl Handle {
fn new(id: u64, resource: R) -> Self {
Handle {
id,
inner: Arc::new(resource),
_state: PhantomData,
}
}
fn close(self) -> Handle {
Handle {
id: self.id,
inner: self.inner,
_state: PhantomData,
}
}
fn resource(&self) -> &R {
&self.inner
}
}
impl Handle {
fn reopen(self) -> Handle {
Handle {
id: self.id,
inner: self.inner,
_state: PhantomData,
}
}
}
impl Clone for Handle {
fn clone(&self) -> Self {
Handle {
id: self.id,
inner: Arc::clone(&self.inner),
_state: PhantomData,
}
}
}
// Closed handles intentionally do not implement Clone
// You cannot share a closed handle Series Summary
Over 10 parts, this series has covered the complete ownership, borrowing, and lifetime system in Rust:
- Part 1: The ownership model – move semantics, Copy, Clone, Drop
- Part 2: Borrowing rules and the borrow checker mental model
- Part 3: Lifetimes demystified – what annotations mean and why they exist
- Part 4: Lifetime elision – the three rules and when the compiler infers automatically
- Part 5: Lifetimes in structs and enums – borrowed data structures
- Part 6: Lifetimes in trait definitions, trait objects, and higher-ranked bounds
- Part 7: Smart pointers and interior mutability – Box, Rc, Arc, RefCell, Mutex
- Part 8: Pin and Unpin – self-referential structs and async state machines
- Part 9: Production patterns and borrow checker diagnosis
- Part 10: Variance, PhantomData, drop check, and zero-cost abstractions
The ownership system is what makes Rust unique. It is not just a memory safety feature – it is a tool for encoding invariants, resource lifetimes, and state transitions directly into the type system. The developers who get the most out of Rust are those who learn to use the type system as a design tool rather than an obstacle to route around.
References
- The Rustonomicon – “Subtyping and Variance” (https://doc.rust-lang.org/nomicon/subtyping.html)
- The Rustonomicon – “PhantomData” (https://doc.rust-lang.org/nomicon/phantom-data.html)
- The Rustonomicon – “Drop Check” (https://doc.rust-lang.org/nomicon/dropck.html)
- Rust Standard Library – “PhantomData” (https://doc.rust-lang.org/std/marker/struct.PhantomData.html)
- Rust Blog – “Abstraction without Overhead: Traits in Rust” (https://blog.rust-lang.org/2015/05/11/traits.html)
