Skip to content

Commit 1001f80

Browse files
committed
Safer exceptions
Introduce a flexible scheme for declaring and checking which exceptions 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 d958644 commit 1001f80

File tree

12 files changed

+180
-12
lines changed

12 files changed

+180
-12
lines changed

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ object Feature:
2727
val erasedDefinitions = experimental("erasedDefinitions")
2828
val symbolLiterals = deprecated("symbolLiterals")
2929
val fewerBraces = experimental("fewerBraces")
30+
val saferExceptions = experimental("saferExceptions")
3031

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

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

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

658658
// in scalac modified to have Any as parent
659659

660-
@tu lazy val ThrowableType: TypeRef = requiredClassRef("java.lang.Throwable")
661-
def ThrowableClass(using Context): ClassSymbol = ThrowableType.symbol.asClass
660+
@tu lazy val ThrowableType: TypeRef = requiredClassRef("java.lang.Throwable")
661+
def ThrowableClass(using Context): ClassSymbol = ThrowableType.symbol.asClass
662+
@tu lazy val ExceptionClass: ClassSymbol = requiredClass("java.lang.Exception")
663+
@tu lazy val RuntimeExceptionClass: ClassSymbol = requiredClass("java.lang.RuntimeException")
664+
662665
@tu lazy val SerializableType: TypeRef = JavaSerializableClass.typeRef
663666
def SerializableClass(using Context): ClassSymbol = SerializableType.symbol.asClass
664667

@@ -824,6 +827,8 @@ class Definitions {
824827
val methodName = if CanEqualClass.name == tpnme.Eql then nme.eqlAny else nme.canEqualAny
825828
CanEqualClass.companionModule.requiredMethod(methodName)
826829

830+
@tu lazy val CanThrowClass: ClassSymbol = requiredClass("scala.CanThrow")
831+
827832
@tu lazy val TypeBoxClass: ClassSymbol = requiredClass("scala.runtime.TypeBox")
828833
@tu lazy val TypeBox_CAP: TypeSymbol = TypeBoxClass.requiredType(tpnme.CAP)
829834

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 if 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+
* an ability 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.isIrrefutable
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._
@@ -938,7 +940,7 @@ trait Checking {
938940
description: => String,
939941
featureUseSite: Symbol,
940942
pos: SrcPos)(using Context): Unit =
941-
if !enabled(name) then
943+
if !Feature.enabled(name) then
942944
report.featureWarning(name.toString, description, featureUseSite, required = false, pos)
943945

944946
/** Check that `tp` is a class type and that any top-level type arguments in this type
@@ -1320,6 +1322,10 @@ trait Checking {
13201322
if !tp.derivesFrom(defn.MatchableClass) && sourceVersion.isAtLeast(`future-migration`) then
13211323
val kind = if pattern then "pattern selector" else "value"
13221324
report.warning(MatchableWarning(tp, pattern), pos)
1325+
1326+
def checkCanThrow(tp: Type, span: Span)(using Context): Unit =
1327+
if Feature.enabled(Feature.saferExceptions) && tp.isCheckedException then
1328+
ctx.typer.implicitArgTree(defn.CanThrowClass.typeRef.appliedTo(tp), span)
13231329
}
13241330

13251331
trait ReChecking extends Checking {
@@ -1332,6 +1338,7 @@ trait ReChecking extends Checking {
13321338
override def checkAnnotApplicable(annot: Tree, sym: Symbol)(using Context): Boolean = true
13331339
override def checkMatchable(tp: Type, pos: SrcPos, pattern: Boolean)(using Context): Unit = ()
13341340
override def checkNoModuleClash(sym: Symbol)(using Context) = ()
1341+
override def checkCanThrow(tp: Type, span: Span)(using Context): Unit = ()
13351342
}
13361343

13371344
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: 30 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,8 @@ import annotation.tailrec
3939
import Implicits._
4040
import util.Stats.record
4141
import config.Printers.{gadts, typr, debug}
42-
import config.Feature._
42+
import config.Feature
43+
import config.Feature.{sourceVersion, migrateTo3}
4344
import config.SourceVersion._
4445
import rewrites.Rewrites.patch
4546
import NavigateAST._
@@ -712,7 +713,7 @@ class Typer extends Namer
712713
case Whole(16) => // cant parse hex literal as double
713714
case _ => return lit(doubleFromDigits(digits))
714715
}
715-
else if genericNumberLiteralsEnabled
716+
else if Feature.genericNumberLiteralsEnabled
716717
&& target.isValueType && isFullyDefined(target, ForceDegree.none)
717718
then
718719
// If expected type is defined with a FromDigits instance, use that one
@@ -1710,10 +1711,30 @@ class Typer extends Namer
17101711
.withNotNullInfo(body1.notNullInfo.retractedInfo.seq(cond1.notNullInfoIf(false)))
17111712
}
17121713

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

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

@@ -1830,7 +1852,7 @@ class Typer extends Namer
18301852
def typedAppliedTypeTree(tree: untpd.AppliedTypeTree)(using Context): Tree = {
18311853
tree.args match
18321854
case arg :: _ if arg.isTerm =>
1833-
if dependentEnabled then
1855+
if Feature.dependentEnabled then
18341856
return errorTree(tree, i"Not yet implemented: T(...)")
18351857
else
18361858
return errorTree(tree, dependentStr)
@@ -1927,7 +1949,7 @@ class Typer extends Namer
19271949
typeIndexedLambdaTypeTree(tree, tparams, body)
19281950

19291951
def typedTermLambdaTypeTree(tree: untpd.TermLambdaTypeTree)(using Context): Tree =
1930-
if dependentEnabled then
1952+
if Feature.dependentEnabled then
19311953
errorTree(tree, i"Not yet implemented: (...) =>> ...")
19321954
else
19331955
errorTree(tree, dependentStr)
@@ -2365,7 +2387,7 @@ class Typer extends Namer
23652387
ctx.phase.isTyper &&
23662388
cdef1.symbol.ne(defn.DynamicClass) &&
23672389
cdef1.tpe.derivesFrom(defn.DynamicClass) &&
2368-
!dynamicsEnabled
2390+
!Feature.dynamicsEnabled
23692391
if (reportDynamicInheritance) {
23702392
val isRequired = parents1.exists(_.tpe.isRef(defn.DynamicClass))
23712393
report.featureWarning(nme.dynamics.toString, "extension of type scala.Dynamic", cls, isRequired, cdef.srcPos)
@@ -3432,7 +3454,7 @@ class Typer extends Namer
34323454
def isAutoApplied(sym: Symbol): Boolean =
34333455
sym.isConstructor
34343456
|| sym.matchNullaryLoosely
3435-
|| warnOnMigration(MissingEmptyArgumentList(sym.show), tree.srcPos)
3457+
|| Feature.warnOnMigration(MissingEmptyArgumentList(sym.show), tree.srcPos)
34363458
&& { patch(tree.span.endPos, "()"); true }
34373459

34383460
// Reasons NOT to eta expand:
@@ -3782,7 +3804,7 @@ class Typer extends Namer
37823804
case ref: TermRef =>
37833805
pt match {
37843806
case pt: FunProto
3785-
if needsTupledDual(ref, pt) && autoTuplingEnabled =>
3807+
if needsTupledDual(ref, pt) && Feature.autoTuplingEnabled =>
37863808
adapt(tree, pt.tupledDual, locked)
37873809
case _ =>
37883810
adaptOverloaded(ref)
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.erasedDefinitions
3+
import annotation.implicitNotFound
4+
5+
/** An ability class that allows to throw exception `E`. When used with the
6+
* experimental.saferExceptions feature, a `throw Ex()` expression will require
7+
* a given of class `CanThrow[Ex]` to be available.
8+
*/
9+
@implicitNotFound("The ability to throw exception ${E} is missing.\nThe ability 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 canThrow Ex
15+
*/
16+
infix type canThrow[R, +E <: Exception] = CanThrow[E] ?=> R
17+
18+
object unsafeExceptions:
19+
given canThrowAny: CanThrow[Exception] = ???

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

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,13 @@ object language:
5151
/** Experimental support for using indentation for arguments
5252
*/
5353
object fewerBraces
54+
55+
/** Experimental support for typechecked exception capabilities
56+
*
57+
* @see [[https://dotty.epfl.ch/docs/reference/experimental/canthrow]]
58+
*/
59+
object saferExceptions
60+
5461
end experimental
5562

5663
/** 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:14:16 ------------------------------------------------------------------------
2+
14 | case 4 => throw Exception() // error
3+
| ^^^^^^^^^^^^^^^^^
4+
| The ability to throw exception Exception is missing.
5+
| The ability 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:19:48 ------------------------------------------------------------------------
15+
19 | def baz(x: Int): Int canThrow Failure = bar(x) // error
16+
| ^
17+
| The ability to throw exception java.io.IOException is missing.
18+
| The ability 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: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
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
8+
`canThrow` Failure
9+
`canThrow` IOException =
10+
x match
11+
case 1 => throw AssertionError()
12+
case 2 => throw Failure() // ok
13+
case 3 => throw java.io.IOException() // ok
14+
case 4 => throw Exception() // error
15+
case 5 => throw Throwable() // ok: Throwable is treated as unchecked
16+
case _ => 0
17+
18+
def foo(x: Int): Int canThrow Exception = bar(x)
19+
def baz(x: Int): Int canThrow Failure = bar(x) // error
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import language.experimental.saferExceptions
2+
3+
4+
class LimitExceeded extends Exception
5+
6+
val limit = 10e9
7+
8+
def f(x: Double): Double canThrow LimitExceeded =
9+
if x < limit then x * x else throw LimitExceeded()
10+
11+
@main def test(xs: Double*) =
12+
try println(xs.map(f).sum)
13+
catch case ex: LimitExceeded => println("too large")
14+
15+

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)