Demystifying Covariant and Contravariant Lifetimes in Rust

Murat Aslan
3 min readMay 14, 2024

Introduction

In Rust, lifetimes are crucial in ensuring memory safety by tracking how long references are valid. But lifetimes can also exhibit variance (covariance and contravariance), which affects how types relate to each other. This article delves into covariant and contravariant lifetimes, providing a clear understanding and practical coding examples.

Covariant Lifetimes

Covariance occurs when a subtype relationship between inner types leads to a subtype relationship between the complex types. In simpler terms, if type B is a subtype of type A, then a collection of Bs is a subtype of a collection of As, given that the collection type is covariant.

Examples of Covariant Lifetimes:

  • References (&T): A reference to a subtype (&B) is a subtype of a reference to the supertype (&A), as long as B is a subtype of A. This makes sense because a function expects a &A can also handle a &B, since B guarantees the same memory layout as A (up to its size).
trait Shape {
fn area(&self) -> f64;
}

struct Square {
side: f64,
}

impl Shape for Square {
fn area(&self) -> f64 {
self.side * self.side
}
}

struct Rectangle {
width: f64,
height: f64,
}

impl Shape for Rectangle {
fn area(&self) -> f64 {
self.width * self.height
}
}

fn print_area(shape: &Shape) {
println!("Area: {}", shape.area());
}

fn main() {
let square = Square { side: 5.0 };
let rect = Rectangle { width: 3.0, height: 4.0 };

print_area(&square); // OK, Square is a subtype of Shape
print_area(&rect); // OK, Rectangle is also a subtype of Shape
}
  • Raw Pointers (*const T): Similar to references, raw pointers to subtypes (*const B) are subtypes of raw pointers to supertypes (*const A). This is because they both point to the same memory region, but the subtype pointer has more specific information about the data it points to.
  • Box<T> (Smart Pointers): Box<B> is a subtype of Box<A> if B is a subtype of A. This is because Box behaves similarly to a reference, and the inner type's subtype relationship applies.
  • Slices ([T]): A slice of a subtype ([B]) is a subtype of a slice of the supertype ([A]), provided the element types have the same lifetime. This is because slices are essentially pointers to the first element and a length, and subtyping preserves these properties.
  • Arrays ([T; n]): Similar to slices, arrays of subtypes ([B; n]) are subtypes of arrays of supertypes ([A; n]), given the same lifetime and element count.
  • Function Return Types (T): When a function returns a type T, and B is a subtype of A, returning B from the function is considered a subtype of returning A. This allows for flexibility in function design.

Contravariant Lifetimes

Contravariance is less common in Rust, but it can be useful in specific scenarios. It occurs when a subtype relationship between inner types leads to a supertype relationship between the complex types themselves. In simpler terms, if the type B is a subtype of type A, then a function accepting arguments of type B can be a subtype of a function accepting arguments of type A.

Example of Contravariant Function Arguments:

trait Printable {
fn format(&self) -> String;
}

struct DebugPrint;

impl Printable for DebugPrint {
fn format(&self) -> String {
format!("{:?}", self)
}
}

struct DisplayPrint;

impl Printable for DisplayPrint {
fn format(&self) -> String {
format!("{}", self)
}
}

fn print_generic<T: Printable>(value: &T) {
println!("{}", value.format());
}

fn main() {
let debug = DebugPrint;
let display

--

--