Most of the time, subtyping is intuitive. Object-oriented programmers are used to the concept of inheritance, where one class specializes another, and we’re familiar with the idea of passing, say, a Dog
object where a function expects an Animal
.
Once you start working with generics, though, there are some fascinating twists that flip our typical understanding of subtyping on its head.
What is Contravariance?
Contravariance describes a relationship between two sets of types where they subtype in opposite directions. As an example, we’ll make the simplest of inheritance hierarchies - classes A
and B
, where B
is a subtype of A
:
open class A
open class B : A()
And, we’ll create a generic class that can hold objects of our A
and B
classes.
class Box<in T> {
private val items = mutableListOf<T>()
fun deposit(item: T) = items.add(item)
}
At this point, this is a mostly useless class - you can deposit items into it, and that’s it. I was going to call this Receptacle
instead of Box
, but it’s ridiculously more difficult to type. So I’m going with Box
. Just think of it as a drop-box for mail at your local post office.
Now, here’s the question - how does the subtyping work for Box
? Is Box<B>
a subtype of Box<A>
, just like B
is a subtype of A
?
In fact, the opposite is true!
As you can see in the diagram above, the subtyping between these two sets of types is working in opposite directions:
B
is a subtype ofA
, whereas…Box<B>
is a supertype ofBox<A>
This happened because we added the magic variance annotation in
to our type parameter T
above.
Examples
Using contravariance
Because of this, we can use Box<A>
anywhere that the code expects Box<B>
:
val boxOfA: Box<A> = Box<A>()
val boxOfB: Box<B> = boxOfA
This also works when you’re passing it to a function:
fun update(box: Box<B>) {
// ...
}
val boxOfA: Box<A> = Box<A>()
update(boxOfA)
Creating contravariance
To create a generic that’s contravariant on its type parameter, just add the word in
before it:
class Box<in T> {
// ...
}
If we were to omit in
from the type parameter declaration, then neither Box<A>
nor Box<B>
would be a subtype of the other. In that case, we’d say the class Box
is invariant.
Contravariance comes with a trade-off - once an object of this type has been constructed, you’ll never be able to return a result of type T from a function. That’s why it’s called in
- because we’re promising that we’ll only ever send an object of type T
in
to the object.
Contravariance and Inheritance
Functions
Variance is a concept that applies not just to generics, but also to inheritance.
- In many languages, including Java and Kotlin, covariant return types allow methods on a subclass to return a more specific type than their superclass.
- On the other hand, most languages do not allow for contravariant argument types. So, even though it would be safe for a subclass to expect a more general argument type than its superclass, it’s not actually allowed.
For example:
open class Super {
open fun execute(arg: B) {
// ...
}
}
class Sub : Super() {
override fun execute(arg: A) {
// ...
}
}
By the rules of contravariance, this is type-safe. But the Kotlin compiler will error on Sub
’s override
, saying:
execute
overrides nothing
Why would language designers choose to keep argument types invariant? The trade-off has to do with function overloading.
For example, if you remove the override
on Sub
, then Sub
will have two methods, both named execute()
, and which method is invoked is determined by the declared type of the argument:
with(Sub()) {
execute(A()) // invokes Sub's execute()
execute(B()) // invokes Super's execute()
execute((B() as A)) // invokes Sub's execute()
}
Properties
If you want contravariant argument types with inheritance, then instead of using a function, you can use a property. For example:
open class Super {
open val execute: (B) -> Unit = {
// ...
}
}
class Sub : Super() {
override val execute: (A) -> Unit = {
// ...
}
}
Invoking the same code on these classes will result in Sub
’s execute
being called in all three cases:
with(Sub()) {
execute(A()) // invokes Sub's execute()
execute(B()) // invokes Sub's execute()
execute((B() as A)) // invokes Sub's execute()
}
This makes perfect sense, because the trade-off of overloading is irrelevant in this case - overloading applies to functions, not to properties. So nothing is lost by allowing contravariant argument types.
More Information
- Check out other concept articles about variance: covariance and invariance.
- Kotlin’s official documentation has more information about variance.