Author profile picture

Contravariance

Most of the time, subtyping is intuitive. Object-oriented programmers are used to the concept of inheritance, where one class specializes another, and we’re familiar with the idea of passing, say, a Dog object where a function expects an Animal.

Once you start working with generics, though, there are some fascinating twists that flip our typical understanding of subtyping on its head.

What is Contravariance?

Contravariance describes a relationship between two sets of types where they subtype in opposite directions. As an example, we’ll make the simplest of inheritance hierarchies - classes A and B, where B is a subtype of A:

open class A
open class B : A()

And, we’ll create a generic class that can hold objects of our A and B classes.

class Box<in T> {
    private val items = mutableListOf<T>()
    fun deposit(item: T) = items.add(item)
}

At this point, this is a mostly useless class - you can deposit items into it, and that’s it. I was going to call this Receptacle instead of Box, but it’s ridiculously more difficult to type. So I’m going with Box. Just think of it as a drop-box for mail at your local post office.

Now, here’s the question - how does the subtyping work for Box? Is Box<B> a subtype of Box<A>, just like B is a subtype of A?

In fact, the opposite is true!

UML diagram depicting subtyping of A and B applying in the opposite direction from Box<A> and Box<B>

As you can see in the diagram above, the subtyping between these two sets of types is working in opposite directions:

  • B is a subtype of A, whereas…
  • Box<B> is a supertype of Box<A>

This happened because we added the magic variance annotation in to our type parameter T above.

Examples

Using contravariance

Because of this, we can use Box<A> anywhere that the code expects Box<B>:

val boxOfA: Box<A> = Box<A>()
val boxOfB: Box<B> = boxOfA

This also works when you’re passing it to a function:

fun update(box: Box<B>) {
    // ...
}

val boxOfA: Box<A> = Box<A>()
update(boxOfA)

Creating contravariance

To create a generic that’s contravariant on its type parameter, just add the word in before it:

class Box<in T> {
	// ...
}

If we were to omit in from the type parameter declaration, then neither Box<A> nor Box<B> would be a subtype of the other. In that case, we’d say the class Box is invariant.

Contravariance comes with a trade-off - once an object of this type has been constructed, you’ll never be able to return a result of type T from a function. That’s why it’s called in - because we’re promising that we’ll only ever send an object of type T in to the object.

Contravariance and Inheritance

Functions

Variance is a concept that applies not just to generics, but also to inheritance.

  • In many languages, including Java and Kotlin, covariant return types allow methods on a subclass to return a more specific type than their superclass.
  • On the other hand, most languages do not allow for contravariant argument types. So, even though it would be safe for a subclass to expect a more general argument type than its superclass, it’s not actually allowed.

For example:

open class Super {
    open fun execute(arg: B) {
        // ...
    }
}

class Sub : Super() {
    override fun execute(arg: A) {
        // ...
    }
}

By the rules of contravariance, this is type-safe. But the Kotlin compiler will error on Sub’s override, saying:

execute overrides nothing

Why would language designers choose to keep argument types invariant? The trade-off has to do with function overloading.

For example, if you remove the override on Sub, then Sub will have two methods, both named execute(), and which method is invoked is determined by the declared type of the argument:

with(Sub()) {
    execute(A())         // invokes Sub's execute()
    execute(B())         // invokes Super's execute()
    execute((B() as A))  // invokes Sub's execute()
}

Properties

If you want contravariant argument types with inheritance, then instead of using a function, you can use a property. For example:

open class Super {
    open val execute: (B) -> Unit = {
        // ...
    }
}

class Sub : Super() {
    override val execute: (A) -> Unit = {
        // ...
    }
}

Invoking the same code on these classes will result in Sub’s execute being called in all three cases:

with(Sub()) {
    execute(A())         // invokes Sub's execute()
    execute(B())         // invokes Sub's execute()
    execute((B() as A))  // invokes Sub's execute()
}

This makes perfect sense, because the trade-off of overloading is irrelevant in this case - overloading applies to functions, not to properties. So nothing is lost by allowing contravariant argument types.

More Information

Share this article:

  • Share on Twitter
  • Share on Facebook
  • Share on Reddit