On the surface, subtypes seem straightforward. Most programmers who write object-oriented code are familiar with the concept of inheritance, and how you can pass off a subclass as if it were its superclass. But subtyping rules aren’t always as intuitive when you start working with generics.
What is Covariance?
Covariance describes a relationship between two sets of types where they both subtype in the same direction. For example, for our first set of types, here are two classes, A
and B
, where B
is a subtype of A
:
open class A
open class B : A()
Simple.
And for our second set of types, we’ll consider a read-only List
of each of those two classes:
List<A>
List<B>
The subtyping rules for A
and B
are obvious. But what are the subtyping rules for List<A>
and List<B>
?
Is List<B>
a subtype of List<A>
, just like B
is a subtype of A
? If so, then the subtypes go in the same direction, and we say that List
is “covariant on its type parameter”.
And in case you’re wondering, in Kotlin:
- A read-only
List
is indeed covariant on its type parameter. SoList<B>
is a subtype ofList<A>
. - A
MutableList
, however, is not covariant, soMutableList<B>
is not a subtype ofMutableList<A>
.
Examples
Using covariance
Because List
is covariant, you can pass off a read-only List<B>
as if it were List<A>
:
val listOfB: List<B> = listOf(B())
val listOfA: List<A> = listOfB
This also works when passing a List
to a function:
fun read(list: List<A>) {
// ...
}
val list: List<B> = listOf(B())
read(list)
Creating covariance
Covariance won’t happen automatically on just any ol’ generic class. For example, here’s a simple generic Box
class that wraps… well… anything that’s not null:
class Box<T>(val item: T)
Box
is not covariant on T
in this case, so the following will fail compilation with a Type mismatch
error on the last line:
fun read(box: Box<A>) {
// ...
}
val box: Box<B> = Box(B())
read(box)
In order to make it covariant, you’ll need to mark the type parameter as out
:
class Box<out T>(val item: T)
The code above will now compile.
What’s the downside? By marking a type parameter with the out
variance annotation, you’re also telling the compiler that, once an object of this type is constructed, it will never accept a function argument of type T
, because doing so would not be type-safe. This is why the type parameter is marked as out
- because the class will only ever send a T
out (i.e., return it from a function).
Note that this also applies to getters and setters of properties. For example, if we were to change the constructor to use var item
instead of val item
, the code would not compile due to the type variance conflict, and you’d get an error message that looks like this:
Type parameter T is declared as ‘out’ but occurs in ‘invariant’ position in type T
Covariance and Inheritance
Covariance doesn’t just apply to generics. When you override a function in a subclass, you can declare a return type that’s more specific than the superclass declares. For example, if you’ve got a superclass with a function that returns an A
, it’s legal for a subclass to declare that it returns a B
instead:
open class SuperClass {
open fun getValue(): A {
return A()
}
}
class SubClass : SuperClass() {
override fun getValue(): B {
return B()
}
}
This is because return types in Kotlin (and Java) are covariant.
More Information
More concept articles related to variance:
- Read up about the related concepts of contravariance and invariance.
- For more information about how variance works in Kotlin, check out the official documentation.