0

I want to implement a function that compares two objects of the same type based on their Comparable properties, such as:

fun <T:Any> reflectCompare(a: T, b: T) : Int {
    a::class
        .memberProperties
        .intersect(b::class.memberProperties.toSet())
        .forEach { prop ->
            val pa = prop.getter.call(a)
            if(pa is Comparable<*>) {
                val cmp = pa.compareTo(prop.getter.call(b)) // ERROR: Type mismatch
                if(cmp != 0) return cmp
            }
        }
    return 0
}

However, how can I achieve this without resorting to an unsafe cast to Comparable<Any>?

  1. For instance, in the above implementation, using prop.getter.call(b) as an argument to pa.compareTo(...) yields a Type mismatch: inferred type is Any? but Nothing was expected error.

  2. Moreover, replacing if(pa is Comparable<*>) with if(pa is Comparable<Any?>) isn't viable. Due to type erasure, it's impossible to check the type argument with is.

  3. Even though filterIsInstance appears safe and compiles without errors, it's not entirely safe. Despite its outward appearance, it's not truly safe.

The following alternative implementation for reflectCompare compiles without errors but fails to provide the intended behavior:

fun <T:Any> reflectCompare(a: T, b: T) : Int {
    a::class
        .memberProperties
        .intersect(b::class.memberProperties.toSet())
        .filterIsInstance<KProperty1<T, Comparable<Any?>>>()
        .forEach { prop ->
            val pa = prop.getter.call(a)
            val cmp = pa.compareTo(prop.getter.call(b))
            if(cmp != 0) return cmp
        }
    return 0
}

If used with a class with incomparable properties, it will attempt to cast the value of that property to Comparable, resulting in an error. For example, the following test fails with: ClassCastException: class Residence cannot be cast to class java.lang.Comparable.

class Individual(val name: String, val addr: Residence)
class Residence(val nr: Int, val street: String)

@Test fun testCompareReflect() {
    val a = Individual("Bart", Residence(45, "Pink Street"))
    val b = Individual("Bart", Residence(23, "Soho"))
    compare(a, b) // ClassCastException: class Residence cannot be cast to class java.lang.Comparable
}

Upon examining the implementation of filterIsInstance, it becomes clear that it doesn't truly provide the expected behavior. It uses if (element is R), where R takes KProperty1<T, Comparable<Any?>> from the above example. Due to type erasure, it merely checks if it's a KProperty, neglecting the type arguments. Perhaps filterIsInstance should warn about this biased behavior, but @kotlin.internal.NoInfer R on the type parameter R obscures this error.

1 Answer 1

0

There are (at least) two major problems with your approach.

First of all, it is conceptually wrong. Only because a property is Comparable<*>, doesn't mean we can compare it to the same property in the second object. It can compare to anything, not necessary to itself:

fun main() {
    val o1 = MyClass()
    val o2 = MyClass()
    o1.prop1.compareTo(o2.prop1) // doesn't compile
}

class MyClass {
    val prop1: Comparable<String> = TODO()
}

We would rather need to check the type parameter of the Comparable and verify the property is of the same type. Then we can safely cast. However, such solution will be much more complicated than what you do right now, and it requires to use KType. Also, there are some corner cases to keep in mind, for example if the T of Comparable is erased and we can't easily verify it.

Second, you assume both a and b are of the same type, which is not necessarily true. What if a is a subtype of b and adds extra properties? In that case prop.getter.call(b) will fail for props b lacks. As a matter of fact, the compiler shouldn't allow you to write such code. If you use the "regular" way to get properties, i.e.: prop.get(b), then the code would not compile, saying types are not correct. You workarounded this by using prop.getter.call(b), which ignores types, but that doesn't mean this is safe.

You can add a runtime check that a::class == b::class or maybe compare only properties of the super type - it depends on your case.

Update

Proper implementation using the T of Comparable to detect if the comparison is safe to do, could be something like this:

fun <T:Any> reflectCompare(a: T, b: T) : Int {
    a::class
        .memberProperties
        .intersect(b::class.memberProperties.toSet())
        .forEach { prop ->
            println("Property: $prop")
            @Suppress("UNCHECKED_CAST")
            val pa = (prop as KProperty1<T, *>).get(a)
            val pb = prop.get(b)
            if (pa !is Comparable<*>) {
                println("Not Comparable, ignoring")
                return@forEach
            }

            // T of Comparable
            val type = pa::class.supertypes
                .single { it.classifier == Comparable::class }
                .arguments[0].type!!.classifier
            
            if (type !is KClass<*>) {
                println("T of Comparable is not a class, ignoring")
                return@forEach
            }
            if (!type.isInstance(pb)) {
                println("Value isn't comparable to itself, ignoring")
                return@forEach
            }

            println("Comparing...")
            @Suppress("UNCHECKED_CAST")
            val cmp = (pa as Comparable<Any?>).compareTo(pb)
            if(cmp != 0) return cmp
        }
    return 0
}

Test:

fun main() {
    val comparable = object : Comparable<String> {
        override fun compareTo(other: String) = 0
    }
    val o = MyClass("hello", MyString("world"), comparable, emptyList(), comparable)
    reflectCompare(o, o)
}

class MyClass<T>(
    val prop1: String, // comparable to itself
    val prop2: MyString, // comparable to itself
    val prop3: Comparable<String>, // not comparable to itself
    val prop4: List<Int>, // not comparable to itself
    val prop5: Comparable<T>, // not sure if comparable to itself
)

data class MyString(val s: String) : Comparable<MyString> {
    override fun compareTo(other: MyString) = s.compareTo(other.s)
}

As said above, this is tricky thing to do, there are corner cases and I suspect this implementation doesn't cover all of them. I ignored some cases for simplicity, e.g.: variance. Also, there is no single way to implement this, for example we could use runtime types (as you did and I did as well) or we could use compile types - this could provide a slightly different result.

We probably need more test cases for generic Comparable<T>. prop5 doesn't really test this case as we use the runtime type of prop5, not the compile type.

Anyway, you can use it as a start.

Sign up to request clarification or add additional context in comments.

10 Comments

Regarding the second issue I updated OP to chain a .intersect(b::class.memberProperties.toSet()). Thanks
Regarding prop.getter.call(b) versus prop.get(b) or even prop.call(b), all compile and work in the same way. I am not seeing any differences. Maybe I can update OP and remove .getter
Not prop.call(b), but prop.get(b). This is the "main" way of reading props and it is statically typed. call is a fully untyped, "just call the function" utility. Please note that after switching, prop.get(a) will also not compile and for the same reason - compiler isn't entirely sure a has the member, because it doesn't understand we iterate over members of a. In that case it is actually safe to cast.
Given val prop: KProperty1<T, Comparable<Any?>> we can write val pb: Comparable<Any?> = prop.get(b) or prop.call(b). Both compile without errors. a and b are both of type T, and .filterIsInstance<KProperty1<T, Comparable<Any?>>>() ensures a receiver of type T for those properties.
This code throws a ClassCastException: val foo = listOf(listOf("hello")).filterIsInstance<List<Int>>().first().first(). No warnings by the compiler, no warnings in docs. :facepalm:
|

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.