Variance modifiers limitations
This is a chapter from the book Advanced Kotlin. You can find it on LeanPub.
In the previous parts, we already discussed variance modifiers
out
andin
with their practical use cases, and a popular pattern I named Covariant Nothing Object.
In Java, arrays are reified and covariant. Some sources state that the reason behind this decision was to make it possible to create functions, like Arrays::sort
, that make generic operations on arrays of every type.
Integer[] numbers= {1, 4, 2, 3};
Arrays.sort(numbers); // sorts numbers
String[] lettrs= {"B", "C", "A"};
Arrays.sort(lettrs); // sorts letters
But there is a big problem with this decision. To understand it, let’s analyze the following Java operations, which produce no compilation time error, but instead throw runtime error:
// Java
Integer[] numbers= {1, 4, 2, 3};
Object[] objects = numbers;
objects[2] = "B"; // Runtime error: ArrayStoreException
As you can see, casting numbers
to Object[]
didn't change the actual type used inside the structure (it is still Integer
), so when we try to assign a value of type String
to this array, then an error occurs. This is clearly a Java flaw, and Kotlin protects us from that by making Array
(as well as IntArray
, CharArray
, etc.) invariant (so upcasting from Array<Int>
to Array<Any>
is not possible).
To understand what went wrong in the above snippet, we should understand what in-positions and out-positions are.
A type is used at in-position when it is used as a parameter type. In the below example, the Dog
type is used at in-position. Notice that every object type can be up-casted, so when we expect a Dog
, we might actually receive any of its subtypes, so a Puppy
or a Hound
.
In-positions work well with contravariant types, so with in
modifier, because it allows transferring a type to a lower one, so from Dog
to Puppy
or Hound
. This is only limiting class use, so it is a safe operation.
However, public in-positions cannot be used with covariance, so with the out
modifier. Just think what would happen if you could upcast Box<Dog>
into Box<Any?>
. If this was possible, you could literally pass any object to the put
method. I hope you can see the implications of that. That is why in Kotlin, it is prohibited to use covariant type (out
modifier) on public in-positions.
This is actually the problem with Java arrays. They should not be covariant because they have methods that allow their modification, like set
.
Covariant type parameters can be safely used in private in-positions.
Covariance (out
modifier) is perfectly safe with public out-positions, and so they are not limited. This is why we use covariance (out
modifier) for types that are produced or only exposed. It is often used for producers or immutable data holders. That is why List
has the covariant type parameter, but MutableList
must have it invariant.
There is also a symmetrical problem (or co-problem, as some like to say) for contravariance and out-positions. Type out-positions are function result types and read-only property types. Those types can also be up-casted to any upper type, but since we are on the other side of an object, it means that we can expect types that are above the expected type. In the example below, Amphibious
is at an out-position, and when we might expect it to be Amphibious
, we can also expect it to be Car
or Boat
.
Out positions work well with covariance, so the out
modifier. Upcasting Producer<Amphibious>
to Producer<Car>
or Producer<Boat>
limits what we can expect from the produce
method, but the result is still correct.
Out-positions do not get along with contravariant type parameters (in
modifier). If Producer
type parameters were contravariant, we could up-cast Producer<Amphibious>
to Producer<Nothing>
and then expect produce
to produce literally anything, which this method cannot do. That is why contravariant type parameters cannot be used on public out-positions.
You cannot use contravariant type parameters (in
modifier) at public out-positions, such as function result or read-only property type.
Again, it is fine when those elements are private:
This way we use contravariance (in
modifier) for type parameters that are only consumed or accepted. One known example is kotlin.coroutines.Continuation
:
Read-write property types are invariant, so public read-write properties support neither covariant nor contravariant types.
UnsafeVariance annotation
Every good rule must have some exceptions. In general, using covariant type parameters (out
modifier) on public in-positions is considered unsafe, so such a situation blocks code compilation. Still, there are situations where we would like to do it anyway because we know we will do it safely. A good example is List
.
As we already explained, the type parameter in the List
interface is covariant (out
modifier), which is conceptually correct because it is a read-only interface. However, it uses this type of parameter in some public in-positions. Just consider the methods contains
or indexOf
. They use covariant type parameters on the public in position, which is a clear violation of the rules we just explained.
How is that possible? According to the previous section, it should not be possible. The answer is the UnsafeVariance
annotation, which is used to turn-off the aforementioned limitations. It is like saying, "I know it is unsafe, but I know what I do, and I will use this type safely".
It is ok to use UnsafeVariance
for methods like contains
or indexOf
, because their parameters are only used for comparison, and their arguments are not set anywhere or returned by any public function. They could as well be of type Any?
, and the type is only for the user of those methods to know what kind of value should be used as an argument.
Variance modifier positions
Variance modifiers can be used in two positions1. The first one, the declaration side, is more common. It is a modifier on the class or interface declaration. It will affect all the places where the class or interface is used.
The other one is the use-site, which is a variance modifier for a particular variable.
We use use-site variance when for some reason we cannot provide variance modifiers for all instances, and yet you need it for one variable. For instance, MutableList
cannot have the in
modifier because then it wouldn't allow returning elements. Still, for a single parameter type, we can make its type contravariant (in
modifier) to allow any collections that can accept some type:
Notice that when we use variance modifiers, some positions are limited. When we have MutableList<out T>
, we can use get
to get elements, and we receive an instance typed as T
, but we cannot use set
because it expects us to pass an argument of type Nothing
. It is because a list with any subtype of T might be passed there, including the subtype of every type that is Nothing
. When we use MutableList<in T>
, we can use both get
and set
, but when we use get
, the returned type is Any?
because there might be a list with any supertype of T
including the supertype of every type that is Any?
. Therefore, we can freely use out
when we only read from a generic object and in
when we only modify that generic object.
Star projection
On the use-side, we can also use star *
instead of type argument to signalize that it can be any type. This is known as star projection.
Star projection should not be confused with Any?
type. It is true that List<*>
behaves effectively like List<Any?>
, but it is only because the associated type parameter is covariant. It might also be said that Consumer<*>
behaves like Consumer<Nothing>
if Consumer
the type parameter is contravariant. However, the behavior of Consumer<*>
is nothing like Consumer<Any?>
, and the behavior of List<*>
is nothing like List<Nothing>
. The most interesting case is MutableList
. As you might guess, MutableList<Any?>
returns Any?
as result in methods like get
or removeAt
but also expects Any?
as an argument to methods like add
or set
. On the other hand, MutableList<*>
returns Any?
as result in methods like get
or removeAt
, but expects Nothing
as an argument to methods like add
or set
. This means MutableList<*>
can return anything but accepts (literally) nothing.
Use-side type | In-position type | Out-position type |
---|---|---|
T | T | T |
out T | Nothing | T |
in T | T | Any? |
* | Nothing | Any? |
Summary
Every Kotlin type parameter has some variance:
- The default variance behavior of a type parameter is invariance. If in
Box<T>
, type parameterT
is invariant andA
is a subtype ofB
, then there is no relation betweenBox<A>
andBox<B>
. out
modifier makes type parameter covariant. If inBox<T>
, type parameterT
is covariant andA
is a subtype ofB
, thenBox<A>
is a subtype ofBox<B>
. Covariant types can be used at public out-positions.
It is also good to know that in Kotlin:
- Type parameters of
List
andSet
are covariant (out
modifier) so for instance, we can pass any list whereList<Any>
is expected. Also, the type parameter representing value type inMap
is covariant (out
modifier). Type parameters ofArray
,MutableList
,MutableSet
, andMutableMap
are invariant (no variance modifier). - In function types, parameter types are contravariant (
in
modifier), and return type is covariant (out
modifier). - We use covariance (
out
modifier) for types that are only returned (produced or exposed). - We use contravariance (
in
modifier) for types that are only accepted (consumed or set).
What is also called mixed-site variance.