Intro
NOTE: A lot of these examples are taken from the rustinomicon
Preface / Prereqs
Be comfortable with Rust. When we say co or contravariant over T,
we are not referring to T's memory layout. Instead, we are talking
about its type-theory meaning.
The theory behind all of this can feel a little dry, but we need some of
it in order to talk about variance precisely. A type constructor F<T>
(read: “F defined over T” or “F generic over T”) is something that takes
a type and produces a new type. For example, Box<T> is not the same as
T. Their guarantees and invariants are different. And as such we can
say that Box is a new type.
A subtype T1 of a supertype T2 is written as: T1 <: T2. This means
that anywhere a T2 is required, we may substitute a T1. However, the
reverse is not necessarily true.
Intuitively, you can think of T1 as being a strictly more useful type.
For example, consider Dog and Animal. If we only rely on behaviors
guaranteed by Animal, then a Dog is acceptable — and even more
specific — because every Dog is an Animal. The guarantees we can make
about a Dog (its invariants) are strictly stronger than those of Animal
in this context. So we say: Dog <: Animal. Please note that this is a
very intuitive analogy but can get very misleading and out of hand, try
and stick to the type theory and not animals.
Example:
let mut hello = "hello";
{
let world = String::from("world");
hello = &world;
}
println!("{hello}");
The intuitive problem is that world gets dropped in the block, and
then we try to read from it through hello after the drop.
The reason this is relavent is that the reason we can't do this is exactly due to the rust compilers rules on subtyping and variance. (your reading a niche blog on subtying and variance I'm pretty sure your good on motivation)
Variance
There are three main variances in Rust: Contravariant, Covariant, and Invariant.
Functions (Contravariance)
T1 and T2 are types. For example, Option is a type, and i64 is a type. We define types very specifically by their invariants. In the case of Option, one invariant is that it must always be either None or Some(T). If it were instead None or Maybe(T), then by definition it could not be called an Option.
T1 = Dog
T2 = Animal
Fn(T1) = functions that take Dog
Fn(T2) = functions that take Animal
Fn(T2) <: Fn(T1)
A function that can take an Animal can also take a Dog. However, a function that only takes a Dog cannot accept all Animals.
Therefore, we can say that the type Fn(T2) is a subtype of Fn(T1).
Everywhere we might want a function that takes a T1 (Dog), we can
instead provide a function that takes a T2 (Animal). The reverse is
not true, because not all Animals are Dogs.
This is called contravariance, because the usual subtyping relationship has been flipped:
T1 <: T2 becomes Fn(T2) <: Fn(T1).
That reversal of the subtype relationship is exactly what contravariance means.
An easier way to think about this is in terms of lifetimes. As explained in Jon Gjengset’s Crust of Rust: Subtyping and Variance , we can consider two function types:
let x: Fn(&'a str);
let y: Fn(&'static str);
&'static str <: &'a str
From category/type theory we know that a subtype T1 of a super type T2 is (loosely a type that can, in all places where a T2 is accepted a T1 can be used instead. But the opposite is not neccecarily true. We can think of T1 being strictly more usefull than T2
Everywhere where we want to pass a Fn that takes a 'a str we can NOT pass a 'static the invariants are not the same. Static can only live for static where as 'a can live for any amount. From this we can conclude that: that a function that takes the more general type is the more usefull type. This indicates that the FUNCTION types x and y defined above are contravariant over the type T.
Fn('a str) <: Fn(&'static str)
See how the subtyping relationship got fliped? before we had static subtying 'a but now its different. This is effectivly (in my understanding) the deffinition of a change of variance (co to conta).
Covariance
let x: &'static str;
let y: &'a str;
&'static str <: &'a str
References are covariant over their lifetime. A longer lifetime can be safely downgraded to a shorter one.
What this means (as explained in the functions section) is that the
subtype is the “more specific” or more flexible one. Consider a
reference to a T with lifetime 'a versus 'static. Suppose our
reference is of lifetime 'b, and 'b may outlive 'a. In that case,
using it could leave us with an invalid pointer. Therefore, a
reference to 'static is always more flexible, because anywhere a
shorter-lived reference is needed, we can safely use the longer-lived
one and downgrade it.
Therefore, the type &'a T is covariant over T. This means that the
subtyping relationship between any T1 and T2 is preserved when
taking a reference. Unlike the function example discussed earlier, where
the subtyping relationship could change due to contravariance in the
argument type, a reference does not alter the original type
relationship.
Invariance
I found this one the hardest to reason about, so let’s motivate it naturally. Suppose we have:
fn assign(input: &mut T, val: T) {
*input = val;
}
fn main() {
let mut hello: &'static str = "hello";
let world = String::from("world");
assign(&mut hello, &world);
println!("{hello}");
}
Now let’s try to apply the tools of covariance and contravariance to
reason about what happens to the type &mut T.
CASE 1: Contravariance (we can provide a more general T)
- We assign
helloto a'static str. - We mutate
helloto hold a'a str, where'ais shorter than'static. - We later use
hello, expecting it to still be'static, but it now refers to a shorter-lived value.
This would allow a shorter lifetime to be written into a location that
originally required 'static, leading to a potential use-after-free.
CASE 2: Covariance (we can provide a more specific T)
- We assign
helloto a'a str. - We mutate
helloto hold a'static str. - The lifetime relationships become inconsistent, breaking the guarantees the type system is supposed to enforce.
In both directions, mutation allows us to violate lifetime guarantees.
Because &mut T permits writing, neither covariance nor contravariance
is sound.
Therefore, we need a new tool: invariance. A type constructor F<T>
is invariant over T if there is no subtyping relationship between
F<T1> and F<T2>, even if T1 and T2 are in a subtype
relationship.
This means that for &mut T, the T must match exactly. If the types
differ in any way — including their lifetimes the assignment is not
allowed.
Conclusion
In conclusion this isint really something you're ever gonna run into. But if you do then now you know :). Also for a more endepth discution of all the type and their variance check out: rustinomicon