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
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.
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)
There are three main variances in Rust: Contravariant, Covariant, and Invariant.
T1 and T2 are types. For example, 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).
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.
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.
hello to a 'static str.hello to hold a 'a str, where 'a is shorter than 'static.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.
hello to a 'a str.hello to hold a 'static str.
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.
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