Kotlin Variance
When working with generic types in Kotlin, you'll eventually encounter a situation where you need to understand how subtypes and supertypes behave in generic contexts. This concept, called variance, is crucial for writing flexible and type-safe code.
What is Variance?
Variance determines how subtyping between more complex types relates to subtyping between their components. In simpler terms, if Cat
is a subtype of Animal
, what's the relationship between Box<Cat>
and Box<Animal>
?
Kotlin provides three types of variance:
- Invariance (default)
- Covariance (using
out
keyword) - Contravariance (using
in
keyword)
Let's explore each one in detail.
Invariance (Default Behavior)
By default, generic types in Kotlin are invariant. This means that Box<Cat>
and Box<Animal>
have no subtyping relationship, even though Cat
is a subtype of Animal
.
// Invariant class
class Box<T>(val item: T) {
fun getItem(): T = item
fun setItem(newItem: T) {
// Imagine we could modify the item
}
}
fun main() {
val catBox = Box<Cat>(Cat("Whiskers"))
// This won't compile - type mismatch
// val animalBox: Box<Animal> = catBox
}
Invariance is the safest default since it prevents potential type errors.
Covariance (out)
Covariance allows you to use a more derived type (subtype) in place of a less derived type (supertype). In Kotlin, we use the out
keyword to make a type parameter covariant.
// Covariant class
class ReadOnlyBox<out T>(val item: T) {
fun getItem(): T = item
// Notice we can't have functions that take T as input
}
open class Animal(val name: String) {
fun breathe() = println("$name is breathing")
}
class Cat(name: String) : Animal(name) {
fun meow() = println("$name says: Meow")
}
fun main() {
val catBox = ReadOnlyBox(Cat("Whiskers"))
// This works! Box<Cat> is a subtype of Box<Animal> due to covariance
val animalBox: ReadOnlyBox<Animal> = catBox
// We can still access the item as an Animal
animalBox.getItem().breathe() // Output: Whiskers is breathing
// But we can't access Cat-specific methods
// animalBox.getItem().meow() // This won't compile
}
Notice that with a covariant type parameter, you can only use that type in "out" positions—that is, as return types, not as parameter types.
Contravariance (in)
Contravariance is the opposite of covariance. It allows you to use a less derived type (supertype) in place of a more derived type (subtype). We use the in
keyword in Kotlin to indicate contravariance.
// Contravariant class
class AnimalProcessor<in T> {
fun process(item: T) {
println("Processing ${item.toString()}")
}
// Notice we can't have functions that return T
}
fun main() {
val animalProcessor = AnimalProcessor<Animal>()
// This works! AnimalProcessor<Animal> is a subtype of AnimalProcessor<Cat>
val catProcessor: AnimalProcessor<Cat> = animalProcessor
catProcessor.process(Cat("Whiskers")) // Output: Processing Cat@...
}
With contravariance, you can only use the type in "in" positions, such as parameter types, not as return types.
Understanding Type Projection
Sometimes you may need variance for a specific usage of a type rather than for the type itself. Kotlin allows this through type projections.
Use-site Variance (Type Projections)
class Box<T>(var value: T)
fun copyFromTo(from: Box<out Any>, to: Box<Any>) {
to.value = from.value // This works because 'from' is projected as covariant
}
fun main() {
val stringBox = Box("Hello")
val anyBox = Box<Any>(42)
copyFromTo(stringBox, anyBox)
println(anyBox.value) // Output: Hello
}
In the example above, Box<out Any>
is a projected type, meaning it can be a Box<String>
, Box<Int>
, or any other Box<T>
where T
is a subtype of Any
.
Star Projection
When you don't care about the specific type arguments, you can use star projections:
fun printBoxContent(box: Box<*>) {
// We can safely read the value as Any?
println("Box contains: ${box.value}")
// But we can't write to it
// box.value = "Something" // This won't compile
}
fun main() {
val stringBox = Box("Hello")
val intBox = Box(42)
printBoxContent(stringBox) // Output: Box contains: Hello
printBoxContent(intBox) // Output: Box contains: 42
}
The star projection is similar to Box<out Any?>
but more concise.
Real-world Applications of Variance
Example 1: Collection APIs
Kotlin's collections make excellent use of variance:
// List is covariant in its type parameter
fun printAnimals(animals: List<Animal>) {
for (animal in animals) {
println("Animal: ${animal.name}")
}
}
fun main() {
val cats = listOf(Cat("Whiskers"), Cat("Mittens"))
// This works because List<Cat> is a subtype of List<Animal> (covariance)
printAnimals(cats) // Will print both cats
// MutableList, however, is invariant
val mutableCats: MutableList<Cat> = mutableListOf(Cat("Felix"))
// The following would not compile:
// val mutableAnimals: MutableList<Animal> = mutableCats
}
Example 2: Event Handlers
Contravariance is useful for callbacks and event handlers:
interface EventListener<in T> {
fun onEvent(event: T)
}
class ButtonClickEvent
class MouseEvent : ButtonClickEvent()
fun main() {
// A listener that can handle any ButtonClickEvent
val buttonListener = object : EventListener<ButtonClickEvent> {
override fun onEvent(event: ButtonClickEvent) {
println("Button was clicked")
}
}
// Because EventListener is contravariant, we can use a ButtonClickEvent listener
// where a MouseEvent listener is expected
val mouseListener: EventListener<MouseEvent> = buttonListener
mouseListener.onEvent(MouseEvent()) // Output: Button was clicked
}
Example 3: Generic Factory
Covariance is useful for factories or producers:
interface Producer<out T> {
fun produce(): T
}
class AnimalProducer : Producer<Animal> {
override fun produce(): Animal = Animal("Generic Animal")
}
class CatProducer : Producer<Cat> {
override fun produce(): Cat = Cat("New Cat")
}
fun feedAnimal(producer: Producer<Animal>) {
val animal = producer.produce()
println("Feeding ${animal.name}")
}
fun main() {
val animalProducer = AnimalProducer()
val catProducer = CatProducer()
feedAnimal(animalProducer) // Output: Feeding Generic Animal
// This works because Producer is covariant
feedAnimal(catProducer) // Output: Feeding New Cat
}
Guidelines for Using Variance
-
Use
out
(covariance) when your class only returns (produces) values of type T, and never consumes them as parameters. -
Use
in
(contravariance) when your class only consumes values of type T as parameters, and never produces them as return values. -
Default to invariance if your class both consumes and produces values of type T.
-
Consider use-site variance with type projections for more flexible API design.
Summary
Variance in Kotlin allows you to create more flexible yet type-safe generic code:
- Invariance (default): No relationship between
Box<Cat>
andBox<Animal>
- Covariance (
out
):Box<Cat>
is a subtype ofBox<Animal>
ifCat
is a subtype ofAnimal
- Contravariance (
in
):Box<Animal>
is a subtype ofBox<Cat>
ifCat
is a subtype ofAnimal
Understanding variance is crucial when designing generic classes and APIs, as it affects how your types can be used in different contexts.
Exercises
-
Create a generic class
Stack<T>
and determine whether it should be invariant, covariant, or contravariant. -
Implement a
Transformer<in I, out O>
class that transforms inputs of typeI
to outputs of typeO
. -
Create a function that can copy elements from any list to a
List<Any>
, using appropriate variance annotations.
Additional Resources
- Kotlin Official Documentation on Generics
- Declaration-site variance in the Type System chapter
- Java Generics vs. Kotlin Generics
Understanding variance will help you write more reusable, type-safe code that properly expresses your design intentions.
If you spot any mistakes on this website, please let me know at [email protected]. I’d greatly appreciate your feedback! :)