Automatic branching for sealed classes?

Consider this example:

sealed interface Node

data class Inner(val left: Node, val right: Node): Node
data class Leaf(val value: Int): Node

data class Tree(val root: Node) {
    fun total() = sum(root)
}

fun sum(n: Node): Int = when(n) {
    is Inner -> sum(n.left) + sum(n.right)
    is Leaf -> n.value
}

Wouldn’t it nice if we could instead provide implementations of sum for all subclasses of Node, like this:

fun sum(n: Inner): Int = sum(n.left) + sum(n.right)
fun sum(n: Leaf): Int = n.value

Maybe with an annotation like @SealedFun(Node::class) on any of the functions to tell the compiler, “Hey, create a synthetic sum function with a big when for all of these cases!”

In this toy example, it doesn’t look like it would save much, but once you have something like ten subclasses, this could really clean up the code.

2 Likes

Sounds like good old polymorphism.

sealed interface Node {
    fun sum(): Int
}

data class Inner(val left: Node, val right: Node) : Node {
    override fun sum() = left.sum() + right.sum()
}

data class Leaf(val value: Int) : Node {
    override fun sum() = value
}

data class Tree(val root: Node) {
    fun total() = root.sum()
}
4 Likes

Yes, but only if you have access to the class hierarchy, and even then you might not want to clutter your classes with methods you need only in a certain context.

Ok. But how is the compiler generated synthetic sum function better than your function based on when? If you rewrite the sum function to take Node as a receiver there’s not really a lot of boilerplate code. You still have to tell the compiler which implementation goes with which type. And with when you actually have to add fewer extra characters for each new type than with your proposal:

Personally I think this is cleaner:

fun Node.sum(): Int = when (this) {
    is Inner -> left.sum() + right.sum()
    is Leaf -> value
    is Sub1 -> TODO()
    is Sub2 -> TODO()
    is Sub3 -> TODO()
    is Sub4 -> TODO()
    is Sub5 -> TODO()
    is Sub6 -> TODO()
}

than this:

fun sum(n: Inner): Int = sum(n.left) + sum(n.right)
fun sum(n: Leaf): Int = n.value
fun sum(n: Sub1): Int = TODO()
fun sum(n: Sub2): Int = TODO()
fun sum(n: Sub3): Int = TODO()
fun sum(n: Sub4): Int = TODO()
fun sum(n: Sub5): Int = TODO()
fun sum(n: Sub6): Int = TODO()

And as a bonus, the IDE helps you add missing branches to when:
image

3 Likes

The when solution looks cleaner when you have one-liners like in the example, but separate functions look cleaner if the function bodies are more complex. In practice, if the content of a when gets too messy, you start to put the logic in sub-functions, so you end up with both the when and a bunch of separate functions anyway.

And then the when-based function becomes boilerplate, that could be generated by a compiler. I agree.

Of course the implementation of the when function becomes trivial and the overhead it adds is small compared to the rest of the code in the case where “the function bodies are more complex”. But still boilerplate.

1 Like

Also, if we have separate functions and we do sum(leaf), then the compiler can dispatch to the proper function at the compile-time, we don’t need a dynamic dispatch. But we still can dispatch sum(node) dynamically. So I see benefits of having the sum(Node) generated automatically.

2 Likes

Do you have a specific example that prompts you to want this? My initial thought is that if you have a case where you have 10 implementations of an interface, your domain may be too complicated. Or if you have a massive when with lots of branches, or basically all the other things you mentioned, they all make me think that the code is too complex.