SIP-57 - Replace non-sensical @unchecked annotations

Language

By: Martin Odersky and Jamie Thompson

History

Date Version
Dec 8th 2023 Initial Draft
Jan 19th 2024 Clarification about current @unchecked behavior

Summary

We propose to replace the mechanism to silence warnings for “unchecked” patterns, in the cases where silencing the warning will still result in the pattern being checked at runtime.

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.

Motivation

As described in Scala 3 Reference: Pattern Bindings, under -source:future it is an error for a pattern definition to be refutable. For instance, consider:

def xs: List[Any] = ???
val y :: ys = xs

This compiled without warning in 3.0, became a warning in 3.2, and we would like to make it an error by default in a future 3.x version. As an escape hatch we recommend to use @unchecked:

-- Warning: ../../new/test.scala:6:16 ------------------------------------------
6 |  val y :: ys = xs
  |                ^^
  |pattern's type ::[Any] is more specialized than the right hand side expression's type List[Any]
  |
  |If the narrowing is intentional, this can be communicated by adding `: @unchecked` after the expression,
  |which may result in a MatchError at runtime.

Similarly for non-exhaustive match expressions, where we also recommend to put @unchecked on the scrutinee.

But @unchecked has several problems. First, it is ergonomically bad. For instance to fix the exhaustivity warning in

xs match
  case y :: ys => ...

we’d have to write

(xs: @unchecked) match
  case y :: ys => ...

Having to wrap the @unchecked in parentheses requires editing in two places, and arguably harms readability: both due to the churn in extra symbols, and because in this use case the @unchecked annotation poorly communicates intent.

Nominally, the purpose of the annotation is to silence warnings (from the API docs):

An annotation to designate that the annotated entity should not be considered for additional compiler checks.

The exact meaning of this description is open to interpretation, leading to differences between Scala 2.13 and Scala 3.x. See the misinterpretation annex for more.

In the following code however, the word unchecked is a misnomer, so could be confused for another meaning by an inexperienced user:

def xs: List[Any] = ???
val y :: ys = xs: @unchecked

After all, the pattern y :: ys is checked, but it is done at runtime (by looking at the runtime class), rather than statically.

As a direct contradiction, in the following usage of unchecked, the meaning is the opposite:

xs match
  case ints: List[Int @unchecked] =>

Here, @unchecked means that the Int parameter will not be checked at runtime: The compiler instead trusts the user that ints is a List[Int]. This could lead to a ClassCastException in an unrelated piece of code that uses ints, possibly without leaving a clear breadcrumb trail of where the faulty cast originally occurred.

Proposed solution

High-level overview

This SIP proposes to fix the ergnomics and readability of @unchecked in the usage where it means “checked at runtime”, by instead adding a new annotation scala.internal.RuntimeCheck.

package scala.annotation.internal

final class RuntimeCheck extends Annotation

In all usages where the compiler looks for @unchecked for this purpose, we instead change to look for @RuntimeCheck.

By placing the annotation in the internal package, we communicate that the user is not meant to directly use the annotation.

Instead, for convenience, we provide an extension method Predef.runtimeCheck, which can be applied to any expression.

The new usage to assert that a pattern is checked at runtime then becomes as follows:

def xs: List[Any] = ???
val y :: ys = xs.runtimeCheck

We also make runtimeCheck a transparent inline method. This ensures that the elaboration of the method defines its semantics. (i.e. runtimeCheck is not meaningful because it is immediately inlined at type-checking).

Specification

The addition of a new scala.Predef method:

package scala

import scala.annotation.internal.RuntimeCheck

object Predef:
  extension [T](x: T)
    transparent inline def runtimeCheck: x.type =
      x: @RuntimeCheck

Compatibility

This change carries the usual backward binary and TASTy compatibility concerns as any other standard library addition to the Scala 3 only library.

Considering backwards source compatibility, the following situation will change:

// source A.scala
package example

extension (predef: scala.Predef.type)
  transparent inline def runtimeCheck[T](x: T): x.type =
    println("fake runtimeCheck")
    x
// source B.scala
package example

@main def Test =
  val xs = List[Any](1,2,3)
  val y :: ys = Predef.runtimeCheck(xs)
  assert(ys == List(2, 3))

Previously this code would print fake runtimeCheck, however with the proposed change then recompiling this code will succeed and no longer will print.

Potentially we could mitigate this if necessary with a migration warning when the new method is resolved (@experimental annotation would be a start)

In general however, the new runtimeCheck method will not change any previously linking method without causing an ambiguity compilation error.

Other concerns

In 3.3 we already require the user to put @unchecked to avoid warnings, there is likely a significant amount of existing code that will need to migrate to the new mechanism. (We can leverage already exisiting mechanisms help migrate code automatically).

Open questions

1) A large question was should the method or annotation carry semantic weight in the language. In this proposal we weigh towards the annotation being the significant element. The new method elaborates to an annotated expression before the associated pattern exhaustivity checks occur. 2) Another point, where should the helper method go? In Predef it requires no import, but another possible location was the compiletime package. Requiring the extra import could discourage usage without consideration - however if the method remains in Predef the name itself (and documentation) should signal danger, like with asInstanceOf.

3) Should the RuntimeCheck annotation be in the scala.annotation.internal package?

Misinterpretation of unchecked

We would further like to highlight that the unchecked annotation is unspecified except for its imprecise API documentation. This leads to a crucial difference in its behavior between Scala 2.13 and the latest Scala 3.3.1 release.

Scala 3 semantics

Say you have the following:

val xs = List(1: Any)

The following expression in Scala 3.3.1 yields two warnings:

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 res0: 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]

using @unchecked on xs has the effect of silencing any warnings that depend on checking xs, so no warnings will be emitted for the following change:

(xs: @unchecked) match {
  case is: ::[Int] => is.head
}

Scala 2.13 semantics

However, in Scala 2.13, this will only silence the match may not be exhaustive warning, and the user will still see the type test for ::[Int] cannot be checked at runtime warning:

scala> (xs: @unchecked) match {
     |   case is: ::[Int] => is.head
     | }          ^
On line 2: 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
val res2: Int = 1

Aligning to Scala 2.13 semantics with runtimeCheck

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

Alternatives

1) make runtimeCheck a method on Any that returns the receiver (not inline). The compiler would check for presence of a call to this method when deciding to perform static checking of pattern exhaustivity. This idea was criticised for being brittle with respect to refactoring, or automatic code transformations via macro.

2) runtimeCheck should elaborate to code that matches the expected type, e.g. to heal t: Any to Int when the expected type is Int. The problem is that this is not useful for patterns that can not be runtime checked by type alone. Also, it implies a greater change to the spec, because now runtimeCheck would have to be specially treated.

  • Pre SIP thread
  • Scala 3 Reference: Pattern Bindings,
  • None of OCaml, Rust, Swift, or Java offer explicit escape hatches for non-exhaustive pattern matches (Haskell does not even warn by default). Instead the user must add a default case, (making it exhaustive) or use the equivalent of @nowarn when they exist.

FAQ

N/A so far.