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 Number
s. 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 String
s when all we know is how to create CharSequence
s. 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 β in, out β 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.
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!
Hey Jason! Thank you so much for the kind words, I’m glad I could help!