Copy a Kotlin Data Class using Reflection

May 31, 2021

First of all: Don’t if it isn’t really necessary. Reflection can be easily abused and it’s very slow. Compared to Java, Kotlin’s reflection has an additional warmup time to fill reflection caches (it was ~1s for my use case on the first run), and up to 100-200ms on consecutive runs. If you’re still undeterred, this is how to do it:

fun copyByReflection(instance: Any): Any {
    val instanceKClass = instance::class
    require(instanceKClass.isData) { "instance must be data class" }

    val copyFunction = instanceKClass.functions.single { function -> function.name == "copy" }
    val copy = copyFunction.callBy(
        mapOf(copyFunction.instanceParameter!! to instance)
    ) ?: error("copy didn't return a new instance")
    return copy
}

@Test
fun `copyByReflection returns a new instance with same values`() {
    data class Example(
        val text: String,
        val number: Int
    )

    val instance = Example(text = "The answer", number = 42)
    val copy = copyByReflection(instance)

    assertEquals(instance, copy)
    assertNotSame(instance, copy)
    println(copy)
}

Side-note: If you define classes in a function body like data class Example above, you might run into KT-25337:

java.lang.reflect.GenericSignatureFormatError: Signature Parse error: expected '<' or ';' but got  
	Remaining input: …

You can workaround by moving the class definition out of the function body that contains spaces in the name. However, I like my tests as self-contained as possible.

Changing a Property

If you don’t want a plain copy, but a copy with some properties changed, use this extended version:

fun copyByReflection(instance: Any, newValues: Map<String, Any?>): Any {
    val instanceKClass = instance::class
    require(instanceKClass.isData) { "instance must be data class" }

    val copyFunction = instanceKClass.functions.single { function -> function.name == "copy" }

    val valueArgs = copyFunction.parameters
        .filter { parameter -> parameter.kind == KParameter.Kind.VALUE }
        .mapNotNull { parameter ->
            newValues[parameter.name]?.let { value -> parameter to value }
        }

    val copy = copyFunction.callBy(
        mapOf(copyFunction.instanceParameter!! to instance) + valueArgs
    ) ?: error("copy didn't return a new instance")
    return copy
}

@Test
fun `copyByReflection returns a new instance with some new values`() {
    data class Example(
        val text: String,
        val number: Int
    )

    val instance = Example(text = "The answer", number = 42)
    val copy = copyByReflection(
        instance,
        mapOf("number" to 1234)
    )

    assertEquals(
        Example(text = "The answer", number = 1234),
        copy
    )
    println(copy)
}

@Test
fun `copyByReflection returns a new instance with multiple new values`() {
    
    val copy = copyByReflection(
        instance,
        mapOf(
            "number" to 1234,
            "text" to "The new text"
        )
    )
	
}

Obfuscation

Things get more complicated if you’re using code obfuscation. With obfuscation turned on, we cannot be sure that the copy-function is called copy. Even worse: We cannot rely on that the parameter names of the copy-function are named like the properties. Even worse: We cannot rely on that the parameters are in the same order as the order returned by instanceKClass.memberProperties (They’re not! I tested it!). Even worse: Annotations that are added on properties are not added to the parameters of the copy-function.

Again: Don’t copy using reflection, especially when using obfuscation. If possible, turn off obfuscation for your data classes that you need to copy. If that’s also not possible, I only found a heuristic to find the copy-function:

The parameters of the primary constructor represent all the properties. The copy function has the exact same parameters in the exact same order. Again, we cannot rely on that the parameter names of the primary constructor and the copy-function are the same, but we can rely on the order and the types. Additionally, in the copy-function all parameters are optional (except the instance parameter):

/**
 * Copy a data class using reflection.
 * 
 * Should also work with obfuscation turned on!
 */
fun copyByReflection(instance: Any): Any {
    val instanceKClass = instance::class
    require(instanceKClass.isData) { "instance must be data class" }

    val primaryConstructor = instanceKClass.primaryConstructor ?: error("No primary constructor")
    val primaryConstructorParameterTypes = primaryConstructor.parameters.map { parameter -> parameter.type }

    val copyFunction = instanceKClass.functions.singleOrNull { function ->

        val functionParameters = function.parameters
            .filter { parameter -> parameter.kind != KParameter.Kind.INSTANCE }

        val allOptional = functionParameters.all { parameter -> parameter.isOptional }
        val functionParameterTypes = functionParameters.map { parameter -> parameter.type }

        // With obfuscation is enabled, we can identify the copy-function:
        // - all parameters are optional
        // - number of parameters is the same as in the primary constructor
        // - the types of the parameters are the same as in the primary constructor
        allOptional && functionParameterTypes == primaryConstructorParameterTypes
    } ?: error("Cannot find copy function")

    val copy = copyFunction.callBy(
        mapOf(copyFunction.instanceParameter!! to instance)
    ) ?: error("copy didn't return a new instance")

    return copy
}

Use with caution! Depending on which obfuscation features are turned on, the code above may be also doesn’t work.

Alternatives

Depending on the use case, you might want to consider delegated properties to a map:

class Example(val map: Map<String, Any?>) {
    val text: String by map
    val number: Int by map
}

Conclusion

I played around with copying via reflection, even got it working, but eventually dropped the idea because of performance concerns. It was unbearable slow (100-1000ms) and was a bad idea to use reflection to begin with. Still, I wanted to share my learnings, as it might be useful for someone else.

You might also like