Understanding Kotlin Variance: A Guide to Type Relationships
Written on
Chapter 1: Introduction to Variance in Kotlin
If you've engaged with a programming language that allows for generic types, you've probably come across concepts such as invariance, covariance, and contravariance. While these terms may initially seem complex, grasping their significance can lead to more efficient and adaptable coding practices. This article utilizes Kotlin as a case study, but the fundamental ideas are relevant across multiple programming languages like Scala, Java, C#, and Swift.
Section 1.1: A Simple Analogy: The Beverage Vending Machine
Consider a straightforward example: a beverage vending machine that accepts payment and dispenses drinks. While this analogy may be basic, it paves the way for a more profound exploration of variance. Some machines are tailored to dispense soft drinks, while others are specifically designed for coffee. Both soft drinks and coffee fall under the broader category of beverages.
In Kotlin, we can model our beverages as follows:
open class Beverage
class SoftDrink : Beverage()
class Coffee : Beverage()
Section 1.2: Covariance: Coffee Machines as a Type of Beverage Dispenser
Every beverage vending machine is fundamentally built for dispensing drinks. This shared purpose means that a coffee vending machine can logically be regarded as a subtype of a general beverage dispenser. Here’s how this is expressed in Kotlin:
class VendingMachine<T> {
fun dispense(): T? = null
}
val coffeeVendingMachine = VendingMachine<Coffee>()
val softDrinkVendingMachine = VendingMachine<SoftDrink>()
val beverageVendingMachine1: VendingMachine<Beverage> = coffeeVendingMachine
val beverageVendingMachine2: VendingMachine<Beverage> = softDrinkVendingMachine
// This will result in a compilation error due to type mismatch.
val invalid: VendingMachine<Beverage> = VendingMachine<Beverage>()
In this example, the VendingMachine class features a single generic parameter. The out keyword preceding this parameter indicates covariance, allowing instances of this generic class to maintain the same inheritance relationships as their respective parameter types.
Section 1.3: The Significance of the out Keyword
The out keyword plays a crucial role in ensuring the Kotlin compiler uses type T solely as a return type, prohibiting its use as a function parameter. This is important for methods designed to refill our vending machine:
class VendingMachine<T> {
fun dispense(): T? = null
// Compile error!
fun fill(beverages: List<T>): Unit = TODO()
}
This design is unworkable because you cannot stock a coffee vending machine with soft drinks. This limitation indicates that VendingMachine<Coffee> is not genuinely a subtype of VendingMachine<Beverage>. To address this, you would need to remove the out keyword, shifting VendingMachine to invariance—a concept we will examine shortly.
Section 1.4: Contravariance: Accepting All Payment Methods
Before quenching our thirst with a beverage, we must consider the payment process.
open class PaymentMethod
class Cash : PaymentMethod()
class CreditCard : PaymentMethod()
class PaymentProcessor<P> {
fun process(payment: P): Unit = TODO()
}
val coinSlot: PaymentProcessor<Cash> = PaymentProcessor()
val creditCardTerminal: PaymentProcessor<CreditCard> = PaymentProcessor()
// Type mismatch, does not compile.
val coinSlotWithCreditCardTerminal: PaymentProcessor<Cash> = PaymentProcessor()
Here, the PaymentProcessor is contravariant, meaning the inheritance relationship is inverted: a PaymentProcessor<PaymentMethod> can be viewed as a subtype of PaymentProcessor<Cash>.
Section 1.5: Invariance: Specific Machines Require Specific Manuals
Imagine a scenario where a vending machine encounters a malfunction. To resolve the issue, you would refer to its specific repair manual. However, can you fix a coffee vending machine using just a general vending machine manual? Not really. A broad manual may provide guidance on basic maintenance, but it would lack detailed instructions for particular components, such as the coffee grinder.
class RepairManual<T>
val genericManual = RepairManual<Beverage>()
val coffeeMachineManual = RepairManual<Coffee>()
val invalid: RepairManual<PaymentMethod> = genericManual
val invalid2: RepairManual<Coffee> = coffeeMachineManual
In this instance, RepairManual is invariant, meaning it doesn’t adopt any inheritance relationships from its type parameters. Specificity is key, and a manual designed for one machine type cannot be interchanged with another.
Chapter 2: Variance in Kotlin's Standard Library
Kotlin's standard library serves as a practical environment for grasping variance. Just as we observed with vending machines and payment systems, the library employs these principles to enhance code flexibility and clarity.
The List interface in Kotlin exemplifies covariance. Similar to how coffee machines fit into the larger category of beverage dispensers, elements within a List can be safely interpreted as elements of a List, thanks to the out keyword.
On the other hand, MutableList represents invariance. Just as specifics are essential for repair manuals, a MutableList<Child> and a MutableList<Parent> remain distinct, preventing potential conflicts when adding or removing items.
The Comparator interface beautifully illustrates contravariance. Similar to a payment processor that accepts various payment types, a Comparator can effectively compare instances of a Child type, but not vice versa, due to the in keyword.
A Brief Excursion: The Pitfalls of Variance in Java Arrays
Java arrays are covariant, which may seem advantageous at first glance, but can lead to unexpected runtime exceptions. Here’s a classic example of where this can go wrong:
Object[] objectArray = new String[10];
objectArray[0] = new Integer(42); // Throws ArrayStoreException at runtime
The code above compiles without issue but will throw an ArrayStoreException at runtime. Even though String[] is a subtype of Object[] (due to covariance), you cannot safely insert any object that isn't a string (or a subtype of string) into the objectArray.
In conclusion, while the Java language offers powerful features, be mindful: what compiles doesn't always run smoothly, much like a day without coffee! ☕️
Conclusion
Gaining insight into variance, as illustrated through Kotlin's standard library, provides valuable knowledge for creating flexible and type-safe code structures. By understanding how invariance, covariance, and contravariance function, developers can craft more robust and adaptable programs.
Thank you for taking the time to read this article! 🙏 If you found it helpful, please leave a comment 💬, give it a clap 👏, and share it with your network 📢.
For further exploration, refer to the official Kotlin documentation on generics. To delve into similar concepts in other languages, check out the Java documentation on wildcards, C# variance in generic interfaces, and the Scala guide on variance.