kotlinkotlin-delegate

Kotlin delegate's ReadOnlyProperty with generic type value dose not cast correctly in getValue


I am expecting to see the output

black
white

with below code

package delegate

import kotlinx.coroutines.runBlocking
import kotlin.properties.ReadOnlyProperty
import kotlin.reflect.KProperty

open class Color(private val name: String) {
    override fun toString(): String {
        return name
    }
}

class Black : Color("black")
class White : Color("white")

class ColorCollection {
    private val black = Black()
    private val white = White()
    val list = listOf(black, white)
}

class Palette {
    val black: Black by ColorDelegate()
    val white: White by ColorDelegate()
    val colorCollection = ColorCollection()
}

class ColorDelegate<T> : ReadOnlyProperty<Palette, T> {
    override fun getValue(thisRef: Palette, property: KProperty<*>): T {
        return thisRef.colorCollection.list.mapNotNull { it as? T }.first()
    }
}

fun main() = runBlocking {
    val palette = Palette()
    println(palette.black)
    println(palette.white)
}

However, I only get black output and then Exception in thread "main" java.lang.ClassCastException: delegate.Black cannot be cast to delegate.White. I found that with this line thisRef.colorCollection.list.mapNotNull { it as? T }, I am expecting it only returns the value in the list that can be safely cast to the generic type, otherwise return null. For example, when accessing black delegated property in Palette, I should only see 1 black element returned by thisRef.colorCollection.list.mapNotNull { it as? T },It actually returns two (black and white). it as? T somehow always works regardless of what T is. I also tried putting a breakpoint at that line, tried "abcdef" as T?, it also works, which I expect to see cast exception that String cannot be cast to Black... enter image description hereenter image description here

Is this a bug...?


Solution

  • Remember that Type Erasure is a thing in Kotlin, so the runtime does not know what the T in it as? T, and hence cannot check the cast for you. Therefore, the cast always succeeds (and something else will fail later down the line). See also this post. IntelliJ should have given you an "unchecked cast" warning here.

    So rather than checking the type using T, you can check the type using the property argument:

    class ColorDelegate<T> {
        operator fun getValue(thisRef: Palette, property: KProperty<*>) =
            // assuming such an item always exists
            thisRef.colorCollection.list.first { 
                property.returnType.classifier == it::class 
            } as T
    }
    
    fun main()  {
        val palette = Palette()
        println(palette.black) // prints "black"
        println(palette.white) // prints "white"
    }
    

    Here, I've checked that the class of the returnType of the property (i.e. the property on which you are putting the delegate) is equal to the list element's runtime class. You can also e.g. be more lenient and check isSubclassOf.

    Do note that this wouldn't find any element in the list if the property's type was another type parameter, rather than a class, e.g.

    class Palette<T> {
        ...
        val foo: T by ColorDelegate()
        ...
    }
    

    But alas, that's type erasure for you :(