Skip to content

Commit b3b3efb

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 55762c6 commit b3b3efb

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

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

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

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._
@@ -914,7 +916,7 @@ trait Checking {
914916
description: => String,
915917
featureUseSite: Symbol,
916918
pos: SrcPos)(using Context): Unit =
917-
if !enabled(name) then
919+
if !Feature.enabled(name) then
918920
report.featureWarning(name.toString, description, featureUseSite, required = false, pos)
919921

920922
/** Check that `tp` is a class type and that any top-level type arguments in this type
@@ -1296,6 +1298,10 @@ trait Checking {
12961298
if !tp.derivesFrom(defn.MatchableClass) && sourceVersion.isAtLeast(`future-migration`) then
12971299
val kind = if pattern then "pattern selector" else "value"
12981300
report.warning(MatchableWarning(tp, pattern), pos)
1301+
1302+
def checkCanThrow(tp: Type, span: Span)(using Context): Unit =
1303+
if Feature.enabled(Feature.saferExceptions) && tp.isCheckedException then
1304+
ctx.typer.implicitArgTree(defn.CanThrowClass.typeRef.appliedTo(tp), span)
12991305
}
13001306

13011307
trait ReChecking extends Checking {
@@ -1308,6 +1314,7 @@ trait ReChecking extends Checking {
13081314
override def checkAnnotApplicable(annot: Tree, sym: Symbol)(using Context): Boolean = true
13091315
override def checkMatchable(tp: Type, pos: SrcPos, pattern: Boolean)(using Context): Unit = ()
13101316
override def checkNoModuleClash(sym: Symbol)(using Context) = ()
1317+
override def checkCanThrow(tp: Type, span: Span)(using Context): Unit = ()
13111318
}
13121319

13131320
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._
@@ -709,7 +710,7 @@ class Typer extends Namer
709710
case Whole(16) => // cant parse hex literal as double
710711
case _ => return lit(doubleFromDigits(digits))
711712
}
712-
else if genericNumberLiteralsEnabled
713+
else if Feature.genericNumberLiteralsEnabled
713714
&& target.isValueType && isFullyDefined(target, ForceDegree.none)
714715
then
715716
// If expected type is defined with a FromDigits instance, use that one
@@ -1739,10 +1740,30 @@ class Typer extends Namer
17391740
.withNotNullInfo(body1.notNullInfo.retractedInfo.seq(cond1.notNullInfoIf(false)))
17401741
}
17411742

1743+
/** Add givens reflecting `CanThrow` capabilities for all checked exceptions matched
1744+
* by `cases`. The givens appear in nested blocks with earlier cases leading to
1745+
* more deeply nested givens. This way, given priority will be the same as pattern priority.
1746+
* The functionality is enabled if the experimental.saferExceptions language feature is enabled.
1747+
*/
1748+
def addCanThrowCapabilities(expr: untpd.Tree, cases: List[CaseDef])(using Context): untpd.Tree =
1749+
def makeCanThrow(tp: Type): untpd.Tree =
1750+
untpd.ValDef(
1751+
EvidenceParamName.fresh(),
1752+
untpd.TypeTree(defn.CanThrowClass.typeRef.appliedTo(tp)),
1753+
untpd.ref(defn.Predef_undefined))
1754+
.withFlags(Given | Final | Lazy | Erased)
1755+
.withSpan(expr.span)
1756+
val caps =
1757+
for
1758+
CaseDef(pat, _, _) <- cases
1759+
if Feature.enabled(Feature.saferExceptions) && pat.tpe.widen.isCheckedException
1760+
yield makeCanThrow(pat.tpe.widen)
1761+
caps.foldLeft(expr)((e, g) => untpd.Block(g :: Nil, e))
1762+
17421763
def typedTry(tree: untpd.Try, pt: Type)(using Context): Try = {
17431764
val expr2 :: cases2x = harmonic(harmonize, pt) {
1744-
val expr1 = typed(tree.expr, pt.dropIfProto)
17451765
val cases1 = typedCases(tree.cases, EmptyTree, defn.ThrowableType, pt.dropIfProto)
1766+
val expr1 = typed(addCanThrowCapabilities(tree.expr, cases1), pt.dropIfProto)
17461767
expr1 :: cases1
17471768
}
17481769
val finalizer1 = typed(tree.finalizer, defn.UnitType)
@@ -1761,6 +1782,7 @@ class Typer extends Namer
17611782

17621783
def typedThrow(tree: untpd.Throw)(using Context): Tree = {
17631784
val expr1 = typed(tree.expr, defn.ThrowableType)
1785+
checkCanThrow(expr1.tpe.widen, tree.span)
17641786
Throw(expr1).withSpan(tree.span)
17651787
}
17661788

@@ -1856,7 +1878,7 @@ class Typer extends Namer
18561878
def typedAppliedTypeTree(tree: untpd.AppliedTypeTree)(using Context): Tree = {
18571879
tree.args match
18581880
case arg :: _ if arg.isTerm =>
1859-
if dependentEnabled then
1881+
if Feature.dependentEnabled then
18601882
return errorTree(tree, i"Not yet implemented: T(...)")
18611883
else
18621884
return errorTree(tree, dependentStr)
@@ -1953,7 +1975,7 @@ class Typer extends Namer
19531975
typeIndexedLambdaTypeTree(tree, tparams, body)
19541976

19551977
def typedTermLambdaTypeTree(tree: untpd.TermLambdaTypeTree)(using Context): Tree =
1956-
if dependentEnabled then
1978+
if Feature.dependentEnabled then
19571979
errorTree(tree, i"Not yet implemented: (...) =>> ...")
19581980
else
19591981
errorTree(tree, dependentStr)
@@ -2370,7 +2392,7 @@ class Typer extends Namer
23702392
ctx.phase.isTyper &&
23712393
cdef1.symbol.ne(defn.DynamicClass) &&
23722394
cdef1.tpe.derivesFrom(defn.DynamicClass) &&
2373-
!dynamicsEnabled
2395+
!Feature.dynamicsEnabled
23742396
if (reportDynamicInheritance) {
23752397
val isRequired = parents1.exists(_.tpe.isRef(defn.DynamicClass))
23762398
report.featureWarning(nme.dynamics.toString, "extension of type scala.Dynamic", cls, isRequired, cdef.srcPos)
@@ -3437,7 +3459,7 @@ class Typer extends Namer
34373459
def isAutoApplied(sym: Symbol): Boolean =
34383460
sym.isConstructor
34393461
|| sym.matchNullaryLoosely
3440-
|| warnOnMigration(MissingEmptyArgumentList(sym.show), tree.srcPos)
3462+
|| Feature.warnOnMigration(MissingEmptyArgumentList(sym.show), tree.srcPos)
34413463
&& { patch(tree.span.endPos, "()"); true }
34423464

34433465
// Reasons NOT to eta expand:
@@ -3787,7 +3809,7 @@ class Typer extends Namer
37873809
case ref: TermRef =>
37883810
pt match {
37893811
case pt: FunProto
3790-
if needsTupledDual(ref, pt) && autoTuplingEnabled =>
3812+
if needsTupledDual(ref, pt) && Feature.autoTuplingEnabled =>
37913813
adapt(tree, pt.tupledDual, locked)
37923814
case _ =>
37933815
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)