From b3b3efb9668e5503d2335b9635e759608b8e78e0 Mon Sep 17 00:00:00 2001 From: Martin Odersky Date: Tue, 8 Jun 2021 22:43:02 +0200 Subject: [PATCH] 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. --- .../src/dotty/tools/dotc/config/Feature.scala | 1 + .../dotty/tools/dotc/core/Definitions.scala | 9 ++++- .../tools/dotc/transform/TypeUtils.scala | 10 +++++ .../src/dotty/tools/dotc/typer/Checking.scala | 11 +++++- .../src/dotty/tools/dotc/typer/ReTyper.scala | 3 ++ .../src/dotty/tools/dotc/typer/Typer.scala | 38 +++++++++++++++---- library/src-bootstrapped/scala/CanThrow.scala | 19 ++++++++++ .../runtime/stdLibPatches/language.scala | 7 ++++ tests/neg/saferExceptions.check | 26 +++++++++++++ tests/neg/saferExceptions.scala | 19 ++++++++++ tests/pos/reference/saferExceptions.scala | 15 ++++++++ tests/run/saferExceptions.scala | 34 +++++++++++++++++ 12 files changed, 180 insertions(+), 12 deletions(-) create mode 100644 library/src-bootstrapped/scala/CanThrow.scala create mode 100644 tests/neg/saferExceptions.check create mode 100644 tests/neg/saferExceptions.scala create mode 100644 tests/pos/reference/saferExceptions.scala create mode 100644 tests/run/saferExceptions.scala diff --git a/compiler/src/dotty/tools/dotc/config/Feature.scala b/compiler/src/dotty/tools/dotc/config/Feature.scala index 15c2baca1a2f..ceff7f91dbaf 100644 --- a/compiler/src/dotty/tools/dotc/config/Feature.scala +++ b/compiler/src/dotty/tools/dotc/config/Feature.scala @@ -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 * diff --git a/compiler/src/dotty/tools/dotc/core/Definitions.scala b/compiler/src/dotty/tools/dotc/core/Definitions.scala index 547ceb292055..037c0532be18 100644 --- a/compiler/src/dotty/tools/dotc/core/Definitions.scala +++ b/compiler/src/dotty/tools/dotc/core/Definitions.scala @@ -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 @@ -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) diff --git a/compiler/src/dotty/tools/dotc/transform/TypeUtils.scala b/compiler/src/dotty/tools/dotc/transform/TypeUtils.scala index ecbfbeb6d6e5..5a8727e4f90d 100644 --- a/compiler/src/dotty/tools/dotc/transform/TypeUtils.scala +++ b/compiler/src/dotty/tools/dotc/transform/TypeUtils.scala @@ -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] diff --git a/compiler/src/dotty/tools/dotc/typer/Checking.scala b/compiler/src/dotty/tools/dotc/typer/Checking.scala index 6abc4ccfd090..a302ddc4fd8b 100644 --- a/compiler/src/dotty/tools/dotc/typer/Checking.scala +++ b/compiler/src/dotty/tools/dotc/typer/Checking.scala @@ -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._ @@ -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 @@ -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 { @@ -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 { diff --git a/compiler/src/dotty/tools/dotc/typer/ReTyper.scala b/compiler/src/dotty/tools/dotc/typer/ReTyper.scala index 0a9943067127..4f604a5a0b93 100644 --- a/compiler/src/dotty/tools/dotc/typer/ReTyper.scala +++ b/compiler/src/dotty/tools/dotc/typer/ReTyper.scala @@ -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 { diff --git a/compiler/src/dotty/tools/dotc/typer/Typer.scala b/compiler/src/dotty/tools/dotc/typer/Typer.scala index 536a80626380..2d7cccb7e332 100644 --- a/compiler/src/dotty/tools/dotc/typer/Typer.scala +++ b/compiler/src/dotty/tools/dotc/typer/Typer.scala @@ -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._ @@ -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 @@ -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) @@ -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) } @@ -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) @@ -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) @@ -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) @@ -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: @@ -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) diff --git a/library/src-bootstrapped/scala/CanThrow.scala b/library/src-bootstrapped/scala/CanThrow.scala new file mode 100644 index 000000000000..a3842db07d7a --- /dev/null +++ b/library/src-bootstrapped/scala/CanThrow.scala @@ -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] = ??? diff --git a/library/src/scala/runtime/stdLibPatches/language.scala b/library/src/scala/runtime/stdLibPatches/language.scala index 0d4fd95c9e53..cd76fc0a11bb 100644 --- a/library/src/scala/runtime/stdLibPatches/language.scala +++ b/library/src/scala/runtime/stdLibPatches/language.scala @@ -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. diff --git a/tests/neg/saferExceptions.check b/tests/neg/saferExceptions.check new file mode 100644 index 000000000000..97cd423746c6 --- /dev/null +++ b/tests/neg/saferExceptions.check @@ -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 + | diff --git a/tests/neg/saferExceptions.scala b/tests/neg/saferExceptions.scala new file mode 100644 index 000000000000..df6feb2d4a65 --- /dev/null +++ b/tests/neg/saferExceptions.scala @@ -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 diff --git a/tests/pos/reference/saferExceptions.scala b/tests/pos/reference/saferExceptions.scala new file mode 100644 index 000000000000..f444a22d8053 --- /dev/null +++ b/tests/pos/reference/saferExceptions.scala @@ -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") + + diff --git a/tests/run/saferExceptions.scala b/tests/run/saferExceptions.scala new file mode 100644 index 000000000000..b08c23c7e20c --- /dev/null +++ b/tests/run/saferExceptions.scala @@ -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)