Can we make a template function to merge any two T objects?

Viewed 288

Example


We have two simple objects:

Store1(
   var domainId = null
   val localId = 123
)

Store2(
   var domainId = 5
   val localId = null
)

So after merging we get:

var domainId = 5
val localId = 123

Half there solution


fun Store.join(other: Store): Store {
    val store = Store(null, null, null, null, null, 0, "", null, 0, null, null, false)

    for (prop in Store::class.memberProperties) {
        prop.get(store) = prop.get(this) ?: prop.get(other)
    }

    return store
}

Current problems:

  1. prop.get(stopDetailModel) is read only, I can't assign value to it.
  2. It is not a generic function.

Is this achievable?

4 Answers

For data classes, this is a solution without using the Java Class, only KClass (and without mutation).

You can implement such a generic function with a reified type parameter, which is a type parameter that you can get its KClass at runtime. Then you can directly call the constructor since you have a data class. For each parameter to the constructor, you lookup the property with the same name and find the first one whose value of that property is not null.

inline fun <reified T : Any> merge(vararg xs: T): T {
  val cls = T::class
  require(cls.isData) { "Only data class can be merged" }
  return cls.constructors.first().call(* ctor.parameters.map { par ->
    val prop = cls.declaredMemberProperties.first { it.name == par.name }
    val picked = xs.find { prop.get(it) != null }
    picked?.let { prop.get(it) }
  }.toTypedArray())
}

This will work for arbitrarily many instances:

data class Store(
  var domainId: Int?,
  val localId: Int?,
)

val x = merge(
  Store(123, null),
  Store(null, 456),
  Store(null, 789),
)

fun main() {
  println(x) // Store(123, 456)
}

More generic solution (with some limitations)

  • Allows to merge not only data classes but any classes (if there is an appropriate constructor, it may be not a no-arg constructor);
  • Allows to merge both mutable and immutable properties(*see Limitations section), including ones defined in superclasses;
  • No Java Reflection API (only Kotlin Reflection)
inline fun <reified T : Any> mergeOrNull(a: T, b: T): T? {
    val availableConstructors = kClass.constructors.filter { it.visibility != PRIVATE && it.visibility != PROTECTED }
    for (constructor in availableConstructors) {
        return mergeOrNull(a, b, constructor) ?: continue
    }
    return null
}

inline fun <reified T : Any> mergeOrNull(a: T, b: T, constructor: KFunction<T>): T? {
    //Match parameters with properties by name
    val properties = T::class.memberProperties.associateBy { it.name }
    val parameterToPropertyMap = constructor.parameters.associateWith { properties[it.name] }

    //check there is no unmatched mandatory parameters
    // (unmatched parameters with default values can be ignored)
    val unmatchedMandatoryParameters = parameterToPropertyMap
        .filterValues { it == null }.keys
        .filter { !it.isOptional }
    if (unmatchedMandatoryParameters.isNotEmpty()) {
        log.debug(
            "Failed to use $constructor constructor for merge. " +
                    "No matching property for mandatory constructor parameters: ${unmatchedMandatoryParameters.joinToString { it.name!! }}."
        )
        return null
    }
    val mergePropertyValues = { prop: KProperty1<T, *> -> prop.get(a) ?: prop.get(b) }
    val args = parameterToPropertyMap.filterValues { it != null }.mapValues { (_, prop) -> mergePropertyValues(prop!!) }

    val result = try {
        constructor.callBy(args)
    } catch (e: Throwable) {
        log.debug("Failed to use $constructor constructor for merge. ${e.stackTraceToString()}")
        return null
    }
    
    val (mutableProperties, immutableProperties) = T::class.memberProperties.partition { it is KMutableProperty1<T, *> }
    mutableProperties.forEach { (it as KMutableProperty1<T, *>).setter.call(result, mergePropertyValues(it)) }
    
    //check that all immutable properties were merged correctly
    val incorrectlyMergedImmutableProperties = immutableProperties.filter { it.get(result) != mergePropertyValues(it) }
    return when {
        incorrectlyMergedImmutableProperties.isEmpty() -> result
        else -> {
            log.debug("Failed to use $constructor constructor for merge. " +
                    "Failed to correctly merge following immutable properties: ${
                        incorrectlyMergedImmutableProperties.joinToString {
                            "${it.name} (expected: ${mergePropertyValues(it)}, actual: ${it.get(result)})"
                        }
                    }")
            null
        }
    }
}

Limitations

  1. Can't correctly merge immutable properties if there are several properties, evaluated from one constructor parameter when some of them evaluated to null, while some others - not.
data class DataStore(val x: String?) {
    val y = "$x!!!"
}

val result = mergeOrNull(DataStore(null), DataStore("2")) // null
//Failed to use fun <init>(kotlin.String?): DataStore constructor for merge.
//Failed to correctly merge following immutable properties: y (expected: null!!!, actual: 2!!!)
  1. Can't correctly merge immutable properties if there is a same-named constructor parameter, but its value is not assigned as-is.
class DataStore(x: String?) {
    val x = "$x!!!"
}

val result = mergeOrNull(DataStore("x"), DataStore(null)) // null
//Failed to use fun <init>(kotlin.String?): DataStore constructor for merge. 
//Failed to correctly merge following immutable properties: x (expected: x!!!, actual: x!!!!!!)

Update (recursive merge for nested objects)

inline fun <reified T : Any> mergeOrNull(a: T, b: T) = mergeOrNull(a, b, T::class)

fun <T : Any> mergeOrNull(a: T, b: T, kClass: KClass<T>): T? {
    //If a and b are primitives, merge strategy is simple
    if (a is Int || a is Long || a is Double || a is Float || a is Short || a is Char || a is Byte || a is Boolean || a is String || a::class.isValue || a::class.objectInstance != null) return a

    //Otherwise we need to construct new object
    val availableConstructors = kClass.constructors.filter { it.visibility != PRIVATE && it.visibility != PROTECTED }
    for (constructor in availableConstructors) {
        return mergeOrNull(a, b, kClass, constructor) ?: continue
    }
    return null
}

private fun <T : Any> mergeOrNull(a: T, b: T, kClass: KClass<T>, constructor: KFunction<T>): T? {
    //Match parameters with properties by name
    val properties = kClass.memberProperties.associateBy { it.name }
    val parameterToPropertyMap = constructor.parameters.associateWith { properties[it.name] }

    //check there is no unmatched mandatory parameters
    // (unmatched parameters with default values can be ignored)
    val unmatchedMandatoryParameters = parameterToPropertyMap
        .filterValues { it == null }.keys
        .filter { !it.isOptional }
    if (unmatchedMandatoryParameters.isNotEmpty()) {
        log.debug(
            "Failed to use $constructor constructor for merge. " +
                    "No matching property for mandatory constructor parameters: ${unmatchedMandatoryParameters.joinToString { it.name!! }}."
        )
        return null
    }
    val mergePropertyValues = { prop: KProperty1<T, *> ->
        val propA = prop.get(a)
        val propB = prop.get(b)
        @Suppress("UNCHECKED_CAST")
        if (propA != null && propB != null) mergeOrNull(propA, propB, propA::class as KClass<Any>) else propA ?: propB
    }

    val args = parameterToPropertyMap.filterValues { it != null }.mapValues { (_, prop) -> mergePropertyValues(prop!!) }

    val result = try {
        constructor.callBy(args)
    } catch (e: Throwable) {
        log.debug("Failed to use $constructor constructor for merge. ${e.stackTraceToString()}")
        return null
    }

    val (mutableProperties, immutableProperties) = kClass.memberProperties.partition { it is KMutableProperty1<T, *> }
    mutableProperties.forEach { (it as KMutableProperty1<T, *>).setter.call(result, mergePropertyValues(it)) }

    //check that all immutable properties were merged correctly
    val incorrectlyMergedImmutableProperties = immutableProperties.filter { it.get(result) != mergePropertyValues(it) }
    return when {
        incorrectlyMergedImmutableProperties.isEmpty() -> result
        else -> {
            log.debug("Failed to use $constructor constructor for merge. " +
                    "Failed to correctly merge following immutable properties: ${
                        incorrectlyMergedImmutableProperties.joinToString {
                            "${it.name} (expected: ${mergePropertyValues(it)}, actual: ${it.get(result)})"
                        }
                    }")
            null
        }
    }
}

Even more generic solution (overcomes all limitations of the previous solution):

  • Allows to merge any classes (if there is at least one constructor without mandatory non-nullable interface/functional parameters; regardless of its accessibility);
  • Allows to merge all properties, both mutable and immutable, including ones defined in superclasses;
  • Merge is always successful (if not fall into an infinite recursive loop)

Price for this:

  • Usage of Java Reflection API aka "dark magic" (so, will work only for JVM, moreover only for JDK < 12)

Bonus:

  • Merge strategy for properties extracted into an argument
  • Several items could be passed for merging
  • No reified generics
fun <T : Any> merge(
    vararg x: T,
    propertiesMergeStrategy: (a: T, b: T, KProperty1<T, *>) -> Any?
): T = x.reduce { acc, t -> mergeUsingDarkMagic(acc, t, propertiesMergeStrategy) }

fun <T : Any> mergeUsingDarkMagic(
    a: T, b: T, propertiesMergeStrategy: (a: T, b: T, KProperty1<T, *>) -> Any?
): T {
    @Suppress("UNCHECKED_CAST")
    val aKlass = a::class as KClass<T>
    //If a and b are primitives, merge strategy is simple
    if (a is Int || a is Long || a is Double || a is Float || a is Short || a is Char || a is Byte || a is Boolean || a is String || aKlass.isValue || aKlass.objectInstance != null || aKlass.java.isArray) return a

    //Need to create new class instance otherwise
    return aKlass.createDummyInstance().apply {
        val (mutableProperties, immutableProperties) = aKlass.memberProperties.partition { it is KMutableProperty1<T, *> }

        //Handle a situation, when properties of `a` are absent in `b`
        // (it may happen if they both implement the same interface, but have different runtime classes)
        val bKlass = b::class
        val bKlassProperties by lazy { bKlass.memberProperties.toSet() }
        val propertiesMerge = { prop: KProperty1<T, *> ->
            if (aKlass == bKlass || bKlassProperties.contains(prop)) propertiesMergeStrategy(a, b, prop)
            else prop.get(a)
        }

        //set mutable properties using basic Kotlin Reflection API
        mutableProperties.forEach {
            //bypass private modifier
            it.isAccessible = true

            (it as KMutableProperty1<T, *>).setter.call(this, propertiesMerge(it))
        }
        //set immutable properties using Java Reflection API via direct access to the underlying class fields
        immutableProperties.forEach {
            val javaField = it.javaField ?: return@forEach
            //bypass private modifier
            it.isAccessible = true

            //bypass final modifier; works only for JDK < 12
            if ((javaField.modifiers and FINAL) == FINAL) {
                val modifiersField = Field::class.java.getDeclaredField("modifiers")
                modifiersField.isAccessible = true
                modifiersField.setInt(javaField, javaField.modifiers and FINAL.inv())
            }

            javaField.set(this, propertiesMerge(it))
        }
    }
}

fun <T : Any> KClass<T>.createDummyInstance(): T {
    if (this.java.isPrimitive) {
        @Suppress("UNCHECKED_CAST")
        return when (this) {
            Int::class -> 0
            Long::class -> 0L
            Double::class -> 0.0
            Float::class -> 0.0f
            Short::class -> 0.toShort()
            Char::class -> 0.toChar()
            Byte::class -> 0.toByte()
            Boolean::class -> false
            else -> 0
        } as T
    } else if (this.java.isArray) {
        @Suppress("UNCHECKED_CAST")
        return when (this) {
            IntArray::class -> intArrayOf()
            LongArray::class -> longArrayOf()
            DoubleArray::class -> doubleArrayOf()
            FloatArray::class -> floatArrayOf()
            ShortArray::class -> shortArrayOf()
            CharArray::class -> charArrayOf()
            ByteArray::class -> byteArrayOf()
            BooleanArray::class -> booleanArrayOf()
            else -> emptyArray<Any>()
        } as T
    }

    //Take the first that comes to hand constructor without mandatory non-nullable interface/functional parameters (god knows how to instantiate them via reflection)
    val constructor = constructors.firstOrNull {
        it.parameters.all { param -> param.isOptional || param.type.isMarkedNullable || (param.type.jvmErasure !is Function<*> && !param.type.jvmErasure.java.isInterface) }
    } ?: throw NoSuchElementException("Failed to instantiate $this; no constructor without mandatory non-nullable interface/functional parameters")
    val args = constructor.parameters
        .filter { !it.isOptional } //Omit optional parameters
        .associateWith {
            when {
                it.type.isMarkedNullable -> null //Pass null for nullable parameters
                else -> it.type.jvmErasure.createDummyInstance() //Recursively instantiate mandatory non-nullable parameters
            }
        }
    return constructor.apply { isAccessible = true }.callBy(args)
}

Simple merge strategies could be passed as a lambda:

//Now it works!
println(merge(DataStore(null), DataStore("2")) { a, b, prop -> prop.get(a) ?: prop.get(b) }) //DataStore(x=2, y='null!!!')

data class DataStore(val x: String?) {
    val y = "$x!!!"

    override fun toString() = "DataStore(x=$x, y='$y')"
}

Recursive merge strategies require declaring them as a function (and passing as a function reference):

fun <T> mergeRecursively(a: T, b: T, prop: KProperty1<T, *>): Any? {
    val propA = prop.get(a)
    val propB = prop.get(b)
    @Suppress("UNCHECKED_CAST")
    return if (propA != null && propB != null) mergeUsingDarkMagic(propA, propB, propA::class as KClass<Any>, ::mergeRecursively) else propA ?: propB)
}

//Now it works too!
println(merge(DataStore("x"), DataStore(null), propertiesMergeStrategy = ::mergeRecursively)) //DataStore(x='x!!!')

class DataStore2(x: String?) {
    val x = "$x!!!"

    override fun toString() = "DataStore2(x='$x')"
}

Generic approach could be achieved by using Reified Type Parameters. So declare following function:

inline fun <reified T> merge(a: T, b: T): T {
    val result = T::class.java.newInstance()

    val properties = T::class.declaredMembers.filterIsInstance<KMutableProperty<*>>()
    properties.forEach {
        val value = it.getter.call(a) ?: it.getter.call(b)
        it.setter.call(result, value)
    }

    return result
}

And call it directly

val result = merge(store1, store2)
println("domainId=" + result.domainId + ", localId=" + result.localId)

or using extension function

fun Store.merge(that: Store): Store {
    return merge(this, that)
}

val result = store1.merge(store2)
println("domainId=" + result.domainId + ", localId=" + result.localId)

Both code samples will output

domainId=5, localId=123

Note: this solution is only suitable for classes with entirely mutable properties (var) and NOT suitable for immutable (val) properties!

Related