Idiomatic Kotlin: Variance
This article is a part of the Idiomatic Kotlin series. The complete list is at the bottom of the article.
In this article, we will talk about Java wildcard generics and variance and work our way to Kotlin generics. Buckle up, this is gonna be a bumpy ride.
Variance and Arrays
Variance describes the relationship between parameterized classes. Variance is important because it extends polymorphism to generic types. To understand this better, let’s look at an example.
If we have a function that accepts an array of number and pass it with an array of number and an array of integers, what do you think would happen?
Surprisingly, this will compile and run without problems. Therefore, we can say that arrays in Java preserves some of its base type relationship. We will define these types of relationship later.
Now, what do you think will happen if we replace the
arrayVariance code with this?
Number so it should be OK right? Apparently no. This will produce a run-time exception because you are trying to insert a
Long inside an
Integer array. We just witnessed the limitation of variance in Array. Let’s see if generics solves this type-safety problem.
Let’s replace the array of the previous example with a generic
Another surprising turn of events. The generic type counterpart does not compile. Now before you complain something about a downgrade, or generic type is worst than array, hear this out. This approach moved the potential run-time exception to a compile time exception. Isn’t that a nice thing? That is one of the benefits of generics, type-safety at compile time. The keyword here is compile time. If you are following this series, you know that types are erased in compile time so the JVM does not know about this generics, it’s only in the compiler.
Now, the function above, assuming that you satisfy the parameter type, will not encounter any run-time exception, because the compiler guaranteed it. The problem now is satisfying that agument type. Obviously, generics by default does not preserve the relationship of its type argument as shown in the example above so your only choice is to pass a list of objects to use the function. This is called invariance. A type T is said to be invariant in its type if T[A] and T[B] does not have any relationship even though A and B has some form of relationship (subtype or supertype).
So how do we enforce relationship or variance in generic types?
A type T is said to be covariant in its type if T[A] is a subtype of T[B] and A is a subtype of B. This is achieved in Java by bounding the parameter type. The syntax for an upper bound wildcard is
Type<? extends BaseParameterType>
This can be interpreted as a generic type with a parameter type that extends the
BaseParameterType. Let’s use our example above.
No compile errors. A list of integer can now be passed in the function. We have achieved covariance in generics. However, there seems to be a problem still. Trying to insert an object of any type that extends number in the list will not work. This is a limitation of covariance. We are only allowed to read from this type. Allowing a set will get us back to the original array problem. Let’s see how.
Imagine we have a function that return a list of
? extends Number. The underlying list is of parameter type
Integer so inserting anything other than
Integer will net you a run-time error. That is why the compiler will not allow it.
So in convention, covariance is for read only and is useful only for methods as a return type (since you cannot modify them). You can say that methods that return these types are Producers. Remember these conventions as they will be tackled again in Kotlin.
Contravariance is somewhat an opposite of covariance. A type T is said to be contravariant in its type if T[A] is a subtype of T[B] and B is a subtype of A. Similar to covariance, this is achieved by bounding the type of the parameter type but this time, a lower bound. The syntax is
Type<? super BaseParameterType>
Let’s check how this works by converting our example in covariance.
There is a compile error. This means that
List<Integer> is not a subtype of
List<Number> in contravariance perspective. In fact, it is the opposite. To be considered a subtype, your parameter type should be a super type. So in this case, we can use an
Object because it is a super type of Number.
Contravariance, in contrast with covariance, allows you so write to it. Because at the method perspective, the underlying type is a super type and can hold any reference to the base parameter subtype. But the restriction lies in reading. Let’s see an example.
Since we know that in contravariance, subtype is anything that is a supertype of the base parameter type, we can return a list of Objects. If we get a hold of this list, we can add anything that is a subtype of the parameter type. But we cannot read from it. Because we do not know the underlying type. You can cast it but type-safety will not be guaranteed. Hence the compile error at line 6.
The convention therefore for contravariance is write only and is useful for methods as an input type. You can say that methods that accepts these types are Consumers.
Variance in Kotlin can be achieved in multiple ways but behaves exactly like Java. The first one we will look at is declaration-site variance.
Declaration-site variance works on the class level and affects all its members. This is a handy way to enforce variance into the whole class without littering each member and fields with wildcards. Before we see an example, let us recap the conventions we defined a while ago.
- Covariance is read-only and useful as a return type (out)
- Contravariance is write-only and useful as an input type (in)
The out and in is a new way of looking at the purpose. If it is a return type, it goes out of a method. If it is an input, it goes into the method.
Now that we have the conventions fresh on our minds, let’s look at how declaration-site covariance is realized in Kotlin. Check this example.
To specify covariance, the type must be appended with the
out modifier. Then you can now assign a
Producer<Number> to a
Producer<Any> because the former is now a subtype of the latter.
There is a catch though. The
out modifier compels you to use the type only as a return type of your members. Doing the opposite will net you a compile error.
Now let’s try contravariance.
in in contravariance. As we can see from example, we can assign a
Consumer<Number> to a
Consumer<Int> because of the subtyping consequence of contravariance.
Same as covariance, you are compelled to use the type as inputs to member functions only.
Now we will see another approach called use-site variance. The name is so fancy but don’t be fooled. This is just the same as Java wildcard generics but with a simpler syntax.
A function can accept arguments with covariant/contravariant type parameters by specifying it in their type declaration. The above example demonstrates using covariance and covariance in a single method. Notice that applying variance restricts the methods that you can call on it (getters for out, setters for in).
That’s it for variance. This is a very complicated topic and is not very intuitive. I hope this article enlightened you somehow regarding the topic.
Check out the other articles in the idiomatic kotlin series. The sample source code for each article can be found here in Github.
- Extension Functions
- Sealed Classes
- Infix Functions
- Class Delegation
- Local functions
- Object and Singleton
- Lambdas and SAM constructors
- Lambdas with Receiver and DSL
- Elvis operator
- Property Delegates and Lazy
- Higher-order functions and Function Types
- Inline functions
- Lambdas and Control Flows
- Reified Parameters
- Noinline and Crossinline
- Annotations and Reflection
- Annotation Processor and Code Generation