Kotlin and Variance: Navigating Type Relationships
Demystifying Generics: A Comprehensive Exploration of Type Variance in Kotlin and its Practical Implications in Software Development
If you’ve worked with a programming language that supports generic types, you’ve likely encountered terms like invariance, covariance, and contravariance. At first glance, these terms can be intimidating. However, a deeper understanding of them allows for more efficient and flexible coding. While this article uses Kotlin for illustration, the core concepts resonate across several programming languages, including Scala, Java, C#, and Swift.
The Beverage Vending Machine
Let’s begin with a simple example: a beverage vending machine. It takes your payment and dispenses a drink. While basic, this example sets the stage for our deeper dive into variance. Some machines are designed to dispense soft drinks, while others are specifically built for coffee.
We will leave the payment for later and model our beverages in Kotlin:
open class Beverage
class Softdrink : Beverage()
class Coffee : Beverage()
Covariance: Coffee Machines as a Subset of Beverage Dispensers
All beverage vending machines share a singular mission: dispensing a drink. This commonality suggests that a coffee vending machine can intuitively be treated as a subtype of a general beverage dispenser. Let us look how this can be solved in Kotlin:
class VendingMachine<out T> {
fun dispense() : T? = null
}
val coffeeVendingMachine = VendingMachine<Coffee>()
val softdrinkVendingMachine = VendingMachine<Softdrink>()
val beverageVendingMachine1 : VendingMachine<Beverage> = coffeeVendingMachine
val beverageVendingMachine2 : VendingMachine<Beverage> = softdrinkVendingMachine
// This does not compile due to type mismatch.
val invalid : VendingMachine<Coffee> = VendingMachine<Beverage>()
Here, we present the VendingMachine
class with a single generic parameter. Importantly, the out
keyword precedes this parameter, signaling covariance. Instances of this generic class retain the same inheritance relationships as their respective parameter types.
So, what’s the role of out
? Prefacing the generic parameter with out
ensures the Kotlin compiler uses type T
exclusively as a return type, barring its use as a function parameter. But why? For instance, consider a method designed to restock our vending machine:
class VendingMachine<out T> {
fun dispense() : T? = null
// Compile error!
fun fill(beverages:List<T>) : Unit = TODO()
}
This approach isn’t feasible. After all, you can’t load a coffee vending machine with bottles of soft drinks. This constraint implies that VendingMachine<Coffee>
isn't truly a subtype of VendingMachine<Beverage>
. To rectify this, you'd have to strip away the out
keyword, transitioning VendingMachine
to invariance—a concept we'll delve into shortly.
Contravariance: Accepting All Payments Naturally Includes Cash
Before we quench our thirst with a beverage, there’s the small matter of payment.
Think of a payment system that takes both credit cards and cash. This system, essentially, can also function as a cash-only system, just by ignoring its credit card feature. But the reverse? Not possible.
open class PaymentMethod
class Cash : PaymentMethod()
class CreditCard : PaymentMethod()
class PaymentProcessor<in P> {
fun process(payment : P) : Unit = TODO()
}
val coinSlot: PaymentProcessor<Cash> = PaymentProcessor<PaymentMethod>()
val creditCardTerminal: PaymentProcessor<CreditCard> = PaymentProcessor<PaymentMethod>()
// Type mismatch, does not compile.
val coinSlotWithCreditCardTerminal: PaymentProcessor<PaymentMethod> = PaymentProcessor<Cash>()
Here, the PaymentProcessor
is contravariant. This means the inheritance relationship is reversed: a PaymentProcessor<PaymentMethod>
may now be seen as a subtype of PaymentProcessor<Cash>
.
This might seem counterintuitive at first. However, if you look closer, it makes sense. If a machine accepts both credit cards and cash, it’s not bothered if only cash is used. Its main concern is receiving payment.
Invariance: Specific Machines Require Specific Manuals
Consider the scenario when a vending machine malfunctions. To address the issue, you’d refer to its repair manual. However, can you mend a coffee vending machine relying solely on a general vending machine manual? Not quite. A broad manual might guide you on light replacements, but it would lack specific details about, say, the coffee grinder.
open class RepairManual<T>
val genericManual = RepairManual<VendingMachine<Beverage>>()
val coffeeMachineManual = RepairManual<VendingMachine<Coffee>>()
val invalid: RepairManual<VendingMachine<Coffee>> = genericManual
val invalid2: RepairManual<VendingMachine<Beverage>> = coffeeMachineManual
In this context, RepairManual
is invariant. This means it does not adopt any inheritance relationships from its type parameters. Specificity matters, and a manual for one type of machine isn't interchangeable with another.
Variance in Practice: Glimpses from Kotlin’s Standard Library
Kotlin’s standard library provides a real-world playground for understanding variance. Just as we saw with vending machines and payment methods, the library utilizes these principles to enhance code flexibility and readability. Let’s delve into a few instances from the standard library that exemplify the application of variance.
The List
interface in Kotlin is a prime example of covariance. Just as our coffee machines fit into the broader category of beverage dispensers, elements of a List<Child>
can be safely read as elements of a List<Parent>
, thanks to the out
keyword.
MutableList
, on the other hand, represents invariance. Similar to our repair manuals, where specifics matter, a MutableList<Child>
and a MutableList<Parent>
remain distinct, preventing potential conflicts when adding or removing items.
The Comparator
interface illustrates contravariance beautifully. Much like a payment processor that accepts various payment methods, a Comparator<Parent>
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, and while this might seem like a useful feature at first, it 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 successfully, but it will throw an ArrayStoreException
at runtime. Even though String[]
is a subtype of Object[]
(due to covariance), you can't safely insert any object other than a string (or a subtype of string) into the objectArray
.
Just remember, with Java arrays, what compiles doesn’t always run smoothly. Much like a morning without coffee ☕️!
Conclusion
Understanding variance, as showcased in Kotlin’s standard library, offers profound insights into creating flexible and type-safe code structures. By recognizing how invariance, covariance, and contravariance operate, developers can write more robust and adaptable programs.
Thank you for investing your time in reading this post! 🙏 If you found it valuable, please leave a comment 💬 and share it with your network 📢.
For further reading, refer to the official Kotlin documentation on generics. To explore similar concepts in other languages, check out the Java documentation on wildcards, C# variance in generic interfaces, and the Scala guide on variance.