A few months ago, I found myself trying to explain type variance to a coworker whose experience is mainly Ruby. Dynamically typed languages such as Ruby don’t ask the developers to specify the type variance. I found that while they develop an instinct of what usages are acceptable and which aren’t, these developers don’t codify it in the same way developers in statically typed languages do. In trying to explain, I had great difficulty driving home the difference between covariance and contravariance. Their good instinct made it hard for me to fill the gap in their understanding. After a while, I remembered the way Simon Génier first explained it to me, and that way was a success.
This post is how I would explain type variance to a Ruby developer, without throwing the entire Computer Science manual at them. Throughout the post, we will suppose a typical
Animal type hierarchy, with
Cat as subtypes.
In computer programming, we can divide types into simple types (ex:
Symbol), and types composed of other members (ex:
Enumerable). This composition is often called parameterization. An
Integer) said to be parameterized by the
Type variance allows us to describe the relationship between the composed type and its members. Intuitively, we know that
Enumerable<Cat> is a subtype of
Enumerable<Animal>, but it’s harder to understand that
Logger<Animal> is a subtype of
Logger<Cat>. I’ll explain why that’s the case, but first, let’s talk about Arrays.
In dynamically typed languages as well as in languages that added generics after-the-fact, Arrays are weird.
Let’s examine a
print_age method takes an Array of Animals and prints their age.
print_age method can be called an array containing any animal:
We can also have another method named
adopt that adds animals to the list:
Now can you
adopt in the same arrays? Let’s see.
The answer is no:
adopt cannot use the same arrays as
print_age. However, the
adopt has a property that
print_age did not have, it accepts
We can also notice that we cannot send
Object doesn’t define the
What can we learn from this? Depending if you read from the array or write to it, the type doesn’t behave the same.
Composed types from which you can exclusively read or take stuff are called sources. Examples include
Reader. These become more specific as their type members become more specific. As a result of
Dog being a subtype of
Enumerable<Dog> is a subtype of
Enumerable<Animal>; you can pass an
Enumerable<Dog> to any method that expects an
Sinks are the opposite of sources: they’re types to which you can exclusively write or put stuff in. Examples include
Writer. Perhaps counter-intuitively, these become more specific as their type member become less specific! That means that
Logger<Animal> is in fact a subtype of
Logger<Animal> will be able to log for any
Dog as well.
What if a method needs to do both? Read and write, put and take? It would appear that they are at the intersection of covariance and contravariance… and that’s exactly right: they’re invariant. No subtyping is possible.
In an ideal world,
Array<Dog> is neither a subtype nor a supertype of
Array<Animal>. In practice, even if
HashMap and other read/write containers should be invariant, many languages will consider them covariant because of historic reasons.
Hopefully this piece will help you understand type variance better, or explain it to a curious coworker.