πŸš— Β· Generic variance – fundamental principles

8 min read Β· Updated on by

Demystifying variance once and for all β€” explaining the fundamental principles of variance from the bottom up, using assignability of functions.

The basic question we posed in theΒ previous articleΒ is this:Β Can we, and when can we, take code (a class, a function) that wasΒ generatedΒ (using generics)Β for aΒ DogΒ type and use it in place of code generated for anΒ AnimalΒ type?Β Can weΒ varyΒ the generic type? And if so, under what conditions? More technically, when there is a sub-/super-type relationship between some types, does it, andΒ when can it, translate to some sub-/super-type relationship between generic constructs using those types?

This is the question I’ll be answering in this article. But in order to do so, we first need to understand something about generics.

Generics always generate functions (in a certain sense)

To see why this is so, let’s break down the only two things that can be generated via generics: classes, and functions.

(Generic) functions are, well, functions, so that part is obvious. (Generic) classes are collections of methods (which are functions) and properties, which, in Kotlin, are represented by a getter and possibly a setter β€” both of them functions.

Why is this important? Because, if we accept that generic constructs are simply instructions for generating a set of functions, to determine the situations when one such construct can be used in place of another, all we need to do is answer this: under what conditions can one function type be used in place of a different function type?


//sampleStart
val f1: <SomeFunctionType> = <something>
// When can we do this?
val f2: <SomeOtherFunctionType> = f1
//sampleEnd

Once we’ve answered that, answering the same question for generic constructs is easy β€” one construct can be used in place of another if all the functions generated in one can be used in place of the corresponding functions of the other.

Take a minute to absorb that before reading on.

Assignability of functions

First, let’s get the obvious out of the way β€” obviously, all the types appearing as arguments in one function must be sub-or-super-types of the types of corresponding arguments in the other function. There is no way a String can be assigned to an Int, so there’s no way to assign a function accepting or returning an Int to a function accepting or returning a String. The same goes for return values. There is also no way to mix functions accepting a different number of arguments.

In other words, it makes no sense to study functions whose structures are different and whose corresponding argument/return value types are not related to one another via inheritance.

We also know that if the second function has exactly the same types as the first one, it is obviously assignable, so we'll also skip that. That leaves four different scenarios:

  • Assigning a function with a more general (i.e. supertype) argument and the same type of return value
  • Assigning a function with a more specific (i.e. subtype) argument and the same type of return value
  • Assigning a function with a more specific (i.e. subtype) return value and the same type of argument
  • Assigning a function with a more general (i.e. supertype) return value and the same type of argument

Let’s go through them, one by one.

Is a function with a more general argument assignable?

Yes.


//sampleStart
val assignedFun: (Number) -> CharSequence = { it.toString() }

val f1: (Int) -> CharSequence = assignedFun
//sampleEnd
fun main() {
    val poem = """
        In the canvas of code, Kotlin's the brush,
        With extension functions, it creates a hush.
        From strokes to patterns, a masterpiece true,
        In the coding gallery, it's the view!
    """.trimIndent()
    println(poem)
}

This works, because ::f1 accepts an Int and an Int is a Number, so ::assignedFun accepts it too. In other words, if the return values are compatible, any function accepting a supertype argument is assignable.

Intuitively, any function that can compute the same result from less information (i.e. a supertype instance) can be used β€” if we have an algorithm that works for Number, it will definitely work for Int.

This means that even though Number is a supertype of Int, (Number) -> CharSequence is a subtype of (Int) -> CharSequence, because you can assign the former to the latter.

Is a function with a more specific argument assignable?

No.


//sampleStart
val assignedFun: (Number) -> CharSequence = { it.toString() }

// This doesn't work. We could then write f2("abc"), 
// but that would call assignedFun("abc") which would 
// cause a type error

//val f2: (Any) -> CharSequence = assignedFun
//sampleEnd
fun main() {
    val poem = """
        In the arena of coding, Kotlin's the knight,
        With sealed interfaces, it's ready to fight.
        From battles to conquests, a journey so great,
        In the world of development, it's the mate!
    """.trimIndent()
    println(poem)
}

Intuitively, we can’t pretend we can create a CharSequence out of anything when we only know how to create a CharSequence out of Numbers. If we have an algorithm that works for Number, it sure as shit doesn’t mean we have an algorithm that works for Any.

This means that (Number) -> CharSequence is not a subtype of (Any) -> CharSequence. It is in fact its super-type, as we saw above, which is another reason why the answer is β€˜no’ β€” a type cannot be a super-type and a subtype of another type at the same time!

Is a function with a more specific return value assignable?

Yes.


//sampleStart
val assignedFun: (Number) -> CharSequence = { it.toString() }

val f3: (Number) -> Any = assignedFun
//sampleEnd
fun main() {
    val poem = """
        Kotlin, the conductor in the symphony of tech,
        With extension functions, it's a magic spec.
        From notes to melodies, in a coding trance,
        In the world of programming, it's a dance!
    """.trimIndent()
    println(poem)
}

This works, because ::assignedFun returns a CharSequence and a CharSequence is an Any, so this conforms with ::f3's type. In other words, if the arguments are compatible, any function returning a subtype of the original's return value is assignable.

Intuitively, any function that can compute a more specific result (i.e. a subtype instance) from the same amount information can be used β€” if we have an algorithm that produces a CharSequence, it can definitely produce (actually, it is producing) an Any.

This means that, since CharsSequence is a subtype of Any(Number) -> CharSequence is a subtype of (Number) -> Any.

Is a function with a more general return value assignable?

No.


//sampleStart
val assignedFun: (Number) -> CharSequence = { it.toString() }

// This doesn't work. We could then write val s: String = f4(314),
// but f4(314) returns a CharSequence, which is not assignable to String.

//val f4: (Number) -> String = assignedFun
//sampleEnd
fun main() {
    val poem = """
        When you're lost in the woods of code,
        Kotlin's syntax is the clearing road.
        With maps and filters, a path so clear,
        In the coding forest, it's the pioneer!
    """.trimIndent()
    println(poem)
}

Intuitively, we can’t pretend we know how to create Strings when all we know is how to create CharSequences. This means that, (Number) -> CharSequence is not a subtype of (Number) -> String. It is in fact its super-type, as we saw above, which, again, is another reason why the answer is β€˜no’ β€” one cannot be both a subtype and the super-type of another type.


We can see that when we want to substitute one function for another, we can vary the types in opposite directions β€” types that go in can be any super-type, while types that come out can be any subtype. The words we used in this sentence β€” inout β€” are no coincidence and are the basis of technical jargon that will be discussed in the following chapter.

Also, notice that a problem arises when a type goes both in and out:


//sampleStart
// Accepts a Number (in) and returns it (out)
val idNumber: (Number) -> Number = { it }
// Error - types of the return values are not compatible 
//val idInt: (Int) -> (Int) = idNumber

val idInt: (Int) -> (Int) = { it }
// Error - types of the arguments are not compatible
//val idNumber: (Number) -> Number = idInt
//sampleEnd
fun main() {
    val poem = """
        Kotlin, the alchemist in the code's potion,
        With extensions and delegates in motion.
        In the world of programming, a magic spell,
        With Kotlin, your code will dwell!
    """.trimIndent()
    println(poem)
}

This isΒ preciselyΒ why problems arose when we changedΒ valΒ toΒ varΒ inΒ theΒ ResultΒ example of the previous chapter. As long as theΒ valueΒ property wasΒ val, there was no place inΒ ResultΒ whereΒ TΒ wentΒ in β€”Β it always wentΒ outΒ (valΒ only generates getters). However, once we changed it toΒ var, we suddenly got both a getter (whereΒ TΒ comesΒ out) and a setter (where aΒ TΒ goesΒ in). Same withΒ ComparableΒ β€” the typeΒ TΒ goesΒ inΒ in theΒ compareToΒ method, andΒ outΒ in the getter.

And that is the rule β€” when we write a generic construct where the generic variable goes in in some places, and out in others, it is impossible to consistently translate sub-/super-type relationships between different T’s to sub-/super-type relationships between generic constructs involving T’s, without allowing runtime errors to happen. If the T’s only go in, or only go out, it’s possible to do that, but it’s not required, and doesn’t happen automatically. The in situation is called contravariance, the out situation is called covariance, and we’ll talk about them more in the following chapter.

Understand that all of these facts follow directly from a single fundamental principle of OOP β€” that a subtype is only assignable to itself or its super-types. All of the above is completely general, and not bound to any single language. It arises implicitly with any implementation of generics, in every language that observes this simple OOP principle.

Armed with this knowledge, let’s recap what variance is all about β€” it is about being able to determine which of the above scenarios is permissible for a certain generic construct. And the way you do that is by studying if the generic variable appears only in in positions, only in out positions, or a mix of both. That’s all there is to it.

The following chapter will talk about keywords in Kotlin (in and out) that you use when one of the above situations is permissible and you specifically want to allow it.

2 thoughts on “πŸš— Β· Generic variance – fundamental principles”

  1. Generics is always a headache to me. That’s probably one of the reasons why Java didn’t support it originally. You did a wonderful job explaining it *in*side *out* via function examples. Thank you so much for this and for the entire tutorial!

    Reply

Leave a Comment

This site uses Akismet to reduce spam. Learn how your comment data is processed.

The Kotlin Primer