I'm not sure if this is the right channel to pose ...
# language-proposals
j
I'm not sure if this is the right channel to pose this question. If not, please point out the correct one and I will cross-post. I've got a question/proposal regarding sealed classes and interfaces. Let me first describe a somewhat contrived example: 🧵
Let's say you have a sealed interface (or I suppose this could be a sealed class) defined like this:
Note that this
Contract
is defined in a package named
packageA
in a module named
moduleA
.
In a different package and module, you try to define other types that use this contract:
Both
ClassFamily
and
InterfaceFamily
attempt to directly inherit from the
Contract
type, but the compiler indicates that you cannot inherit from a sealed type defined in a different package or module:
However, both
ClassFamily
and
InterfaceFamily
themselves are abstract - you cannot create instances of them.
Couldn't the requirement to properly inherit from one of `Contract`'s variants be passed down to the non-abstract variants of
ClassFamily
and
InterfaceFamily
?
e
I mean its pretty clear no. Its a sealed interface, why should you be able to extend it from a completely diff module? If thats allowed than any external module can do the same thing you are doing ie extend your own sealed interface with their own (abstract?) class and that defeats the purpose of ā€œsealingā€ the class hierarchy
j
Yes, but the only way any external module could create instances of objects that inherited from the sealed interfaces is if they also inherited from one of the variants of the sealed interface. So you aren't really extending the interface, you passing a requirement down to non-abstract variants that they must implement a variant from your sealed interface.
I appreciate that explanation is confusing.
e
It also breaks the contract that at compile time all subclasses would be known. Therefore you cannot possibly write safe code around the interface.
j
But
ClassFamily
and
InterfaceFamily
don't introduce any new variants of
Contract
.
āž• 1
I disagree.
e
Extending the ā€œvariantsā€ (I’m taking that to mean
First
&
Second
) is allowed, those aren’t sealed. But your snippet is extending the actual sealed Contract interface
j
Yes, but all
First
and
Second
types implement a variant from
Contract
, so safe code wouldn't break.
e
But
ClassFamily
and
InterfaceFamily
don’t introduce any new variants of
Contract
.
ClassFamily itself is the introduced variant. Even though its sealed absolutely nothing stops you from adding a subclass of it that in fact does not extend First or Second. And just like that moduleA invariant (that contract is of type First or Second) is broken. Thats not ā€œsafeā€ code.
j
nothing stops you from adding a subclass of it that in fact does not extend First or Second
- unless the compiler enforces that you implement the
First
or
Second
variants of the
Contract
interface, which you see in the example I did.
If the compiler did that, then the following
when
expression would still be exhaustive:
Copy code
when(val c: Contract<A, B> = funcThatReturnsClassFamily()) {
  is Contract.First -> println("first")
  is Contract.Second -> println("second")
}
Look at the example code - not one non-abstract implementation would fall though that
when
expression and not match one of the arms.
e
The sealed contract is less about a ā€œrequirement to implementā€ but more about the class hierarchy. Again there is an invariant of sealed; that all subclasses are known. If its allowed to be extended in a downstream module, all of a sudden that invariant is at risk. That your example is ā€œcompliantā€ doesnt mean that its safe. Because anyone could just as easily write unsafe code. For instance, anyne couldve written ClassFamily to be non abstract.
j
Basically what I'm suggesting is that
abstract/sealed
types in
packageB
should be able to inherit from
sealed
types in
packageA
, but that the compiler would require anything non-
abstract
derived from the new types in
packageB
must implement/inherit from one of the variants of the
sealed
type in
packageA
.
e
Also, making it extendable outside of the module would probably imply non final sealed class/interface (whatever that means), then someone could generate bytecode of a class that implements the interface, and the java VM would be non the wiser
j
Yes, all subclasses would be known, since all non-abstract types would be required to implement/inherit from one of the known
Contract
variant types.
e
And what if First & Second were plain un-extendable classes? šŸ¤”
j
Then you never be able it create a derived type - you'd never be able to satisfy the forced inheritance. In other words, you'd get a compiler error.
e
But there is no such thing as forced inheritance here, there is absolutely no way for the compiler to guarantee that. If the class is not final at the bytecode level then it can be extended, even outside of the constraints of the compiler (eg in bytecode manipulation tools). That is not ā€œsafeā€.
j
Yes, it could be enforced by the compiler when compiling the types in
packageB
.
e
Not if the tools in question run after compilation (which most do)
j
I'm looking at it from the type-soundness perspective, not an implementation perspective. I think this would be sound from the perspective of the types, but perhaps there is some implementation detail that prohibits this. That's why I posted here, to see if this is a possible language enhancement - if this is sound from a type-soundness perspective, could the language, compiler, tooling, and implementations be enhanced to support it.
e
Thats fair šŸ‘šŸ¾. Lets see what others will have to say here
j
So, here is a less contrived example:
In `packageA`:
Yet another example of the Error/Either/Result monad (😱), but I've got other non-monadic needs for this language enhancement. This was just an easy example.
In `packageB`:
Note that
Happy
implements
Possible.Success
and
Sad
implements
Possible.Error
.
So the
when
expression on the
useLogic
function is exhaustive, if and only if the compiler was improved to note that
DomainSpecificResult
inherits from
Possible
and it verified that all `DomainSpecificResult`'s non-abstract variants implement one of `Possible`'s variants.
Without this enhancement, the compiler currently says that
DomainSpecificResult
cannot inherit from
Possible
(as @efemoney noted) because it's a sealed type in a different package/module, and it complains that the
when
expression is not exhaustive. I think this is unnecessarily restrictive and could be enhanced to support this. That said, I welcome someone pointing out reasons why this is not sound from a type system perspective or that there is some underlying implementation detail that prohibits this.
w
I too think this makes sense. I'd like to see this work too!
āž• 2
g
yeah @Jordan Terrell your suggestion is that
sealed
restrictions only apply to direct children and not grandchildren and further projeny on the type hierarchy. In my head I associate
sealed
with
final
classes, in that way I think of them as a kind of "dont worry compiler you wont have to invokeVirtual", but that's not strictly necessary --and indeed on my codebase we have some places with sealed class hierarchies that are more complicated. right now you can also write
Copy code
sealed interface A
abstract class B: A
class First: B()
class Second: B()

val a: A = ....
when(a){
  is First -> TODO()
  is Second -> TODO()
}
but with this feature it would fail with which would fail with "what about other descendants of B?"
w
your suggestion is that
sealed
restrictions only apply to direct children and not grandchildren and further projeny on the type hierarchy.
@groostav This is not what he is suggesting. This proposal wouldn't change the existing contract of interfaces or sealed interfaces. It will only relax the existing restriction of defining abstract subtypes in other modules as long as all of their instantiable subtypes conform to the sealed requirement. Probably explained it worse than @Jordan Terrell, be sure to read his messages :)
j
@groostav In your example, the
when
expression would not be considered exhaustive, even with today's compiler, even if everything was in the same package/module. You would have to add a
is B -> TODO()
branch for it to be considered exhaustive. If some type in a different package or module inherited from
B
, then that new
is B
when
branch would cover it.
Looking at the compiler source code - looks like it might be relatively easy to make changes the frontend "checkers" to allow this, strictly as a proof of concept. Not sure how much of a backend change it would require, but I might try to pull the source code for the compiler to experiment.
(eg. some changes to the SealedInheritorInSameModuleChecker)