Skip to content

Commit

Permalink
Safer exceptions
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
odersky committed Jun 8, 2021
1 parent 55762c6 commit b3b3efb
Show file tree
Hide file tree
Showing 12 changed files with 180 additions and 12 deletions.
1 change: 1 addition & 0 deletions compiler/src/dotty/tools/dotc/config/Feature.scala
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ object Feature:
val erasedDefinitions = experimental("erasedDefinitions")
val symbolLiterals = deprecated("symbolLiterals")
val fewerBraces = experimental("fewerBraces")
val saferExceptions = experimental("saferExceptions")

/** Is `feature` enabled by by a command-line setting? The enabling setting is
*
Expand Down
9 changes: 7 additions & 2 deletions compiler/src/dotty/tools/dotc/core/Definitions.scala
Original file line number Diff line number Diff line change
Expand Up @@ -657,8 +657,11 @@ class Definitions {

// in scalac modified to have Any as parent

@tu lazy val ThrowableType: TypeRef = requiredClassRef("java.lang.Throwable")
def ThrowableClass(using Context): ClassSymbol = ThrowableType.symbol.asClass
@tu lazy val ThrowableType: TypeRef = requiredClassRef("java.lang.Throwable")
def ThrowableClass(using Context): ClassSymbol = ThrowableType.symbol.asClass
@tu lazy val ExceptionClass: ClassSymbol = requiredClass("java.lang.Exception")
@tu lazy val RuntimeExceptionClass: ClassSymbol = requiredClass("java.lang.RuntimeException")

@tu lazy val SerializableType: TypeRef = JavaSerializableClass.typeRef
def SerializableClass(using Context): ClassSymbol = SerializableType.symbol.asClass

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

@tu lazy val CanThrowClass: ClassSymbol = requiredClass("scala.CanThrow")

@tu lazy val TypeBoxClass: ClassSymbol = requiredClass("scala.runtime.TypeBox")
@tu lazy val TypeBox_CAP: TypeSymbol = TypeBoxClass.requiredType(tpnme.CAP)

Expand Down
10 changes: 10 additions & 0 deletions compiler/src/dotty/tools/dotc/transform/TypeUtils.scala
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,16 @@ object TypeUtils {
def isErasedClass(using Context): Boolean =
self.underlyingClassRef(refinementOK = true).typeSymbol.is(Flags.Erased)

/** Is this type a checked exception? This is the case if the type
* derives from Exception but not from RuntimeException. According to
* that definition Throwable is unchecked. That makes sense since you should
* neither throw nor catch `Throwable` anyway, so we should not define
* an ability to do so.
*/
def isCheckedException(using Context): Boolean =
self.derivesFrom(defn.ExceptionClass)
&& !self.derivesFrom(defn.RuntimeExceptionClass)

def isByName: Boolean =
self.isInstanceOf[ExprType]

Expand Down
11 changes: 9 additions & 2 deletions compiler/src/dotty/tools/dotc/typer/Checking.scala
Original file line number Diff line number Diff line change
Expand Up @@ -34,8 +34,10 @@ import NameOps._
import SymDenotations.{NoCompleter, NoDenotation}
import Applications.unapplyArgs
import transform.patmat.SpaceEngine.isIrrefutable
import config.Feature._
import config.Feature
import config.Feature.sourceVersion
import config.SourceVersion._
import transform.TypeUtils.*

import collection.mutable
import reporting._
Expand Down Expand Up @@ -914,7 +916,7 @@ trait Checking {
description: => String,
featureUseSite: Symbol,
pos: SrcPos)(using Context): Unit =
if !enabled(name) then
if !Feature.enabled(name) then
report.featureWarning(name.toString, description, featureUseSite, required = false, pos)

/** Check that `tp` is a class type and that any top-level type arguments in this type
Expand Down Expand Up @@ -1296,6 +1298,10 @@ trait Checking {
if !tp.derivesFrom(defn.MatchableClass) && sourceVersion.isAtLeast(`future-migration`) then
val kind = if pattern then "pattern selector" else "value"
report.warning(MatchableWarning(tp, pattern), pos)

def checkCanThrow(tp: Type, span: Span)(using Context): Unit =
if Feature.enabled(Feature.saferExceptions) && tp.isCheckedException then
ctx.typer.implicitArgTree(defn.CanThrowClass.typeRef.appliedTo(tp), span)
}

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

trait NoChecking extends ReChecking {
Expand Down
3 changes: 3 additions & 0 deletions compiler/src/dotty/tools/dotc/typer/ReTyper.scala
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,9 @@ class ReTyper extends Typer with ReChecking {
super.handleUnexpectedFunType(tree, fun)
}

override def addCanThrowCapabilities(expr: untpd.Tree, cases: List[CaseDef])(using Context): untpd.Tree =
expr

override def typedUnadapted(tree: untpd.Tree, pt: Type, locked: TypeVars)(using Context): Tree =
try super.typedUnadapted(tree, pt, locked)
catch {
Expand Down
38 changes: 30 additions & 8 deletions compiler/src/dotty/tools/dotc/typer/Typer.scala
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,8 @@ import annotation.tailrec
import Implicits._
import util.Stats.record
import config.Printers.{gadts, typr, debug}
import config.Feature._
import config.Feature
import config.Feature.{sourceVersion, migrateTo3}
import config.SourceVersion._
import rewrites.Rewrites.patch
import NavigateAST._
Expand Down Expand Up @@ -709,7 +710,7 @@ class Typer extends Namer
case Whole(16) => // cant parse hex literal as double
case _ => return lit(doubleFromDigits(digits))
}
else if genericNumberLiteralsEnabled
else if Feature.genericNumberLiteralsEnabled
&& target.isValueType && isFullyDefined(target, ForceDegree.none)
then
// If expected type is defined with a FromDigits instance, use that one
Expand Down Expand Up @@ -1739,10 +1740,30 @@ class Typer extends Namer
.withNotNullInfo(body1.notNullInfo.retractedInfo.seq(cond1.notNullInfoIf(false)))
}

/** Add givens reflecting `CanThrow` capabilities for all checked exceptions matched
* by `cases`. The givens appear in nested blocks with earlier cases leading to
* more deeply nested givens. This way, given priority will be the same as pattern priority.
* The functionality is enabled if the experimental.saferExceptions language feature is enabled.
*/
def addCanThrowCapabilities(expr: untpd.Tree, cases: List[CaseDef])(using Context): untpd.Tree =
def makeCanThrow(tp: Type): untpd.Tree =
untpd.ValDef(
EvidenceParamName.fresh(),
untpd.TypeTree(defn.CanThrowClass.typeRef.appliedTo(tp)),
untpd.ref(defn.Predef_undefined))
.withFlags(Given | Final | Lazy | Erased)
.withSpan(expr.span)
val caps =
for
CaseDef(pat, _, _) <- cases
if Feature.enabled(Feature.saferExceptions) && pat.tpe.widen.isCheckedException
yield makeCanThrow(pat.tpe.widen)
caps.foldLeft(expr)((e, g) => untpd.Block(g :: Nil, e))

def typedTry(tree: untpd.Try, pt: Type)(using Context): Try = {
val expr2 :: cases2x = harmonic(harmonize, pt) {
val expr1 = typed(tree.expr, pt.dropIfProto)
val cases1 = typedCases(tree.cases, EmptyTree, defn.ThrowableType, pt.dropIfProto)
val expr1 = typed(addCanThrowCapabilities(tree.expr, cases1), pt.dropIfProto)
expr1 :: cases1
}
val finalizer1 = typed(tree.finalizer, defn.UnitType)
Expand All @@ -1761,6 +1782,7 @@ class Typer extends Namer

def typedThrow(tree: untpd.Throw)(using Context): Tree = {
val expr1 = typed(tree.expr, defn.ThrowableType)
checkCanThrow(expr1.tpe.widen, tree.span)
Throw(expr1).withSpan(tree.span)
}

Expand Down Expand Up @@ -1856,7 +1878,7 @@ class Typer extends Namer
def typedAppliedTypeTree(tree: untpd.AppliedTypeTree)(using Context): Tree = {
tree.args match
case arg :: _ if arg.isTerm =>
if dependentEnabled then
if Feature.dependentEnabled then
return errorTree(tree, i"Not yet implemented: T(...)")
else
return errorTree(tree, dependentStr)
Expand Down Expand Up @@ -1953,7 +1975,7 @@ class Typer extends Namer
typeIndexedLambdaTypeTree(tree, tparams, body)

def typedTermLambdaTypeTree(tree: untpd.TermLambdaTypeTree)(using Context): Tree =
if dependentEnabled then
if Feature.dependentEnabled then
errorTree(tree, i"Not yet implemented: (...) =>> ...")
else
errorTree(tree, dependentStr)
Expand Down Expand Up @@ -2370,7 +2392,7 @@ class Typer extends Namer
ctx.phase.isTyper &&
cdef1.symbol.ne(defn.DynamicClass) &&
cdef1.tpe.derivesFrom(defn.DynamicClass) &&
!dynamicsEnabled
!Feature.dynamicsEnabled
if (reportDynamicInheritance) {
val isRequired = parents1.exists(_.tpe.isRef(defn.DynamicClass))
report.featureWarning(nme.dynamics.toString, "extension of type scala.Dynamic", cls, isRequired, cdef.srcPos)
Expand Down Expand Up @@ -3437,7 +3459,7 @@ class Typer extends Namer
def isAutoApplied(sym: Symbol): Boolean =
sym.isConstructor
|| sym.matchNullaryLoosely
|| warnOnMigration(MissingEmptyArgumentList(sym.show), tree.srcPos)
|| Feature.warnOnMigration(MissingEmptyArgumentList(sym.show), tree.srcPos)
&& { patch(tree.span.endPos, "()"); true }

// Reasons NOT to eta expand:
Expand Down Expand Up @@ -3787,7 +3809,7 @@ class Typer extends Namer
case ref: TermRef =>
pt match {
case pt: FunProto
if needsTupledDual(ref, pt) && autoTuplingEnabled =>
if needsTupledDual(ref, pt) && Feature.autoTuplingEnabled =>
adapt(tree, pt.tupledDual, locked)
case _ =>
adaptOverloaded(ref)
Expand Down
19 changes: 19 additions & 0 deletions library/src-bootstrapped/scala/CanThrow.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
package scala
import language.experimental.erasedDefinitions
import annotation.implicitNotFound

/** An ability class that allows to throw exception `E`. When used with the
* experimental.saferExceptions feature, a `throw Ex()` expression will require
* a given of class `CanThrow[Ex]` to be available.
*/
@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}")
erased class CanThrow[-E <: Exception]

/** A helper type to allow syntax like
*
* def f(): T canThrow Ex
*/
infix type canThrow[R, +E <: Exception] = CanThrow[E] ?=> R

object unsafeExceptions:
given canThrowAny: CanThrow[Exception] = ???
7 changes: 7 additions & 0 deletions library/src/scala/runtime/stdLibPatches/language.scala
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,13 @@ object language:
/** Experimental support for using indentation for arguments
*/
object fewerBraces

/** Experimental support for typechecked exception capabilities
*
* @see [[https://dotty.epfl.ch/docs/reference/experimental/canthrow]]
*/
object saferExceptions

end experimental

/** The deprecated object contains features that are no longer officially suypported in Scala.
Expand Down
26 changes: 26 additions & 0 deletions tests/neg/saferExceptions.check
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
-- Error: tests/neg/saferExceptions.scala:14:16 ------------------------------------------------------------------------
14 | case 4 => throw Exception() // error
| ^^^^^^^^^^^^^^^^^
| The ability to throw exception Exception is missing.
| The ability can be provided by one of the following:
| - A using clause `(using CanThrow[Exception])`
| - A `canThrow` clause in a result type such as `X canThrow Exception`
| - an enclosing `try` that catches Exception
|
| The following import might fix the problem:
|
| import unsafeExceptions.canThrowAny
|
-- Error: tests/neg/saferExceptions.scala:19:48 ------------------------------------------------------------------------
19 | def baz(x: Int): Int canThrow Failure = bar(x) // error
| ^
| The ability to throw exception java.io.IOException is missing.
| The ability can be provided by one of the following:
| - A using clause `(using CanThrow[java.io.IOException])`
| - A `canThrow` clause in a result type such as `X canThrow java.io.IOException`
| - an enclosing `try` that catches java.io.IOException
|
| The following import might fix the problem:
|
| import unsafeExceptions.canThrowAny
|
19 changes: 19 additions & 0 deletions tests/neg/saferExceptions.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
object test:
import language.experimental.saferExceptions
import java.io.IOException

class Failure extends Exception

def bar(x: Int): Int
`canThrow` Failure
`canThrow` IOException =
x match
case 1 => throw AssertionError()
case 2 => throw Failure() // ok
case 3 => throw java.io.IOException() // ok
case 4 => throw Exception() // error
case 5 => throw Throwable() // ok: Throwable is treated as unchecked
case _ => 0

def foo(x: Int): Int canThrow Exception = bar(x)
def baz(x: Int): Int canThrow Failure = bar(x) // error
15 changes: 15 additions & 0 deletions tests/pos/reference/saferExceptions.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import language.experimental.saferExceptions


class LimitExceeded extends Exception

val limit = 10e9

def f(x: Double): Double canThrow LimitExceeded =
if x < limit then x * x else throw LimitExceeded()

@main def test(xs: Double*) =
try println(xs.map(f).sum)
catch case ex: LimitExceeded => println("too large")


34 changes: 34 additions & 0 deletions tests/run/saferExceptions.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import language.experimental.saferExceptions

class Fail extends Exception

def foo(x: Int) =
try x match
case 1 => throw AssertionError()
case 2 => throw Fail()
case 3 => throw java.io.IOException()
case 4 => throw Exception()
case 5 => throw Throwable()
case _ => 0
catch
case ex: AssertionError => 1
case ex: Fail => 2
case ex: java.io.IOException => 3
case ex: Exception => 4
case ex: Throwable => 5

def bar(x: Int): Int canThrow Exception =
x match
case 1 => throw AssertionError()
case 2 => throw Fail()
case 3 => throw java.io.IOException()
case 4 => throw Exception()
case _ => 0

@main def Test =
assert(foo(1) + foo(2) + foo(3) + foo(4) + foo(5) + foo(6) == 15)
import unsafeExceptions.canThrowAny
val x =
try bar(2)
catch case ex: Fail => 3 // OK
assert(x == 3)

0 comments on commit b3b3efb

Please sign in to comment.