Wouldn’t it be nice if a sealed interface/class wi...
# compiler
a
Wouldn’t it be nice if a sealed interface/class with a single implementation could be auto-cast to that implementation, if accessible? When implementing types with a separate public and private interface, (e.g. memento or handle object) it would mean I don’t need to do the silly cast.
3
👌 2
f
Why define a sealed interface or class if there is only one implementation? The only reasoning I can see to do so is because we want to prepare for extension, however, that also means that we cannot assume anywhere in code that there only is one implementation. At the same time we have to ask ourselves if sealing it makes sense if we want to extend it. The ability to seal while not being exhaustive is something that was already requested, and something that makes sense. It is the desire to have control over who implements an interface or extends a class, and this is totally valid. However, in this case nobody can assume once more that there only is a single implementation. Hence, no matter how I turn this in my brain, I see no use case. Maybe I'm missing something? 🤔
2
a
As I mentioned, it would be useful for implementing a memento, or a handle object pattern. You want to hand out some objects and for the users to give you back these objects at a later time. You want these objects to have some internal state, but you don't want to expose it to the users.
So you make a sealed Memento interface with the public API (possibly empty) and a sole implementation that adds the state you want to keep private.
The compiler doesn't even warn when you cast it though, so it's a very mild annoyance. It also indicates that the compiler already knows there's only one implementation.
n
Why do you need an interface (sealed or otherwise) for this instead of just having a
Memento
class that has
internal
and
private
functions?
Give it a private constructor if you want to avoid people creating their own copies of the class.
a
Because private is too restrictive and internal is too permissive.
n
What do you gain by the interface, then? The implementation class will either be private or internal, and if they implement auto-casting you're back to the private state being too accessible.
a
If there was a private-in-file modifier, then it would work.
A private type is private-in-file
A private attribute is private to the class.
n
Ah, fair.
a
But yes, it's a workaround for language limitations, not an intrinsically useful thing.
f
Access modifiers in Kotlin are very limited, same is true for any other JVM language I'm afraid. 😞
e
as a workaround, I'd prefer to add a helper like
Copy code
public sealed interface Foo {
    internal class Impl : Foo
}

internal fun Foo.asImpl(): Foo.Impl = when (this) {
    is Impl -> this
}
over open-coding
as Impl
everywhere
a
I think you don't even need the check. Just “as Impl” works.
f
If the example given by @ephemient works for the use case then there is no difference to:
Copy code
public class Foo internal constructor() {
  internal val state: Any? = TODO()
  internal fun doSomething() = Unit
}
And if Java is a concern we can tack on
@JvmSynthetic
on all the `internal`s. Regardless, having a
public as Private
is less work then anything else and it's very clear. Having a dedicated auto-casting for it, however, seems unsafe and going against Kotlin's idea of being explicit about type changes. That said, all of these are workarounds for the absence of more fine grained access modifiers and I totally agree that there should be more: • Friend classes • Package private where the package(s) the symbol is visible in can be chosen with wildcard support. • Module private where the module(s) the symbol is visible in can be chosen with wildcard support. • … I think the Java module system is a way to get there and I'm curious how it might evolve beyond what we have today (which is too narrow in functionality by focusing on the needs of the JDK itself, which is totally fine as a starting point).
a
This isn't an object type conversion, it's a refinement of the expression type. Kotlin already does this when it, for example, auto-casts T? to T. It's completely safe with no chance of errors (other than compiler errors).
f
But the type needs to be specified somewhere, so it's either
val private: Private = public
or
val private = public as Private
because without having the type anywhere it's impossible for the compiler to know that one didn't want
Public
. Now continue this to
f(public)
where
f
is
fun f(private: Private)
. In the current compilation where the sealed class only has one member this is legal, in the next one it is not legal anymore since the concrete type is only known at runtime. This may lead to a codebase being broken all over (imagine 100+
f
calls). This is where I see the unsafety. Btw. @ephemient doesn't suffer from this problem, because the conversion is confined into a dedicated function that makes usage search and global fixing easy.
The direction this topic took reminds me of something I was discussing the other day with a colleague regarding Rust's traits vs Kotlin's special casing and limitations it inherits from Java.
Copy code
interface Into<T> {
  fun into(): T
}
How cool would it be to have this but with multiple inheritance like Rust has it. Kotlin special cased this for
Comparable<T>
with
operator
support. Of course, generic support would be nice, but it would break Java compatibility. However, the
Into<T>
trait would be doable.
Copy code
public sealed interface Public {
  internal class Private : Public
}

internal operator fun Public.into(): Private = this as Private
For Java we generate:
Copy code
public static Private toPrivate(Public public) {}
The whole thing would work exactly as it does in Rust with turbofish to help the compiler if it's confused.
Copy code
f(public.into())
😎
Copy code
class A {
  operator fun into(): B = TODO()
  operator fun into(): C = TODO()
}

fun g(a: A) {
  val b: B = a.into()
  val c = a.into<C>()
}

// Java
void g(A a) {
  B b = a.toB();
  C c = a.toC();
}
a
It’s exactly like that with sealed classes/interfaces, and it’s intended, by design. Today your
when
is exhaustive, and tomorrow, when another type is added, it’s not, and doesn’t compile.
f
Yes, but not entirely, here you have a choice how you write your code (and the non-exhaustive feature request is exactly about forcing people to write code to handle additions while staying in full control of additions). There are situations in which one knows that there is only going to be n implementations, that's when a fixed sealing is appropriate. This is basically the situation you have too, in your case it's just that there's ever only going to be one and the casting is an annoyance. An unnecessary one at that as you already identified. There is no existing functionality in Kotlin to fix or remedy this, so all we've discussed are workarounds and alternatives. However, we also identified that the real problem is not with sealed but with a lack of expressiveness when it comes to access modifiers. This is what really needs to be addressed, and what would solve your use cases too.
a
Kind of, but not exactly. With more fine-grained access modifiers, the use-case I presented can be solved in a better way, without auto-casting a single sealed type. But it’s possible there are other use-cases, although I can’t think of any at the moment.
Given how Kotlin auto-casts
T?
to
T
as soon as the compiler can infer that the value is not
null
, I think the auto-casting for a single sealed type is natural.
Doubly so when we can see the compiler already understands that the actual type of
Public
is
Private
, as it doesn’t give a warning when you cast it manually.
f
But it’s possible there are other use-cases, although I can’t think of any at the moment.
Absolutely, that's what I wrote in my very first message: maybe I'm missing something. Handling of
T?
to
T
is a very special case that complicates the language, but its applicability is universal so that it's warranted. Here we talk about an edge case that only very few are going to encounter. Complicating the language for such things is never a good idea. It's better to abstract the actual underlying issue and address it with a solution that solves a whole bunch of issues in a generic way. This is always hard for language designers to convey to users, since each user has a particular unique pain point, but that's what language designers have to do. There's also always the danger of introducing a particular solution to a particular problem and then other things break or get harder, but taking the feature back is close to impossible. Another situation that needs to be avoided by all means. Sealed types is a proven thing, but auto-casting if a sealed type has a single implementation is not. What if that single subtype has additional subtypes? Is the cast still safe in that situation? What does it mean for this and that? Lots of ground to cover. From the ad-hoc analysis done by some of us in this thread it already seems not worthwhile to go further for the reasons explained. The special casing of
T?
and
T
is on its own a problem and a fully fledged union/intersection system that works for all types (built-in or user) like Ceylon has it would be that general, universal solution. Everybody wants it, but it's hard. 🤞 we'll get it at some point.
a
Actually, maybe I’m misunderstanding something. Why doesn’t the compiler warn at the cast here?
Copy code
sealed interface Base{

}

class Sub1: Base{
    val n = 5
}

class Sub2: Base{
    val k = 6
}


fun foo (b: Base){
    println(
        (b as Sub2).k
    )
}
f
It doesn't warn here either:
Copy code
sealed interface I {
    private class C : I
    private class D : I
}

fun f(i: I) {
    val c = i as C
}
Nor does it warn here:
Copy code
import java.util.UUID

fun f(x: UUID) {
    val y = x as String
}
a
Hmm, I think the warning is only for casts to a generic type.
f
It is. 🙂
a
It would’ve been more intuitive for
as
to be
as!
or
as!!
f
as
(a cast) is by definition a forced operation, adding more symbols to it does not change that fact. Sure, the JVM (and other systems) have runtime protections to avoid totally silly casts, but they don't have to. In Rust silly casts are possible with
unsafe {}
and in C any cast is always possible. It's just 0s and 1s after all we are dealing with and how we interpret them is up to us.
Copy code
import java.util.UUID

fun f(x: UUID) {
    val y = x as? String
}
This is possible to avoid the runtime exception.
e
unchecked cast warning is because casting a
List<String>
to a
List<Int>
can appear to succeed even though it'll cause problems later. but
UUID as String
is immediate
f
What @ephemient wrote, for the JVM we perform a
List as List
cast due to type erasure.
a
I understand now. I’m just saying an exclamation mark has the connotation of an unsafe operation.
So making it
as!!
would more clearly mark an unsafe operation.
f
It does in writing, yes, but there's is no precedence for this in Kotlin.
!
is used to mark types where nullability is unknown and
!!
is used to throw a NPE if something shall never be null. Very different things.
a
?
is used in more than one place to mark “operation or null if operation can’t succeed”
So that’s a precedent
f
?
is consistently used together with
null
. It either marks a type as nullable or an expression to short circuit if the previous expression is null. There are many languages out there that use symbols in different contexts for totally different things, and that can be fine, or you end up with Perl. 🤷 It's all about trade offs.