Pre SIP: Replace non-sensical @unchecked annotations

I think this is a very well thought of proposal, and I support it, it also has the benefit of allowing more documentation !
Since we now have a method and a type, we can clearly explain what each does, and what happens when you use them

@alvae and @lihaoyi have already articulated quite well many reasons why checkedCast as a method that returns : x.type does not behave like a method. I wonā€™t repeat that here.

I just want to reply to two concrete comments:

Target typing cannot explain what is proposed here.

Until now, target typing can always be explained away as desugaring during typechecking. After typechecking (after elaboration to be precise), the behavior of a program can be fully explained without target typing. For asInstanceOf in particular, it did receive an actual type parameter, and that type parameter will not change anymore. If we move it and assign it to a separate val (for example, in a macro or compiler phase), it will keep that concrete type parameter.

With the proposed behavior of checkedCast, and if it wants to be explained in terms of target typing, this assumption is broken. Even in a macro or a compiler phase, you are not allowed to extract the call in a separate val, as it will cause compile errors that would not be there otherwise. This illustrates that the Match(Apply(_, checkedCast), ...) is magical as a pair of syntactically nested nodes.

This is not hair splitting. It has very concrete consequences that will break peopleā€™s code.

Take for example a macro for async/await. Such a macro typically needs to perform some sort of ANF transform, which does mean extracting subexpressions and putting them into vals. The macro would be well-behaved, well-tested, on every possible Tree of the metaprogramming API. And then when a user happens to use x.checkedCast match inside async { ... }, bam! they get a compile error that makes no sense.

To fix that, the macro author will have to add a specific case for Match(Apply(x, checkedCast), ...) in order not to extract that Apply. How do we expect a macro author to know that? Or to think about testing that particular combination of features? We cannot. Macros will get broken, and it wonā€™t the macro authorsā€™ fault; it will be our own, for designing an elaborated form of the language that is not compositional.

This can only explain the cases where a better type is enough to guarantee exhaustivity checking. But the original proposal definitely wants to be able to do things like

val Foo(Bar(x)) = any.checkedCast

or

val List(one, two) = list.checkedCast

which cannot be explained that way. So no, that does not work and we cannot move on :wink:

If we could, then my suggestion from way earlier to give a simple, user-land definition of .checkedCast would have sufficed. As a reminder, it looked like:

extension [S] (x: S) def checkedCast[T](using TypeTest[S, T]): T = x match
  case x: T => x
  case _    => throw MatchError(x)

That one would have been completely explained by target typing as we know it today. Not just the signature but even the implementation!

4 Likes

the original proposal also mentions

which I took to mean if a valid type that would silence warnings can be extracted, then it will become that type. e.g. ::[Any] can come from List[Any], but not ::[Int], as Int is again unchecked

I had these features in my proposal too (hidden in a scastie link), but with one key difference; [T] is not the type of what you were but rather what you want to become. You explicitly lose track of what you were.

extension [A](any: A)
  inline def checkedCast[T]: CheckedCast[T] = any

At that point I think it fills all the requirements: a little magic on how to fill in [T] and how you match against CheckedCast[T]ā€“mostly acting as if there is a Conversion[CheckedCast[T], T] in scopeā€“and youā€™re good.

Iā€™m still confused what casting has to do with exhaustivity checking.

(Also, Iā€™d like to know more about when and to what extent patterns affect the expected type, for target typing to be relevant. I didnā€™t know it could affect it at all if the patterns are part of a match.)

3 Likes

what about writing this as:

val x: Any = ???
case val (y: Int) = x
case val y: Int = x

or

val x: Any = ???
val case (y: Int) = x
val case y: Int = x

I could live with that proposal, even though I am not convinced the added complication is necessary. checkedCast as a simple method works perfectly well IMO. But if others feel we have to reify this as a type, I could go along with it.

Just as a counter-point: We use implicit conversions (user- or system-defined) all the time, and yet nobody so far has proposed ā€œbeing convertibleā€ should be reified into a type. So I think itā€™s a bit of overkill to do so for CheckedCast, but itā€™s certainly better than the status quo.

It does not even have to be implicit conversions. For instance here is a very nice method that
makes conversions explicit:

extension [A](x: A) def convert[B](using Conversion[A, B]): B

Now, you write

x.convert

indicating that an implicit conversion will kick in at this point which depends on the target type. In my opinion, as far as the user is concerned, x.checkedCast is very similar to this. The technical argument against is that unlike convert, checkedCast cannot be given a type signature, But adding CheckedCast as a magical type only shifts the problem around and complicates it further.

1 Like

Thatā€™s because the reification is done by a given Conversion being in scope. I like that much better than the old completely-non-explicit way of doing things.

Iā€™m fine with a similar type-witness to the checked-castedness of a particular thing; I just think the ergonomics would be poor considering how itā€™s usually used.

given CheckedCast[xs.type] = ???
val y :: ys = xs

So while I love the explicit conversions method you wrote (.convert is basically Rust .into()), to me this argues in favor of the same kind of explicitness you get with Conversion for checkedCast as well, which most easily would be solved by giving it an explicit type.

I still think the name could use some bikeshedding. What weā€™re actually doing is trusting at compile-time that the cast is valid, even though the type suggests that itā€™s not, but verifying at runtime.

// Not an actual suggestion
val y :: ys = trustButVerify{ xs }

Maybe something about trusting or runtimeVerify or somesuch would capture the intent and mechanism enough better so that people could use it with only minimal training. If you read checkedCast and you donā€™t know what it means, you donā€™t have much chance of figuring it out on your own. But something along the trust/verify lines might be self-evident enough.

1 Like

We had a brief discussion about this at the last SIP meeting. An important point that emerged from that is that we want to distinguish the source syntax from its elaboration. Dot syntax is convenient as source syntax, but if it translates to a method call, all meta programming has to treat this call specially, which is problematic.

Moreover, if we accept source syntax is different from elaboration, we donā€™t need to change the language proper at all. The following trick, suggested by @bishabosha, works:

  extension [A](x: A)
    transparent inline def checkedCast: x.type = x: @unchecked

  val xs = List(1, 2, 3)

  val z :: zs = xs.checkedCast

  xs.checkedCast match
    case y :: ys =>

So that means we can drastically cut down on the size of the suggested changes. I suggest we just do the following:

  1. Deprecate @unchecked in the role it is used here and replace it by a more sensical name. As I have argued at the start of this thread @unchecked is bad since it is used in two different places, one where it genuinely means ā€œuncheckedā€ (on type parameters in patterns) and the one here where it means ā€œchecked at runtimeā€ and is used to suppress exhaustivity or refutability warnings. So I propose to introduce a new annotation named @checkedCast that replaces unchecked in the second role.

  2. Add the checkedCast method above to Predef, adjusted so that it uses the @checkedcast annotation instead of @unchecked (we can do that in Scala 3 without waiting for the standard library to thaw).

The @checkedCast annotation could be defined in annotation.internal to avoid two ways of expressing the same thing.

7 Likes

I think this sounds like a very good solution, and also a less ā€œintrusiveā€ change compared to changing the language! :+1:

Iā€™d prefer if the name indicated something about that the check is made at runtime; I agree with @Ichoran that the name checkedCast is not that obvious in what it does, just from reading it the first time, without looking up docs, and the intention is to move the check from compiletime to runtime, and I think the name should reflect the intention and be comprehensible at a first-time read by new-comer to Scala.

3 Likes

I think any replacement for checkedCast should not be longer than that, since checkedCast is already quite long.

I quite like the typescript syntax of ! for this isnā€™t null or undefined (although that would probably play havok with akka etc). Not helpful I know but perhaps there is a short an sweet symbol which can be used

Iā€™d prefer not using a symbol as it is cryptic/esoteric to beginner/seldom-users. So something short but with letters that says something about the intention.

3 Likes

Yes, short is good. And maybe the shorter checked is better and less esoteric compared to checkedCast as the term ā€œcastā€ might be unknown by the reader (we donā€™t even use that term in the method name when using asInstanceOf[T])

But I think it might be worth spending some more letters and go for runtimeChecked as it says what it does (c.f. the explanation in you original post where you describe it as ā€œchecked at runtimeā€). I guess this is not a very commonly used or normal thing to do, comparing to asInstanceOf[...] that is also quite long but OK as it is unusual.

scala> "checkedCast".size
val res9: Int = 11
                                                                                
scala> "runtimeChecked".size
val res10: Int = 14
2 Likes

If weā€™re counting characters ā€“ my instinct here was the same as @bjornregnellā€™s, but I landed on .runtimeCheck, which comes across slightly more as an imperative command than a statement. (Which I think is correct for the sense of whatā€™s happening.)

But yeah: IMO itā€™s extremely helpful to spell out the ā€œruntimeā€ part of this, which makes it much clearer whatā€™s going on.

And I also agree that Iā€™m not that concerned about length: in practice, this is a tool I reach for relatively rarely, so Iā€™d much rather it be glanceably clear than short.

3 Likes

Yeah after tasting it for a while I also like the imperative nature and its saving of two chars:

scala> "runtimeCheck".length
val res11: Int = 12
1 Like
runtimeCast

?
(11)

1 Like

Itā€™s shorter! However, there is a check at runtime and then, if it is followed by a match, there might be a cast as in type pattern, but the main thing with this new extension method is postponing the check until runtime so I prefer runtimeCheck over runtimeCast, and I think the term cast maybe is not that obvious to users who are not so seasoned in imperative OO lingo.

1 Like

I think that given that the method makes a value ā€œruntime checkableā€ (i.e the actual check is still delayed until the match) perhaps we should reuse the same terminology - asRuntimeCheckable, we already have asMatchable too.

Actually, why canā€™t we repurpose asMatchable for this? it is intended to enable recovering the runtime type of Any

1 Like

I like the correspondance of the leading ā€œasā€ as in ā€œasInstanceOfā€, but as @odersky pointed out it is rather long (18 chars).

Here is a summary of proposals so far (did I forget any?):

  • checked
  • checkedCast
  • checkedAtRuntime
  • runtimeChecked
  • runtimeCheck
  • runtimeCast
  • asRuntimeCheckable

I think I want runtimeCheck even if I also like the last one starting with as.