Skip to content

SIP-57 - Replace non-sensical @unchecked annotations #67

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 3 commits into from
Feb 20, 2024

Conversation

bishabosha
Copy link
Member

Currently, a user can silence warnings that a scrutinee may not be matched by a pattern, by annotating the scrutinee with the @unchecked annotation.

This SIP proposes to use a new annotation @RuntimeCheck to replace @unchecked for this purpose. For convenience, an extension method will be added to Predef that marks the receiver with the annotation (used as follows: foo.runtimeCheck).

Functionally it behaves the same as the old annotation, but improves readability at the callsite.

@bishabosha bishabosha changed the title Add proposal for runtimeChecked method SIP NN - Add proposal for runtimeChecked method Dec 8, 2023
@bishabosha bishabosha changed the title SIP NN - Add proposal for runtimeChecked method SIP-NN - Replace non-sensical @unchecked annotations Dec 8, 2023
@bishabosha bishabosha force-pushed the replace-unchecked-annotation branch from f8c06f9 to d28b69f Compare December 8, 2023 20:05
@anatoliykmetyuk anatoliykmetyuk changed the title SIP-NN - Replace non-sensical @unchecked annotations SIP-57 - Replace non-sensical @unchecked annotations Dec 11, 2023
@JD557
Copy link

JD557 commented Dec 11, 2023

A bit of bike shedding, but all annotations that I can find in scala.annotation seem to use lower camel case.
Is there any particular reason for this one to use upper camel case?

@odersky
Copy link
Contributor

odersky commented Dec 11, 2023

It's an internal annotation. Other annotations in package annotation.internal also start with an upper case letter, e.g. Child, Body, InlineParam.

@bishabosha
Copy link
Member Author

bishabosha commented Dec 15, 2023

I will need to add an amendment that we would also like to change the following behavior:

scala> val xs = List(1: Any)
val xs: List[Any] = List(1)
                                                                                                    
scala> xs match {
     |   case is: ::[Int] => is.head
     | }
2 warnings found
-- [E029] Pattern Match Exhaustivity Warning: ----------------------------------
1 |xs match {
  |^^
  |match may not be exhaustive.
  |
  |It would fail on pattern case: List(_, _*), Nil
  |
  | longer explanation available when compiling with `-explain`
val res11: Int = 1
-- Unchecked Warning: ----------------------------------------------------------
2 |  case is: ::[Int] => is.head
  |       ^
  |the type test for ::[Int] cannot be checked at runtime because its type arguments can't be determined from List[Any]

when we try again with the @unchecked on the scrutinee, all the warnings disappear

scala> (xs: @unchecked) match {
     |   case is: ::[Int] => is.head
     | }
val res12: Int = 1

with xs.runtimeCheck we should still produce an unchecked warning for case is: ::[Int] =>

scala> xs.runtimeChecked match {
     |   case is: ::[Int] => is.head
     | }
1 warning found
-- Unchecked Warning: ----------------------------------------------------------
2 |  case is: ::[Int] => is.head
  |       ^
  |the type test for ::[Int] cannot be checked at runtime because its type arguments can't be determined from List[Any]
val res13: Int = 1

This is because xs.runtimeChecked means trust the user as long as the pattern can be checked at runtime.

To fully avoid warnings, the @unchecked will be put on the type argument:

scala> xs.runtimeChecked match {
     |   case is: ::[Int @unchecked] => is.head
     | }
val res14: Int = 1

This has a small extra migration cost because if the scrutinee changes from (xs: @unchecked) to xs.runtimeCheck now some individual cases might need to add @unchecked on type arguments to avoid creating new warnings - however this cost is offset by perhaps revealing unsafe patterns previously unaccounted for.

Once again @nowarn can be used to fully restore any old behavior

@chrisandrews-ms
Copy link

(I'm an assigned reviewer and manager for this SIP)

I think purpose of this SIP is clearly explained and valid, and the proposed solution is good. The name runtimeChecked is also natural to read and captures the behavior well. It's a little bit long to write but that's probably a good thing.

@sjrd my understanding is that your concerns about breaking macros are resolved by the transparent inline approach, because macros will now see the annotation rather than the method call, and will hopefully leave it well alone. But please let us know if that isn't the case.

@Kordyjan as the other assigned reviewer, do you have any thoughts?

Unless someone raises new concerns, I'm inclined to vote to approve this proposal at the next meeting.

@lrytz
Copy link
Member

lrytz commented Jan 5, 2024

In Scala 3, @unchecked on the scrutinee seems to also disable reachability

// Scala 3
scala> def f(x: Any) = (x: @unchecked) match { case Some(_) => 0; case Some(1) => 1 } // no warning
def f(x: Any): Int

Would that remain the same?

Note that's different in Scala 2 where @unchecked only affects exhaustivity.

@bishabosha regarding this comment #67 (comment), in Scala 2 @unchecked on the scrutinee doesn't silence unchecked type arguments:

// Scala 2
scala> def f(xs: List[Any]) = (xs: @unchecked) match { case is: ::[Int] => is.head }
                                                                ^
       warning: non-variable type argument Int in type pattern scala.collection.immutable.::[Int] (the underlying of ::[Int]) is unchecked since it is eliminated by erasure
def f(xs: List[Any]): Int

Maybe that can be considered a bug in Scala 3.

@lrytz
Copy link
Member

lrytz commented Jan 5, 2024

To someone unfamiliar, x match vs x.runtimeCheck match might look like some check is introduced, or maybe some check is delayed from compiletime to runtime; either way it looks like something is different at runtime.

But this is not the case. What it's doing is disabling compiletime exhaustivity checking. The warning for a non-exhaustive match says "match may not be exhaustive", so "exhaustive" is already a term that users need to learn. Can we use it in the new syntax introduced by this SIP, e.g. x.nonExhaustive match { ... }?

@odersky
Copy link
Contributor

odersky commented Jan 5, 2024

But the check is delayed, no? If we have a selector without a matching case, we get a MatchError at runtime. So disabling exhaustivity checking reveals a runtime check.

@lrytz
Copy link
Member

lrytz commented Jan 5, 2024

It's maybe a matter of perspective; the runtime checks are always there and unchanged, whether we do the compiletime checks or not.

@odersky
Copy link
Contributor

odersky commented Jan 5, 2024

Yes, but theoretically we could drop the throw MatchError if the test is proven exhaustive statically.

@bjornregnell
Copy link

The main thing here is that we tell the compiler that we want to wait until runtime to check this. So I think runtimeCheck is more intuitive than nonExhaustive (it could be exhaustive anyway even if it is not checked by the compiler) and a beginner should at least start to appreciate that there is a difference between compile time and runtime.

@lrytz
Copy link
Member

lrytz commented Jan 5, 2024

theoretically we could drop the throw MatchError

True, but that's purely an optimization; we'd still need to evaluate the last extractor to preserve semantics. Anyway, my main point is that runtimeCheck suggests that something's different at runtime, which is not the case. Understanding what it does is much more likely to require googling. x.nonExhaustive match { ... } looks almost like x nonExhaustiveMatch { ... }, which is what this is about.

@odersky
Copy link
Contributor

odersky commented Jan 5, 2024

But everything we do also has to work for the case of a val where there is no apparent match.

  val x: Any = ???
  val y: Int = x.runtimeCheck

is more obvious than

  val x: Any = ???
  val y: Int = x.nonExhaustive

@lrytz
Copy link
Member

lrytz commented Jan 5, 2024

There's no match keyword, but it's still exhaustiveness checking that's being suppressed in val pattern bindings, so it doesn't look wrong to me.

@Kordyjan
Copy link
Contributor

I like the idea of decoupling exhaustivity warnings and unchecked warnings resulting from type information being lost due to erasure. It was a mistake in the first place that Scala 3 uses a single annotation to silence both kinds of warnings.
I also really like how the solution with a transparent inline extension method is elegant and readable.

I have a slight problem with the name of the method, while

val x :: tail = someList.runtimeCheck

is intuitive; I agree with @lrytz that the distinction between x match and x.runtimeCheck match may be confusing for newcomers.

I am for accepting this proposal, but I believe that we should spend some time before the vote to be sure we have chosen the best name for the method.

@bishabosha
Copy link
Member Author

I have integrated the comments about scala 2.13 behavior of unchecked, in comparison to the proposed semantics of runtimeCheck

@He-Pin
Copy link

He-Pin commented Jan 19, 2024

So this will not bring in any runtime overhead?does runtime check means unsafecast?

@bishabosha
Copy link
Member Author

bishabosha commented Jan 19, 2024

So this will not bring in any runtime overhead?does runtime check means unsafecast?

runtime bytecode is identical, it only silences the pattern match exhaustiveness check.

@bishabosha
Copy link
Member Author

bishabosha commented Jan 19, 2024

If we are to solve the naming issue, then let's spell out some of the ways of interpreting the effect of .runtimeCheck (feel free to add more):

  • cast the value to the type expected by the pattern(s) (and guarantee to check it at runtime)
  • assert I know better than the compiler that this will always match
  • I declare match is exhaustive (at runtime) because the invariants of my code mean that other cases will never appear
  • tell the compiler that checking of pattern exhaustivity happens at runtime (which may result in MatchError)

@sjrd
Copy link
Member

sjrd commented Jan 19, 2024

My favorite is actually the "assert" meaning. Mixed with the 3rd one I would actually think of it as "assert exhaustive". Then I'm wondering if it should throw an AssertionError rather than a MatchError. 🤔

@He-Pin
Copy link

He-Pin commented Jan 19, 2024

@soronpo
Copy link
Contributor

soronpo commented Jan 20, 2024

My favorite is actually the "assert" meaning. Mixed with the 3rd one I would actually think of it as "assert exhaustive". Then I'm wondering if it should throw an AssertionError rather than a MatchError. 🤔

I was thinking about assumeChecked. If the assumption is incorrect then an assertion is triggered.

@JD557
Copy link

JD557 commented Jan 20, 2024

My favorite is actually the "assert" meaning. Mixed with the 3rd one I would actually think of it as "assert exhaustive". Then I'm wondering if it should throw an AssertionError rather than a MatchError. 🤔

I was thinking about assumeChecked. If the assumption is incorrect then an assertion is triggered.

What about assumeExhaustive?

assumeChecked makes me want to ask "checked for what?"

@Kordyjan
Copy link
Contributor

Kordyjan commented Jan 22, 2024

I'm for assumeMatching, assertMatching, or maybe even assume alone. For me all of

xs.assume match
  case x0 :: tail => ???

val x0 :: tail = xs.assume

assume(xs) match
  case x0 :: tail => ???

val x0 :: tail = assume(xs)

look pretty good.

And I agree that AssertionError is fitting here.

@bjornregnell
Copy link

bjornregnell commented Jan 22, 2024

Hmm... assume perhaps looks good and reads well and align with assert and require but to me it is not obvious what happens, compared to runtimeCheck, which tells me better what is happening even if I haven't read up on it. I'm not really sure (yet), but I think I'd vote for runtimeCheck rather than assume.

But assume subjectively to me looks better (it's shorter and it is a verb like assert and require to in that sense regular) so it's a matter if we prioritize upfront readability or not, I guess.

@chrisandrews-ms
Copy link

I think assumeMatching or assume match { ... are a little misleading because we're only assuming anything at compile time; at runtime we are actually checking. To me, assume sounds a bit more like the other meaning of @unchecked (i.e. erased type argument matching) where we're asking both compiler and runtime to assume that we're correct, and not actually checking. Distinguishing those two meanings of the old @unchecked is an important part of this SIP as I understand it.

I prefer @JD557's suggestion assumeExhaustive because that describes more precisely what we're doing: it says only that we're assuming that we've covered all of the cases. To me, exhaustivity checking is naturally a compile-time thing, so it wouldn't make me think that there would be no checking at runtime.

@odersky
Copy link
Contributor

odersky commented Feb 12, 2024

There is the possibility that we want to expand this further. We now allow:

val (x: T) = y.runtimeChecked

since (x: T) is technically a pattern. It seems silly to require the parentheses and natural to also allow

val x: T = y.runtimeChecked

With runtimeChecked this is fine, but anything that mentions "matching" or "exhaustive" will have a problem.

To be sure, this is not part of the current SIP, but it would be a likely next step.

I still think runtimeChecked is by far the best of all proposals.

@bjornregnell
Copy link

I like the trailing ed and runtimeChecked reads better that runtimeCheck as it means "it is runtimeChecked", so better from an English grammar perspective, I think.

@chrisandrews-ms
Copy link

I'm fine with runtimeChecked or assumeExhaustive. Both convey the meaning to me that Scala is not checking at compile time but is checking at runtime

@chrisandrews-ms
Copy link

I'm pleased to announce that the SIP Committee voted in favor of this SIP to be "Accepted for implementation"

@anatoliykmetyuk anatoliykmetyuk merged commit fd3f6dc into scala:main Feb 20, 2024
@bishabosha bishabosha deleted the replace-unchecked-annotation branch February 20, 2024 10:55
@bishabosha
Copy link
Member Author

bishabosha commented Feb 20, 2024

since (x: T) is technically a pattern. It seems silly to require the parentheses and natural to also allow

val x: T = y.runtimeChecked

With runtimeChecked this is fine, but anything that mentions "matching" or "exhaustive" will have a problem.

To be sure, this is not part of the current SIP, but it would be a likely next step.

Is there a precedent for using annotations to rewrite expressions?, i.e. would this

val x: T = y: @RuntimeChecked // inlining of y.runtimeChecked

rewrite to

val x: T = y match
  case y: T => y
  case _ => throw ClassCastException(...)

do we need another primitive? should we add type parameters?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

Successfully merging this pull request may close these issues.