
Before jumping into variance, let’s quickly take a look at how generic classes look in Kotlin
class Box<T> {
fun wrap(item: T) {
println("$item is now wrapped in a box")
}
}
Here, we have a class Box
with a single function, wrap
. Wrap
accepts an argument of type T. T represents a generic type. When creating an object of the Box class, we must define what class T represents for that instance. Once a box is created for a particular type, the wrap function will expect the item to be of that class. For example, if T = String:
val stringBox = Box<String>()
Box class effectively becomes
class Box {
fun wrap(item: String)
}
Similarly, if T = Float
val floatBox = Box<Float>()
Box class evaluates to
class Box {
fun wrap(item: Float)
}
This is why generic types are an important language feature. It allows us to write functions and classes without being tied to any particular class, yet it provides type safety at run time.
Variance
Now that we know what generic types are and when they can be used, we can look at variance in Kotlin.
Contravariant (in)
Let’s say we have the following classes:
interface Fruit {
fun getColor(): String
}
class Apple: Fruit {
override fun getColor(): String {
return "Red"
}
}
Now we declare a generic interface Eater
which can eat items of type T
interface Eater<T> {
fun eat(food: T) {}
}
With this interface, let’s create a fruit eater
val fruitEater = object: Eater<Fruit> {
override fun eat(food: Fruit) {
println("Eating ${food.getColor()} color fruit")
}
}
Now, if we try to assign a fruit eater to a variable that expects an apple eater
val appleEater: Eater<Apple> = fruitEater
It will give a compiler error saying Required Apple, found Fruit
.
This basically means it was expecting an eater who could eat apples, but we gave it an eater who could eat fruits. This sounds incorrect. If an eater can eat (all) fruits, and an apple is a fruit, it should also be able to eat an apple as well, right?
To fix this, let’s make a small change to the Eater
interface
interface Eater<in T> {
fun eat(food: T) {}
}
We add in
operator in front of T to mark it as contravariant on type T. This means that type T can only be consumed and not produced in this interface. When the compiler has this information about T, it can infer that we are only consuming variables of type T, so it is safe to use in places where subclasses of T are expected.
Going back to our example, our Eater
interface has one function that takes in a parameter of type T. Nowhere do we produce objects of type T. Hence, we can safely mark our interface Eater
as contravariant on type T. Eater<Fruit>
now means we are only consuming fruits and that makes it compatible with Eater of all Fruit’s subclasses. This makes the following statement compile
val appleEater: Eater<Apple> = fruitEater
Let us try assigning an apple eater to a variable of type fruit eater
val fruitEater: Eater<Fruit> = appleEater
The above statement won’t compile. Since apple eater can only consume apples and not any other fruit, we cannot assign it to a variable of type Eater<Fruit>
.
To summarize, if a class or interface is contravariant on type T (marked as <in T>
), it means it expects (or consumes) an object of type T. In the example above, interface Eater<in T>
means
If T = Apple, it can only consume apples, so it can’t be assigned to a variable that expects a fruit consumer as not all fruits are apples.
If T = Fruit, it can consume any fruit, so it can be assigned to a variable that expects an apple consumer as all apples are fruits.
Job Offers
Invariant
For this third and final type of variance, let’s consider this interface
interface Basket<T> {
fun put(item: T)
fun pick(): T?
}
This interface has two methods, one which returns an item of type T and the other which accepts an input of type T. If we try to create a basket of type Fruits, it would look like
class FruitBasket : Basket<Fruit> {
private val fruits = mutableListOf<Fruit>()
override fun put(item: Fruit) {
fruits.add(item)
}
override fun pick(): Fruit? {
if (fruits.isEmpty()) {
return null
}
return fruits.removeAt(fruits.lastIndex)
}
}
If we try to assign a variable of type FruitBasket
to a variable of type AppleBasket
val appleBasket: Basket<Apple> = FruitBasket()
This does not compile, giving an error Required Basket<Apple>, found FruitBasket
. In this case, it cannot be fixed because an apple basket not only consumes apples but produces them as well. A fruit basket can accept (via the put method) apples, but it cannot guarantee to produce just apples as it can produce any fruit.
val fruitBasket: Basket<Fruit> = AppleBasket()
On the flip side, we cannot assign an appleBasket to a variable of type Basket<Fruit>
because fruitBasket expects any type of fruit to be added, not just apples.
Since Basket
produces and consumes an item of type T
, it is invariant on type T
. When we specify neither in
nor out
for a generic type, it is treated as an invariant by default.
If you made it this far, hopefully, variance became a friendlier topic than what it was before. For me, personally, I had read about variance multiple times but this only clicked when I was able to establish a mental model for myself.
In this post, I tried to paint a picture of that mental model. If this did not click for you, I would urge you to think in terms of producers and consumers and build a model which you can relate to. Once that happens, it should become a lot less intimidating.
This article is previously published on proandroiddev.com