Covariance, Contravariance, and Invariance — What do they mean? (Part 2)
In Kotlin, Java; and a little bit of generics too.
In the first part of this post, I gave a brief explanation and provided some examples on the different variance type systems. As promised, in this post we will go deeper into this topic. We will find out what these actually imply in the Java and Kotlin type systems.
To do this, I will set up a simple inheritance structure, just like we had before.
We will explore four different scenarios for each of the variance types, where it makes sense to: assignment statements, method overriding, arrays and generic collections.
Let’s begin.
Covariance in Java and Kotlin
Assignment statement: Variable assignment is covariant in nature in both Java and Kotlin. Therefore, the following Java lines in the java snippet are valid:
GrandParent grandParent = new Parent();
GrandParent anotherGrandParent = new BabyChild();
Parent parent = new BabyChild();
The same is valid for the Kotlin snippet too:
val grandParent: GrandParent = Parent()
val anotherGrandParent: GrandParent = BabyChild()
val parent: Parent = BabyChild()
Method overriding: The behaviour for both languages are the same in this case too. The return types are both covariant and the method parameters are invariant. Consider this snippet:
Arrays: There is a difference in approach in this case for Java and Kotlin. Java goes the route of covariance while Kotlin sticks with invariance here. Consider the following snippet which are considered valid in Java:
// Valid statements
GrandParent[] grandParents = new BabyChild[10];
GrandParent[] anotherGrandParents = new Parent[10];
Parent[] parents = new BabyChild[10];// Side comment: The above snippets can be a pain because of the
// potential ArrayStoreException that can happen when one tries to
// add a new item to the collection that is incompatible with
// the type that the collection was initialised with. The following
// line will cause a crash at runtime.grandParents[1] = new Parent();// It will compile but it will crash at runtime because you can't
// cast from a `Parent` to a `BabyChild`.
However, this is not the case in Kotlin:
// These are valid
val grandParents: Array<GrandParent> = arrayOfNulls<GrandParent>(10)
val parents: Array<Parent> = arrayOfNulls<Parent>(10)
val children: Array<BabyChild> = arrayOfNulls<BabyChild>(10)// These will NOT compile. Kotlin arrays are invariant.
val grandParents: Array<GrandParent> = arrayOfNulls<Parent>(10)
val grandParents2: Array<GrandParent> = arrayOfNulls<BabyChild>(10)
val parents: Array<Parent> = arrayOfNulls<BabyChild>(10)
Generic Collections in Java: To make generic collections covariant in Java, we have to explicitly specify the variance type. This is called use-site variance. Consider the following snippet:
// This will not compile. The parameterised types of collections
// are invariant by default.
List<Parent> willNotCompileParents = new ArrayList<BabyChild>();// Turn on the covariant behaviour through use-site variance.
// This list contains (or produces) `Parent` and anything that is a
// subtype of `Parent`.
List<? extends Parent> okayParents = new ArrayList<BabyChild>();
Now you might notice that there’s a potential ArrayStoreException
(or similar exception) that can happen with the covariant generic collection. We are protected from this at compile time because the compiler will prevent us from calling any method that has the parameterised type as an argument. However we, can call any method that has the parameterised type as a return type.
// This will cause a compile error.
okayParents.add(new Parent());
Within the context of generic collections, this means that the list is now effectively a read-only list. This is because the ‘add(…)’ method in the List accepts the parameterised type and will cause a compile error if someone tries to call it. We can safely say that the list is a producer of generic elements.
Generic Collections in Kotlin: The covariance approach for generic collections is handled differently in Kotlin. Kotlin has a declaration-site variance feature that enables developers to define covariant generic types preemptively, this is done during the class definition.
Unlike Java, Kotlin takes it a step forward, the compiler will not allow you to define methods in your class that have the parameterised type as an argument. If you can’t define them, you can’t call them.
If you can’t define them, you can’t call them.
If I add the following snippet into the body of the TestParameter
class, then I won’t be able to compile my code:
// From IntelliJ: "Type parameter T is declared as 'out' but occurs
// in 'in' position in type T", so it won't compile.
fun accept(t: T) {}
There’s a way to suppress this error in IntelliJ as seen with the use of the @UnsafeVariance
annotation in Kotlin’s Collections API. I would not recommend it.
// IntelliJ error goes away but this is a code smell.
fun accept(t: @UnsafeVariance T) {}
There is also a fallback use-site variance feature for Kotlin in a case where it is not possible to use the declaration-site variance feature. For example if one is using a generic collection from an API that wasn’t created by oneself.
Assuming that I’m working with the class above and it exists in a third party API. I can still make the generic class covariant by using the following snippet:
// Notice the use of the `out` keyword and how this allows me to
// assign `myVar` to subtype-parameterised instance.
val myVar: ThirdPartyGenericClass<out Parent> = ThirdPartyGenericClass(BabyChild())
We have looked at covariance in Java and Kotlin in this post, for sanity the next and final post in this series will be on contravariance and invariance in both languages.
Special thanks to this Stack Overflow answer, this medium post and this blog post. They gave me different perspectives for this series.