Kotlin Generics and Variance compared to Java

s1m0nw1
4,179 views

Open Source Your Knowledge, Become a Contributor

Technology knowledge has to be shared and made accessible for free. Join the movement.

Create Content

Disclaimer: My articles are published under "Attribution-NonCommercial-NoDerivatives 4.0 International (CC BY-NC-ND 4.0)".

© Copyright: Simon Wirtz, 2017

Feel free to share.

Generic Types and Variance in Kotlin compared to Java

Basics - What is Variance?

Many programming languages support the concept of subtyping, which allows us to implement hierarchies like "A Cat IS-An Animal". In Java we can either use the extends keyword in order to change/expand behaviour 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 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 types, 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 overriden 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

Subclasses can be cast up the inheritance tree, while downcasting will cause an error here. 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 could image another programming language that allows contravariant method arguments in overriding methods (In Java such a method would be treated as overloaded). Let's say we have a class ClassB that extends 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. It can be observed 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 an aspect like "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
Arrays in Java are covariant in their type, which means an array of Strings can be assigned to a variable of type "Array of Object".

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

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

1
2
3
4
5
6
7
8
9
10
import java.util.Arrays;
public class Main {
public static void main(String[] args) {
Object [] arr = new Object [2];
arr[0] = 1;
arr[1] = "2";
System.out.println(Arrays.toString(arr));
}
}
XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX

This seams to be quite handy but can cause errors at runtime. Consider the example above: 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 paramter is expected to be of type Object []. It will cause an ArrayStoreException at runtime, easily shown here:

ArrayStoreException-Example

1
2
3
4
5
6
public class Main {
public static void main(String[] args) {
Object [] arr = new String [] {"hello", "world"};
arr[1] = new Object();//will throw Exception; java.lang.ArrayStoreException: java.lang.Object
}
}
XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX

Generic Collections
As of Java 1.5 we can use Generics in order to tell the compiler which elements are supposed to be stored in our collections (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 are much more inflexible regarding subtyping of collections.

Fortunately, the user can specify the variance of type parameters when using generics; a process being called use-site variance.

  1. Covariant collections

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import java.util.*;
abstract class Animal {
}
class Cat extends Animal {
}
public class Main {
public static void main(String[] args) {
List<Cat> cats = new ArrayList<>();
cats.add(new Cat());
List<? extends Animal> animals = cats;
System.out.println("List contains: " + animals);
}
}
XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX

Anyways, such a covariant list is still different to an array, because the covariance is encoded in its type parameter. We can only read from the list, whereas adding is prohibited. The list is said to be a Producer of Animals. The generic type ? extends Animal (? is the "wildcard" character) only indicated that the list contains any type with an upper bound of Animal, which could mean list of Cat, Dog or any other animal. This approach turns the runtime error encountered in ArrayStoreException-Example into a compile error.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import java.util.*;
abstract class Animal {
}
class Cat extends Animal {
}
public class Main {
public static void main(String[] args) {
List<Cat> cats = new ArrayList<>();
List<? extends Animal> animals = cats;
animals.add(new Cat()); //will not compile
}
}
XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
  1. Contravariant collections

It is also possible to work with contravariant collections. Such a list can be declared with the generic type parameter ? super Animal, which means a lower bound of type Animal. Such a list may be of type List<Animal> itself or a list of any super type of Animal, even Object. Like with covariant lists, we do not know for sure which type the list really represents (indicated by the wildcard again). The difference is, we can not read from a contravariant list, since it is unclear if we will get Animals or just plain Objects. But now we can write to the list as we know that at least Animals may be added. This allows us to safely add Cats as well as Dogs. Such a list is said to be a Consumer.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import java.util.*;
abstract class Animal {
}
class Cat extends Animal {
}
class Dog extends Animal {
}
class Main {
public static void main(String[] args) {
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
}
}
XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX

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

Variance of collections types in Kotlin

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 point. Kotlin is different to Java regardings generics and also arrays in a few ways and it might look odd to an experienced Java developer at first glance.

The first and maybe 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. But is there a way to safely work with subtyped arrays? Sure, there is - we'll look at it next.

Declarion-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" (see http://kotlinlang.org/docs/reference/generics.html#type-projections). The whole thing is called "use-site variance". Kotlin does not use these at all. Instead, in Kotlin we use declarion-site variance. Let's recall the initial problem again: Let's imagine, we have a class ReadableList<E> with one simple producer method get():T. Java prohibits to assign 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 everthing works fine. Kotlin approaches this problem in a different way. The type T can be marked as 'only produced' with the out keyword, so that the compiler instantly gets it: ReadableList is never gonna consume any T, which makes T covariant.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
abstract class ReadableList<out T> {
abstract operator fun get(i: Int): T
}
fun workWithReadableList(strings: ReadableList<String>): Any {
val objects: ReadableList<Any> = strings // This is OK, since T is an out-parameter
return objects[0]
}
fun main(args: Array<String>) {
class MyRl : ReadableList<String>() {
override fun get(i: Int): String = "simpleImpl"
}
println(workWithReadableList(MyRl()))
}
XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX

As you can see in the snippet, the type T is annotated as an out type via declaration-site variance - it is also referred to as variance annotation. The compiler does not prohibit us to use T as an covariant type. Of course there is also a complementary annotation to mark generic types as consumers, i.e. makes them contravariant: in. This approach has also been used in C# sucessfully for some years already.

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

Use-site Variance, Type projections

Unfortunately, it is 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 co- or contravariant in its type parameter. As an alternative Kotlin also allows use-site variance, which is very similar to Java 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>
1
2
3
4
fun copy(from: Array<out Any>, to: Array<Any>) {
to[1] = from[1]//all right
from[1] = to[1]//does not compile because it's out-projected
}
XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX

This example shows how from is declared as a producer of its type and thus the 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.

Bottom line

With this post I wanted to provide same basic information on the quite complex aspects of variance in the context of generics. Mostly, Java was used to demonstrate the concepts of co-, contra- and invariance, which are hard to understand in connection with Java's wildcard types. 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 generics compared to Java and also eliminates another problem, which is covariant arrays. Allowing declaration-site variance simplifies a lot of client code where it's not necessary to use complex declarations like we're used to in Java. Also even if we have to fall back on use-site variance its a bit simpler.

If you like Kotlin and want to learn even more about it, I seriously like to suggest the fantastic book Kotlin in Action to you. Also, if you like to get in touch with me, feel free to contact me on my homepage or visit me on Twitter.

Have fun and best of luck with Kotlin!

Open Source Your Knowledge: become a Contributor and help others learn. Create New Content