Skip to content

Commit

Permalink
Implement SIP 57 - add runtimeChecked method and annotation (#20987)
Browse files Browse the repository at this point in the history
ref:
https://docs.scala-lang.org/sips/replace-nonsensical-unchecked-annotation.html

Also this doesn't change synthetic code that is generated to use
`@unchecked`, or make `@unchecked` meaningless for this use case - this
should probably come at a later stage after the definition is no longer
experimental

fixes #21012
  • Loading branch information
bishabosha authored Jul 24, 2024
2 parents 0cf25db + 5595bdd commit a64c295
Show file tree
Hide file tree
Showing 19 changed files with 237 additions and 7 deletions.
1 change: 1 addition & 0 deletions compiler/src/dotty/tools/dotc/core/Definitions.scala
Original file line number Diff line number Diff line change
Expand Up @@ -1037,6 +1037,7 @@ class Definitions {
@tu lazy val TransparentTraitAnnot: ClassSymbol = requiredClass("scala.annotation.transparentTrait")
@tu lazy val NativeAnnot: ClassSymbol = requiredClass("scala.native")
@tu lazy val RepeatedAnnot: ClassSymbol = requiredClass("scala.annotation.internal.Repeated")
@tu lazy val RuntimeCheckedAnnot: ClassSymbol = requiredClass("scala.annotation.internal.RuntimeChecked")
@tu lazy val SourceFileAnnot: ClassSymbol = requiredClass("scala.annotation.internal.SourceFile")
@tu lazy val ScalaSignatureAnnot: ClassSymbol = requiredClass("scala.reflect.ScalaSignature")
@tu lazy val ScalaLongSignatureAnnot: ClassSymbol = requiredClass("scala.reflect.ScalaLongSignature")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -458,14 +458,15 @@ object ExtractSemanticDB:
def unapply(tree: ValDef)(using Context): Option[(Tree, Tree)] = tree.rhs match

case Match(Typed(selected: Tree, tpt: TypeTree), CaseDef(pat: Tree, _, _) :: Nil)
if tpt.span.exists && !tpt.span.hasLength && tpt.tpe.isAnnotatedByUnchecked =>
if tpt.span.exists && !tpt.span.hasLength && tpt.tpe.isAnnotatedByUncheckedOrRuntimeChecked =>
Some((pat, selected))

case _ => None

extension (tpe: Types.Type)
private inline def isAnnotatedByUnchecked(using Context) = tpe match
case Types.AnnotatedType(_, annot) => annot.symbol == defn.UncheckedAnnot
private inline def isAnnotatedByUncheckedOrRuntimeChecked(using Context) = tpe match
case Types.AnnotatedType(_, annot) =>
annot.symbol == defn.UncheckedAnnot || annot.symbol == defn.RuntimeCheckedAnnot
case _ => false

def collectPats(pat: Tree): List[Tree] =
Expand Down
1 change: 1 addition & 0 deletions compiler/src/dotty/tools/dotc/transform/patmat/Space.scala
Original file line number Diff line number Diff line change
Expand Up @@ -794,6 +794,7 @@ object SpaceEngine {
}

!sel.tpe.hasAnnotation(defn.UncheckedAnnot)
&& !sel.tpe.hasAnnotation(defn.RuntimeCheckedAnnot)
&& {
ctx.settings.YcheckAllPatmat.value
|| isCheckable(sel.tpe)
Expand Down
1 change: 1 addition & 0 deletions compiler/src/dotty/tools/dotc/typer/Checking.scala
Original file line number Diff line number Diff line change
Expand Up @@ -981,6 +981,7 @@ trait Checking {
def recur(pat: Tree, pt: Type): Boolean =
!sourceVersion.isAtLeast(`3.2`)
|| pt.hasAnnotation(defn.UncheckedAnnot)
|| pt.hasAnnotation(defn.RuntimeCheckedAnnot)
|| {
patmatch.println(i"check irrefutable $pat: ${pat.tpe} against $pt")
pat match
Expand Down
2 changes: 1 addition & 1 deletion compiler/src/dotty/tools/dotc/typer/Typer.scala
Original file line number Diff line number Diff line change
Expand Up @@ -2073,7 +2073,7 @@ class Typer(@constructorOnly nestingLevel: Int = 0) extends Namer
result match {
case result @ Match(sel, CaseDef(pat, _, _) :: _) =>
tree.selector.removeAttachment(desugar.CheckIrrefutable) match {
case Some(checkMode) if !sel.tpe.hasAnnotation(defn.UncheckedAnnot) =>
case Some(checkMode) if !(sel.tpe.hasAnnotation(defn.UncheckedAnnot) || sel.tpe.hasAnnotation(defn.RuntimeCheckedAnnot)) =>
val isPatDef = checkMode == desugar.MatchCheck.IrrefutablePatDef
if !checkIrrefutable(sel, pat, isPatDef)
&& sourceVersion.isAtLeast(`3.2`)
Expand Down
5 changes: 3 additions & 2 deletions compiler/test/dotty/tools/repl/TabcompleteTests.scala
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import org.junit.Test
class TabcompleteTests extends ReplTest {

@Test def tabCompleteList = initially {
val comp = tabComplete("List.r")
val comp = tabComplete("List.ra")
assertEquals(List("range"), comp.distinct)
}

Expand Down Expand Up @@ -112,7 +112,7 @@ class TabcompleteTests extends ReplTest {
val comp = tabComplete("(null: AnyRef).")
assertEquals(
List("!=", "##", "->", "==", "asInstanceOf", "ensuring", "eq", "equals", "formatted",
"getClass", "hashCode", "isInstanceOf", "ne", "nn", "notify", "notifyAll", "synchronized", "toString", "wait", ""),
"getClass", "hashCode", "isInstanceOf", "ne", "nn", "notify", "notifyAll", "runtimeChecked", "synchronized", "toString", "wait", ""),
comp.distinct.sorted)
}

Expand Down Expand Up @@ -163,6 +163,7 @@ class TabcompleteTests extends ReplTest {
"nn",
"notify",
"notifyAll",
"runtimeChecked",
"synchronized",
"toString",
"valueOf",
Expand Down
133 changes: 133 additions & 0 deletions docs/_docs/reference/experimental/runtimeChecked.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
---
layout: doc-page
title: "The runtimeChecked method"
nightlyOf: https://docs.scala-lang.org/scala3/reference/experimental/runtimeChecked.html
---

The `runtimeChecked` method is an extension method, defined in `scala.Predef`. It can be called on any expression. An expression ending in `.runtimeChecked` is exempt from certain static checks in the compiler, for example pattern match exhaustivity. The idiom is intended to replace a `: @unchecked` type ascription in these cases.

## Example

A common use case for `runtimeChecked` is to assert that a pattern will always match, either for convenience, or because there is a known invariant that the types can not express.

E.g. looking up an expected entry in a dynamically loaded dictionary-like structure:
```scala
// example 1
trait AppConfig:
def get(key: String): Option[String]

val config: AppConfig = ???

val Some(appVersion) = config.get("appVersion").runtimeChecked
```

or to assert that a value can only match some specific patterns:
```scala
// example 2
enum Day:
case Mon, Tue, Wed, Thu, Fri, Sat, Sun

val weekDay: Option[Day] = ???

weekDay.runtimeChecked match
case Some(Mon | Tue | Wed | Thu | Fri) => println("got weekday")
// case Some(Sat | Sun) => // weekend should not appear
case None =>
```

In both of these cases, without `runtimeChecked` there would either be an error (example 1), or a warning (example 2), because statically, the compiler knows that there could be other cases at runtime - so is right to caution the programmer.

```scala
// warning in example 2 when we don't add `.runtimeChecked`.
-- [E029] Pattern Match Exhaustivity Warning: ----------------------------------
6 |weekDay match
|^^^^^^^
|match may not be exhaustive.
|
|It would fail on pattern case: Some(Sat), Some(Sun)
```

## Safety

The `runtimeChecked` method only turns off static checks that can be soundly performed at runtime. This means that patterns with unchecked type-tests will still generate warnings. For example:
```scala
scala> val xs = List(1: Any)
| xs.runtimeChecked match {
| case is: ::[Int] => is.head
| }
1 warning found
-- Unchecked Warning: ---------------------------------------
3 | 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 res0: Int = 1
```
As the warning hints, the type `::[Int]` can not be tested at runtime on a value of type `List[Any]`, so using `runtimeChecked` still protects the user against assertions that can not be validated.

To fully avoid warnings, as with previous Scala versions, `@unchecked` should be put on the type argument:
```scala
scala> xs.runtimeChecked match {
| case is: ::[Int @unchecked] => is.head
| }
val res1: Int = 1
```


## Specification

We add a new annotation `scala.internal.RuntimeChecked` as a part of the standard Scala 3 library. A programmer is not expected to use this annotation directly.

```scala
package scala.annotation.internal

final class RuntimeChecked extends Annotation
```

Any term that is the scrutinee of a pattern match, and that has a type annotated with `RuntimeChecked`, is exempt from pattern match exhaustivity checking.


The user facing API is augmented with a new extension method `scala.Predef.runtimeChecked`, qualified for any value:
```scala
package scala

import scala.annotation.internal.RuntimeChecked

object Predef:
...
extension [T](x: T)
inline def runtimeChecked: x.type @RuntimeChecked =
x: @RuntimeChecked
```

The `runtimeChecked` method returns its argument, refining its type with the `RuntimeChecked` annotation.

## Motivation

As described in [Pattern Bindings](../changed-features/pattern-bindings.md), under `-source:future` it is an error for a pattern definition to be refutable. For instance, consider:
```scala
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 in 3.2 we recommended to use a type ascription of `: @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.
```

However, `: @unchecked` is syntactically awkward, and is also a misnomer - in fact in this case the the pattern _is_ fully checked, but the necessary checks occur at runtime. The `runtimeChecked` method is intended to replace `@unchecked` for this purpose.

The `@unchecked` annotation is still retained for silencing warnings on unsound type tests.

### Restoring Scala 2.13 semantics with runtimeChecked

In Scala 3, the `: @unchecked` type ascription has the effect of turning off all pattern-match warnings on the match scrutinee - this differs from 2.13 in which it strictly turns off only pattern exhaustivity checking. `runtimeChecked` restores the semantics of Scala 2.13.
1 change: 1 addition & 0 deletions docs/sidebar.yml
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,7 @@ subsection:
- page: reference/experimental/named-tuples.md
- page: reference/experimental/modularity.md
- page: reference/experimental/typeclasses.md
- page: reference/experimental/runtimeChecked.md
- page: reference/syntax.md
- title: Language Versions
index: reference/language-versions/language-versions.md
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1028,6 +1028,7 @@ class CompletionTest {
("ensuring", Method, "(cond: Boolean): Foo.Bar.type"),
("##", Method, "=> Int"),
("nn", Method, "=> Foo.Bar.type"),
("runtimeChecked", Method, "=> Foo.Bar.type"),
("==", Method, "(x$0: Any): Boolean"),
("ensuring", Method, "(cond: Boolean, msg: => Any): Foo.Bar.type"),
("ne", Method, "(x$0: Object): Boolean"),
Expand Down
11 changes: 11 additions & 0 deletions library/src/scala/annotation/internal/RuntimeChecked.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package scala.annotation.internal

import scala.annotation.Annotation
import scala.annotation.experimental

/**An annotation marking an intention that all checks on a value can be reliably performed at runtime.
*
* The compiler will remove certain static checks except those that can't be performed at runtime.
*/
@experimental
final class RuntimeChecked() extends Annotation
16 changes: 16 additions & 0 deletions library/src/scala/runtime/stdLibPatches/Predef.scala
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package scala.runtime.stdLibPatches

import scala.annotation.experimental
import scala.annotation.internal.RuntimeChecked

object Predef:
import compiletime.summonFrom
Expand Down Expand Up @@ -80,4 +81,19 @@ object Predef:
@experimental
infix type is[A <: AnyKind, B <: Any{type Self <: AnyKind}] = B { type Self = A }

extension [T](x: T)
/**Asserts that a term should be exempt from static checks that can be reliably checked at runtime.
* @example {{{
* val xs: Option[Int] = Option(1)
* xs.runtimeChecked match
* case Some(x) => x // `Some(_)` can be checked at runtime, so no warning
* }}}
* @example {{{
* val xs: List[Int] = List(1,2,3)
* val y :: ys = xs.runtimeChecked // `_ :: _` can be checked at runtime, so no warning
* }}}
*/
@experimental
inline def runtimeChecked: x.type @RuntimeChecked = x: @RuntimeChecked

end Predef
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,7 @@ class CompletionSuite extends BaseCompletionSuite:
|fromSpecific(from: Any)(it: IterableOnce[Nothing]): List[Nothing]
|fromSpecific(it: IterableOnce[Nothing]): List[Nothing]
|nn: List.type & List.type
|runtimeChecked scala.collection.immutable
|toFactory(from: Any): Factory[Nothing, List[Nothing]]
|formatted(fmtstr: String): String
|→[B](y: B): (List.type, B)
Expand Down
5 changes: 5 additions & 0 deletions tests/neg/runtimeChecked-2.check
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
-- [E030] Match case Unreachable Warning: tests/neg/runtimeChecked-2.scala:10:11 ---------------------------------------
10 | case is: Some[t] => ??? // unreachable
| ^^^^^^^^^^^
| Unreachable case
No warnings can be incurred under -Werror (or -Xfatal-warnings)
13 changes: 13 additions & 0 deletions tests/neg/runtimeChecked-2.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
//> using options -Werror -source:future -experimental

object Foo {

val xs: Option[Int] = Some(1)

def test: Int =
xs.runtimeChecked match { // this test asserts that reachability is not avoided by runtimeChecked
case is: Some[t] => is.get
case is: Some[t] => ??? // unreachable
}
}
// nopos-error: No warnings can be incurred under -Werror (or -Xfatal-warnings)
7 changes: 7 additions & 0 deletions tests/neg/runtimeChecked.check
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
-- [E092] Pattern Match Unchecked Warning: tests/neg/runtimeChecked.scala:11:11 ----------------------------------------
11 | case is: ::[Int/* can not be checked so still err */] => is.head
| ^
|the type test for ::[Int] cannot be checked at runtime because its type arguments can't be determined from List[Any]
|
| longer explanation available when compiling with `-explain`
No warnings can be incurred under -Werror (or -Xfatal-warnings)
14 changes: 14 additions & 0 deletions tests/neg/runtimeChecked.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
//> using options -Werror -source:future -experimental

object Foo {

val xs: List[Any] = List(1: Any)

def test: Int =
xs.runtimeChecked match { // this test asserts that unsound type tests still require @unchecked
// tests/run/runtimeChecked.scala adds @unchecked to the
// unsound type test to avoid the warning.
case is: ::[Int/* can not be checked so still err */] => is.head
}
}
// nopos-error: No warnings can be incurred under -Werror (or -Xfatal-warnings)
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,10 @@ val experimentalDefinitionInLibrary = Set(

// New feature: functions with erased parameters.
// Need quotedPatternsWithPolymorphicFunctions enabled.
"scala.quoted.runtime.Patterns$.higherOrderHoleWithTypes"
"scala.quoted.runtime.Patterns$.higherOrderHoleWithTypes",

// New feature: SIP 57 - runtimeChecked replacement of @unchecked
"scala.Predef$.runtimeChecked", "scala.annotation.internal.RuntimeChecked"
)


Expand Down
8 changes: 8 additions & 0 deletions tests/run/runtimeChecked-2.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
//> using options -Werror -source:future -experimental

val xs: List[Any] = List(1: Any)

@main
def Test: Unit =
val head :: _ = xs.runtimeChecked
assert(head == 1)
12 changes: 12 additions & 0 deletions tests/run/runtimeChecked.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
//> using options -Werror -source:future -experimental

val xs: List[Any] = List(1: Any)

@main
def Test: Unit =
val head = xs.runtimeChecked match {
// tests/neg/runtimeChecked.scala asserts that @unchecked is
// still needed for unsound type tests.
case is: ::[Int @unchecked] => is.head
}
assert(head == 1)

0 comments on commit a64c295

Please sign in to comment.