Was there ever a conversation about adding even mo...
# language-evolution
b
Was there ever a conversation about adding even more power to
sealed
types by adding
copy
behaviour for when child types share fields and all have
copy
behaviour already (e.g. are all data classes)?
Copy code
sealed class Fruit {
    abstract val pricePerKg: Float

    data class Apple(override val pricePerKg: Float, val variety: String) : Fruit()

    data class Banana(override val pricePerKg: Float) : Fruit()
}
Copy code
val fruit: Fruit = ...

val aMoreExpensiveFruit = fruit.copy(pricePerKg = fruit.pricePerKg * 2)
a
Fruit
-level
copy
would be a shortcut for:
Copy code
fun Fruit.copy(pricePerKg: Float = this.pricePerKg): Fruit =
  when (this) {
    is Apple -> this.copy(pricePerKg = pricePerKg)
    is Banana -> this.copy(pricePerKg = pricePerKg)
  }
5
🚫 1
a
one problem there is figuring out what are the "primary properties" of the interface (since those the chosen ones for
copy
). Maybe for sealed classes this could be more easily done...
c
Just to see if I understand correctly; the problem is that
Copy code
sealed class Fruit(
    val pricePerKg: Float
) {
    data class Apple(override val pricePerKg: Float, val variety: String) : Fruit()

    data class Banana(override val pricePerKg: Float) : Fruit()
}
although clear and sufficiently explicit as to which properties are "primary", is an anti-pattern because it leads to
Apple
and
Banana
both storing the price twice, because
Fruit
has a private backing-field for
pricePerKg
, and thus we cannot encourage this syntax, right?
r
plus1 I've encountered this use case.
The workaround is very boiler-platey.
a
but conversely, the interface syntax is not good enough to distinguish primary properties from derived ones
c
Copy code
sealed interface Fruit {
    val pricePerKg: Float

    contract {
        copy(pricePerKg)
    }
}
where
contract.copy
takes a
vararg fields: Any?
? Not particularly beautiful, and probably doesn't fit the negative 100 points rule, but…
r
If its a sealed class, can't the compiler just check if all the derived classes are data classes? And if so, then all the
abstract val
would be eligible for the sealed class copy?
Alternatively, maybe "sealed data class" would enforce that behavior.
c
> If its a sealed class, can't the compiler just check if all the derived classes are data classes? And if so, then all the
abstract val
would be eligible for the sealed class copy? Counter-example:
Copy code
sealed class Fruit {
    abstract val pricePerKg: Float
    abstract val color: Color
}

data class Orange(
    override val pricePerKg: Float
) : Fruit {
    override val color: Color get() = Color.Orange // duh!
}
Here,
color
cannot be an argument of
Fruit.copy
.
blob thinking upside down 1
r
Yep, fair
c
Also, it would be very unintuitive to work with. Create a new subclass that doesn't fit exactly the requirements, and boom
copy
is gone for the parent class
r
The compiler could limit the sealed class copy to all fields that are eligible for the subclass copy, so I don't think the counter-case is a blocker.
> Also, it would be very unintuitive to work with. Create a new subclass that doesn't fit exactly the requirements, and boom copy is gone for the parent class This might be a feature, not a bug. Analagous to breaking exhaustive when by adding a new enum or sealed class derivation.
I can see newbies being tripped up by it though. Its certainly less "obvious" why the error might be happening then the exhaustive when.
c
But that means that if I create a new subclass with a fixed value (like
color
in my example), all usages of
Fruit.copy
that use color break. When exhaustive breaks, it's clear why, and it happens where you're checking for the types, so it's clear that you can add a new option. In this case, though, it would break completely normal code 😕
Also, I'm not sure the compiler can analyze the subclasses during the parent class compilation? Wouldn't it break incremental compilation if it could?
r
Sealed classes have to be declared together with their subclasses so not sure why it would be an issue. I'm definitely not a compiler expert though.
c
They can be in other files of the same package
r
Oh, I thought they had to be in the same file. Was that restriction lifted at some point or something?
c
Yes. I believe 1.3? But I could be wrong. Now, it's "same package and same module" IIRC
b
Very good points. Maybe the solution would need to be too complex to be worth adding. For this type of feature to be useful and not misleading the “copy” functionality would need to be explicitly requested. That way compiler (or a plugin) could raise an error on the contract specifying why the
copy
function cannot be created (e.g. due to potential ambiguity). e.g. something like you mentioned @CLOVIS
Copy code
sealed interface Fruit {
    val pricePerKg: Float

    contract { copy() }
}
if compiler (or a plugin) detected an unclear situation it would just error out on
copy()
r
If you're gonna be explicit, why not list the fields there as well, like @CLOVIS did originally? That way its not half explicit and half magic.
b
I mean… both solutions could coexist. The more `vararg`s you specify, the more restrictive it would be. So that you could choose how much magic you want in your codebase 🪄
c
Multiple ways to do the same thing is almost always the wrong way to do language design, because it forces beginners to learn all the ways before being able to do anything.
👍 1
Also,
copy()
already has a meaning in my version: it means "none of the fields are primary", whereas you use it for "all the fields are primary", which is incompatible
b
fair points, it’s just getting to a point in which you’d still need to add a lot of boilerplate
e
What about the compiler taking the intersection of all the properties available in the derived classes' copy functions that come from the interface?
h
I prefer the sealed data class: every property declared in the primary constructor must also be declared in the data subclass primary constructor and there will be a copy method for the sealed data class.
2
And every subclass of a sealed data class must be a data class too.
Might be possible with a compiler plugin too 🤔
Copy code
@SealedDataClass
sealed class Fruit(val pricePerKg: Float)

data class Apple(override val pricePerKg: Float, val variety: String) : Fruit(pricePerKg)
data class Banana(override val pricePerKg: Float) : Fruit(pricePerKg)
Generates:
Copy code
fun Fruit.copy(pricePerKg = this.pricePerKg): Fruit = when (this) {
  is Apple -> copy(pricePerKg = pricePerKg)
  is Banana -> copy(pricePerKg = pricePerKg)
}
w
IMO, making this feature intuitive is too hard. I think this is too easy to work around with such a design change:
Copy code
data class PricedFruit(val pricePerKg: Float, val fruit: Fruit)

sealed class Fruit
data class Apple(val variety: String): Fruit()
data object Banana: Fruit()
1
👍 2
s
Sounds like a problem that could be solved with a ksp processor to generate the copy method on the parent class?