Demystifying Variance in Kotlin Generics: A Comprehensive Guide
Written on
Understanding Variance in Kotlin Generics
Have you ever ventured into Kotlin generics and found yourself puzzled by the terms "in" and "out"? You're certainly not the only one! These terms, referred to as variance modifiers, can initially seem perplexing. However, this guide will clarify their roles, enabling you to craft more adaptable and reusable code.
The Concept of Variance Through Weaponry
Let's visualize a scenario involving weaponry, where we establish a hierarchy of classes:
open class Weapon
open class Rifle : Weapon()
class SniperRifle : Rifle()
Now, we aim to create a generic container class named Case to store these weapons. The question arises: how do we manage various weapon types? This is where the concept of variance becomes essential!
The Power of "out": The Producer
The out keyword signifies a covariant type. You can think of covariance as a producer. A covariant generic type is designed to return values, but it cannot accept them as inputs.
Consider this Case class utilizing out:
class Case<out T> {
private val contents = mutableListOf<T>()
fun produce(): T = contents.last() // Producer: OK
fun consume(item: T) = contents.add(item) // Consumer: Error!
}
In this Case class, you can produce any weapon of type T or its subtypes (like Rifle or SniperRifle), but you cannot add items (consume) because the generic type T could be something unexpected.
The elegance of out lies in its ability to utilize subtyping. Since SniperRifle is a subtype of Rifle, a Case can be used in any context where a Case is expected!
fun useProducer(case: Case<Rifle>) {
val rifle = case.produce() // Produces Rifle and its subtypes
}
useProducer(Case<Rifle>()) // OK, subtyping preserved
useProducer(Case<SniperRifle>()) // OK
useProducer(Case<Weapon>()) // Error, not a subtype of Rifle
This approach enhances the reusability of your code, but keep in mind that Case is read-only.
The Role of "in": The Consumer
The in keyword represents a contravariant type. Contravariance denotes a consumer. A contravariant generic type can only accept arguments, but it cannot return them.
Here’s a Case class that employs in:
class Case<in T> {
private val contents = mutableListOf<T>()
fun produce(): T = contents.last() // Producer: Error!
fun consume(item: T) = contents.add(item) // Consumer: OK
}
In this instance, Case can accept any weapon of type T or its supertypes (like Rifle or Weapon). The subtyping relationship is inverted here, making Case a subtype of Case, which allows you to add any weapon type into it.
fun useConsumer(case: Case<Rifle>) {
case.consume(SniperRifle()) // Consumes Rifle and its subtypes
}
useConsumer(Case<Weapon>()) // Error, not a supertype of Rifle
useConsumer(Case<Rifle>()) // OK
useConsumer(Case<Rifle>()) // OK, subtyping reversed
This method offers flexibility for consuming various weapon types, but note that Case becomes write-only.
The Invariant Approach: Producer and Consumer
Without variance modifiers, we encounter an invariant generic type. This type can both produce and consume values of type T.
class Case<T> {
private val contents = mutableListOf<T>()
fun produce(): T = contents.last() // Producer: OK
fun consume(item: T) = contents.add(item) // Consumer: OK
}
This approach is the most restrictive, as it does not permit subtyping for the generic type.
fun useProducerConsumer(case: Case<Rifle>) {
case.produce()
case.consume(SniperRifle())
}
useProducerConsumer(Case<Rifle>()) // Error, no subtyping
useProducerConsumer(Case<Rifle>()) // OK
useProducerConsumer(Case<Weapon>()) // Error, no subtyping
While it provides read-write access, it lacks the flexibility offered by out and in.
Conclusion
Understanding variance in Kotlin generics introduces a significant level of control and flexibility to your coding practices. By grasping the concepts of out, in, and invariant types, you can develop generic classes that are not only more reusable but also more expressive. Remember, out transforms your class into a producer (read-only), in makes it a consumer (write-only), and without variance modifiers, it's both a producer and consumer (read-write but less adaptable).
Recognizing when to apply each method empowers you to write cleaner and more maintainable Kotlin code. Therefore, the next time you encounter generics, don’t shy away from in and out—embrace them as tools to elevate your Kotlin expertise!
The first video titled "Variance... without Generics!" delves into the fundamental concepts of variance in Kotlin, offering insights into how it operates without the complexity of generics.
The second video, "Advanced Kotlin: Generics, Type Erasure, and Reflection Explained," provides a thorough exploration of advanced topics in Kotlin, including generics and their implications in programming.