sealed class Base
class A: Base()
class B: Base()
fun doSomething(thing: Base) {
when (thing) {
is A -> doIt(thing)
is B -> doIt(thing)
}
}
fun doIt(a: A) { println("A") }
fun doIt(b: B) { println("B") }
The body of doSomething seems a little silly at first glance, because it calls doIt(thing) in both cases. However, the following does not compile:
sealed class Base
class A: Base()
class B: Base()
fun doSomething(thing: Base) {
doIt(thing)
}
fun doIt(a: A) { println("A") }
fun doIt(b: B) { println("B") }
The reason is that thing in this case cannot be narrowed to either A or B, and so none of the overloads match. This is expected if Base wasnāt a sealed class, but since it is sealed, the compiler should be able to tell that there is, indeed, a perfectly matching implementation of doIt for all existing subclasses of Base.
I think allowing this kind of polymorphism would be a benefit. What do you guys think? Is there any situation where allowing this could be problematic?
My only concern is that this isnāt really a dynamic call. It is a static call, compiler would have to internally replace doIt(thing) with your original code where we check all possible cases. I find this a little confusing, because normally compiler doesnāt generate complex control flow logic that we didnāt request explicitly. For me your original code doesnāt āseem a little silly at first glanceā. It feels rather natural: we have multiple branches and each of them calls a different specialized function.
But I donāt know. I definitely see your point, the idea sounds interesting.
I think my concern would be that this is not how Kotlin works in other places.
Your example is what a multiple-dispatch language would do (in all cases). Kotlin is a single-dispatch language like Java ; you may like or dislike this, but I would find it confusing if Kotlin used single dispatch everywhere except in the case of a sealed class, even if it is understood as just being syntactic sugar.
For this I would expect the sealed class to have a doIt() method, and A and B to override it. I surmise you have a good reason not to do that but you havenāt explained it
The reason why I donāt want doIt to be an abstract method of Base is because of separation of concern. The functions doSomething and doIt are complex functions, and the thing passed to it is just one of the data structures it uses. I like to have my classes short and concise with a very strict separation of concern.
For me, being able to just doIt(thing) would be quite natural. As a matter of fact, I was first surprised when it did not work. Adding this kind of polymorphism shouldnāt break anything, because this kind of thing did not compile until now.
Iām not sure what other places there are where Kotlin is a āsingle-dispatch languageā as you say; but maybe those cases are also worth having a look at?
What should happen if you try to create a reference to the āmethodā doIt()? (Presumably that should either fail, or give a synthetic method.)
Which method should an IDE show if you hover over or Cmd+click on the doIt()? (When hovering, I guess it could list all the possible doIt() methods. Cmd+click would have to fail.)
What should the type of doIt() be if the various candidate methods donāt all have the same return type? (Presumably the nearest common supertype.)
Iām sure there any many other complications too.
(As Iām sure you know, all this stems from the fact that there isnāt one doIt() method. Instead, there are several unrelated methods that just happen to have the same name ā but in a strongly-typed language like Kotlin, thatās really just a coincidence, and changing that doesnāt feel right somehow.)
First of all, the original requirement could be achieved by moving the thing value out of the arguments and use as a receiver of the Base extension function. That wonāt create any confusion.
Your design is confusing as the signature of the function is used to distinct overloading methods, but not methods, declared in separate (not related to each other) classes as in your example. Here the fact that 2 methods have the same names is just coincidence.
Also, even in case of sealed class it is preferably the parent class to do not reference the child class by name. As was suggested, would be better to define abstract or base implementation of the method in the Base class and override it in the children.
The following functions are overloads of a kind that is supported:
fun make(a: Int) { ... }
fun make(a: String) { ... }
Here, the compiler knows at compile time that make(4) will call the first implementation, while make("blah") will call the latter.
On the other hand, there is also polymorphism through inheritance:
interface Maker {
fun make(a: Int)
}
class A: Maker {
override fun make(a: Int) { println("A makes") }
}
class B: Maker {
override fun make(a: Int) { println("A makes") }
}
In this example, if you have an instance of Maker and call make, the compiler canāt tell at compile-time which implementation is going to be invoked. Itās dynamic, that is, somewhere there will be code that decides dynamically what function to call.
My suggested semantics is kind of a combination of the two. The implementation is determined not by inheritance (aka. dynamic call), but by choosing from overloads with the same name. But it is also dynamic, because the compiler cannot choose the implementation at compile-time, but must figure it out at runtime.
@cabman Iām not sure if I understand your suggestion correctly, but a receiver of a function is just a different way to pass the argument. The real difference is how the this reference is going to be resolved in the body. The problem with the overload is the same, the following does not compile:
fun doSomething(thing: Base) {
thing.doIt() // ambiguous
}
fun A.doIt() { println("A") }
fun B.doIt() { println("B") }
We all understand your idea. This is just something non-standard. Classic OOP assumes we use dynamic dispatch with function overrides and static dispatch with overloads. And not without a reason. For example, the compiler ensures overrides are compatible with each other. It canāt do the same with overloads as we can create whatever functions we like. Byte code / assembler is optimized to do dynamic calls as quick as possible, with vtables, etc. We canāt apply the same optimizations to overloads, and checking the type manually will be probably many times slower than usual dynamic call. If we implement a new class, we can decide to override the super function and it will have immediate effect on the code that calls the function. If we use overloads, we created a new class and we even provided a new overload, we have to re-compile all the code calling the function, to re-generate the bytecode. If we donāt, it will fail at runtime. What about the diamond problem? (we could probably fail at the compile time)
I mean, class inheritance was designed specifically to deal with these kinds of problems. Overloads were never designed with this in mind. It might be much more complicated and problematic than it initially seems.
I agree that itās non-standard, but that makes it an interesting question (at least to me). I also agree that it hides the complexity of the when-statement, although that complexity is not a big one.
Hereās one strange implication, though:
sealed class RigidBody
class SphereBody: RigidBody()
class CapsuleBody: RigidBody()
fun collide(body1: RigidBody, body2: RigidBody) {
doCollide(body1, body2)
}
fun doCollide(b1: SphereBody, b2: CapsuleBody) { println("sphere vs capsule") }
fun doCollide(b1: CapsuleBody, b2: SphereBody) { println("capsule vs sphere") }
fun doCollide(b1: SphereBody, b2: SphereBody) { println("sphere vs sphere") }
fun doCollide(b1: CapsuleBody, b2: CapsuleBody) { println("capsule vs capsule") }
If collide is called with a sphere and a capsule, or a capsule and a sphere, the expected functions are called. But if both bodies are a sphere, or if both bodies are a capsule, the compiler would assign body1 to the first argument and body2 to the second argument, even though the reversed ordering of the arguments is also valid.
In the example when two bodies are collided against each-other, and both are of the same type, the implementation of doCollide can probably be expected to have the same outcome for both permutations, but this assumption probably does not hold in general.
This seems just an another example for the syntax, you rooting for. @broot explained nicely the difference between dynamic overriding and static overloading.
In this example, using sealed class implementation allows to implement all 4 variations if the logic mostly the same and overriding in derived classes if logic is different. Finally, all 4 very different methods could be implemented by overloading method in the sealed class with different argument and override them in each of the derived classes.
I understand that you try to convince to extend the language into this very narrow way, which can be achieved with current language features.
Iām not trying to convince anybody of anything, Iām just thinking about it. Yes, my previous example is the same as the original proposal, I was just thinking about what happens if the function has two arguments. Of course, the same can be achieved with current language features, it now involves two nested when statements:
fun collide(body1: RigidBody, body2: RigidBody) {
when (body1) {
is SphereBody -> when (body2) {
is SphereBody -> doCollide(body1, body2)
is CapsuleBody -> doCollide(body1, body2)
}
is CapsuleBody -> when (body2) {
is SphereBody -> doCollide(body1, body2)
is CapsuleBody -> doCollide(body1, body2)
}
}
}
On the one hand, the suggested semantics is much more compact, which I like. On the other hand, the explicit when statement makes it clear that if both body1 and body2 are spheres, it will never call doCollide(body2, body1) even though this is a valid match. The suggested semantics hides this fact, which I donāt like.
To be honest, I donāt see why doCollide(body1, body2) would become doCollide(body2, body1). When looking for a matching overload we never change the order of params, and this feature shouldnāt do this as well.
Iām more concerned with the cases like: we have doIt(Animal, Dog) and doIt(Dog, Animal), we call it with Animal, Animal and at runtime it turns out both params are Dog. But I guess this is not a big problem neither. We have similar cases with standard overloading, compiler should be able to detect ambiguous branches and either fail the compilation or show a warning and throw exception at runtime.