Abstract over the "copy" function from a data class

101 Views Asked by At

Let's say that I have a sealed interface, and each implementation of the interface is a data class

sealed interface Animal {
  val name: String
  val age: Int
}

data class Cat(
    override val name: String, 
    override val age: Int,
) : Animal

data class Dog(
    override val name: String, 
    override val age: Int,
) : Animal

// Possibly many more animals

I want to have a function which let's me rename any Animal and return the same type of Animal.

fun <T : Animal> T.rename(newName: String): T

It's surprisingly difficult to come up with an implementation for this. The best I could do is this, which is extraordinarily redundant and results in a compiler warning Unchecked cast: Animal to T even though we know this cast is guaranteed to succeed.

fun <T : Animal> T.rename(newName: String): T = when(this) {
  is Cat -> copy(name=newName)
  is Dog -> copy(name=newName)
  // and so on for many other animals
  else -> throw IllegalStateException(::class.toString())
} as T

Another thing I tried is to add copy(name: String) to Animal, but the copy function generated by Kotlin doesn't count as an implementation.

sealed interface Animal {
    val name: String
    val age: Int
    fun copy(name: String): Animal
}

// Class 'Cat' is not abstract and does not implement abstract member public abstract fun copy(name: String): Animal defined in com.example.Animal
data class Cat(
    override val name: String,
    override val age: Int,
) : Animal

Seems like I ran into a limitation of Kotlin's type system. Is there any way to implement the rename function without manually implementing it for each subtype of Animal? And without casting?

3

There are 3 best solutions below

0
k314159 On

The full signature of the data class copy function would look similar to this:

sealed interface Animal {
    val name: String
    val age: Int
    fun copy(name: String = this.name, age: Int = this.age): Animal
}

But this causes another error:

error: function 'copy' generated for the data class has default values for parameters, and conflicts with member of supertype 'Animal'

In fact, it's not possible for the copy function to be an override: see discussion and YouTrack issue.

Instead of using a when block, you can add the copy to the data class using a different function name:

sealed interface Animal {
    val name: String
    val age: Int
    fun with(name: String = this.name, age: Int = this.age): Animal
}

data class Cat(
    override val name: String,
    override val age: Int,
) : Animal {
    override fun with(name: String, age: Int): Cat = copy(name = name, age = age)
}

inline fun <reified T : Animal> T.rename(newName: String): T = with(name = newName) as T

Unfortunately, this doesn't buy you much, as it just moves the code duplication to a different place.

If you want to keep your current code as it is, note that when you use a sealed interface, the when is exhaustive, so you don't need an else part, and you're guaranteed you won't miss a subtype.

0
Slaw On

Unfortunately, you won't be able to do this directly. You could try to declare the following function in Animal:

fun copy(name: String = this.name, age: Int = this.age): Animal

But this won't work for two reasons.

  1. It won't compile; see k314159's answer for the error.

  2. You'll run into issues if and when you want to add properties to an implementation of Animal but not to Animal itself.

If you're okay with using reflection, however, then the following may work for you.

import kotlin.reflect.full.*

@Suppress("unchecked_cast")
fun <T : Animal> T.rename(name: String): T {
    require(this::class.isData) { "${this::class} is not a data class" }

    val copyFun = this::class.declaredMemberFunctions.single { it.name == "copy" }
    val instanceParam = copyFun.instanceParameter!!
    val nameParam = copyFun.findParameterByName("name")!!

    val args = mapOf(instanceParam to this, nameParam to name)
    return copyFun.callBy(args) as T // unchecked cast is still necessary
}

Note this will require adding the kotlin-reflect library to your project. And I believe it will restrict you to Android/JVM.

0
feczkob On

I have come across the very same problem and came up with a workaround that has the identical limitation that you point out.

sealed interface Animal {
    val name: String
    val age: Int
    
    fun copy(
        name: String = this.name,
        age: Int = this.age,
    ): Animal
}

data class Cat(
    override val name: String,
    override val age: Int,
    val color: String,
) : Animal {
    override fun copy(
        name: String,
        age: Int,
    ): Cat = Cat(name, age, color)

}

data class Dog(
    override val name: String,
    override val age: Int,
    val breed: String,
) : Animal {
    override fun copy(
        name: String,
        age: Int,
    ): Dog = copy(name = name, age = age, breed = breed)
}
  • the implementing data classes' props must not be the same as the ones defined on the interface (there should be some other prop to avoid conflicting definition)
  • the implementation is easy, but still has to be done for each subclass

This copy method behaves similarly to the one that we get for data classes, so you can modify any property of the interface (not just the name).

I created a github repo to demonstrate the usage. I would point that out that this solution is better in my opinion than using when expressions everywhere.