Skip to content

Commit 0134f2c

Browse files
Merge pull request #7555 from dotty-staging/fix-#7554
Fix #7554: Add TypeTest for sound pattern type test
2 parents ced6639 + 919acc2 commit 0134f2c

28 files changed

+687
-18
lines changed

compiler/src/dotty/tools/dotc/core/Definitions.scala

+2
Original file line numberDiff line numberDiff line change
@@ -790,6 +790,8 @@ class Definitions {
790790
@tu lazy val ClassTagModule: Symbol = ClassTagClass.companionModule
791791
@tu lazy val ClassTagModule_apply: Symbol = ClassTagModule.requiredMethod(nme.apply)
792792

793+
@tu lazy val TypeTestClass: ClassSymbol = requiredClass("scala.reflect.TypeTest")
794+
@tu lazy val TypeTestModule_identity: Symbol = TypeTestClass.companionModule.requiredMethod(nme.identity)
793795

794796
@tu lazy val QuotedExprClass: ClassSymbol = requiredClass("scala.quoted.Expr")
795797
@tu lazy val QuotedExprModule: Symbol = QuotedExprClass.companionModule

compiler/src/dotty/tools/dotc/typer/Applications.scala

+1-1
Original file line numberDiff line numberDiff line change
@@ -1331,7 +1331,7 @@ trait Applications extends Compatibility {
13311331
val result = assignType(cpy.UnApply(tree)(unapplyFn, unapplyImplicits(unapplyApp), unapplyPatterns), ownType)
13321332
unapp.println(s"unapply patterns = $unapplyPatterns")
13331333
if ((ownType eq selType) || ownType.isError) result
1334-
else tryWithClassTag(Typed(result, TypeTree(ownType)), selType)
1334+
else tryWithTypeTest(Typed(result, TypeTree(ownType)), selType)
13351335
case tp =>
13361336
val unapplyErr = if (tp.isError) unapplyFn else notAnExtractor(unapplyFn)
13371337
val typedArgsErr = args mapconserve (typed(_, defn.AnyType))

compiler/src/dotty/tools/dotc/typer/Synthesizer.scala

+33
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,38 @@ class Synthesizer(typer: Typer)(using @constructorOnly c: Context):
4545
case _ => EmptyTree
4646
end synthesizedClassTag
4747

48+
val synthesizedTypeTest: SpecialHandler =
49+
(formal, span) => formal.argInfos match {
50+
case arg1 :: arg2 :: Nil if !defn.isBottomClass(arg2.typeSymbol) =>
51+
val tp1 = fullyDefinedType(arg1, "TypeTest argument", span)
52+
val tp2 = fullyDefinedType(arg2, "TypeTest argument", span)
53+
val sym2 = tp2.typeSymbol
54+
if tp1 <:< tp2 then
55+
// optimization when we know the typetest will always succeed
56+
ref(defn.TypeTestModule_identity).appliedToType(tp2).withSpan(span)
57+
else if sym2 == defn.AnyValClass || sym2 == defn.AnyRefAlias || sym2 == defn.ObjectClass then
58+
EmptyTree
59+
else
60+
// Generate SAM: (s: <tp1>) => if s.isInstanceOf[<tp2>] then Some(s.asInstanceOf[s.type & <tp2>]) else None
61+
def body(args: List[Tree]): Tree = {
62+
val arg :: Nil = args
63+
val t = arg.tpe & tp2
64+
If(
65+
arg.select(defn.Any_isInstanceOf).appliedToType(tp2),
66+
ref(defn.SomeClass.companionModule.termRef).select(nme.apply)
67+
.appliedToType(t)
68+
.appliedTo(arg.select(nme.asInstanceOf_).appliedToType(t)),
69+
ref(defn.NoneModule))
70+
}
71+
val tpe = MethodType(List(nme.s))(_ => List(tp1), mth => defn.OptionClass.typeRef.appliedTo(mth.newParamRef(0) & tp2))
72+
val meth = newSymbol(ctx.owner, nme.ANON_FUN, Synthetic | Method, tpe, coord = span)
73+
val typeTestType = defn.TypeTestClass.typeRef.appliedTo(List(tp1, tp2))
74+
Closure(meth, tss => body(tss.head).changeOwner(ctx.owner, meth), targetType = typeTestType).withSpan(span)
75+
case _ =>
76+
EmptyTree
77+
}
78+
end synthesizedTypeTest
79+
4880
val synthesizedTupleFunction: SpecialHandler = (formal, span) =>
4981
formal match
5082
case AppliedType(_, funArgs @ fun :: tupled :: Nil) =>
@@ -374,6 +406,7 @@ class Synthesizer(typer: Typer)(using @constructorOnly c: Context):
374406

375407
val specialHandlers = List(
376408
defn.ClassTagClass -> synthesizedClassTag,
409+
defn.TypeTestClass -> synthesizedTypeTest,
377410
defn.EqlClass -> synthesizedEql,
378411
defn.TupledFunctionClass -> synthesizedTupleFunction,
379412
defn.ValueOfClass -> synthesizedValueOf,

compiler/src/dotty/tools/dotc/typer/Typer.scala

+23-16
Original file line numberDiff line numberDiff line change
@@ -764,7 +764,7 @@ class Typer extends Namer
764764
TypeComparer.constrainPatternType(tpt1.tpe, pt)
765765
}
766766
// special case for an abstract type that comes with a class tag
767-
tryWithClassTag(ascription(tpt1, isWildcard = true), pt)
767+
tryWithTypeTest(ascription(tpt1, isWildcard = true), pt)
768768
}
769769
cases(
770770
ifPat = handlePattern,
@@ -773,21 +773,28 @@ class Typer extends Namer
773773
}
774774
}
775775

776-
/** For a typed tree `e: T`, if `T` is an abstract type for which an implicit class tag `ctag`
777-
* exists, rewrite to `ctag(e)`.
776+
/** For a typed tree `e: T`, if `T` is an abstract type for which an implicit type test or class tag `tt`
777+
* exists, rewrite to `tt(e)`.
778778
* @pre We are in pattern-matching mode (Mode.Pattern)
779779
*/
780-
def tryWithClassTag(tree: Typed, pt: Type)(using Context): Tree = tree.tpt.tpe.dealias match {
780+
def tryWithTypeTest(tree: Typed, pt: Type)(using Context): Tree = tree.tpt.tpe.dealias match {
781781
case tref: TypeRef if !tref.symbol.isClass && !ctx.isAfterTyper && !(tref =:= pt) =>
782-
require(ctx.mode.is(Mode.Pattern))
783-
withoutMode(Mode.Pattern)(
784-
inferImplicit(defn.ClassTagClass.typeRef.appliedTo(tref), EmptyTree, tree.tpt.span)
785-
) match {
786-
case SearchSuccess(clsTag, _, _) =>
787-
typed(untpd.Apply(untpd.TypedSplice(clsTag), untpd.TypedSplice(tree.expr)), pt)
788-
case _ =>
789-
tree
782+
def withTag(tpe: Type): Option[Tree] = {
783+
require(ctx.mode.is(Mode.Pattern))
784+
withoutMode(Mode.Pattern)(
785+
inferImplicit(tpe, EmptyTree, tree.tpt.span)
786+
) match
787+
case SearchSuccess(clsTag, _, _) =>
788+
Some(typed(untpd.Apply(untpd.TypedSplice(clsTag), untpd.TypedSplice(tree.expr)), pt))
789+
case _ =>
790+
None
790791
}
792+
val tag = withTag(defn.TypeTestClass.typeRef.appliedTo(pt, tref))
793+
.orElse(withTag(defn.ClassTagClass.typeRef.appliedTo(tref)))
794+
.getOrElse(tree)
795+
if tag.symbol.owner == defn.ClassTagClass && config.Feature.sourceVersion.isAtLeast(config.SourceVersion.`3.1`) then
796+
report.warning("Use of ClassTag for type testing may be unsound. Consider using `reflect.Typable` instead.", tree.srcPos)
797+
tag
791798
case _ => tree
792799
}
793800

@@ -1835,10 +1842,10 @@ class Typer extends Namer
18351842
val body1 = typed(tree.body, pt)
18361843
body1 match {
18371844
case UnApply(fn, Nil, arg :: Nil)
1838-
if fn.symbol.exists && fn.symbol.owner == defn.ClassTagClass && !body1.tpe.isError =>
1839-
// A typed pattern `x @ (e: T)` with an implicit `ctag: ClassTag[T]`
1840-
// was rewritten to `x @ ctag(e)` by `tryWithClassTag`.
1841-
// Rewrite further to `ctag(x @ e)`
1845+
if fn.symbol.exists && (fn.symbol.owner.derivesFrom(defn.TypeTestClass) || fn.symbol.owner == defn.ClassTagClass) && !body1.tpe.isError =>
1846+
// A typed pattern `x @ (e: T)` with an implicit `tt: TypeTest[T]` or `ctag: ClassTag[T]`
1847+
// was rewritten to `x @ tt(e)` `x @ ctag(e)` by `tryWithTypeTest`.
1848+
// Rewrite further to `tt(x @ e)` or `ctag(x @ e)`
18421849
tpd.cpy.UnApply(body1)(fn, Nil,
18431850
typed(untpd.Bind(tree.name, untpd.TypedSplice(arg)).withSpan(tree.span), arg.tpe) :: Nil)
18441851
case _ =>

docs/docs/reference/changed-features/pattern-matching.md

+10-1
Original file line numberDiff line numberDiff line change
@@ -240,4 +240,13 @@ def foo(f: Foo) = f match {
240240
```
241241

242242
There are plans for further simplification, in particular to factor out *product
243-
match* and *name-based match* into a single type of extractor.
243+
match* and *name-based match* into a single type of extractor.
244+
245+
## Type testing
246+
247+
Abstract type testing with `ClassTag` is replaced with `TypeTest` or the alias `Typeable`.
248+
249+
- pattern `_: X` for an abstract type requires a `TypeTest` in scope
250+
- pattern `x @ X()` for an unapply that takes an abstract type requires a `TypeTest` in scope
251+
252+
[More details on TypeTest](../other-new-features/type-test.md)
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
---
2+
layout: doc-page
3+
title: "TypeTest"
4+
---
5+
6+
TypeTest
7+
--------
8+
9+
When pattern matching there are two situations where were a runtime type test must be performed.
10+
The first is kind is an explicit type test using the ascription pattern notation.
11+
```scala
12+
(x: X) match
13+
case y: Y =>
14+
```
15+
The second is when an extractor takes an argument that is not a subtype of the scrutinee type.
16+
```scala
17+
(x: X) match
18+
case y @ Y(n) =>
19+
20+
object Y:
21+
def unapply(x: Y): Some[Int] = ...
22+
```
23+
24+
In both cases, a class test will be performed at runtime.
25+
But when the type test is on an abstract type (type parameter or type member), the test cannot be performed because the type is erased at runtime.
26+
27+
A `TypeTest` can be provided to make this test possible.
28+
29+
```scala
30+
package scala.reflect
31+
32+
trait TypeTest[-S, T]:
33+
def unapply(s: S): Option[s.type & T]
34+
```
35+
36+
It provides an extractor that returns its argument typed as a `T` if the argument is a `T`.
37+
It can be used to encode a type test.
38+
```scala
39+
def f[X, Y](x: X)(using tt: TypeTest[X, Y]): Option[Y] =
40+
x match
41+
case tt(x @ Y(1)) => Some(x)
42+
case tt(x) => Some(x)
43+
case _ => None
44+
```
45+
46+
To avoid the syntactic overhead the compiler will look for a type test automatically if it detects that the type test is on abstract types.
47+
This means that `x: Y` is transformed to `tt(x)` and `x @ Y(_)` to `tt(x @ Y(_))` if there is a contextual `TypeTest[X, Y]` in scope.
48+
The previous code is equivalent to
49+
50+
```scala
51+
def f[X, Y](x: X)(using TypeTest[X, Y]): Option[Y] =
52+
x match
53+
case x @ Y(1) => Some(x)
54+
case x: Y => Some(x)
55+
case _ => None
56+
```
57+
58+
We could create a type test at call site where the type test can be performed with runtime class tests directly as follows
59+
60+
```scala
61+
val tt: TypeTest[Any, String] =
62+
new TypeTest[Any, String]
63+
def unapply(s: Any): Option[s.type & String] =
64+
s match
65+
case s: String => Some(s)
66+
case _ => None
67+
68+
f[AnyRef, String]("acb")(using tt)
69+
```
70+
71+
The compiler will synthesize a new instance of a type test if none is found in scope as:
72+
```scala
73+
new TypeTest[A, B]:
74+
def unapply(s: A): Option[s.type & B] =
75+
s match
76+
case s: B => Some(s)
77+
case _ => None
78+
```
79+
If the type tests cannot be done there will be an unchecked warning that will be raised on the `case s: B =>` test.
80+
81+
The most common `TypeTest` instances are the ones that take any parameters (i.e. `TypeTest[Any, T]`).
82+
To make it possible to use such instances directly in context bounds we provide the alias
83+
```scala
84+
package scala.reflect
85+
86+
type Typeable[T] = TypeTest[Any, T]
87+
```
88+
89+
This alias can be used as
90+
91+
```scala
92+
def f[T: Typeable]: Boolean =
93+
"abc" match
94+
case x: T => true
95+
case _ => false
96+
97+
f[String] // true
98+
f[Int] // fasle
99+
```
100+
101+
### TypeTest and ClassTag
102+
`TypeTest` is a replacement for functionality provided previously by `ClassTag.unapply`.
103+
Using `ClassTag` instances was unsound since classtags can check only the class component of a type.
104+
`TypeTest` fixes that unsoundness.
105+
`ClassTag` type tests are still supported but a warning will be emitted after 3.0.
106+
107+
108+
Examples
109+
--------
110+
111+
Given the following abstract definition of `Peano` numbers that provides `TypeTest[Nat, Zero]` and `TypeTest[Nat, Succ]`
112+
113+
```scala
114+
trait Peano:
115+
type Nat
116+
type Zero <: Nat
117+
type Succ <: Nat
118+
def safeDiv(m: Nat, n: Succ): (Nat, Nat)
119+
val Zero: Zero
120+
val Succ: SuccExtractor
121+
trait SuccExtractor {
122+
def apply(nat: Nat): Succ
123+
def unapply(nat: Succ): Option[Nat]
124+
}
125+
given TypeTest[Nat, Zero] = typeTestOfZero
126+
protected def typeTestOfZero: TypeTest[Nat, Zero]
127+
given TypeTest[Nat, Succ] = typeTestOfSucc
128+
protected def typeTestOfSucc: TypeTest[Nat, Succ]
129+
```
130+
131+
it will be possible to write the following program
132+
133+
```scala
134+
val peano: Peano = ...
135+
import peano._
136+
def divOpt(m: Nat, n: Nat): Option[(Nat, Nat)] =
137+
n match
138+
case Zero => None
139+
case s @ Succ(_) => Some(safeDiv(m, s))
140+
141+
val two = Succ(Succ(Zero))
142+
val five = Succ(Succ(Succ(two)))
143+
println(divOpt(five, two))
144+
```
145+
146+
Note that without the `TypeTest[Nat, Succ]` the pattern `Succ.unapply(nat: Succ)` would be unchecked.
+28
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
package scala.reflect
2+
3+
/** A `TypeTest[S, T] contains the logic needed to know at runtime if a value of
4+
* type `S` can be downcasted to `T`.
5+
*
6+
* If a pattern match is performed on a term of type `s: S` that is uncheckable with `s.isInstanceOf[T]` and
7+
* the pattern are of the form:
8+
* - `t: T`
9+
* - `t @ X()` where the `X.unapply` has takes an argument of type `T`
10+
* then a given instance of `TypeTest[S, T]` is summoned and used to perform the test.
11+
*/
12+
@scala.annotation.implicitNotFound(msg = "No TypeTest available for [${S}, ${T}]")
13+
trait TypeTest[-S, T] extends Serializable:
14+
15+
/** A TypeTest[S, T] can serve as an extractor that matches only S of type T.
16+
*
17+
* The compiler tries to turn unchecked type tests in pattern matches into checked ones
18+
* by wrapping a `(_: T)` type pattern as `tt(_: T)`, where `tt` is the `TypeTest[S, T]` instance.
19+
* Type tests necessary before calling other extractors are treated similarly.
20+
* `SomeExtractor(...)` is turned into `tt(SomeExtractor(...))` if `T` in `SomeExtractor.unapply(x: T)`
21+
* is uncheckable, but we have an instance of `TypeTest[S, T]`.
22+
*/
23+
def unapply(x: S): Option[x.type & T]
24+
25+
object TypeTest:
26+
27+
/** Trivial type test that always succeeds */
28+
def identity[T]: TypeTest[T, T] = Some(_)
+12
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
package scala.reflect
2+
3+
/** A shorhand for `TypeTest[Any, T]`. A `Typeable[T] contains the logic needed to
4+
* know at runtime if a value can be downcasted to `T`.
5+
*
6+
* If a pattern match is performed on a term of type `s: Any` that is uncheckable with `s.isInstanceOf[T]` and
7+
* the pattern are of the form:
8+
* - `t: T`
9+
* - `t @ X()` where the `X.unapply` has takes an argument of type `T`
10+
* then a given instance of `Typeable[T]` (`TypeTest[Any, T]`) is summoned and used to perform the test.
11+
*/
12+
type Typeable[T] = TypeTest[Any, T]
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import scala.reflect.ClassTag
2+
3+
object IsInstanceOfClassTag {
4+
def safeCast[T: ClassTag](x: Any): Option[T] = {
5+
x match {
6+
case x: T => Some(x) // TODO error: deprecation waring
7+
case _ => None
8+
}
9+
}
10+
11+
def main(args: Array[String]): Unit = {
12+
safeCast[List[String]](List[Int](1)) match {
13+
case None =>
14+
case Some(xs) =>
15+
xs.head.substring(0)
16+
}
17+
18+
safeCast[List[_]](List[Int](1)) match {
19+
case None =>
20+
case Some(xs) =>
21+
xs.head.substring(0) // error
22+
}
23+
}
24+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import scala.reflect.TypeTest
2+
3+
object IsInstanceOfClassTag {
4+
def safeCast[T](x: Any)(using TypeTest[Any, T]): Option[T] = {
5+
x match {
6+
case x: T => Some(x)
7+
case _ => None
8+
}
9+
}
10+
11+
def main(args: Array[String]): Unit = {
12+
safeCast[List[String]](List[Int](1)) match { // error
13+
case None =>
14+
case Some(xs) =>
15+
}
16+
17+
safeCast[List[_]](List[Int](1)) match {
18+
case None =>
19+
case Some(xs) =>
20+
}
21+
}
22+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
import scala.language.`3.0-migration`
2+
import scala.reflect.ClassTag
3+
4+
def f3_0m[T: ClassTag](x: Any): Unit =
5+
x match
6+
case _: T =>
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
import scala.language.`3.0`
2+
import scala.reflect.ClassTag
3+
4+
def f3_0[T: ClassTag](x: Any): Unit =
5+
x match
6+
case _: T =>

0 commit comments

Comments
 (0)