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.