Invariance, Covariance and Contravariance - Is there a metaphor?

239 Views Asked by At

I want to start by saying that I know this topic has been discussed in depth on Stack Overflow. But I don't feel like the explanations given provide a strong story that helps to retain the examples and knowledge.

Can someone provide a metaphor or story to help me understand the concept of covariance and contravariance in relation to the following example in Kotlin?

Here are the constructs:

open class Animal
class Dog : Animal()

fun doSomething(fn: (Dog) -> Number) {}

I'm trying to understand why the following code works:

animalToIntMethod: (Animal) -> Int = {TODO()}
doSomething(animalToIntMethod)

In Kotlin, the internal representation of (Dog) -> Number is Function<Dog, Number>, which involves the use of contravariance and covariance, specifically Function<in T, out R>. However, I'm having trouble understanding how these concepts actually work. Could someone please explain the principles of contravariance and covariance in this context?

3

There are 3 best solutions below

3
broot On

It is hard to explain the whole concept in a few words and as you said, you already read some explanations, so I think I would pretty much repeat what was said in other places. Instead, I'll focus on your example.

Your function doSomething() requires another function which "converts" a dog into a number: (Dog) -> Number. Let's say doSomething() is a documentalist who creates some kind of a catalogue of dogs. This person has to know the height of each dog to put it into the catalogue, but they don't know how to measure the height of a dog, so they need a qualified veterinarian for this. Veterinarian is our (Dog) -> Number function - they know how to provide a height for a given dog.

We found a veterinarian animalToIntMethod who is super-qualified, they know how to measure any animal in the world - therefore their type is (Animal) -> Int.

The area of focus of both persons is different. Documentalist needs someone, who can measure dogs specifically. Veterinarian doesn't focus on dogs, they can measure animals in general, their area of expertise is much wider. But of course, that doesn't mean they can't cooperate. Veterinarian can measure any animal, including dogs.

This is an example of contravariance, represented as in in the Function<in T, out R>. Documentalist doesn't need someone working with dogs exclusively (that would be invariance). It is fine if the provided veterinarian can work with mammals or animals in general - as long as they can work with dogs.

0
Tenfour04 On

Contravariance

Dog is an input to doSomething()'s requested function, so it is contravariant. As the input (contravariant) type gets more specific, the function type gets less specific, and vice versa.

A function that can accept Any as an input is also a function that can accept Dogs as an input. Therefore (Any)->____ also qualifies as (and is a subtype of) a (Dog)->_____.

Or another way to put it...doSomething() is expecting to be able to use this function by passing Dogs to it, so the function passed in must be able to accept Dogs as input. A function that accepts Any as an input can accept any input, so it can definitely accept Dogs.

Covariance

Number is an output of doSomething()'s requested function, so it is covariant. As the output (covariant) type gets more specific, the function type gets more specific, and vice versa.

A function that can produce Ints is also a function that produces Numbers. Therefore (___)->Int also qualifies as (and is a subtype of) (___)->Number.

Another way to put it...doSomething() expects to be able to use this function to produce Numbers. If it gets a function that produces Ints, then it is getting Numbers so the request is satisfied.

Invariance

This isn't supported for function type parameters. Since functions are first class types, it would violate the Liskov substitution principle to force invariance for the inputs or outputs.

0
user16118981 On

It took me a while to find a resource that helped me gain a solid grasp of these concepts. I know it can sometimes seem like a bit of a cop out to post a link and move on, but this article (read in its entirety) really proved a fantastic resource to help me understand these concepts (especially for understanding the more esoteric contravariance): https://proandroiddev.com/understanding-type-variance-in-kotlin-d12ad566241b

The most elucidating example they include is a user-defined copyData function that illustrates the harder concept of contravariance as a practical example (not to mention it's an example where if they used covariance it would be error-prone, and if they used invariance it would be unnecessarily strict).

That is to say, if you had a generic function that copied each element from one mutable list (say of type "Dog") into another (say of type "Animal"), that the function could be made to accept a specific "species" for the "copy from" parameter and any supertypes of said species for the "copy to" parameter (using the "in" keyword). Here's the function from the article:

fun <T> copyData(source: MutableList<T>,
                 destination: MutableList<in T>) {
    for (element in source) {
        destination.add(element)
    }
}

This means that:

  1. You can copy a list of Dogs into a list of Animals, or a list of Cats into a list of Animals (perfectly acceptable); however...
  2. The function won't allow you to copy a list of Dogs into a list of Cats without an exception at compile time.

The former (1) illustrates how we can achieve more versatility than invariance otherwise would allow. Nevertheless, the latter (2) illustrates how type safety is better ensured (by catching issues at compile time as opposed to runtime) than if we tried to mess with the "Any" type to achieve a similar functionality...

But, as a summary, I'm not doing the fullness of the article justice. After scouring the internet, this one was the one that made many of the concepts "click" for me and I do suggest reading it in full.