None
Photo by Ji Seongkwang on Unsplash

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.

None
Soft drink and coffee are both beverages

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.

None
Covariance: Instances of the generic class have the same inheritance relationships as their parameter type.

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.

None
Payment Methods

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>.

None
Contravariance flips the inheritance relationship

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 Comparatorinterface 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 💬, give it a clap 👏, 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.

None
Kt. academy Blog