Kotlin Contravariance
In Kotlin's type system, contravariance is a powerful concept that allows you to create more flexible and safer generic classes and functions. It's part of Kotlin's variance system, which helps manage subtyping relationships between generic types.
Introduction to Contravariance
Contravariance is one of three variance behaviors in Kotlin (alongside covariance and invariance). Put simply, contravariance is the opposite of covariance and allows you to use a more general (supertype) where a more specific type is expected.
In Kotlin, contravariance is declared using the in
modifier before a type parameter.
Understanding the "in" Modifier
The in
keyword indicates that a type parameter can only be used as an input (consumed) but not as an output (produced).
Here's how contravariance is declared:
// A contravariant interface with type parameter T
interface Consumer<in T> {
fun consume(item: T)
}
The in
modifier tells the compiler that instances of Consumer<SuperType>
can be used wherever a Consumer<SubType>
is expected.
Contravariance in Action
Let's see a concrete example to understand how contravariance works:
// Define a simple class hierarchy
open class Animal {
open fun makeSound() {
println("Some animal sound")
}
}
class Dog : Animal() {
override fun makeSound() {
println("Woof!")
}
}
class Cat : Animal() {
override fun makeSound() {
println("Meow!")
}
}
// Define a contravariant interface
interface AnimalConsumer<in T> {
fun feed(animal: T)
fun pet(animal: T)
}
// Implement the interface for Animal
class GeneralAnimalCarer : AnimalConsumer<Animal> {
override fun feed(animal: Animal) {
println("Feeding an animal")
animal.makeSound()
}
override fun pet(animal: Animal) {
println("Petting an animal")
animal.makeSound()
}
}
fun main() {
// This is where contravariance works its magic
val dogCarer: AnimalConsumer<Dog> = GeneralAnimalCarer()
val dog = Dog()
// We can use a GeneralAnimalCarer as a Dog-specific carer
dogCarer.feed(dog) // Works fine!
dogCarer.pet(dog) // Works fine!
}
Output:
Feeding an animal
Woof!
Petting an animal
Woof!
In the example above:
- We have
Animal
as a superclass withDog
andCat
as subclasses - We define a contravariant interface
AnimalConsumer<in T>
GeneralAnimalCarer
implementsAnimalConsumer<Animal>
- We can use
GeneralAnimalCarer
as anAnimalConsumer<Dog>
This works because if a class can handle any Animal
, it's certainly capable of handling just Dog
instances. This is the essence of contravariance.
Why Contravariance Makes Sense
To understand why contravariance is logical, think of it in terms of substitutability:
- If a function expects a
Consumer<Dog>
, what would it pass to that consumer? ADog
. - If we provide a
Consumer<Animal>
instead, is that safe? Yes! Because aConsumer<Animal>
can handle any animal, including a dog.
This relationship can be counterintuitive at first, but remember this rule: if a type parameter is only used as input (consumed), it can be contravariant.
Real-World Applications
Example 1: Comparators
A common use case for contravariance is with comparators:
// A contravariant comparator
interface Comparator<in T> {
fun compare(a: T, b: T): Int
}
// A comparator for any number
class NumberComparator : Comparator<Number> {
override fun compare(a: Number, b: Number): Int {
return a.toDouble().compareTo(b.toDouble())
}
}
fun main() {
// We can use the NumberComparator as an Integer-specific comparator
val integerComparator: Comparator<Int> = NumberComparator()
println(integerComparator.compare(5, 10)) // -1 (5 is less than 10)
println(integerComparator.compare(10, 5)) // 1 (10 is greater than 5)
println(integerComparator.compare(5, 5)) // 0 (equal)
}
Output:
-1
1
0
Example 2: Event Listeners
Another practical example is with event listeners:
// Base event class
open class Event(val timestamp: Long)
// More specific event types
class ClickEvent(val x: Int, val y: Int, timestamp: Long) : Event(timestamp)
class KeyEvent(val keyCode: Int, timestamp: Long) : Event(timestamp)
// Contravariant event handler
interface EventListener<in E : Event> {
fun onEvent(event: E)
}
// A listener for all events
class GeneralEventLogger : EventListener<Event> {
override fun onEvent(event: Event) {
println("Event logged at ${event.timestamp}")
}
}
fun main() {
// We can use the general logger as a specific event listener
val clickListener: EventListener<ClickEvent> = GeneralEventLogger()
val keyListener: EventListener<KeyEvent> = GeneralEventLogger()
clickListener.onEvent(ClickEvent(10, 20, System.currentTimeMillis()))
keyListener.onEvent(KeyEvent(65, System.currentTimeMillis())) // 'A' key
}
Output:
Event logged at 1628097433262
Event logged at 1628097433263
When to Use Contravariance
Use contravariance when:
- Your generic type only consumes values of the type parameter (like parameters to functions)
- You want to allow the use of more general (supertype) implementations where more specific ones are expected
Remember the mnemonic: IN = Consumer (the type parameter is consumed)
Contravariance vs. Covariance vs. Invariance
Let's compare the three variance annotations in Kotlin:
Variance Type | Annotation | Description | Use Case |
---|---|---|---|
Contravariance | in T | Accepts supertypes | When T is only consumed |
Covariance | out T | Accepts subtypes | When T is only produced |
Invariance | No annotation | Exact type match | When T is both consumed and produced |
Type Projection with Contravariance
Besides declaring a class as contravariant, you can also use contravariant type projections for individual functions:
class BasicContainer<T>(var item: T)
fun processItems(container: BasicContainer<in Int>) {
container.item = 42 // This is safe because 'in Int' means container can hold Int or any supertype
}
fun main() {
val numberContainer = BasicContainer<Number>(0.0)
processItems(numberContainer) // This works because of the 'in' projection
println(numberContainer.item) // Output: 42
}
Output:
42
Common Mistakes and Issues
Mistake 1: Using a Contravariant Type as Output
interface Problematic<in T> {
fun process(item: T) // This is fine
fun get(): T // Compilation error: Type parameter T is declared as 'in' but occurs in 'out' position
}
Mistake 2: Mixing Variance Annotations
// This won't compile because T is used in both in and out positions
interface MixedUsage<in T> {
fun consume(item: T) // in position (ok)
fun produce(): T // out position (error!)
}
Summary
Contravariance in Kotlin allows you to use more general types where specific ones are expected, which is particularly useful for consumers of data. The key features are:
- Mark type parameters with the
in
modifier to make them contravariant - Contravariant type parameters can only be used as inputs (consumed), not outputs
- A type
Consumer<SuperType>
can be used wherever aConsumer<SubType>
is needed - Contravariance is appropriate for parameters that only serve as inputs to functions
Understanding contravariance improves the flexibility and type safety of your Kotlin code, particularly when working with hierarchies of types and APIs that consume data.
Further Exercises
- Implement a contravariant
Printer<in T>
interface that can print different types of objects. - Create a validation system with a contravariant
Validator<in T>
interface. - Implement a
Filter<in T>
interface and create implementations that can filter different types in a type-safe manner. - Create a contravariant event handler for UI events (e.g., mouse events, keyboard events, etc.).
Additional Resources
If you spot any mistakes on this website, please let me know at [email protected]. I’d greatly appreciate your feedback! :)