Proposal: abstract class implementing sealed interface, relaxing the rules

Consider the following interfaces in a module defining a protocol:

sealed interface Animal {
    val name: String
}

interface Cat : Animal {
    fun meow()
}

interface Dog : Animal {
    fun bark()
}

Then one needs to implement a Cat and a Dog in a separate module, so we may have something like:

data class MyCat(override val name: String) : Cat {
    override fun meow() {
        println("$name: meow!")
    }
}

data class MyDog(override val name: String) : Dog {
    override fun bark() {
        println("$name: bark!")
    }
}

Did you notice? We actually want to preserve a specific format for logging: the name of the animal, followed by colon, followed by a description of the noise they make. What if we wanted to share part of the implementation?

Sure, you might say — we can use delegated interface implementation. But it would be difficult to share state that way, so it’s not suitable for every case. And it’s more costly, as well — for it requires an additional instance to be constructed.

So, we could do the following:

abstract class MyAnimal internal constructor() : Animal {
    protected fun makeNoise(description: String) {
        println("$name: $description")
    }
}

data class MyCat(override val name: String) : MyAnimal(), Cat {
    override fun meow() {
        makeNoise(description = "meow!")
    }
}

data class MyDog(override val name: String) : MyAnimal(), Dog {
    override fun bark() {
        makeNoise(description = "bark!")
    }
}

The only problem is… Well, we can’t do that because the Animal interface is sealed and as such can’t be implemented from within another module (or even just another package). The inability of MyAnimal to implement Animal results in what I’d call our unavoidable inconsistency of the taxonomy.

But if Kotlin allowed sealed interfaces to be implemented by abstract classes (with an internal constructor[1]) or any sealed classes, without adding those implementations to the sealed taxonomy[2] of the interfaces, it wouldn’t be of any issue, as long as the language imposes concrete[3] implementations of those classes to implement one of the interfaces inheriting from the sealed one.
(NOTE: I elaborated the necessary rules better in this post.)

In our example,

  • MyAnimal is allowed to implement Animal because MyAnimal is abstract;
  • MyCat and MyDog are allowed to inherit from MyAnimal because they also implement Cat or Dog, which are subtypes in the sealed taxonomy[2] of the sealed Animal interface.

In conclusion,

  • literally any protocol that we wish to separate from its implementation may benefit from the proposed changes;
  • the proposed changes are not breaking changes and won’t harm backwards compatibility;
  • the taxonomy of sealed classes and sealed interfaces is already only enforced on compile-time, so an implementation of the changes would probably be quite straightforward, as it doesn’t affect code generation, only code validation.

ADDENDUM: Among the various reasons why MyAnimal should implement Animal:

  • that’s logically the most sensible thing (i.e. my animals are still animals);
  • we shouldn’t need workarounds (such as those suggested by various users), in order to do something that is logical, sound, and does in no way defeat the purpose of sealed interfaces;
  • in this post I elaborated a practical example showing how necessary it can be to be able to upcast MyAnimal to Animal.

Notes:

  1. The abstract class needs to have an internal constructor or be sealed altogether, since the compiler needs to track subclasses so it can enforce the rule I specified for concrete[3] implementations. This applies recursively to any abstract subclasses and abstract subclasses of abstract subclasses.
  2. By (sealed) taxonomy, I mean the ‘restricted class hierarchy’ of (direct or indirect) subclasses of a sealed type, as in the first paragraph here.
  3. By concrete, I’m referring to any non-abstract class, including ones that are open.
2 Likes

The rules that would need to be enforced for my proposed changes to work may be formalized as follows:

  • let I be a sealed interface defined in package P, in module M;
  • let I' be a set of all the non-sealed interfaces inheriting, either directly or indirectly, from I in the sealed taxonomy of I;
  • a class A defined in package P', in module M', where P' != P or M' != M, is allowed to implement I if and only if criteria(A) holds.

Given a class T, criteria(T) holds if and only if any of the following conditions are true:

  • T implements at least one interface in I';
  • all the following conditions are true at the same time:
    • T is sealed;
    • for each subclass T' of T, criteria(T') holds;
  • all the following conditions are true at the same time:
    • T is abstract;
    • no constructor of T satisfies any of the following conditions:
      • it is more visible than internal;
      • it is annotated with @PublishedApi;
    • for each subclass T' of T, criteria(T') holds.

I probably miss something important in your post, but… doesn’t it work exactly as you said right now? sealed applies only to direct subtypes, so you can create abstract MyAnimal and extend it in other modules.

Ahh, ok, I guess the thing you miss is a possibility to create subtypes that are not part of “sealed taxonomy”, so you can have MyAnimal and still use exhaustive when with only Cat and Dog. Yes, I came across the same problem once and I agree it would be helpful.

2 Likes

Yeah, l think you got me right in your second post.

Currently, MyAnimal can’t implement the Animal interface because it’s in a different module. The key is, MyAnimal doesn’t need to be part of the Animal taxonomy, as long as the rules I proposed are enforced, so it would be possible to allow for such a scenario. This would be a relaxation of the current rules that apply to inheritance from sealed types.

Note you can do this:

// module 1

interface BaseAnimal {
    val name: String
}

sealed interface Animal : BaseAnimal

// module 2
abstract class MyAnimal internal constructor() : BaseAnimal { ... }

data class MyCat(override val name: String) : MyAnimal(), Cat { ... }

Not ideal, but works.

2 Likes

Interesting take. I thought about doing the same already, and I’m still weighting on that for a project I’m currently working on.

Hopefully that’s gonna be helpful for some, but like you said it’s definitely far from ideal.

You can use a “shadow twin” for Animal interface. This can be used if you don’t have access to the original module:

// module 1 unchanged from original post
sealed interface Animal {
    val name: String
}

// module 2
private interface AnimalShadow {
    val name: String
}

abstract class MyAnimal internal constructor() : AnimalShadow { ... }

data class MyCat(override val name: String) : MyAnimal(), Cat { ... }

data class MyDog(override val name: String) : MyAnimal(), Dog { ... }

The shadow twin (AnimalShadow) contains a subset of the content of the original sealed interface (Animal) – all parts that you need in your shared implementation (MyAnimal). (In this use-case AnimalShadow has all the content of Animal, because MyAnimal needs the name and Animal does not have any other content).

Note: I just came up with these names (“shadow twin”). I guess these are not commonly used.

1 Like

Thanks for your suggestion. However, this way, nothing enforces that AnimalShadow actually declares all the members in Animal, and there’s no public interface to upcast MyAnimal to.

Well, I probably misunderstood your intention then.
I thought MyAnimal is solely an “implementation helper” to prevent code duplication - therefore it would only be present as superclass of MyCat and MyDog, but not actually used directly as type (in a variable declaration). For that purpose, the shadow interface with minimal scope and minimal content is a good thing.

But if I understand it correctly, you instead want to use it as a type (in variable declarations?) which can be upcasted to Animal? Can you give a small example code snippet for that?

That’s right - although a similar comment could be made about that nothing enforces Animal that it does not declare additional members not present in BaseAnimal. The advantage of BaseAnimal/Animal is that it has less code duplication; but it requires access to module 1 to put an implementation detail helper interface into it.
Converesly, the advantage of Animal/AnimalShadow is that it does not require access to module 1 for implementation details, but it requires to duplicate the needed signatures.

Admittedly I haven’t clearly stated my intention to have MyAnimal publicly exposed and implementing Animal so it can be used directly as a type.
(NOTE: Now I edited my original post so as to clarify this point.)

Here we go.

Let’s say my animals, being particularly picky, unlike others’ animals, got a favorite color. LOL
So we proceed to define an additional property favoriteColor:

abstract class MyAnimal internal constructor() : Animal {

    abstract val favoriteColor: String

    protected fun makeNoise(description: String) {
        println("$name: $description")
    }
}

data class MyCat(
    override val name: String,
    override val favoriteColor: String
) : MyAnimal(), Cat {

    override fun meow() {
        makeNoise(description = "meow!")
    }
}

data class MyDog(
    override val name: String,
    override val favoriteColor: String
) : MyAnimal(), Dog {

    override fun bark() {
        makeNoise(description = "bark!")
    }
}

Then we may wish to randomly create cats and dogs of my “color-picky” variation:

fun myRandomAnimal(
    name: String,
    favoriteColor: String
): MyAnimal =
    when (Random.nextInt(0, 1)) {
        0 -> MyCat(name = name, favoriteColor = favoriteColor)
        1 -> MyDog(name = name, favoriteColor = favoriteColor)
        else -> error("Broken random implementation")
    }

At this point, we may want to create one of those “color-picky” animals:

fun main() {

    val myColorPickyAnimal = myRandomAnimal(
        name = "Weirdo",
        favoriteColor = "beige"
    )

    println("${myColorPickyAnimal.name}'s favorite color is ${myColorPickyAnimal.favoriteColor}")
}

Alright, everything feels fine… Except that it’s not. My animals are still animals, for as weird and color-picky as they may be — as such, MyAnimal should still implement Animal. A case where it’d be useful follows.

Let’s say we want to log every animal’s name, regardless of whether they’re my weirdos or not. So we got the following function:

fun logAnimal(animal: Animal) {
    println("${animal.name} is here!")
}

…and now we need to apply logAnimal to myColorPickyAnimal:

fun main() {

    val myColorPickyAnimal = myRandomAnimal(
        name = "Weirdo",
        favoriteColor = "beige"
    )

    logAnimal(animal = myColorPickyAnimal) // Error: `MyAnimal` is not assignable to `Animal`
    println("${myColorPickyAnimal.name}'s favorite color is ${myColorPickyAnimal.favoriteColor}")
}

We can’t, since myColorPickyAnimal is typed MyAnimal, which is not assignable to Animal.

Ultimately,

Well, that’s right. Both your and @broot’s suggestions are valid, in different circumstances — though they don’t solve in an ideal way the issue I’m having, which definitely does apply to several scenarios, as I pointed out in my original post.

You want to use the returned value of myRandomAnimal as both MyAnimal and Animal.

The return type of a method can be specified as an intersection type using where-conditions:

fun <A> myRandomAnimal(
    name: String,
    favoriteColor: String
): A where A : MyAnimal, A : Animal { ... }

This way the method’s return value is of both types. However, I haven’t yet used it myself in Kotlin, so I am not sure how calling this method works. Can this probably help for your use-case? Maybe @broot knows about how to use it!?

3 Likes

Interesting, @tlin47. Actually, the example I posted was not intended to be an exact representation of my particular use case. Your solution (well, more of a workaround) may be helpful in many circumstances, but it does not necessarily solve any situation.

  • How would you call logAnimal? Oh, well, let’s add another generics trick on every function accepting an Animal. LOL.
  • If only just conceptually, it’s still inconsistent that MyAnimal is actually not an Animal. I’ll refer to this as the unavoidable inconsistency of the taxonomy.

Of course, Kotlin is a Turing-complete language, and as such, any use case can be made to work, somehow. The thing is, whatever solution one comes up with, it’s still a workaround that doesn’t eliminate the unavoidable inconsistency of the taxonomy. My proposal would fix this by relaxing some rules that are currently stricter than they need to be.

I don’t know what is your real case, but the last time I lacked a similar feature (actually, I think it was discussed somewhere here), it was really a simple case. Add a subtype of sealed interface that provides some additional functionality, but it isn’t just one of sealed subtypes. See this:

sealed interface Shape
interface Square : Shape
interface Triangle : Shape

interface ColoredShape : Shape {
    val color: String
}

object UncoloredSquare : Square
object UncoloredTriangle : Triangle

object RedSquare : Square, ColoredShape {
    override val color get() = "red"
}
object GreenTriangle : Triangle, ColoredShape {
    override val color get() = "green"
}

In this case ColoredShape is a shape with additional properties, but it doesn’t represent one of available shapes. Exhaustive when should be still possible with Square and Triangle only. I think this is a valid and not that rare case, but unfortunately right now it is hard to do it in Kotlin.

I don’t know if this is the same problem you have, something similar or just entirely different one.

1 Like

It’s actually not the same one. I probably wouldn’t even need to add any properties in my real use case, unlike you did on ColoredShape and I showed on MyAnimal.

Unfortunately, your idea comes at detriment of forcing any additional properties to be foreseen and be added to the protocol module, which uselessly hinders extensibility.

Also, your way, we cannot have a shared implementation for RedSquare and GreenTriangle, which was the main point I made in my original post, where MyCat and MyDog would have MyAnimal as their shared implementation of the Animal interface.

Nice acknowledgement! That’s where my proposal comes in.

Hi.
What would exhaustive when do when you pass this not-actually-sealed object to it?

An object that is not actually sealed can’t exist, since objects can’t be made of non-concrete types. And my specs specify that such class would be abstract and subclasses would be required to implement a non-sealed interface that extends the sealed one.

I think I got it. looks like a nice feature to have