The Dog Riddle
2019-04-25 • Márton Braun
I’ve posed a small challenge on Twitter last week, and successfully flooded my inbox on an already busy day. Now it’s time to summarize the submitted answers, and discuss the solutions! You can find the riddle itself in the gist that I posted originally, but I’ll also sum it up here.
Problem statement
Our task at hand is to model different types of animals. For now, we have a Cat
covered:
sealed class Animal {
object Cat : Animal()
}
As you can see, cats - in our model - are very simple. All of them are represented as a single object
, they have no special traits.
Now we want to model dogs. For some dogs, we’ll just want to know that they’re a dog. However, others might also have a specific breed, which presents us with our challenge.
In the case of dogs that don’t have a breed, we want the same syntax for referring to this type as cats already have:
val cat: Animal = Cat
val dog: Animal = Dog
For dogs that have a breed, we want to supply it as a parameter:
val husky: Animal = Dog("husky")
We don’t want people to misuse our API. If a dog doesn’t have a breed, it should be referred to strictly as a Dog
. These should not compile:
val dog: Animal = Dog()
val dog: Animal = Dog(null)
We’re also using a sealed class for our animals for a reason. We want a sealed hierarchy, with no open classes in the implementation. This makes sure we know about all types of animals that will ever exist in the code. It also allows us to distinguish the various animals in an exhaustive when
expression:
val opinion = when (val animal = getAnimal()) {
is Cat -> {
"Cat"
}
is Dog -> {
if (animal.breed == null)
"Generic dog"
else
"Specific dog: ${animal.breed}"
}
}
Something I had to add as clarification after some initial replies is that references to these types should behave in a predictable way, and the creation of a new one should not affect any existing ones. For a concrete example, this code should work:
val labrador = Dog("labrador")
val husky = Dog("husky")
println(labrador.breed) // labrador
println(husky.breed) // husky
If you haven’t done so yet, at this point I encourage you to give the riddle a go yourself. Even if you don’t succeed completely, you’re likely to find interesting new ways of combining language features and achieving exciting syntax with Kotlin!
With that, let’s jump into the solutions.
I’m serious.
Spoilers ahead!
You’ve been warned.
.
.
.
A frequent pitfall
Let’s start by looking at the edit mentioned in the problem statement above first. A common approach to solving the riddle was something like this:
object Dog : Animal() {
var breed: String? = null
private set
operator fun invoke(breed: String) : Dog {
this.breed = breed
return this
}
}
With this solution, writing down Dog
refers to the object itself, and Dog("rottweiler")
will be a call to the invoke
method. This method can not be called with no parameters (Dog()
), or with a nullable value (Dog(null)
), so it does satisfy all the syntax requirements of the challenge!
The issue is that there’s never new instances of Dog
created - there’s just one. If you “create” multiple Dog
instances, all but the last one will end up with an unexpected breed
value. This happens because they’ll all actually be the same instance, with only one breed
property that can hold state. This results in odd behaviour:
val pug = Dog("pug")
val beagle = Dog("beagle")
println(pug.breed) // beagle
println(beagle.breed) // beagle
Notice that after setting a breed
, the Dog
syntax will no longer return a generic dog either.
My original solution
Next up, let’s see the solution I originally had for this problem. Both Gabor Varadi and Tomasz Linkowski found this exact solution, the former being the first to submit a complete solution overall!
sealed class Dog(val breed: String? = null) : Animal() {
private class DogWithBreed(breed: String) : Dog(breed)
companion object : Dog(null) {
operator fun invoke(breed: String): Dog {
return DogWithBreed(breed)
}
}
}
Dog
here is yet another nested sealed class within Animal
. It has a nullable breed
property, as expected by the client code. The two implementations are as follows:
- Its companion object, which initializes a dog with a
null
breed. This is what’s accessed with theDog
syntax. - A private nested class that represents dogs with breeds. This takes a non-nullable breed that it passes on to its parent. To create instances of this class with the
Dog("maltese")
syntax, the companion’sinvoke
method is used as a factory function.
Its worth noting that something similar can be achieved without the extra inner class, if open classes are allowed - the credit for this variation goes to István Juhos:
open class Dog private constructor(val breed: String? = null) : Animal() {
companion object : Dog() {
operator fun invoke(breed: String): Dog {
return Dog(breed)
}
}
}
Unlike a sealed class, an open class could be instantiated with its constructor, allowing for Dog(null)
. This is prevented here by making it private, which also does restrict subclassing significantly, making the class being left open negligable.
An awesome alternative
The other solution that I’d give full grades for is by Bjarte Karlsen. He’s actually submitted two slightly different versions, which I’ve merged into the following code.
class Dog(_breed: String) : Animal() {
var breed: String? = _breed
private set
companion object {
val Dog = Dog("").apply { this.breed = null }
}
}
Let’s look at the highlights of what makes this work:
- The constructor parameter of
Dog
isn’t the actualbreed
property. This parameter only allows for non-null values. - The generic dog case is covered by a property named
Dog
inside the companion object. This way it has access to the otherwise private setter of thebreed
property. It’s initialized by creating aDog
instance with a dummy parameter, and then overwriting its breed immediately withnull
.
Slightly flimsy
The third and last group of solutions I’ve received are ones that work, but aren’t quite as clean as the previous ones. The credit here goes to Mauricio Barbosa and Tibi Giurgiu, both of whom submitted something amongst these lines:
sealed class Animal {
object Cat : Animal()
class Dog(breedParam: String) : Animal() {
var breed: String? = null
private set
init {
if (breedParam.isNotEmpty()) breed = breedParam
}
}
}
val Dog = Dog("")
This solution includes the previous trick of separating the constructor parameter and the property. Instead of overriding an already set value with null
, it uses a null
value by default. It also converts any dogs created with a ""
breed to a generic one, which is used by a top level property that we can of course reference as Dog
to get a generic dog.
Honourable mentions
Shoutouts to the following people for also participating in the riddle:
What’s next?
Did you find a solution that wasn’t covered by the ones mentioned? Send it my way, preferably on the Kotlin Slack - you’ll find me there as @zsmb.
If you’ve enjoyed this look at the language, you might want to read some more about Kotlin API design! Here are some recommendations:
- Delightful Delegate Design, showing off an Android SharedPreferences library based on delegates, as well as the design choices made in its implementation.
- Village DSL, an exploration of exciting syntax possibilities when creating domain specific languages with Kotlin.
You might also like...
The conflation problem of testing StateFlows
StateFlow behaves as a state holder and a Flow of values at the same time. Due to conflation, a collector of a StateFlow might not receive all values that it holds over time. This article covers what that means for your tests.
How I Finally Memorized Modifier Ordering in Compose
For the longest time, I proudly had no idea of how Modifier ordering works, and would just guess and then guess again when something didn't look quite right. Here's how I finally ended up remembering how the ordering works.
Building a macOS screen saver in Kotlin
This article documents how I created my own custom screen saver for macOS, built almost entirely in Kotlin, using the powers of Kotlin Multiplatform compiled to macOS. Join me for a tale of Kotlin success and macOS failures.
Wrap-up 2021
Another year over, a new one's almost begun. Here's a brief summary of what I've done in this one.