Kotlin Generics and Variance (Compared to Java)

This article covers the concepts of Generics and Variance in Kotlin and compares it to Java. Kotlin Generics differ from Java's in how users can define how they behave in sub-typing relationships. As opposed to Java, Kotlin allows defining variance on declaration-site, whereas Java only knows use-site variance.

Kotlin Generics - What is Variance?

Many programming languages support the concept of sub-typing, which allows implementing hierarchies that represent relationships like "A cat IS-an animal".  In Java, we can either use the extends keyword to change/expand behavior of an existing class (inheritance) or use implements to provide implementations for an interface.  According to Liskov’s substitution principle, every instance of a class A can be substituted by instances of its subtype B. The word variance, often referred to in mathematics as well, is used to describe how sub-typing in complex aspects like method return types, type declarations, generic types or arrays relates to the direction of inheritance of the involved classes. There are three terms we need to take into account: Covariance, Contravariance, and Invariance.

Variance in Practice (Java)

Covariance

In Java, an overriding method needs to be covariant in its return type, i.e., the return types of the overridden and the overriding method must be in line with the direction of the inheritance tree of the involved classes. A method treat(): Animal of class AnimalDoctor can be overridden with treat(): Cat in class CatDoctor, which extends AnimalDoctor. Another example of covariance would be type conversion, shown here:

public class Animal {}
public class Cat extends Animal {}
(Animal) new Cat() //works fine
(Cat) new Animal() //will not work

We can cast subclasses up the inheritance tree, while down-casting causes an error. This is also the case if we take a look at variable declarations. It isn’t a problem to assign an instance of Cat to a variable of type Animal, whereas doing the opposite will cause failure.

Contravariance

Contravariance, on the other hand, describes the exact opposite. In Java, we have to deal with it when working with generics, which we're going to look at later. To make it clear, we can imagine another programming language that allows contravariant method arguments in overriding methods (In Java, this would be an overloaded method instead). Let’s say we have a class ClassB which extends another class ClassA and overrides a method by changing the original parameter type T' to its supertype T.

ClassA::someMethod(t: T')
ClassB::someMethod(t: T)

You can see that the type hierarchy of method parameter t is contrary to the hierarchy of the surrounding classes. Up versus down the tree, someMethod is contravariant in its parameter type.

Invariance

Last but not least, the easiest one: Invariance. We can observe this concept when we think of overriding methods in Java again as we’ve just seen in the example before. An overriding method must accept only the same parameters as the overridden method. We speak of invariance if the types of, e.g. method parameters do not differ in super- and subtype.

Variance of collection types in Java

Another aspect we want to consider is arrays and other kinds of generic collections. Arrays in Java are covariant in their type, which means an array of Strings can be assigned to a variable of type Object [].

Object [] arr = new String [] {"hello", "world"};

But more importantly, arrays are covariant in the type that they hold. This means you can add Integers, Strings or whatever kind of Object to an Object [].

Object [] arr = new Object [2];
arr[0] = 1;
arr[1] = "2";

Covariant arrays might appear quite handy but can cause errors at runtime. A variable of type Object [] can possibly reference an object of String []. What happens if we pass the variable to a method expecting an array of Objects? This method might want to add an Object to the array, which seems legit because the parameter is expected to be of type Object []. The problem is that the caller has no idea what the method is possibly going to put into their array which possibly causes an ArrayStoreException at runtime, which we see in this simplified example:

Object [] arr = new String [] {"hello", "world"};
arr[1] = new Object(); //throws: java.lang.ArrayStoreException: java.lang.Object 

Generic Collections

As of Java 1.5, generics can be used to inform the compiler which elements are supposed to be stored in a particular collections instance (i.e. List, Map, Queue, Set, etc.). Unlike arrays, generic collections are invariant in their parameterized type by default. This means you can’t substitute a List<Animal> with a List<Cat>. It won’t even compile. As a result, it is not possible to run into unexpected runtime errors which makes generic collections safer than covariant arrays (Effective Java). As a drawback, we aren't as flexible in regards to subtyping of collections at first. Fortunately, the user can specify the variance of type parameters explicitly when using generics. We call this use-site variance.

Covariant collections

The following code example shows how we declare a covariant list of Animal and assign a list of Cat to it.

List<Cat> cats = new ArrayList<>();
List<? extends Animal> animals = cats;

A covariant list like that is still different from an array because the covariance is encoded in its type parameter, which makes it more obvious to the reader. We can only read from the list, whereas the compiler prohibits adding elements. The list is said to be a Producer of Animals. The generic type ? extends Animal (? is the "wildcard" character) only indicates that the list contains any type with an upper bound of Animal, which could mean a list of Cat, Dog or any other animal. This approach turns the runtime error encountered with covariant arrays into the preferable compile error:

 public void processAnimals(List<? extends Animal> collection){
    Animal a = collection.get(0);
    Cat c = collection.get(0); //will not compile
    collection.add(new Dog()) //will not compile
}

Now, if your invoke processAnimals with a list of Cat, as a caller, you can be sure that the function won't add anything to it. The function itself can only retrieve Animals from the collection since that's what the wildcard syntax indicates.

Contravariant collections

It is also possible to work with contravariant collections, which we declare with a generic type parameter ? super Animal (lower bound of type Animal). A list like that may be of type List<Animal> itself or a list of any supertype of Animal, even Object. Like with covariant lists, we can't know which type the list really represents (again indicated by the wildcard). The difference is, we can not read from a contravariant list since it is unclear whether we will get Animals or just plain Objects. Writing to the list is permitted though since we know that the original caller expects Animals or its supertypes which makes it possible to add any subtype of Animal. The list is acting as a Consumer of Animals:

public void addAnimals(List<? super Animal> collection) {
    collection.add(new Cat());
    collection.add(new Dog());
}

The function shown above accepts a contravariant list of Animals. As a caller of this function you could pass a List<Animal> and also List<Object> or lists of other possible supertypes of Animal. You can be sure that only animals or its subtypes will be added to your collection which means that, even after the method call, you are safe to retrieve animals from that list:

List<Animal> animals = new ArrayList<>();
addAnimals(animals);
//here we can still safely get Animals from the list, we don't care which subtype it actually has
Animal someAnimal = animals.get(0);

addAnimals(new ArrayList<Object>());

Joshua Bloch created a rule of thumb in his fantastic book Effective Java: "Producer-extends, consumer-super (PECS)" which helps memorizing the relation between covariant producers and the extends keyword as well as between contravariant consumers and the super keyword.

Variance with Kotlin Generics

After we’ve had a look at what variance in general means and how Java makes use of these concepts, we now want to take a look at how Kotlin handles it. Kotlin is different from Java when it comes to generics, also in combination with arrays, in a few ways and it might look odd to an experienced Java developer at first glance. The first and maybe the most comfortable difference is: Arrays in Kotlin are invariant. As a result, as opposed to Java, it is not possible to assign an Array<String> to a reference variable of type Array<Object>. That's great because it ensures compile-time safety and prevents runtime errors you may encounter in Java with its covariant arrays. But there still needs to be some other way to work with subtyped arrays or course which we'll look at next.

Declaration-site Variance

As shown earlier, Java uses so-called "wildcard types" to make generics variant, which is said to be [the most tricky part[s] of Java’s type system] (http://kotlinlang.org/docs/reference/generics.html#type-projections). Since the user of particular generic types has to handle the variance every time a specific type is used, we refer to it as use-site variance. Kotlin does not use wildcards at all. Instead, in Kotlin we use declaration-site variance. Let’s recall the initial problem again: Imagine, we have a class ReadableList<E> with one simple producer method get(): T. Java prohibits the assignment of an instance of ReadableList<String> to a variable of type ReadableList<Object> because generic types are invariant by default. To fix this, the user can change the variable type to ReadableList<? extends Object> and everything works fine. Kotlin approaches that issue differently. We can mark the type T as only being produced with the out keyword so that the compiler immediately understands: ReadableList is never going to consume any T, which makes it covariant in T.

abstract class ReadableList<out T> {
    abstract fun get(): T
}

fun workWithReadableList(strings: ReadableList<String>) {
    val objects: ReadableList<Any> = strings // This is OK, since T is an out-parameter
    // ...
}

As you can see, the type T is annotated as an out type via declaration-site variance - also called variance annotation. The compiler does not prohibit the use of T as a covariant type. A great example of a covariant collection in the standard library is List<T>:

val ints: List<Int> = listOf(1, 2, 3, 4)

fun takeNumbers(nums: List<Number>) {
    val number: Number = nums[0]
     nums.add(1) // add is not visible
}

takeNumbers(ints)

You can easily pass a List<Int> to a function accepting a List<Number> since List is only a producer of T and takeNumbers will never add something to the class. On the other hand, if the parameter nums was defined with type MutableList<Number>, the add would work just fine, but the caller could not pass in a List<Int> anymore and it would be evident that the method can add stuff to the list passed into it.

Of course, there is also a corresponding annotation to mark generic types as consumers, i.e., make them contravariant: in. Folks have been using the presented approach in C# successfully for some years already.

The Kotlin rule to memorize: Producer out, Consumer in

Use-site Variance, Type projections

Unfortunately, it's not always sufficient to have the opportunity of declaring a type parameter T as either out or in. Just think of arrays for example. An array offers methods for adding and receiving objects, so it cannot be either covariant or contravariant in its type parameter. As an alternative, Kotlin also allows use-site variance which we can apply using the already defined keywords in and out:

Array<in String> corresponds to Java’s Array<? super String> and Array<out String> corresponds to Java’s Array<? extends Object>

fun copy(from: Array<out Any>, to: Array<Any>) {
 // ...
}

The example shows how from is declared as a producer of its type and thus the copy method cannot do bad things like adding to the Array. This concept is called type projection since the array is restricted in its methods: only those methods that return type parameter T may be called.

Reified Types

Same as in Java, generic types are only available at compile time and erased at runtime. This is good enough for most use cases since we use generics to ensure type safety. Sometimes though, it's great to be able to retrieve the actual type information at runtime as well. Fortunately, Kotlin comes with a feature called reified types, which makes it possible to work on generic types at runtime (e.g., perform a type check). I described this feature in-depth in this stackoverflow article and also published a blog post about it.

Bottom Line

In this article, we looked at the quite complex aspects of variance in the context of generics. We've used Java to demonstrate the concepts of covariance, contravariance, and invariance and compared it to Kotlin. Kotlin tries to simplify generics using different approaches such as declaration-site variance and also introduces more obvious keywords (in, out). In my opinion, Kotlin really improves and simplifies the usage of generics and additionally eliminates the problem of covariant arrays. Declaration-site variance simplifies client code a lot by liberating it from using complex declarations as known from Java's wildcard syntax. Also, even if we have to fall back to use-site variance, the syntax appears clearer and easier to understand. I know this topic is not the simplest one, but hopefully, some aspects were made a bit clearer in this article. If you still struggle with variance and generics in Kotlin, the book Kotlin in Action is my recommended resources which you should read.

5 thoughts on “Kotlin Generics and Variance (Compared to Java)

Leave a Reply

Your email address will not be published. Required fields are marked *