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:
Bis 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:
executeoverrides 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.