Oh, I never knew that Rust had variance. I always just assumed everything was invariant. Strange that they've got no way to write it down in the type system.
In prehistoric Rust, variance used to be named more explicitly. However, the terminology of covariant and contravariant subtyping of lifetimes is a language theory jargon. This is the right perspective for language design, but programmers using the language don't necessarily use these terms.
It's been replaced with a "by example" approach. It's much easier to teach it: just add a fake field that acts if you had this type in your struct. Rust then figures out all of the details it needs.
Years ago, I introduced Flow gradual typing (JS) to a team. It has explicit annotations for type variance which came up when building bindings to JS libraries, especially in the early days.
I had a loose grasp on variance then, didn't teach it well, and the team didn't understand it either. Among other things, it made even very early and unsound TypeScript pretty attractive just because we didn't have to annotate type variance!
I'm happy with Rust's solution here! Lifetimes and Fn types (especially together) seem to be the main place where variance comes up as a concept that you have to explicitly think about.
Note that this works because Rust doesn't have inheritance, so variance only comes up with respect to lifetimes, which don't directly affect behavior/codegen. In an object-oriented language with inheritance, the only type-safe way to do generics is with variance annotations.
Variance is an absolute disaster when it comes to language pedagogy. One of the smartest things Rust ever did was avoiding mentioning it in the surface-level syntax.
The difficulty is that even trivial generic types aren't cleanly one or the other. A mutable reference type is covariant on read, and contra on write.
Scala was the first language in my exposure to try to simplify that away by lifting the variance annotations into the type parameter directly. It reduced some of the power but it made things easier to understand for developers. A full variance model would annotate specific features (methods) of a type as co/contra/invariant.
I'm not sure what approach C# takes - I haven't looked into it.
Rust doesn't expose variances for data structures at all. It exposes them for traits (type classes) and lifetimes, but neither of those are accessible in a higher order form. Trait usage is highly constrained and so is lifetime usage.
Traits mainly can be used as bounds on types. Some subset of traits, characterized as object traits, can be used as types themselves, but only in an indirect context. These are highly restricted. For example, if you have traits T and U where U extends T, and you have a reference `&dyn U`, you can't convert that to a `&dyn T` without knowledge of the underlying concrete type. You _can_ convert `&A where A: U` to `&B where B: T`, but that just falls out of the fact that the compiler has access to all the concrete type and trait definitions there and can validate the transform.
Rust's inheritance/variance story is a bit weird. They've kept it very minimal and constrained.
> A mutable reference type is covariant on read, and contra on write.
No it isn't. The type is covariant because a reference is a subtype of all parent types. The the function read must have it's argument invariant because it both takes and returns an instance of the type. I think you're confusing the variance of types for the variance of instances of those types. Read is effectively a Function(t: type, Function(ref t, t)). If it was covariant as you suggest, we would have a serious problem. Consider that (read Child) works by making a memory read for the first (sizeof Child) bytes of it's argument. If read were covariant, then that would imply you could call (read Child) on a Parent type and get a Parent back, but that won't work because (sizeof Child) can be less than (sizeof Parent). Read simply appears covariant because it's generic. (read Child) ≠ (read Parent), but you can get (read Parent). It also appears contravariant because you can get (read Grandchild).
Scala doesn't simplify anything, that's just how variance works.
It's not particularly easy to teach what that actually means and why it's a thing. It's quite easy to show why in general G<A> cannot be a subtype of G<B> even if A is a subtype of B, it's rather more involved pedagogically to explain when it can, and even more confusing when it's actually the other way around (contravariance).
Anyway, Rust has no subtyping except for lifetimes ('a <: 'b iff 'a lasts at least as long as 'b) , so variance only arises in very advanced use cases.
I’m getting old. I can understand the words, but not the content. At this point, show me dissassembly so that I can understand what actually happens on the fundamental byte/cpu instruction level, then I can figure out what you’re trying to explain.
Sure, you can keep telling me that and it doesn't stay. I'm completely happy writing Rust, and I am aware it needs variance to work in principle and when I do need that information I know the magic words to type into doc search.
It's like how I can hold in my head how classical DH KEX works and I can write a toy version with numbers that are too small - but for the actual KEX we use today, which is Elliptic Curve DH I'm like "Well, basically it's the same idea but the curves hurt my head so I just paste in somebody else's implementation" even in a toy.
One day the rote example finally made sense to me, and I go back to it every time I hear about variance.
Got a Container<Animal> and want to treat it as a Container<Cat>? Then you can only write to it: it's ok to put a Cat in a Container<Cat> that's really a Container<Animal>.
Reading from it is wrong. If you treat a Container<Animal> like a Container<Cat> and read from it, you might get a Dog instead.
The same works in reverse, treating a Container<Cat> like a Container<Animal> is ok for read, but not for write.
Luckily variance only affects lifetimes, and these are already barely understood even without lifetimes. If you ignore them, the need for variance disappears.
> but programmers using the language don't necessarily use these terms.
this always annoyed me about the python type annotations, you are supposed to already know what contravariant / covariant / invariant means, like: `typing.TypeVar(name, covariant=False, contravariant=False, infer_variance=False)`
Its used in documentation and error messages too:
> the SendType of Generator behaves contravariantly, not covariantly or invariantly.
1 point by James_K 0 minutes ago | root | parent | next | edit | delete [–]
That's horrible design. I was utterly perplexed whenever the compiler asked me to add one of those strange fields to a struct. If it had just asked me to include the variance in generic parameters, I would have had no such confusion. Asking programmers to learn the meaning of an important concept in programming is entirely reasonable, especially in Rust which is a language for advanced programmers that expects knowledge of many other more complicated things.
What's more, the implicit variance approach might create dangerous code. It is possible to break the interface for a module without making any change to its type signature. The entire point of types is to provide a basic contract for the behaviour of an object. A type system with variance in it that doesn't let you write it down in the signature is deficient in this property.
Rather, any language with both generics and subtyping needs to consider variance, and Rust doesn't have subtyping in general, but lifetimes do technically have subtyping relationships, so lifetimes are the only place where this matters, and fortunately Rust can just infer the right behavior based on some simple type-level heuristics.
Unfortunately, more like. I'd much rather have some explicit syntax for it that potentially never realise it's a feature. I was baffled beyond belief the first time the compiler asked me to put a ghost in my struct.
Aside from better documentation (it would be quite nice if rustdoc automatically showed the computed variance for types where it mattered), what would writing it down in the type system get you?
Separately, if everything were invariant, you wouldn't be able to use a `&'static T` where a `&'non_static T` was expected, which would be quite unpleasant!