Kotlin Generics and Variance (Compared to Java)

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 their type of variance. 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 subtyping, which allows implementing hierarchies that represent relationships like “A Cat IS-An Animal“. In Java, we can either use the extends keyword in order 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 subtyping 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 downcasting 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, this concept only reveals itself when we work with generics, which I’m going to describe in-depth later. Just to make it clear, we can imagine another programming language that allows contravariant method arguments in overriding methods [1]. 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::method(t: T')

ClassB::method(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, method 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 like we’ve just seen in the example before. An overriding method must accept just the same parameters as the overridden method. This means 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 [].

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

Also, 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";

This seems to be quite handy but can cause errors at runtime. Looking at the example Array Variance again: The variable is of type Object [] but the referenced object is a 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 []. It will cause an ArrayStoreException at runtime, easily shown here:

Array Runtime Error
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 in order to inform the compiler which elements are supposed to be stored in a particular collections instance (i.e. List, Map, Queue, Set). 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 like it is when working with covariant arrays. 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;

Such a covariant list is still different from an array because the covariance is encoded in its type parameter. 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 [2] 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 in Array Runtime Error into a compile error:

List<Cat> cats = new ArrayList<>();
List<? extends Animal> animals = cats;
animals.add(new Cat()); //will not compile
Contravariant collections

It is also possible to work with contravariant collections, which can be declared with the generic type parameter ? super Animal (lower bound of type Animal). Such a list 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 if we will get Animals or just plain Objects. Writing to the list is permitted though since we know that at least Animals can safely be added. This makes adding Cats as well as Dogs possible. The list is acting as a Consumer.

List<Animal> animals = new ArrayList<>();
List<? super Animal> contravariantAnimals = animals;
contravariantAnimals.add(new Cat());
contravariantAnimals.add(new Dog());
Animal pet = contravariantAnimals.get(0); // will not compile

Joshua Bloch created a rule of thumb in his fantastic book Effective Java:
“Producer-extends, consumer-super (PECS)”

 

Variance of Kotlin Generics

After we’ve seen what variance in general means and how Java makes use of these concepts, I’d like to get to this blog post’s main part. 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 easiest difference is: Arrays in Kotlin are invariant. This means, as opposed to Java, it is not possible to assign an Array<String> to a reference variable of type Array<Object>. This ensures compile-time safety and prevents runtime errors like you may encounter in Java with its covariant arrays. Still, is there a way to safely work with subtyped arrays? Sure, there is – we’ll look at it next.

Declaration-site Variance

As we’ve seen, 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” [3]. The term “use-site variance” describes the approach. 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 in a different way. The type T can be marked as only produced with the out keyword so that the compiler immediately understands: ReadableList is never going to consume any T, which makes T covariant.

Kotlin out
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 in “Kotlin out” 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. Of course, there is also a complementary annotation to mark generic types as consumers, i.e. makes them contravariant: in.

People have been using the presented approach C# successfully for some years already.

The Kotlin rule to memorize: Producer out, Consumer in (POCI)

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>
  • Array<out String> corresponds to Java’s Array<? extends Object>
Type projection Example
fun copy(from: Array<out Any>, to: Array<Any>) {
 // ...
}

The previous example shows how from is declared as a consumer 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. Read about further details in the Kotlin documentation.

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 in order to ensure type safety. Sometimes though, it’s good to be able to retrieve the actual 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). The concept of reified types has been explained in this article.

Bottom line

In this article, I p basic information on the quite complex aspects of variance in the context of generics. Mostly, Java was used to demonstrate the concepts of covariance, contravariance, and invariance. I’ve shown how Kotlin tries to simplify the whole thing using different approaches (declaration-site variance) and more obvious keywords (in, out).

In my opinion, Kotlin really improves and simplifies the usage of generics. In addition, it eliminates the problem of covariant arrays. Declaration-site variance simplifies client code a lot by liberating it from using complex declarations like we do to in Java. Also, even if we have to fall back on use-site variance its made a bit simpler in Kotlin.

More features of the fantastic Kotlin language are described in my other posts like the one on Vert.x. If you want to read about this topic in more detail, I highly recommend the book Kotlin in Action to you!


1. In Java, we call such a method overloaded
2. ? is the “wildcard” character

Please follow and like this Blog 🙂

One Reply to “Kotlin Generics and Variance (Compared to Java)”

Leave a Reply

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