DEV Community

Sendil Kumar
Sendil Kumar

Posted on • Updated on • Originally published at sendilkumarn.com

Kotlin - Generics & Type variance

Programming is all about abstractions.

Instead of instructing machines with binaries (1 and 0s), we created higher level abstractions. These higher level languages (abstractions) make it easy for us to instruct the machines.

During execution, machines should allocate and deallocate the memory. We added types in abstraction to control how to allocate and de-allocate memory.

With Types, it is easy to read, understand, and debug. Types allow compilers to allocate the right memory and ensure there are no runtime surprises.

Then we created statically typed languages. In a statically typed language, the compilers force you to have concrete types (either referenced or primitive) in (almost) every expression.

Note: Primitive types are types like Int, Float, Character, etc., and Referenced types are those that are created by you.

✨ Types are awesome right ✨

But what if I need to implement List for both Integer and String?

You can create two separate implementation for both Integer and String. But it creates redundant code that is harder to maintain and debug.

Generics will help you here.

Generics

Generic programming is a style of computer programming in which algorithms are written in terms of types to-be-specified-later that are then instantiated when needed for specific types provided as parameters. - Wikipedia

Generics add one more level of abstraction. With Generics, we can write a piece of code that is shared across various types.

Generics prevents us from writing repetitive code for every single type that we need to implement. We provide some generic type information to the compiler. The compiler (or) runtime will do the rest.

In some lanaguages like C++, the compiler will expand the Generic code with the necessary type information. This code generation happens during compilation.

For example if you have defined a class List with a Generic type T and used List for String and Integer. Then the compiler will generate a separate class for both String and Integer.

But why T? T is generic way to define the type. You can very well use any letter there. Oh! hey, T for Type.

✅ The generated code will have higher performance(⚡️), because the runtime knows exactly what to expect and how to deal with the types.

✅ There is no need for the runtime to unbox the value when consuming it and box it while returning it.

💥 Since there will be separate class for each type, this method will generate bloated code.

On the other hand in JVM languages, the compiler erases the type information completely.

The mechanism that Java uses is called Type Erasure.

Read more about Type Erasure here

The compilers will generate a class with no type information in it. Then JVM in runtime uses cast to get and set the value.

Thus the same class is used for both String and Integer.

✅ This leads to lesser code bloat and backward compatibility.

💥 On the other hand it leads to heap pollution and lesser performance.

In order to handle this, JVM provides cast iron guarantee when defining a generics. Thus generics in Java are invariant by default.


Suppose Class Animal is a superType of Class Dog.

open class Animal

class Dog: Animal()
Enter fullscreen mode Exit fullscreen mode

Note: in order to extend the Animal class we need to open the animal class. In Kotlin, by default classes are final.

interface List<T> {
    fun getAll(): List<T>
}
Enter fullscreen mode Exit fullscreen mode

Then we have a generic List interface defined. The interface has one method getAll.

Now let us create AnimalList and DogList classes that implements the generic interface defined.


class AnimalList: List<Animal> {
    override fun getAll(): List<Animal> {
        TODO("not implemented")
    }
}

class DogList: List<Dog> {
    override fun getAll(): List<Dog> {
        TODO("not implemented")
    }
}

Enter fullscreen mode Exit fullscreen mode

Covariance with out

Eventhough Animal is the parent class of Dog, List<Animal> is not a parent class of List<Dog>.

fun main() {
    val dogList = DogList()
    val d: List<Animal> = dogList.getAll() // 💥 Type mismatch
}
Enter fullscreen mode Exit fullscreen mode

The compiler will not allow us to use subType in place of superType. That is the types Animal and Dog are not covariant.

In Kotlin, we can inform the compiler to accept subType in place of superType with an out keyword.

interface List<out T> {
    fun getAll(): List<T>
}
Enter fullscreen mode Exit fullscreen mode

The out keyword tells the compiler that Animal and Dog are covariant types and it is okay to use them interchangably.

Now this will work,

fun main() {
    val dogList = DogList()
    val d: List<Animal> = dogList.getAll() // ✅
}
Enter fullscreen mode Exit fullscreen mode

Use out for immutable types to avoid nasty runtime errors.

Contravariance with in

Contravariance is the opposite of Covariance. Contravariance allows us to use superType in place of subType.

Consider the following class Inventory. The Inventory class accepts an item of type T.

class Inventory<T> (item: T) 
Enter fullscreen mode Exit fullscreen mode

We have Shop that holds the Inventory.

class Shop<T> (items: Inventory<T>) 
Enter fullscreen mode Exit fullscreen mode

Consider that we have two shops, Animal and Dog Shop. Then for some reason, the animal shop decides to sell all their dogs to dog shop.

But the dogs in the AnimalShop are still tagged inside Inventory<Animal>.

If we want Shop<Dog> to accept all the Animal from the Shop<Animal>, then

fun main() {
    val animal = Animal()
    val dog = Dog()

    val animalList = Inventory<Animal>(a)
    val dogShop = Shop<Dog>(animalList) // 💥 Type error
}    
Enter fullscreen mode Exit fullscreen mode

But we are trying to use superType in place of subType, the compiler throws a type error.

We can instruct the compiler to accept the superType in the place of subType with an in keyword in the type definition.

class Inventory<in T> (item: T) 
Enter fullscreen mode Exit fullscreen mode

This will tell the compiler to use the superType in the place of subType (i.e., Animal in the place of Dog).

A nice quote that can help remember these rules is: be liberal in what you accept and conservative in what you produce.


Types of Type variance

The type that we pass in can either be a subType, superType.

There are following types of type variance in general:

  1. Covariant - This allows using superType in place of subType.
  2. Contravariant - This allows using subType in place of superType.
  3. Bivariant - covariant and contravariant.
  4. Invariant - neither covariant nor contravariant.

Check out this post on how to generate a Full Stack application with Kotlin, React and Spring Boot using KHipster here.

Wondering what is KHipster - check out here.

You can follow me on Twitter.

If you like this article, please leave a like or a comment. ❤️


Interested to explore further:


Top comments (2)

Collapse
 
temnur profile image
temnur • Edited

Thanks for post.
Instead of here:

val d: Animal = dogList.getAll() // 💥 Type mismatch

I waited for:

val d: List<Animal> = dogList.getAll() // 💥 Type mismatch
Collapse
 
sendilkumarn profile image
Sendil Kumar

oops you are correct, I guess I didnt update the code.