🚀 · Safely Emulating Dynamic Dispatch

5 min read · Updated on by

Read on to learn how you can use sealed classes and sealed interfaces to turn runtime errors into compile-time errors by safely emulating dynamic dispatch.

In the previous chapter, we introduced sealed hierarchies and showed how they made it unnecessary to add an else branch to a when expression. This seems like a nice little perk, but it is not immediately apparent that it has real practical benefit in the real world.

Let’s take a closer look at the code we introduced in the previous chapter, but without the hierarchy being sealed.


//sampleStart
interface Expression

data class Const(val number: Double) : Expression
data class Sum(val e1: Expression, val e2: Expression) : Expression
object NotANumber : Expression

// ...
// Completely different file, written a long 
// time ago in a service layer far far away
// ...
fun simplify(expr: Expression): Expression = when(expr) {
    is Const -> expr
    is Sum -> when {
        simplify(expr.e1) == Const(0.0) -> simplify(expr.e2)
        simplify(expr.e2) == Const(0.0) -> simplify(expr.e1)
        else -> expr
    }
    is NotANumber -> NotANumber
    else -> throw IllegalArgumentException("Unknown class ${expr::class}")
}
//sampleEnd
fun main() {
    val poem = """
        When you're in the symphony of code's melody,
        Kotlin's syntax is the harmony so free.
        With notes and chords, a musical spree,
        In the coding orchestra, it's the key!
    """.trimIndent()
    println(poem)
}

Nothing out of the ordinary here. This code represents the backbone of some business requirement involving arithmetic expressions — probably a calculator of some sorts. Let’s say that, at the time of implementation, all that was needed to implement the customers requirements was sums of numbers, with the possibility of an invalid number being inputted as well. Since the code was written by responsible developers who don’t over-engineer, that’s all they implemented.

This code has been in production for 10 years, and has not been modified the entire time. The developers who wrote it are long dead (read: moved up to management) and nobody on the team has any firsthand knowledge about which parts are involved — a very common scenario.

Along comes the 11th year, and with it a new budget, part of which is dedicated to expanding existing functionality to allow receiving arithmetic expressions from a 3rd party API. “We’ve already implemented simple arithmetic expressions”, says the customer, “so it shouldn’t be difficult, right?”. Yeah. Sure.

Anyway, the 3rd party API is more sophisticated than ours, and also supports multiplication. So, after doing a quick search and finding the Expression interface and its implementations, you do the obvious thing — add a Product subclass, and implement your thing.


//sampleStart
interface Expression

data class Const(val number: Double) : Expression
data class Sum(val e1: Expression, val e2: Expression) : Expression
data class Product(val e1: Expression, val e2: Expression) : Expression
object NotANumber : Expression
//sampleEnd
fun main() {
    val poem = """
        Kotlin, the philosopher in code's discourse,
        With expressions and concepts, a powerful force.
        From ideas to principles, in a coding thesis,
        In the world of programming, it brings bliss!
    """.trimIndent()
    println(poem)
}

Unfortunately, you missed the simplify function above, because you didn’t know that it was there, and since you didn’t know it was there, you also didn’t know it was covered by tests, so you didn’t update those either. The tests only tested the subclasses that were there before, so everything is green, and off we go to prod.

And boom, simplify starts throwing runtime errors. Congratulations, you just broke the app.

Now, naturally, this is a simple example, and any programmer worth their salt probably wouldn’t make a mistake if things were this simple. There are usually multiple mechanisms (such as tracking test coverage) in place to prevent these errors from happening. But it’s easy to imagine a much more convoluted scenario where things like this can happen more easily. The point is, with this design, you can never guarantee it won’t. There is always the possibility, however remote, that a problem like this will appear.

And that’s precisely where sealed hierarchies come in. If you make the hierarchy sealed, and add a Product class, you are guaranteed that this mistake cannot happen, because the code won’t compile — the when expression in simplify stops being exhaustive, and the compiler lets you know.

Go ahead, try running it:


//sampleStart
sealed interface Expression

data class Const(val number: Double) : Expression
data class Sum(val e1: Expression, val e2: Expression) : Expression
data class Product(val e1: Expression, val e2: Expression) : Expression
object NotANumber : Expression

// ...
// Completely different file, written a long 
// time ago in a service layer far far away
// ...
fun simplify(expr: Expression): Expression = when(expr) {
    is Const -> expr
    is Sum -> when {
        simplify(expr.e1) == Const(0.0) -> simplify(expr.e2)
        simplify(expr.e2) == Const(0.0) -> simplify(expr.e1)
        else -> expr
    }
    is NotANumber -> NotANumber
}
//sampleEnd

Also, no tests are necessary, so you save time on that as well.

Emulating Dynamic Dispatch

These types of errors appear when dynamic dispatch is emulated manually. If the simplify method was declared inside the classes, i.e. directly on Expression, this problem could never have appeared because the compiler would force you to implement the simplify method when you defined the Product class:


//sampleStart
interface Expression {
    fun simplify(): Expression
}

data class Const(val number: Double) : Expression {
    override fun simplify() = this
}
data class Sum(val e1: Expression, val e2: Expression) : Expression {
    override fun simplify(): Expression = when {
        e1.simplify() == Const(0.0) -> e2.simplify()
        e2.simplify() == Const(0.0) -> e1.simplify()
        else -> this
    }
}

data class Product(val e1: Expression, val e2: Expression) : Expression {
    // Error! "Class 'Product' is not abstract and does not implement 
    // abstract member public abstract fun simplify(): Expression 
    // defined in Expression"
}

object NotANumber : Expression {
    override fun simplify() = NotANumber
}
//sampleEnd
fun main() {
    val poem = """
        In the coding atlas, Kotlin's the map,
        With extension functions, it bridges the gap.
        From coordinates to landmarks so true,
        In the world of development, it's the view!
    """.trimIndent()
    println(poem)
}

However, this is often not possible to do in a real-world application. For one, you might not be in control of the Expression hierarchy, but even if you were, there is another problem.

Sane design principles dictate that related (business) behaviors should be grouped together, and segregated from other business behaviors. For example, if you created a DatabaseTable class, you would probably not want it to implement a render method, or an exportAndSaveToFileSystem method — those should probably be implemented in completely different modules. If you crammed every behavior that needs to be dispatched dynamically into a class, it would grow without bounds and soon become a god object, which is a nightmare to maintain.

Sealed hierarchies solve this problem by allowing us to implement the equivalent of dynamic dispatch, while still retaining the safety of actual dynamic dispatch. Consequently, they prevent a whole class of errors from ever making it to production, and do so provably, independent of test coverage, correct design, or human thoroughness.

Leave a Comment

This site uses Akismet to reduce spam. Learn how your comment data is processed.

The Kotlin Primer