Kotlin generics: counterintuitive type inference and checking with out keyword
Asked Answered
K

1

3

I've been recently learning Kotlin, while having some questions with covariant type.

The sample code is here. I have Option and Option2 both having a type parameter T and a run extension.

I could understand the first two run in validation(), since they are behaved as Java. But why does the third line compile? Option<T> is invariant in T. We cannot passing Option<C> instance into where Option<B> is expected.

After I add an out keyword for T, now all of them could compile. Why?

open class A
open class B : A()
open class C : B()


class Option<T>(val item: T)

fun <T> Option<T>.run(func: (Int) -> Option<T>): Option<T> = func(1)


class Option1<out T>(val item: T) //out keyword

fun <T> Option1<T>.run(func: (Int) -> Option1<T>): Option1<T> = func(1)


fun validation() {
    val opt: Option<B> = Option(B())
    opt.run { Option(A()) } //won't compile as expected
    opt.run { Option(B()) } //return type is Option<B>
    opt.run { Option(C()) } //return type is Option<B>; why could this compile?

    val opt1: Option1<B> = Option1(B())
    opt1.run { Option1(A()) } //return type is Option<A>; why could this compile?
    opt1.run { Option1(B()) } //return type is Option<B>
    opt1.run { Option1(C()) } //return type is Option<B>
}
Kopans answered 19/4, 2019 at 16:50 Comment(1)
have you read kotlinlang.org/docs/reference/generics.html#variance? I think it's hard to put those words into something simpler.Seoul
E
4
  • opt.run { Option(C()) } //return type is Option<B>; why could this compile?

    Here, you can approximate the behavior as follows by decomposing the call into the two lines that are type-checked separately:

    val func: (Int) -> Option<B> = { Option(C()) }
    opt.run(func)
    

    The first line is correct because:

    • the lambda is expected to return Option<B> (with exactly B, as Option is invariant),
    • so the Option(item: T): Option<T> constructor call needs to accept a B,
    • the argument that is passed is C(),
    • as C : B, C() passes the check for being B,
    • and so Option(C()) can also be typed as Option<B> and passes the check,
    • OK, the lambda passes the check for (Int) -> Option<B>.


    Sanity check: what if you replace the first line as follows?

    val func: (Int) -> Option<B> = { Option(C()) as Option<C> }
    

    Then it won't get compiled, as the expression inside the lambda is now typed as Option<C> which is not a subtype of Option<B>.


  • opt1.run { Option1(A()) } //return type is Option<A>; why could this compile?

    In this sample, the type that the compiler chose for T is not B, it is A. The compiler is allowed to do that because of covariance of the type parameter T.

    • opt1 is Option1<B>
    • Option1<out T> is covariant on T, which allows substituting T with any supertype of B,

      This is allowed because for any Z such that B : Z, opt1 can also be treated as Option1<out Z> thanks to the out modifier, and the compiler can then type-check the call against a receiver type Option1<Z>.

    • the substition for T would be the least common supertype of B and whatever X such that the lambda returns Option1<X>,

    • the lambda returns Option1<A>,
    • find the least common supertype of B and A,
    • given that B : A, the least common supertype is A
    • substitute T := A.


    Sanity check: what if you change the expression as follows?

    opt1.run { Option1(0) }
    

    It will still compile successfully, but the inferred return type will be Option1<Any>. This is totally reasonable according to the above, because the least common supertype of B and Int is Any.


Disclaimer: this is not how the compiler works internally, but using this way of reasoning you may often get the results that agree with the compiler's results.

Exemplary answered 19/4, 2019 at 17:46 Comment(1)
You really help me a lot to understand this question. Thanks!Kopans

© 2022 - 2024 — McMap. All rights reserved.