1️⃣: We often use interfaces to define a contract for a set of our classes in Object Oriented Programming. Sometimes the implementing classes are
data
classes 📦 that have powerful methods like copy
and equals
.
The copy
function allows us to copy an object while altering some of its properties.
2️⃣: It is also common to declare properties as val
in order to make them immutable. Furthermore, not only data
but also regular classes
(why wouldn't I make every eligible class a data
class?)
may implement the interface and those classes do not have the copy
method by default.
1️⃣-2️⃣: These yield to (immutable) classes that conform to a common interface, without the possibility
of handling them uniformly (i.e. without the need to know their actual (sub)types when dealing with the interface).
This leads to retrieving the actual (sub)types of the objects each time and either
invoking the copy
method or modifying them in some other way.
We seek 👀 for a solution that allows us to update the properties of our objects in an easier manner, avoiding boilerplate and without the need of knowing their actual types. We want to define a method on the interface that lets us update its properties. We focus on the interface itself rather than the implementing classes: if the subtypes would really imply totally different flows (e.g. flows that operate on the subtype specific properties too), then the Visitor pattern 🤠 should be taken into consideration as detailed here. Thus, the problem formulation is really similar to this.
ℹ️ As we will see, the proposed solution does not work if
- the
copy
method is defined on the interface - the implementing
data
class' properties are the same as the declared props of the interface
Spoiler
❗ Even though the data
class defines a copy
method, we cannot mark it as an override
of the one defined on the interface:
Function 'copy' generated for the data class has default values for parameters, and conflicts with member of supertype 'Fruit'
The Fruit
interface declares the name
, color
and taste
properties as val
, and the implementing classes
extend this model with an appropriate property for demonstration: the Apple
🍎, Banana
🍌 and Tomato
🍅 classes,
the former two are data
classes, while the latter is a regular one.
In the following subsections we will update the name
property of our Fruit
objects, but keep in mind that any other property of
the interface could be updated in a similar manner.
src/main/kotlin/com/copy/iface/separate
and run src/main/kotlin/com/copy/iface/separate/MainSeparate.kt
.
See src/main/kotlin/com/copy/iface/separate/modifier/ModifierMethods.kt
.
We can define a conditional expression with multiple branches using Kotlin's
when
construct (it's useful to combine it with the is
and !is
operators).
We differentiate between the types of the objects and call the copy
method (for Apple
and Banana
) or
create a new object (for Tomato
). Whenever we want to modify the name of the Fruit
we have to get its actual subtype
(this is what we would like to avoid), unless
we put this method into a dedicated service (but then what about the possibly modifiable other props of Fruit
?).
So we cannot really deal with a Fruit
as Fruit
.
Furthermore, this approach violates the O (Open-closed principle: software entities should be open for extension, closed for modification) of the
SOLID principles 📚: a new branch should be added to the
when
expressions every time we create a new subtype of Fruit
(this wouldn't happen often, but the possibility is there).
See src/main/kotlin/com/copy/iface/separate/modifier/FruitModifier.kt
and its implementations.
A similar solution to the one above (1.1) is to define a FruitModifier
interface and implement it with Fruit
subtype specific subclasses.
The modifyName
method delegates to the copy
method of the data
classes or calls the constructor of Tomato
.
This approach would mean that a new Modifier
class should be created for each new subtype of Fruit
and also a new method should be added to FruitModifier
and implemented in its subclasses whenever we want to modify another property of Fruit
.
src/main/kotlin/com/copy/iface/common
and run src/main/kotlin/com/copy/iface/common/MainCommon.kt
.
We may define the copy
method on the interface: this method takes all of the Fruit
properties as arguments and returns a new Fruit
object.
An override
of this either calls the copy
method (Banana
) originating from the data
class or the constructor (Apple
and Tomato
).
This method should have default values for all of its parameters, so that it can be called with an arbitrary number of arguments.
ℹ️ The implementing data
classes will have two copy
methods: one from the interface and one from the data
class.
Notice that the name of the method is up to us: the chosen copy
name is suitable because its purpose is the same as the copy
method of the data
classes.
It's important to note here that these methods should be defined only once ♻️, and their implementation is very easy: they simply delegate to the appropriate object creation.
Now we may use the copy
method defined on the Fruit
interface (notice the Fruit
type declaration) as follows:
val apple: Fruit = Apple()
val modifiedApple: Fruit = apple.copy(name = "Modified ${apple.name}")
💡 Play a bit with the Tomato
class: add the data
modifier to it and see what happens.
Spoiler
❗ The IDE complains about the copy
method that takes all primary constructor arguments as parameters:
Conflicting overloads: public final fun copy(name: String = ..., color: String = ..., taste: Taste = ..., didIKnowThatItsAFruit: Boolean = ...): Tomato defined in com.copy.iface.common.fruit.Tomato
💡 As it was mentioned in the Problem formulation, the copy
method of the implementing data
classes cannot
override the one defined on the interface. Replace the Apple
class with the following:
data class Apple(
override val name: String = "Apple",
override val color: String = "Red",
override val taste: Taste = Taste.SWEET,
) : Fruit
Function 'copy' generated for the data class has default values for parameters, and conflicts with member of supertype 'Fruit'
We cannot use the copy
method of the data
class as an implementation of the one defined on the interface since it lacks the override
modifier.
Because of this, the proposed solution would not work for data
classes that have the same properties as the interface.
See src/main/kotlin/com/copy/iface/common/modifier/ModifierMethods.kt
.
There are three different methods that modify the name
property of the Fruit
objects defined in this class,
but they all ultimately call the copy
method of the Fruit
interface. There are certain scenarios where
they may or may not be used, the programmer has to decide which one to use in a specific case.
:exclamation: As we can see the IDE complains that there is an unchecked cast in the modifyNameWithGenericParam
method:
Unchecked cast: Fruit to T
T
to be the subtype of Fruit
with <T : Fruit>
, in my opinion we can disregard this warning.
See src/main/kotlin/com/copy/iface/common/modifier/FruitModifier.kt
and its implementation.
Similarly to 1.2 we can define a FruitModifier
interface, but this time without the unnecessary
type parameter T
. This should be implemented by only one class, the FruitModifierImpl
that delegates to the copy
method of the Fruit
interface.
With this solution we reduced ⬇️ the number of classes and methods, however we still have to create a new method in
FruitModifierImpl
whenever we want to modify another property of Fruit
, but this is what we wanted all along:
to treat the Fruit
objects uniformly.
✅ This repository shows how to implement the copy
method for an interface in order to handle these objects uniformly,
without the need to retrieve their actual types and to reduce boilerplate and duplicated code. Also, the same can be done
for abstract
classes.
- Unchecked cast for Kotlin Collections
- Unchecked cast at generic Kotlin factory
- Handling unchecked cast warning in Kotlin
- Copyable interface without arguments in Kotlin
- Open-closed principle
See classes in src/main/kotlin/com/copy/nested
and compare src/main/kotlin/com/copy/nested/arrow/MainArrow.kt
with
src/main/kotlin/com/copy/nested/regular/MainRegular.kt
.
A simple demonstration how the Arrow library can make one's life easier when trying to update nested properties.