11

The Dog Riddle

 4 years ago
source link: https://www.tuicool.com/articles/hit/MZNvErQ
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

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 the Dog 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’s invoke 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 actual breed 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 the breed property. It’s initialized by creating a Dog instance with a dummy parameter, and then overwriting its breed immediately with null .

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.

About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK