Covariance, Contravariance, and Invariance — What do they mean? (Part 3)
In Kotlin, Java; and a little bit of generics too.
In the previous post, I gave a brief explanation and provided some examples on how covariance works in Java and Kotlin. As promised, in this post we will go deeper into the remaining two: contravariance and invariance. 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. We will also look into generics where necessary.
Let’s begin.
Contravariance in Java and Kotlin
Assignment statements: This is not permitted in Java and Kotlin. The compiler will flag this action as an error. Each of the following statements in the snippet will not compile in Java:
Parent parent = new GrandParent();
BabyChild babyChild = new Parent();
BabyChild anotherBabyChild = new GrandParent();
The same goes for Kotlin:
val parent: Parent = GrandParent()
val babyChild: BabyChild = Parent()
val anotherBabyChild = GrandParent()
Method overriding: In both languages, contravariance is not supported for both the parameters and the return type of an overridden method.
Arrays: In both languages, arrays aren’t contravariant at all. Each of the following statements in the snippet below will not compile in Java.
Parent[] parents = new GrandParent[10];
BabyChild[] children = new GrandParent[10];
BabyChild[] children2 = new Parent[10];
The same goes for Kotlin:
val parents: Array<Parent> = arrayOfNulls<GrandParent>(10)
val children: Array<BabyChild> = arrayOfNulls<GrandParent>(10)
val children2: Array<BabyChild> = arrayOfNulls<Parent>(10)
Generic Collections in Java: Recall that by default The parameterised types of collections are invariant. Therefore we have to utilise use-site variance to “turn on” this feature.
// This will not compile. The parameterised types of collections
// are invariant by default.
List<Parent> willNotCompileParents = new ArrayList<GrandParent>();// Turn on the contravariant behaviour through use-site variance.
// This list can accept (or consumes) `Parent` and anything that is
// a supertype of `Parent`.
List<? super Parent> okayParents = new ArrayList<GrandParent>();
Now you might notice that there’s a potential ClassCastException
(or similar exception) that can happen with the contravariant generic collections. This exception can occur when we try get an item from the 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 a return type. However, we can call any method that has the parameterised type as a method argument.
// This will cause a compile error.
Parent aParent = okayParents.get(0);
Within the context of generic collections, this means that the list is now effectively a write-only list. This is because the ‘get(…)’ method in the List returns the parameterised type and will cause a compile error if someone tries to call it. We can safely say that the list is a consumer of generic elements.
Generic Collections in Kotlin: Contravariant generic collections is handled using declaration-site variance feature that enables developers to define contravariance generic types preemptively, this is done during the class definition.
The Kotlin compiler will not allow you to define methods in your class that have the parameterised type as a return type. Again, from the previous post:
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 'in' but occurs
// in 'out' position in type T", so it won't compile.
fun produce(): T? {
return null
}
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 produce(): @UnsafeVariance T? {
return null
}
Recall that we can also utilise the 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 contravariant by using the following snippet:
// Notice the use of the `in` keyword and how this allows me to
// assign `myVar` to supertype-parameterised instance.
val myVar: ThirdPartyGenericClass<in Parent> = ThirdPartyGenericClass(GrandParent())
That wraps it for contravariance in Java and Kotlin. Last but not least:
Invariance in Java and Kotlin:
This section will be pretty quick since we have already mentioned cases where invariance occurs.
Variable assignment is covariant in nature for both languages.
In method overriding, the behaviour is the same for both languages. The return types are both covariant and the method parameters are invariant.
Arrays are where the two languages behave differently. Java arrays are covariant, while Kotlin arrays are invariant.
Finally, in generic collections, the parameterised types of collections
are invariant by default in both languages.
Developers can use use-site variance in Java and Kotlin to change the behaviour of generic collections. They can be made to be covariant or contravariant, depending on the use case required. Kotlin has a declaration-site variance feature that allows developers define the variance of a parameterised type when the class is being defined.
I learnt a lot about the Java and Kotlin type system when compiling this series, I hope you do too when you read them. Until next time ✌🏽.
Special thanks to this Stack Overflow answer, this medium post and this blog post. They gave me different perspectives for this series.