Understanding Generics in Kotlin

Generics is a powerful tool, but it often seems confusing. In this article, we’ll try to explain how to use it in Kotlin.

What is Generics?

Generics in Java or Kotlin is similar to templates in C++. It enables types to be used as parameters when defining classes, interfaces, and methods. Compared to non-generic code, Generics provides compile-time type safety checks, and also makes it easier to implement generic algorithms.

The follow code snippet shows the basic usage of Generics:

// defines a generic class Box with a type variable T
class Box<T> {
    // defines a variable, t, of type nullable T
    private var t: T? = null

    fun get(): T? = t

    fun set(t: T) {
        this.t = t
    }
}

// to use it, we need to specify the type variable to be e.g. Int
val boxOfInt: Box<Int> = Box()
boxOfInt.set(5) // won't compile if passing e.g. a string
val i = boxOfInt.get() // type of i is known to be Int? at compile time

Upper Bounds

We can also specify an upper bound to limit the type accepted, e.g.:

class Box<T : Number> {
    ...
}

It means that the type must be either Number or a subclass of it. If no upper bound is specified, the default bound is Any?.

We can also specify the upper bound to be more than one type using where clause, e.g.:

class Box<T> where T : Number, T : AutoCloseable {
    ...
}

It means that the type T must extend Number and implement AutoCloseable. Obviously, there can be at most one of the upper bounds can be a class.

Type Erasure

One thing to note is that the type information is only available at compile time. At runtime, JVM does not hold any information about the actual type argument. This is called type erasure.

For example, JVM can’t distinguish between MutableList<String> and MutableList<Int>, so the follow code actually works:

val strings = mutableListOf<String>()
val unsafeCast = strings as MutableList<Int> // compiler just throws an unchecked cast warning
unsafeCast.add(666)

However, this is dangerous, because it will crash with a ClassCastException when we try to get the element from strings: we expect a String, but get an Int. This is why Effective Java suggests us to eliminate all unchecked cast warnings.

Variance

Now let’s take one step further. Think about the following two lines of code:

val strings = mutableListOf<String>()
val objs: MutableList<Any> = strings

At first glance, this might look reasonable: String is subclass of Any, so MutableList<String> should be subclass of MutableList<Any>.

However, we will still end up with the same problem:

objs.add(666)
val str: String = strings[0]

Here, we’re trying to add an integer to objs, which is actually a list of strings. Then when fetching it, a runtime exception will be thrown.

To solve this problem, generic types are invariant, meaning MutableList<String> is NOT a subclass of MutableList<Any>. Therefore, we can’t assign strings to objs, and the code won’t compile.

However, this comes with a price. Think about a simple addAll() method:

interface MutableCollection<E> {
    fun addAll(c: MutableCollection<E>);
}

It looks correct, but the following code, which is perfectly safe, doesn’t compile, because List<String> is not a subclass of List<Any>:

val objs = arrayListOf<Any>()
val strings = arrayListOf<String>()
objs.addAll(strings)

To solve this problem, Kotlin provides declaration-site variance and use-site variance.

Declaration-site Variance

As the name suggests, with declaration-site variance, we annotate how the type parameter is used when declaring it.

For example, Kotlin’s Collection interface is annotated as below:

interface Collection<out E> {
}

Here, the out modifier indicates that the class only produces elements of type E, but never consumes them. In another word, clients can safely read elements of type E from the class, but cannot write to it. This is called covariant.

In practice, it means that e.g. Collection<String> is a subclass of Collection<Any>. Therefore, we can get the addAll() method working by changing the parameter type to Collection<E>:

interface MutableCollection<E> {
    fun addAll(c: Collection<E>)
}

There’s also the complementary variance annotation, in. It makes the type contravariant, meaning it can only be consumed, but never produced by the class, e.g.:

interface Comparable<in T> {
    operator fun compareTo(other: T): Int
}

In Kotlin, we call this Consumer in, Producer out.

Use-site Variance

However, it’s not always possible to annotate the type parameter when declaring it, such as MutableList<E> or Array<T>, because they can both produce and also consume the generic type. In this case, we need to annotate when using them, thus called use-site variance.

For example, we can define a copy function like below:

fun copy(src: Array<out Any>, dest: Array<Any>) {
    ...
}

Here, src is no longer a normal array, but projected to be a “producer” array. It means that we can only read from it, but cannot write to it. This technique is called type projection.

Star Projection

There are also cases when we do not know anything about the type argument, e.g. when dealing Java’s raw types. Kotlin provides star projection for us to use it in a safe way:

  • For Foo<T : Upper> and Foo<out T : Upper>, using Foo<*> for reading is equivalent to Foo<out Upper>. It means that we can safely read values of type T from Foo<*>.

  • For Foo<T : Upper> and Foo<in T>, using Foo<*> for writing is equivalent to Foo<in Nothing>, meaning we cannot write values to it.

Conclusion

Hopefully, this article could give you enough idea about Generics in Kotlin, especially variance.


See also