Logo

dev-resources.site

for different kinds of informations.

Playing around with Kotlin Sealed Classes

Published at
9/24/2020
Categories
kotlin
jackson
java
Author
alxgrk
Categories
3 categories in total
kotlin
open
jackson
open
java
open
Author
6 person written this
alxgrk
open
Playing around with Kotlin Sealed Classes

While working on my Master's thesis, I recently had to configure probabilities. These values were organized in a tree-like structure. Because of this, I thought of using inheritance - and with Kotlin already in place, using sealed classes seemed at least like an interesting opportunity to me.

Disclaimer: This post is just for fun and for showing some cool Kotlin-specific things. In the end, using Jackson to deserialize input into data classes is probably more effective.

Sample

Let's say, we want to configure the likeliness in percent of some actions using YAML for instance. This could look like the following:

Configuration:
    Greeting:
        SayHello: 70
        WaveAt: 80
        Hug: 5
    Talking:
        DoSmallTalk: 90
        Insult: 1
Enter fullscreen mode Exit fullscreen mode

As you can see, there are two subsections. However, this can easily get an arbitrary depth.

Before we'll reproduce that structure, we should clarify what sealed classes are.

Sealed Classes

The official documentation calls them extensions to enum classes, used for representing restricted class hierarchies.

Sealed classes are declared using the keyword sealed, are abstract and must have only private constructors.

Simply put, a sealed class ensures, that all possible subtypes are known at compile time. This comes especially handy, when used in conjunction with Kotlin's when clause:

sealed class TruthOrDare
class Truth(val question: String) : TruthOrDare()
class Dare(val task: String) : TruthOrDare()

fun nextTurn(input: TruthOrDare) = when(input) {
    is Truth -> println("Answer the following question: ${input.question}")
    is Dare -> println("Your task is: ${input.task}")
    // the `else` clause is not required because we've covered all the cases
}
Enter fullscreen mode Exit fullscreen mode

Sealed Classes in Action

Let's come back to the original task: using sealed classes for parsing configuration.

The structure of the above YAML snippet can be represented as follows:

sealed class Configuration {

    sealed class Greeting : Configuration() {

        object SayHello : Greeting()
        object WaveAt : Greeting()
        object Hug : Greeting()

    }

    sealed class Talking : Configuration() {

        object DoSmallTalk : Talking()
        object Insult : Talking()

    }

}
Enter fullscreen mode Exit fullscreen mode

We have nested sealed subclasses and objects (a shortcut for Singletons in Kotlin). We'll see later, why we are using objects here.

However, yet there is nothing configured. So how do we use this structure?

The answer is: Reflection.

Digression

Before we come to the actual structure though, I would like to introduce a small wrapper class, to encapsulate our findings:

class Probabilities private constructor(
        private val backingMap: MutableMap<Configuration, Int> = mutableMapOf()
) : Map<Configuration, Int> by backingMap {

    constructor(configuration: Probabilities.() -> Unit) : this() {
        configuration()
    }

    infix fun Configuration.withProbabilityOf(percent: Int) = backingMap.put(this, percent)

    override fun toString(): String = backingMap.entries.joinToString { "${it.key::class.simpleName} = ${it.value}" }
}
Enter fullscreen mode Exit fullscreen mode

In this class you can see some other cool Kotlin features: implementation by delegation, receiver functions and infix functions.

Implementing the Map interface can be a pain in Java. Kotlin provides a smart solution with implementation by delegation. All you need to do, is to use the by keyword in conjunction with a value, that already implements the interface. So in this case we simply delegate all Map-specific operations to a MutableMap, which is created by the default constructor.

To make configuration of the Probabilities class easy, we provide a second constructor taking a receiver function. This means, that the this keyword of the provided lambda points to an instance of Probabilities class.

Another receiver function is also provided by withProbabilityOf. However, this is also an infix function, which is marked by the infix keyword and enables you to write something like:

SayHello withProbabilityOf 99
Enter fullscreen mode Exit fullscreen mode

Parsing

Finally, here is the code to parse e.g. a Map of String and Any:

fun fromMap(configuration: Map<String, Any>) = Probabilities { // (1)
    @Suppress("UNCHECKED_CAST")
    fun parseFor( // (2)
            configuration: Map<String, Any>, // (3)
            parent: KClass<out Configuration>?,
            clazz: KClass<out Configuration>
    ) {
        // (4)
        if (configuration.containsKey(clazz.simpleName) && parent?.isSuperclassOf(clazz) != false) {
            when (val value = configuration[clazz.simpleName]) {
                is Map<*, *> -> { // (5)
                    clazz.sealedSubclasses.forEach { subclass ->
                        parseFor(value as Map<String, Any>, clazz, subclass)
                    }
                }
                is Int -> { // (6)
                    if (clazz.objectInstance == null)
                        throw RuntimeException("${clazz.simpleName} should be an object")

                    clazz.objectInstance!! withProbabilityOf value
                }
                else -> throw RuntimeException("unknown property ${clazz.simpleName}")
            }
        }
    }

    parseFor(configuration, null, Configuration::class)
}
Enter fullscreen mode Exit fullscreen mode

Some explanation (mind the comments in code):

  • (1) we construct a new instance of Probabilities by making use of one of Kotlin's greatest features: if the last parameter of any function or constructor is a lambda function, we can omit parentheses and only use curly braces
  • (2) another beautiful thing are local functions: since we don't need the recursive function parseFor anywhere else, we simply declare it inside our fromMap function
  • (3) the parameters of our parseFor function are:
    • the subtree of the configuration
    • the assumed parent as specified by the configuration
    • the current class as specified by the configuration's property
  • (4) if somewhere on top level of the current subtree the expected class is found and the assumed parent matches the real superclass, then we use when on the property value's type
  • (5) if it's a Map, apply parseFor on each sealed subclass
  • (6) if it's an Int and clazz is an object, we configure the probability to be that value

In the above code, you might see, why having leaves being objects is a good idea: there will always be exactly one instance. So they are perfect for being used as a key in a map.

Sweet. - But why?

To be honest, all of the above might seem like an over-engineered construct. Compared to the example using sealed classes, an equivalent structure with data classes would be similar and could be easily used with e.g. Jackson out of the box:

data class Configuration(
    val greeting: Greeting?,
    val talking: Talking?
) {

    data class Greeting(
        val sayHello: Int?,
        val waveAt: Int?,
        val hug: Int?
    )

    data class Talking(
        val doSmallTalk: Int?,
        val insult: Int?
    )
}
Enter fullscreen mode Exit fullscreen mode

This is the great thing about Kotlin: you have so many language constructs that help you build your software the best way, whatever this means. Therefore, it's necessary to play around with and get to know these possibilities.

Using Jackson

To make use of the concept of using sealed classes in conjunction with Jackson, we need to provide a custom deserializer. Since we already have the mapping logic however, this is an easy task:

class ProbabilitiesDeserializer : StdDeserializer<Probabilities>(Probabilities::class.java) {

    override fun deserialize(p: JsonParser?, ctxt: DeserializationContext?) =
            p?.readValueAs<Map<String, Any>>(object : TypeReference<Map<String, Any>>() {})
                    ?.let { fromMap(it) }

}
Enter fullscreen mode Exit fullscreen mode

For this and the ability to parse YAML we need the following dependencies:

  • com.fasterxml.jackson.module:jackson-module-kotlin:2.11.2
  • com.fasterxml.jackson.dataformat:jackson-dataformat-yaml:2.11.2

Finally, we are ready to create an ObjectMapper and read our YAML configuration:

val mapper = ObjectMapper(YAMLFactory()).apply {
    registerModule(SimpleModule().addDeserializer(Probabilities::class.java, ProbabilitiesDeserializer()))
    registerModule(KotlinModule())
}
val probabilities = mapper.readValue<Probabilities>(yamlInput)
println(probabilities.toString())
println("Say hello with probability of ${probabilities[SayHello]} percent.")
Enter fullscreen mode Exit fullscreen mode

The result will be:

SayHello = 70, WaveAt = 80, Hug = 5, DoSmallTalk = 90, Insult = 1
Say hello with probability of 70 percent.
Enter fullscreen mode Exit fullscreen mode

That's it.

Bonus: Ensuring all properties are set

One last thing I wanted to add, is how you enforce all objects to be configured.

All we have to do is to find all the leaves of our configuration tree and compare them to the input's content:

val leaves = leavesOf(Configuration::class)
fun leavesOf(baseClass: KClass<out Configuration>): List<KClass<out Configuration>> =
    if (!baseClass.isSealed) {
        listOf(baseClass)
    } else {
        baseClass.sealedSubclasses.flatMap(::leavesOf)
    }

fun Probabilities.ensureAllActionsCovered() {
    val keys = keys.map { it::class }
    val unconfigured = leaves.filter { leaf -> !keys.contains(leaf) }
    if (unconfigured.isNotEmpty())
        throw RuntimeException("Unconfigured leaves: ${unconfigured.joinToString()}")
}
Enter fullscreen mode Exit fullscreen mode

Finally, we've seen a last cool Kotlin feature: extension functions. They allow us to add some functionality to an otherwise closed class. Like a bonus in a way.

You call them as if they were a class method:

probabilities.ensureAllActionsCovered()
Enter fullscreen mode Exit fullscreen mode

Closing notes

Thanks for reading, I hope you liked it. You can find all the code in a kscript on Github as a Gist.

jackson Article's
30 articles in total
Favicon
Why Do We Still Need Jackson or Gson in Java?
Favicon
A simple GeoJSON serializer for Jackson
Favicon
[Java Spring Boot] Como Criar Serializador Personalizado para seus Responses ou Json de saรญda
Favicon
[Java Spring Boot] How to implement a Custom Serializer for your Responses or Json
Favicon
[Java SpringBoot] Como Criar Deserializador Personalizado para seus Requests
Favicon
[Java SpringBoot] How to implement a Custom Deserializer for your Requests
Favicon
Java Jackson JSON: How to Handle Custom Keys?
Favicon
Create a custom Jackson JsonSerializer und JsonDeserializer for mapping values
Favicon
Anotaciรณn @JsonUnwrapped
Favicon
A tale of fixing a tiny OpenAPI bug
Favicon
Kotlin Springboot -- Part 21 ไปปๆ„ใฎ key value ใฎ json ใ‚’ POST ใ™ใ‚‹ API E2E ใ‚’ๆ›ธใ
Favicon
Formatting json Date/LocalDateTime/LocalDate in Spring Boot
Favicon
Jackson's @JsonView with SpringBoot Tutorial
Favicon
Jackson JSON parsing top-level map into records
Favicon
Using Jackson Subtypes to Write Better Code
Favicon
Java โ€“ Convert Excel File to/from JSON (String/File) โ€“ using Apache Poi + Jackson
Favicon
How to resolve Json Infinite Recursion problem when working with Jackson
Favicon
Java โ€“ Convert Excel File to/from JSON (String/File) โ€“ using Apache Poi + Jackson
Favicon
Practical Java 16 - Using Jackson to serialize Records
Favicon
Kotlin โ€“ Convert Object to/from JSON with Jackson 2.x
Favicon
๐Ÿ’พ Java Records ๐Ÿ’ฟ with Jackson 2.12
Favicon
Jackson, JSON and the Proper Handling of Unknown Fields in APIs
Favicon
Polymorphic deserialization with Jackson and no annotations
Favicon
Playing around with Kotlin Sealed Classes
Favicon
Moonwlker: JSON without annotation
Favicon
Jackson Readonly properties and swagger UI
Favicon
Registering Jackson sub-types at runtime in Kotlin
Favicon
Parsing JSON in Spring Boot, part 1
Favicon
Customize how Jackson does LocalDate Parsing
Favicon
Painless JSON with Kotlin and jackson

Featured ones: