Kotlin sealed classes are pretty great for modeling a fixed set of choices that may have associated data1. However, newfangled technology tends to cost something extra2. Compilers, virtual machines, and operating systems can often compensate over time for the incurred performance cost.
Sealed sealed classes are mostly a compiler concept that doesn’t exist in the Java runtime. Kotlin’s smart casts and when expressions’ exhaustivity requirements provide a compelling reason to lean into modeling types in the type system, rather than a type-like enum property on a class.
enum class AssetKind {
IMAGE,
VIDEO,
PDF,
}
data class VideoAsset( val uri: Uri, val thumbnailUri: Uri)
data class ImageAsset(val uri: Uri, val width: Int, val height: Int)
data class PdfAsset(val uri: Uri, val pageCount: Int)
data class Asset(
val kind: AssetKind,
/** Not null if the asset is of kind AssetKind.VIDEO */
val video: VideoAsset?,
/** Not null if the asset is of kind AssetKind.IMAGE */
val image: ImageAsset?,
/** Not null if the asset is of kind AssetKind.PDF */
val pdf: PdfAsset?,
)
Modeling categories with enums
sealed class Asset {
abstract val uri: Uri
data class Image(
override val uri: Uri,
val width: Int,
val height: Int,
) : Asset()
data class Video(
override val uri: Uri,
val thumbnailUri: Uri,
) : Asset()
data class Pdf(
override val uri: Uri,
val pageCount: Int
) : Asset()
}
Modeling categories with types
Without ending up too derailed on the merrits of the latter, the compiler’s type checker gets involved in this scheme. So more opportunities for interesting expressions are possible now. For example, you can make another class’s constructor only accept Asset.Pdf
and none of the other siblings.
If you have any background in C or other systems languages, you might be surprised that enums on the JVM are not fundamentally integral. They’re a statically allocated reference type that comes with all of the memory indirection tradeoffs. Even still, I was surprised to find Kotlin’s is
operator to be roughly as quick as enum comparisons in the Android Runtime.
I used the Jetpack Benchmark library to run a few small benchmarks on a an older Android phone. whenIs
measures modeling categories with types used in a when expression. whenEnum
measures modeling categories with enums used in a when expression. compareIs
measures a direct use of the is operator. compareEnum
measures a direct comparison of a enums. The library runs the benchmarks many times over to avoid variance, and I ran each of the benchmarks five times for good measure.
whenIs | whenEnum | compareIs | compareEnum |
---|---|---|---|
59ns | 66ns | 55ns | 57ns |
58ns | 54ns | 56ns | 51ns |
42ns | 64ns | 55ns | 62ns |
58ns | 60ns | 57ns | 57ns |
60ns | 57ns | 58ns | 44ns |
— | — | — | — |
55.4 | 60.2 | 56.2 | 54.2 |
Sealed classes provide powerful semantic value over modeling categories with enum-typed properties on a class. The Kotlin compiler generates pretty different code for when expressions with type patterns and when expressions with an enum subject. The latter is represented as a switch on the enum ordinal (its order of declaration in the enum class, an integer).
INVOKEVIRTUAL com/example/benchmark/EnumOrInstanceOf$AssetKind.ordinal ()I
IALOAD
L17
TABLESWITCH
1: L18
2: L19
3: L20
The former generates a series of if/else like branches.
INSTANCEOF com/example/benchmark/EnumOrInstanceOf$Asset$Image
IFEQ L18
L19
ICONST_0
GOTO L20
L18
Hunting down the exact explaination of how the performance ends up roughly comparable is outside the scope of this inquiry, but I’d venture a guess that it has something to do with if/else playing nicely with the ARM branch predictor.
It should also be mentioned that in addition to the Kotlin compiler’s tricks, R8 does a good deal to make enums quick3. Then again, R8 has some tricks for classes also4.