Enum Comparison vs is Operator

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 {

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
      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
    GOTO L20

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.