Skip to content

Commit

Permalink
add method, annotation and test cases
Browse files Browse the repository at this point in the history
  • Loading branch information
bishabosha committed Jul 3, 2024
1 parent 594306d commit 9a866c2
Show file tree
Hide file tree
Showing 13 changed files with 221 additions and 4 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 @@ -1034,6 +1034,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
3 changes: 2 additions & 1 deletion 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 Expand Up @@ -903,7 +904,7 @@ object SpaceEngine {
def checkMatch(m: Match)(using Context): Unit =
checkMatchExhaustivityOnly(m)
if reachabilityCheckable(m.selector) then checkReachability(m)

def checkMatchExhaustivityOnly(m: Match)(using Context): Unit =
if exhaustivityCheckable(m.selector) then checkExhaustivity(m)
}
125 changes: 125 additions & 0 deletions docs/_docs/reference/experimental/runtimeChecked.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
---
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 marked as `runtimeChecked` is exempt from certain static checks in the compiler, for example pattern match exhaustivity. It is intended to replace `: @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` then 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, `::[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`, this is part of the standard Scala 3 library. A programmer is not expected to use this annotation directly.

```scala
package scala.annotation.internal

@experimental
final class RuntimeChecked extends Annotation
```

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


The user facing API is provided by a new extension method `scala.Predef.runtimeChecked`, qualified for any value:
```scala
extension (x: Any)
@experimental
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.
```

We suggest that `: @unchecked` is syntactically awkward, and 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 then a successor to `@unchecked` for this purpose.

We propose that `@unchecked` will still be necessary 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
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
13 changes: 13 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,16 @@ object Predef:
@experimental
infix type is[A <: AnyKind, B <: Any{type Self <: AnyKind}] = B { type Self = A }

extension (x: Any)
/**Asserts that a term should be exempt from static checks that can be reliably checked at runtime.
* @example {{{
* val xs: Option[Int] = Some(1)
* xs.runtimeChecked match {
* case is: Some[Int] => is.get
* } // no warning about exhaustiveness, as all patterns can be checked at runtime.
* }}}
*/
@experimental
inline def runtimeChecked: x.type @RuntimeChecked = x: @RuntimeChecked

end Predef
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:13:11 ---------------------------------------
13 | case is: Some[t] => ??? // unreachable
| ^^^^^^^^^^^
| Unreachable case
No warnings can be incurred under -Werror (or -Xfatal-warnings)
16 changes: 16 additions & 0 deletions tests/neg/runtimeChecked-2.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
//> using options -Werror -source:future

import annotation.experimental

@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:14:11 ----------------------------------------
14 | 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)
17 changes: 17 additions & 0 deletions tests/neg/runtimeChecked.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
//> using options -Werror -source:future

import annotation.experimental

@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)
3 changes: 3 additions & 0 deletions tests/run-tasty-inspector/stdlibExperimentalDefinitions.scala
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,9 @@ val experimentalDefinitionInLibrary = Set(
"scala.annotation.internal.WitnessNames",
"scala.compiletime.package$package$.deferred",
"scala.runtime.stdLibPatches.Predef$.is",

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


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

import annotation.experimental


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

@experimental
@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 9a866c2

Please sign in to comment.