Skip to content

Commit 7a2fa1e

Browse files
committed
Introduce checked exceptions
Introduce a flexible scheme for declaring and checking exceptions that can be thrown. It relies on the _effects as implicit capabilities_ pattern. The scheme is not 100% safe yet since it does not track and prevent capability capture. Nevertheless, it's already useful for declaring thrown exceptions and finding mismatches between provided and required capabilities.
1 parent fdb1e9d commit 7a2fa1e

File tree

12 files changed

+168
-17
lines changed

12 files changed

+168
-17
lines changed

compiler/src/dotty/tools/dotc/config/Feature.scala

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,12 +19,13 @@ object Feature:
1919
private def deprecated(str: String): TermName =
2020
QualifiedName(nme.deprecated, str.toTermName)
2121

22-
private val namedTypeArguments = experimental("namedTypeArguments")
23-
private val genericNumberLiterals = experimental("genericNumberLiterals")
24-
private val macros = experimental("macros")
22+
val namedTypeArguments = experimental("namedTypeArguments")
23+
val genericNumberLiterals = experimental("genericNumberLiterals")
24+
val macros = experimental("macros")
2525

2626
val dependent = experimental("dependent")
2727
val erasedTerms = experimental("erasedTerms")
28+
val saferExceptions = experimental("saferExceptions")
2829
val symbolLiterals: TermName = deprecated("symbolLiterals")
2930

3031
/** Is `feature` enabled by by a command-line setting? The enabling setting is

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

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -653,8 +653,11 @@ class Definitions {
653653

654654
// in scalac modified to have Any as parent
655655

656-
@tu lazy val ThrowableType: TypeRef = requiredClassRef("java.lang.Throwable")
657-
def ThrowableClass(using Context): ClassSymbol = ThrowableType.symbol.asClass
656+
@tu lazy val ThrowableType: TypeRef = requiredClassRef("java.lang.Throwable")
657+
def ThrowableClass(using Context): ClassSymbol = ThrowableType.symbol.asClass
658+
@tu lazy val ExceptionClass: ClassSymbol = requiredClass("java.lang.Exception")
659+
@tu lazy val RuntimeExceptionClass: ClassSymbol = requiredClass("java.lang.RuntimeException")
660+
658661
@tu lazy val SerializableType: TypeRef = JavaSerializableClass.typeRef
659662
def SerializableClass(using Context): ClassSymbol = SerializableType.symbol.asClass
660663

@@ -818,6 +821,8 @@ class Definitions {
818821
val methodName = if CanEqualClass.name == tpnme.Eql then nme.eqlAny else nme.canEqualAny
819822
CanEqualClass.companionModule.requiredMethod(methodName)
820823

824+
@tu lazy val CanThrowClass: ClassSymbol = requiredClass("scala.CanThrow")
825+
821826
@tu lazy val TypeBoxClass: ClassSymbol = requiredClass("scala.runtime.TypeBox")
822827
@tu lazy val TypeBox_CAP: TypeSymbol = TypeBoxClass.requiredType(tpnme.CAP)
823828

compiler/src/dotty/tools/dotc/transform/TypeUtils.scala

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,16 @@ object TypeUtils {
2424
def isErasedClass(using Context): Boolean =
2525
self.underlyingClassRef(refinementOK = true).typeSymbol.is(Flags.Erased)
2626

27+
/** Is this type a checked exception? This is the case of the type
28+
* derives from Exception but not from RuntimeException. According to
29+
* that definition Throwable is unchecked. That makes sense since you should
30+
* neither throw nor catch `Throwable` anyway, so we should not define
31+
* a capability to do so.
32+
*/
33+
def isCheckedException(using Context): Boolean =
34+
self.derivesFrom(defn.ExceptionClass)
35+
&& !self.derivesFrom(defn.RuntimeExceptionClass)
36+
2737
def isByName: Boolean =
2838
self.isInstanceOf[ExprType]
2939

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

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,8 +34,10 @@ import NameOps._
3434
import SymDenotations.{NoCompleter, NoDenotation}
3535
import Applications.unapplyArgs
3636
import transform.patmat.SpaceEngine.isIrrefutableUnapply
37-
import config.Feature._
37+
import config.Feature
38+
import config.Feature.sourceVersion
3839
import config.SourceVersion._
40+
import transform.TypeUtils.*
3941

4042
import collection.mutable
4143
import reporting._
@@ -889,7 +891,7 @@ trait Checking {
889891
description: => String,
890892
featureUseSite: Symbol,
891893
pos: SrcPos)(using Context): Unit =
892-
if !enabled(name) then
894+
if !Feature.enabled(name) then
893895
report.featureWarning(name.toString, description, featureUseSite, required = false, pos)
894896

895897
/** Check that `tp` is a class type and that any top-level type arguments in this type
@@ -1283,6 +1285,10 @@ trait Checking {
12831285
report.warning(
12841286
em"""${kind} should be an instance of Matchable,
12851287
|but it has unmatchable type $tp instead""", pos)
1288+
1289+
def checkCanThrow(tp: Type, span: Span)(using Context): Unit =
1290+
if Feature.enabled(Feature.saferExceptions) && tp.isCheckedException then
1291+
ctx.typer.implicitArgTree(defn.CanThrowClass.typeRef.appliedTo(tp), span)
12861292
}
12871293

12881294
trait ReChecking extends Checking {
@@ -1295,6 +1301,7 @@ trait ReChecking extends Checking {
12951301
override def checkAnnotApplicable(annot: Tree, sym: Symbol)(using Context): Boolean = true
12961302
override def checkMatchable(tp: Type, pos: SrcPos, pattern: Boolean)(using Context): Unit = ()
12971303
override def checkNoModuleClash(sym: Symbol)(using Context) = ()
1304+
override def checkCanThrow(tp: Type, span: Span)(using Context): Unit = ()
12981305
}
12991306

13001307
trait NoChecking extends ReChecking {

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,9 @@ class ReTyper extends Typer with ReChecking {
114114
super.handleUnexpectedFunType(tree, fun)
115115
}
116116

117+
override def addCanThrowCapabilities(expr: untpd.Tree, cases: List[CaseDef])(using Context): untpd.Tree =
118+
expr
119+
117120
override def typedUnadapted(tree: untpd.Tree, pt: Type, locked: TypeVars)(using Context): Tree =
118121
try super.typedUnadapted(tree, pt, locked)
119122
catch {

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

Lines changed: 31 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,8 @@ import annotation.tailrec
3838
import Implicits._
3939
import util.Stats.record
4040
import config.Printers.{gadts, typr, debug}
41-
import config.Feature._
41+
import config.Feature
42+
import config.Feature.{sourceVersion, migrateTo3}
4243
import config.SourceVersion._
4344
import rewrites.Rewrites.patch
4445
import NavigateAST._
@@ -695,7 +696,7 @@ class Typer extends Namer
695696
case Whole(16) => // cant parse hex literal as double
696697
case _ => return lit(doubleFromDigits(digits))
697698
}
698-
else if genericNumberLiteralsEnabled
699+
else if Feature.enabled(Feature.genericNumberLiterals)
699700
&& target.isValueType && isFullyDefined(target, ForceDegree.none)
700701
then
701702
// If expected type is defined with a FromDigits instance, use that one
@@ -1711,10 +1712,30 @@ class Typer extends Namer
17111712
.withNotNullInfo(body1.notNullInfo.retractedInfo.seq(cond1.notNullInfoIf(false)))
17121713
}
17131714

1715+
/** Add givens reflecting `CanThrow` capabilities for all checked exceptions matched
1716+
* by `cases`. The givens appear in nested blocks with earlier cases leading to
1717+
* more deeply nested givens. This way, given priority will be the same as pattern priority.
1718+
* The functionality is enabled if the experimental.saferExceptions language feature is enabled.
1719+
*/
1720+
def addCanThrowCapabilities(expr: untpd.Tree, cases: List[CaseDef])(using Context): untpd.Tree =
1721+
def makeCanThrow(tp: Type): untpd.Tree =
1722+
untpd.ValDef(
1723+
EvidenceParamName.fresh(),
1724+
untpd.TypeTree(defn.CanThrowClass.typeRef.appliedTo(tp)),
1725+
untpd.ref(defn.Predef_undefined))
1726+
.withFlags(Given | Final | Lazy | Erased)
1727+
.withSpan(expr.span)
1728+
val caps =
1729+
for
1730+
CaseDef(pat, _, _) <- cases
1731+
if Feature.enabled(Feature.saferExceptions) && pat.tpe.widen.isCheckedException
1732+
yield makeCanThrow(pat.tpe.widen)
1733+
caps.foldLeft(expr)((e, g) => untpd.Block(g :: Nil, e))
1734+
17141735
def typedTry(tree: untpd.Try, pt: Type)(using Context): Try = {
17151736
val expr2 :: cases2x = harmonic(harmonize, pt) {
1716-
val expr1 = typed(tree.expr, pt.dropIfProto)
17171737
val cases1 = typedCases(tree.cases, EmptyTree, defn.ThrowableType, pt.dropIfProto)
1738+
val expr1 = typed(addCanThrowCapabilities(tree.expr, cases1), pt.dropIfProto)
17181739
expr1 :: cases1
17191740
}
17201741
val finalizer1 = typed(tree.finalizer, defn.UnitType)
@@ -1733,6 +1754,7 @@ class Typer extends Namer
17331754

17341755
def typedThrow(tree: untpd.Throw)(using Context): Tree = {
17351756
val expr1 = typed(tree.expr, defn.ThrowableType)
1757+
checkCanThrow(expr1.tpe.widen, tree.span)
17361758
Throw(expr1).withSpan(tree.span)
17371759
}
17381760

@@ -1828,7 +1850,7 @@ class Typer extends Namer
18281850
def typedAppliedTypeTree(tree: untpd.AppliedTypeTree)(using Context): Tree = {
18291851
tree.args match
18301852
case arg :: _ if arg.isTerm =>
1831-
if dependentEnabled then
1853+
if Feature.dependentEnabled then
18321854
return errorTree(tree, i"Not yet implemented: T(...)")
18331855
else
18341856
return errorTree(tree, dependentStr)
@@ -1925,7 +1947,7 @@ class Typer extends Namer
19251947
typeIndexedLambdaTypeTree(tree, tparams, body)
19261948

19271949
def typedTermLambdaTypeTree(tree: untpd.TermLambdaTypeTree)(using Context): Tree =
1928-
if dependentEnabled then
1950+
if Feature.dependentEnabled then
19291951
errorTree(tree, i"Not yet implemented: (...) =>> ...")
19301952
else
19311953
errorTree(tree, dependentStr)
@@ -2303,7 +2325,7 @@ class Typer extends Namer
23032325
ctx.phase.isTyper &&
23042326
cdef1.symbol.ne(defn.DynamicClass) &&
23052327
cdef1.tpe.derivesFrom(defn.DynamicClass) &&
2306-
!dynamicsEnabled
2328+
!Feature.dynamicsEnabled
23072329
if (reportDynamicInheritance) {
23082330
val isRequired = parents1.exists(_.tpe.isRef(defn.DynamicClass))
23092331
report.featureWarning(nme.dynamics.toString, "extension of type scala.Dynamic", cls, isRequired, cdef.srcPos)
@@ -3216,7 +3238,7 @@ class Typer extends Namer
32163238
case args =>
32173239
args.lengthCompare(1) > 0
32183240
&& isUnary(funType)
3219-
&& autoTuplingEnabled
3241+
&& Feature.autoTuplingEnabled
32203242

32213243
def adaptToArgs(wtp: Type, pt: FunProto): Tree = wtp match {
32223244
case wtp: MethodOrPoly =>
@@ -3424,7 +3446,7 @@ class Typer extends Namer
34243446
def isAutoApplied(sym: Symbol): Boolean =
34253447
sym.isConstructor
34263448
|| sym.matchNullaryLoosely
3427-
|| warnOnMigration(MissingEmptyArgumentList(sym.show), tree.srcPos)
3449+
|| Feature.warnOnMigration(MissingEmptyArgumentList(sym.show), tree.srcPos)
34283450
&& { patch(tree.span.endPos, "()"); true }
34293451

34303452
// Reasons NOT to eta expand:
@@ -3755,7 +3777,7 @@ class Typer extends Namer
37553777
case ref: TermRef =>
37563778
pt match {
37573779
case pt: FunProto
3758-
if needsTupledDual(ref, pt) && autoTuplingEnabled =>
3780+
if needsTupledDual(ref, pt) && Feature.autoTuplingEnabled =>
37593781
adapt(tree, pt.tupledDual, locked)
37603782
case _ =>
37613783
adaptOverloaded(ref)

docs/docs/reference/metaprogramming/erased-terms.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ title: "Erased Terms And Classes"
99
import scala.language.experimental.erased
1010
```
1111
or by setting the command line option `-language:experimental.erased`.
12+
1213
## Why erased terms?
1314

1415
Let's describe the motivation behind erased terms with an example. In the
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
package scala
2+
import language.experimental.erasedTerms
3+
import annotation.implicitNotFound
4+
5+
/** A capability class that allows to throw exception `E`. When used with the
6+
* experimental.saferThrows feature, a `throw Ex` expression will require
7+
* a given of class `CanThrow[Ex]` to be available.
8+
*/
9+
@implicitNotFound("The capability to throw exception ${E} is missing.\nThe capability can be provided by one of the following:\n - A using clause `(using CanThrow[${E}])`\n - A `canThrow` clause in a result type such as `X canThrow ${E}`\n - an enclosing `try` that catches ${E}")
10+
erased class CanThrow[-E <: Exception]
11+
12+
/** A helper type to allow syntax like
13+
*
14+
* def f(): T throws Ex
15+
*/
16+
infix type canThrow[R, +E <: Exception] = CanThrow[E] ?=> R
17+
18+
object unsafeExceptions:
19+
given canThrowAny[E <: Exception]: CanThrow[E] = ???

library/src/scala/runtime/stdLibPatches/language.scala

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,9 +37,15 @@ object language:
3737
*/
3838
object genericNumberLiterals
3939

40-
/** Experimental support for `erased` modifier */
40+
/** Experimental support for `erased` modifier
41+
*
42+
* @see [[https://dotty.epfl.ch/docs/reference/metaprogramming/erased-terms]]
43+
*/
4144
object erasedTerms
4245

46+
/** Experimental support for typechecked exception capabilities */
47+
object saferExceptions
48+
4349
end experimental
4450

4551
/** The deprecated object contains features that are no longer officially suypported in Scala.

tests/neg/saferExceptions.check

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
-- Error: tests/neg/saferExceptions.scala:12:16 ------------------------------------------------------------------------
2+
12 | case 4 => throw Exception() // error
3+
| ^^^^^^^^^^^^^^^^^
4+
| The capability to throw exception Exception is missing.
5+
| The capability can be provided by one of the following:
6+
| - A using clause `(using CanThrow[Exception])`
7+
| - A `canThrow` clause in a result type such as `X canThrow Exception`
8+
| - an enclosing `try` that catches Exception
9+
|
10+
| The following import might fix the problem:
11+
|
12+
| import unsafeExceptions.canThrowAny
13+
|
14+
-- Error: tests/neg/saferExceptions.scala:17:48 ------------------------------------------------------------------------
15+
17 | def baz(x: Int): Int canThrow Failure = bar(x) // error
16+
| ^
17+
| The capability to throw exception java.io.IOException is missing.
18+
| The capability can be provided by one of the following:
19+
| - A using clause `(using CanThrow[java.io.IOException])`
20+
| - A `canThrow` clause in a result type such as `X canThrow java.io.IOException`
21+
| - an enclosing `try` that catches java.io.IOException
22+
|
23+
| The following import might fix the problem:
24+
|
25+
| import unsafeExceptions.canThrowAny
26+
|

tests/neg/saferExceptions.scala

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
object test:
2+
import language.experimental.saferExceptions
3+
import java.io.IOException
4+
5+
class Failure extends Exception
6+
7+
def bar(x: Int): Int canThrow Failure canThrow IOException =
8+
x match
9+
case 1 => throw AssertionError()
10+
case 2 => throw Failure() // ok
11+
case 3 => throw java.io.IOException() // ok
12+
case 4 => throw Exception() // error
13+
case 5 => throw Throwable() // ok: Throwable is treated as unchecked
14+
case _ => 0
15+
16+
def foo(x: Int): Int canThrow Exception = bar(x)
17+
def baz(x: Int): Int canThrow Failure = bar(x) // error

tests/run/saferExceptions.scala

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import language.experimental.saferExceptions
2+
3+
class Fail extends Exception
4+
5+
def foo(x: Int) =
6+
try x match
7+
case 1 => throw AssertionError()
8+
case 2 => throw Fail()
9+
case 3 => throw java.io.IOException()
10+
case 4 => throw Exception()
11+
case 5 => throw Throwable()
12+
case _ => 0
13+
catch
14+
case ex: AssertionError => 1
15+
case ex: Fail => 2
16+
case ex: java.io.IOException => 3
17+
case ex: Exception => 4
18+
case ex: Throwable => 5
19+
20+
def bar(x: Int): Int canThrow Exception =
21+
x match
22+
case 1 => throw AssertionError()
23+
case 2 => throw Fail()
24+
case 3 => throw java.io.IOException()
25+
case 4 => throw Exception()
26+
case _ => 0
27+
28+
@main def Test =
29+
assert(foo(1) + foo(2) + foo(3) + foo(4) + foo(5) + foo(6) == 15)
30+
import unsafeExceptions.canThrowAny
31+
val x =
32+
try bar(2)
33+
catch case ex: Fail => 3 // OK
34+
assert(x == 3)

0 commit comments

Comments
 (0)