Understanding Generics and Variance in Kotlin
One of the biggest selling points of Object-Oriented Programming languages is inheritance.
It allows us to do things like assigning a subclass instance to superclass:
Integer integer = new Integer(1);
Number number = integer;
And thanks to it we can write our code in a more abstract way by declaring a method:
void printNumber(Number number)
Which we can use with both types:
printNumber(integer);
printNumber(number);
Unfortunately, with generics in Java it is not always so straightforward:
List<Integer> integerList = new ArrayList<>();
List<Number> numberList = integerList; // Compiler error
To truly understand generics, first, we need to understand three main concepts:
- class vs type
- subclass vs subtype
- variance: covariance, contravariance and invariance
Class vs type
You might not have thought about classes and types as distinct concepts.
In Java and Kotlin all classes have at least one type that is the same as the class, for example, an Integer
is a class and at the same time a type.
On the other hand, in Kotlin we also have nullable types, consider String?
` we can’t really say that String?
is a class, because notionally, it is still a String
.
Another example in Java and Kotlin is List. List
is a class, but List<String>
is not a class, it’s a type.
The following table summarizes these examples:
Subclass vs subtype
For a class to be a subclass of another class, it needs to inherit from it. For example, Integer
inherits from Number
, so Integer
is a subclass of Number
.
That also means that we can make the following assignment:
Java:
Integer integer = new Integer(1);
Number number = integer;
Kotlin:
val integer: Int = 1
val number: Number = integer
This is possible because Integer
is a subtype of Number
.
But let’s try the same with nullable type:
val integer: Int = 1;
val nullableInteger: Int? = integer;
Here Int
is a subtype of nullable Int
so we can assign Int
to Int?
.
Variance
First, let’s define a few Kotlin classes:
abstract class Animal(val size: Int)
class Dog(val cuteness: Int): Animal(100)
class Spider(val terrorFactor: Int): Animal(1)
Now let’s check if Dog
and Spider
are subtypes of Animal
:
val dog: Dog = Dog(10)
val spider: Spider = Spider(9000)
var animal: Animal = dog
animal = spider
It works nicely: no compiler error. Try it for yourself!
Covariance
Let’s use some more complex types by wrapping our types into generic List
s.
One important thing to remember, this list is an immutable List
from Kotlin: you are not able to modify its contents after you create it. You will find out why this matters shortly.
val dogList: List<Dog> = listOf(Dog(10), Dog(20))
val animalList: List<Animal> = dogList
Variance tells us about the relationship between List<Dog>
and List<Animal>
where Dog
is a subtype of Animal
.
In Kotlin, dogList
can be assigned to Animal
list (val animalList: List<Animal> = dogList
) so the type relation is preserved and List<Dog>
is a subtype of List<Animal>
. This is called covariance.
Invariance
In Java, even though Dog
is a subtype of Animal
, you cannot do the following:
List<Dog> dogList= new ArrayList<>();
List<Animal> animalList = dogList; // Compiler error
This is because generics in Java ignore type vs subtype relation between its components. In the case when List<Dog>
cannot be assigned to List<Animal>
nor vice versa, this is called invariance. There is no subtype to supertype relationship here.
Contravariance
Perhaps we want to compare our animals, that’s why we can create an interface Compare<T>
, with a method compare(T item1, T item2)
and that method can say which item is first and which item is second.
Whenever we compare dogs we look how cute the dogs are, here is code for comparing dogs:
val dogCompare: Compare<Dog> = object: Compare<Dog> {
override fun compare(first: Dog, second: Dog): Int {
return first.cuteness - second.cuteness
}
}
What will happen if we would try to assign this compare mechanism to animal comparator:
val animalCompare: Compare<Animal> = dogCompare // Compiler error
There is a really good reason why this does not work. If it worked, we would be able to pass spiders to animalCompare
, but this would be an error because dogCompare
can only compare dogs and not spiders.
On the other hand, if we would have a way to compare all the animals, that mechanism ought to work for dogs and spiders:
val animalCompare: Compare<Animal> = object: Compare<Animal> {
override fun compare(first: Animal, second: Animal): Int {
return first.size - second.size
}
}val spiderCompare: Compare<Spider> = animalCompare // Works nicely!
We can see that Spider is a subtype of Animal
, but Compare<Animal>
is a subtype of Compare<Spider>
— the type relation is reversed. It’s also known as contravariance.
Java vs Kotlin
Thankfully in Java, we are able to make generics covariant or contravariant.
Java
To make a generic type covariant you just need to write:
List<Dog> dogs = new ArrayList<>();
List<? extends Animal> animals = dogs;
extends
makes the relation between animals and dogs list types covariant.
For contravariant you can write:
Compare<Animal> animalCompare = (first, second) -> first.getSize() — second.getSize();
Compare<? super Spider> spiderCompare = animalCompare;
super
makes animal and spider compare contravariant.
This way of creating type variance at their point of use is called use-site variance.
Kotlin
In Kotlin there is no magic (at least officially proven), it also has a way to define generics to be covariant or contravariant.
Out
If you look into the definition of the immutable list in Kotlin you will see this line:
interface List<out E> {
fun get(index: Int): E
}
The out
keyword says that methods in a List
can only return type E
and they cannot take any E
types as an argument.
This limitation allows us to make List
covariant.
in
When you check the definition of Compare
you can see something different:
interface Compare<in T> {
fun compare(first: T, second: T): Int
}
In this case, there is in
keyword next to the parameter.
This means that all methods in Compare
can have T
as an argument but cannot return T
type.
This makes Compare contravariant.
In Kotlin, the developer that writes the class declaration needs to consider the variance and not the developer who uses the code. This is why it is called declaration-site variance.
Now you should know what kind of generic class you need to declare(otherwise ask me in the comments).
If you’d like to check out the whole examples, here you can find them.
In case you want to learn more about generics, check out Kotlin’s documentation page. I also highly recommend book Kotlin in Action which is written by JetBrains people.