Things tend to be similar. Cars and bikes are different for sure but both have wheels, engines, exhaust systems and so on. Such similarities between models are usually described using basic polymorphism in virtual domain modeling.
Kotlin makes this process a bit more pragmatic using
sealed class
declarations. Specifying type hierarchies using sealed classes is simple,
but what about declaring common properties?
Fortunately or not there are multiple ways to achieve this — partially because
of the Java baggage. Which one is the best?
Domain
There is a drawing system. We want to declare shapes we are able to draw.
sealed class Shape {
data class Circle(val radius: Int, val color: Int) : Shape()
data class Square(val size: Int, val color: Int) : Shape()
}
This is a fine piece of code but Shape
drawing is a bit messy.
fun draw(shape: Shape) {
val paint = Paint(antialias = true, color = when (shape) {
is Shape.Circle -> shape.color
is Shape.Square -> shape.color
})
when (shape) {
is Shape.Circle -> canvas.drawCircle(shape.radius, paint)
is Shape.Square -> canvas.drawSquare(shape.size, paint)
}
}
The Paint
part looks weird. The color
is common for both shapes
but we are forced to dance around types to make it work. Let’s change that.
Refactoring
val
The basic refactoring is moving the code into the type declaration. This way we can use it everywhere.
sealed class Shape {
data class Circle(val radius: Int, val color: Int) : Shape()
data class Square(val size: Int, val color: Int) : Shape()
val commonColor = when (this) {
is Circle -> color
is Square -> color
}
}
There is a number of issues with this approach.
- It is impossible to name the property
color
since it conflicts withdata class
declarations. - It is easy to miss the
commonColor
property and usewhen
in-place instead. - The generated bytecode is not very efficient —
color is stored both in the supertype and subtypes plus
a bunch of nested
if
are called on creating an object:
public abstract class Shape {
private final int commonColor;
public final int getCommonColor() {
return this.commonColor;
}
private Shape() {
int color;
if (this instanceof Shape.Circle) {
color = ((Shape.Circle) this).getColor();
} else {
if (!(this instanceof Shape.Square)) {
throw new NoWhenBranchMatchedException();
}
color = ((Shape.Square) this).getColor();
}
this.commonColor = color;
}
open val
We can define a Shape
-level open
property.
sealed class Shape(open val color: Int) {
data class Circle(val radius: Int, override val color: Int) : Shape(color)
data class Square(val size: Int, override val color: Int) : Shape(color)
}
It is better than the previous approach, but…
- Each
sealed class
variant is forced to pass values into the constructor —Shape(color)
. - The bytecode is still not efficient — the
color
value is stored in the supertype and subtypes:
public abstract class Shape {
private final int color;
public int getColor() {
return this.color;
}
private Shape(int color) {
this.color = color;
}
interface
Back to Java roots!
interface PaintedShape {
val color: Int
}
sealed class Shape : PaintedShape{
data class Circle(val radius: Int, override val color: Int) : Shape()
data class Square(val size: Int, override val color: Int) : Shape()
}
It works and the supertype does not have anything not relevant.
public interface PaintedShape {
int getColor();
}
public abstract class Shape implements PaintedShape {
private Shape() {
}
However, this approach requires creating a separate interface
.
And — as we know — naming is the hardest CS issue.
abstract val
Like an interface
but causes less friction.
sealed class Shape {
abstract val color: Int
data class Circle(val radius: Int, override val color: Int) : Shape()
data class Square(val size: Int, override val color: Int) : Shape()
}
The bytecode is as simple as the interface
one.
public abstract class Shape {
public abstract int getColor();
private Shape() {
}
Results
Use abstract val
, Luke!
This kind of research is fun and all but sometimes Kotlin makes me sad.
In this particular issue I’ve been struggling to reason what is better —
open val
or abstract val
. Both work but it is easy to forget
about the bytecode and make a random choice. The language could be more
strict with this kind of choices but oh well.