Covariance and Contravariance in Generics
Yesterday I was supposed to finish my notes on the Advanced Kotlin course, but then I got to that part in any book or course that exists when the language supports generics. You know which part. Hint in post title.
Context
open class Person
class Astronaut: Person()
You can imagine they have whatever properties you wish. The main point is that Astronaut
derives from Person
. With that said, the following should work:
class Container<T>(val content: T)
fun printContents(container: Container<Person>) {
println(container.content)
}
fun main(args: Array<String>) {
val astronautContainer = Container(Astronaut())
printContents(astronautContainer)
}
But it doesn’t.
Error:(13, 19) Kotlin: Type mismatch: inferred type is Container<Astronaut> but Container<Person> was expected
You see, by default, things work like this:
Astronaut
can be used in place ofPerson
, due to polymorphism.Container<Astronaut>
cannot be used in place ofContainer<Person>
, because it’s potentially dangerous.
It’s dangerous because of cases like the following:
val alexey = Astronaut()
var personc: Container<Person> = Container<Astronaut>(alexey)
val alexis = Person()
personc.content = alexis
The contents of the Container<Astronaut>
have been modified, and now is a Person
.
A more realistic example would be the addition of instances of a base class to a mutable
list of a derived class. Our example is simplified.
Now… an observant reader would notice that our “dangerous” example wouldn’t even
compile, as we defined the contents of the Container<T>
as readonly (using the val
keyword), so silly shenanigans like that couldn’t ever happen.
Covariance
As we (and the compiler) know that we aren’t going to mutate Container<T>
instances, we
can add the out
modifier to our generic type declaration, leaving it like this:
class Container<out T>(val item: T)
Our example, composed of the first two code samples together, will now compile.
Let’s define more formally what’s covariance then:
Covariance is enabling the compiler to treat
Structure<DerivedType>
as if it were a subclass ofStructure<BaseType>
. Covariance for a generic type parameter can only be enabled if it’s only used for returns and/or immutable properties.
This of course is far from an academic explanation of covariance, but it better reflects what happens in practice. If we wish to be more specific: returns and immutable properties are described as out positions by the Kotlin compiler. You can mark a type parameter as covariant if it’s only used in out positions.
Contravariance
Covariance makes sense, no? After all, if we have something that requires a
Structure<Person>
, then it’s logical that it should take a Structure<Astronaut>
, as
Astronaut
is a Person
, so every operation that could be done in a Person
is also
available in Astronaut
.
Contravariance is the opposite of that. It’s about using a Structure<Person>
where a
Structure<Astronaut>
is expected. It throws out of the window any intuition we got from
our existing OOP studies. We clutch our books filled with cute animal taxonomies, we pray
to Craig Larman (but hear no response), and we finally surrender to our fear of the unknown.
It makes no sense!, you say in anger. How is that can we use a Person in place of Astronaut?.
The first step is stop thinking of it as if Person
subclassed Astronaut
, because
that makes no sense, and we both know it. Variances aren’t about the type parameters
themselves, but about the structures that have those type parameters.
As we said, contravariance is about how Structure<Person>
can be used in place of
Structure<Astronaut>
if Structure
is defined as Structure<in T>
.
Person
cannot be used in place of Astronaut
by itself.
Study the following example:
// https://kotlinlang.org/docs/reference/generics.html
interface Comparable<in T> {
operator fun compareTo(other: T): Int
}
fun demo(x: Comparable<Number>) {
x.compareTo(1.0) // 1.0 has type Double, which is a subtype of Number
// Thus, we can assign x to a variable of type Comparable<Double>
val y: Comparable<Double> = x // OK!
}
Let’s walk through it together, step by step
Comparable<in T>
only consumes T, using it as a parameter for it’s only member functioncompareTo
.
It is declared contravariant, noted by the in
keyword. This is only posible because it
doesn’t produce T. Trying to declare, let’s say, a multiplyByTwo(): T
function would
cause a compilation error.
We’ll cover why this is relevant later on.
Double
is aNumber
Any operation that can be done in a Number
can also be done in a Double
.
The function
compareTo(other: Number)
can also be called with aDouble
, and in fact that’s what’s done inx.compareTo(1.0)
.
Basic polymorphism, nothing new here.
Therefore,
Comparable<Number>
can be used in any place where aComparable<Double>
is required.
And here’s the fucker.
Comparable<Number>
is able to treat Double
s as Number
s as the former derives from
the latter, and therefore whenever we required a Comparable<Double>
we could just pass a
Comparable<Number>
to do the job. Therefore contravariance. QED.
But why can’t we return T things in a contravariant data type?
Let’s suppose we were able to have the following (we can’t; it doesn’t even compile):
class MutableList<in T> {
fun add(entity: T): Unit
fun getAll(): List<T>
}
Then, in a stroke of genius:
val personList = MutableList<Person>()
personList.add(alexis)
val astronautList: MutableList<Astronaut>() = personList // contravariance OK
val astronauts: List<Astronaut> = astronautList.getAll() // oopsie woopsie!
You can’t ensure that getAll
will be able to return instances of the derived type.
In fact, there’s no reason to think that MutableList<Person>.getAll()
would return
List<Astronaut>
. Even if for some reason it actually returned instances of
Astronaut
, they’re still typed Person
due to the return type. You would need to do
some UGLY upcasting in that case for you to be able to use them in a context that requires
Astronaut
s. AND EVEN THEN, there’s no runtime guarantee that we just didn’t add a person
instance somewhere like we did in our example.
So that’s why you can’t return anything of a contravariant generic type.
Summary
Covariance is telling the compiler that it can use Structure<DerivedType>
wherever
Structure<BaseType>
is expected by adding the out
modifier to a generic type
parameter. For this to be possible, covariant generic type parameters cannot be used in
in positions in the class or interface definition. Example in positions are as
function parameters, or as properties with a setter (i.e. mutable).
Contravariance is telling the compiler that it can use Structure<BaseType>
wherever
Structure<DerivedType>
is expected by adding the in
modifier to a generic type
parameter. For this to be possible, contarvariant generic type parameters cannot be used
in out positions in the class or interface definition. Example out positions are as
return values, or as properties with a getter.