From ffda8d0dc0313ece456ffb2a6807880d38f3fd8f Mon Sep 17 00:00:00 2001 From: Martin Odersky Date: Tue, 8 Jun 2021 22:43:02 +0200 Subject: [PATCH 1/6] 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 df2cab2c3375..f83873ce4b26 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 0816ee23889a..a2dfcf794f38 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 @@ -830,6 +833,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 0f838ac20755..850c104556aa 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._ @@ -930,7 +932,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 @@ -1312,6 +1314,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 { @@ -1324,6 +1330,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 7604158e0511..fa78747f1445 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._ @@ -712,7 +713,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 @@ -1712,10 +1713,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) @@ -1734,6 +1755,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) } @@ -1832,7 +1854,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) @@ -1928,7 +1950,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) @@ -2399,7 +2421,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) @@ -3468,7 +3490,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: @@ -3819,7 +3841,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) From 623b80fedb3bcec04ecb08a33f88dda4546d531c Mon Sep 17 00:00:00 2001 From: Martin Odersky Date: Wed, 9 Jun 2021 18:40:40 +0200 Subject: [PATCH 2/6] Mark CanThrow @experimental --- library/src-bootstrapped/scala/CanThrow.scala | 5 ++++- library/src/scala/runtime/stdLibPatches/language.scala | 2 ++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/library/src-bootstrapped/scala/CanThrow.scala b/library/src-bootstrapped/scala/CanThrow.scala index a3842db07d7a..2ef2bcce0342 100644 --- a/library/src-bootstrapped/scala/CanThrow.scala +++ b/library/src-bootstrapped/scala/CanThrow.scala @@ -1,11 +1,12 @@ package scala import language.experimental.erasedDefinitions -import annotation.implicitNotFound +import annotation.{implicitNotFound, experimental} /** 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. */ +@experimental @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] @@ -13,7 +14,9 @@ erased class CanThrow[-E <: Exception] * * def f(): T canThrow Ex */ +@experimental infix type canThrow[R, +E <: Exception] = CanThrow[E] ?=> R +@experimental 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 cd76fc0a11bb..d7084e8850d8 100644 --- a/library/src/scala/runtime/stdLibPatches/language.scala +++ b/library/src/scala/runtime/stdLibPatches/language.scala @@ -50,12 +50,14 @@ object language: /** Experimental support for using indentation for arguments */ + @compileTimeOnly("`fewerBraces` can only be used at compile time in import statements") object fewerBraces /** Experimental support for typechecked exception capabilities * * @see [[https://dotty.epfl.ch/docs/reference/experimental/canthrow]] */ + @compileTimeOnly("`saferExceptions` can only be used at compile time in import statements") object saferExceptions end experimental From 6d6714740c1d8d2588e7ca22762c5bb247f91f5a Mon Sep 17 00:00:00 2001 From: Martin Odersky Date: Wed, 9 Jun 2021 19:35:08 +0200 Subject: [PATCH 3/6] Update MiMa filters --- project/MiMaFilters.scala | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/project/MiMaFilters.scala b/project/MiMaFilters.scala index d4d837e271a8..0199e471dfe2 100644 --- a/project/MiMaFilters.scala +++ b/project/MiMaFilters.scala @@ -4,6 +4,15 @@ import com.typesafe.tools.mima.core.ProblemFilters._ object MiMaFilters { val Library: Seq[ProblemFilter] = Seq( + // Experimental API for saferExceptions + exclude[MissingClassProblem]("scala.CanThrow"), + exclude[MissingClassProblem]("scala.CanThrow$package"), + exclude[MissingClassProblem]("scala.CanThrow$package$"), + exclude[MissingClassProblem]("scala.unsafeExceptions"), + exclude[MissingClassProblem]("scala.unsafeExceptions$"), + exclude[MissingFieldProblem]("scala.runtime.stdLibPatches.language#experimental.saferExceptions"), + exclude[MissingClassProblem]("scala.runtime.stdLibPatches.language$experimental$saferExceptions$"), + // New APIs that will be introduced in 3.1.0 exclude[ReversedMissingMethodProblem]("scala.quoted.Quotes#reflectModule.Wildcard"), exclude[ReversedMissingMethodProblem]("scala.quoted.Quotes#reflectModule.WildcardTypeTest"), From 6e50277fdc3bd4c61a68fbf39566fd927c2aa8b5 Mon Sep 17 00:00:00 2001 From: Martin Odersky Date: Sun, 25 Jul 2021 14:04:10 +0200 Subject: [PATCH 4/6] Use soft keyword `throws` instead of `canThrow` --- .../src/dotty/tools/dotc/ast/Desugar.scala | 8 ++++ .../src/dotty/tools/dotc/core/StdNames.scala | 2 + .../src/dotty/tools/dotc/typer/Typer.scala | 5 ++- docs/docs/reference/experimental/canthrow.md | 42 ++++++++++++------- docs/docs/reference/soft-modifier.md | 2 +- library/src-bootstrapped/scala/CanThrow.scala | 8 ++-- tests/neg/safeThrowsStrawman.check | 4 +- tests/neg/safeThrowsStrawman.scala | 8 ++-- tests/neg/safeThrowsStrawman2.scala | 6 +-- tests/neg/saferExceptions.check | 26 ++++++------ tests/neg/saferExceptions.scala | 8 ++-- tests/pos/reference/saferExceptions.scala | 2 +- tests/run/safeThrowsStrawman.scala | 8 ++-- tests/run/safeThrowsStrawman2.scala | 12 +++--- tests/run/saferExceptions.scala | 2 +- 15 files changed, 85 insertions(+), 58 deletions(-) diff --git a/compiler/src/dotty/tools/dotc/ast/Desugar.scala b/compiler/src/dotty/tools/dotc/ast/Desugar.scala index 8fd06ddc3933..c8cbec56fe78 100644 --- a/compiler/src/dotty/tools/dotc/ast/Desugar.scala +++ b/compiler/src/dotty/tools/dotc/ast/Desugar.scala @@ -1261,6 +1261,14 @@ object desugar { makeOp(left, right, Span(left.span.start, op.span.end, op.span.start)) } + /** Translate throws type `A throws E1 | ... | En` + */ + def throws(tpt: Tree, op: Ident, excepts: Tree)(using Context): AppliedTypeTree = excepts match + case InfixOp(l, bar @ Ident(tpnme.raw.BAR), r) => + throws(throws(tpt, op, l), bar, r) + case e => + AppliedTypeTree(cpy.Ident(op)(tpnme.THROWS), tpt :: excepts :: Nil) + /** Translate tuple expressions of arity <= 22 * * () ==> () diff --git a/compiler/src/dotty/tools/dotc/core/StdNames.scala b/compiler/src/dotty/tools/dotc/core/StdNames.scala index b6aea21bee8b..2e0b229ca42c 100644 --- a/compiler/src/dotty/tools/dotc/core/StdNames.scala +++ b/compiler/src/dotty/tools/dotc/core/StdNames.scala @@ -305,6 +305,7 @@ object StdNames { val SPECIALIZED_INSTANCE: N = "specInstance$" val THIS: N = "_$this" val TRAIT_CONSTRUCTOR: N = "$init$" + val THROWS: N = "$throws" val U2EVT: N = "u2evt$" val ALLARGS: N = "$allArgs" @@ -602,6 +603,7 @@ object StdNames { val this_ : N = "this" val thisPrefix : N = "thisPrefix" val throw_ : N = "throw" + val throws: N = "throws" val toArray: N = "toArray" val toList: N = "toList" val toObjectArray : N = "toObjectArray" diff --git a/compiler/src/dotty/tools/dotc/typer/Typer.scala b/compiler/src/dotty/tools/dotc/typer/Typer.scala index fa78747f1445..b77c21893ef7 100644 --- a/compiler/src/dotty/tools/dotc/typer/Typer.scala +++ b/compiler/src/dotty/tools/dotc/typer/Typer.scala @@ -2636,7 +2636,10 @@ class Typer extends Namer val untpd.InfixOp(l, op, r) = tree val result = if (ctx.mode.is(Mode.Type)) - typedAppliedTypeTree(cpy.AppliedTypeTree(tree)(op, l :: r :: Nil)) + typedAppliedTypeTree( + if op.name == tpnme.throws && Feature.enabled(Feature.saferExceptions) + then desugar.throws(l, op, r) + else cpy.AppliedTypeTree(tree)(op, l :: r :: Nil)) else if (ctx.mode.is(Mode.Pattern)) typedUnApply(cpy.Apply(tree)(op, l :: r :: Nil), pt) else { diff --git a/docs/docs/reference/experimental/canthrow.md b/docs/docs/reference/experimental/canthrow.md index 261e4b3edd3e..4f28cdd6cf1f 100644 --- a/docs/docs/reference/experimental/canthrow.md +++ b/docs/docs/reference/experimental/canthrow.md @@ -47,9 +47,9 @@ However, a programming language is not a framework; it has to cater also for tho Why does `map` work so poorly with Java's checked exception model? It's because `map`'s signature limits function arguments to not throw checked exceptions. We could try to come up with a more polymorphic formulation of `map`. For instance, it could look like this: ```scala - def map[B, E](f: A => B canThrow E): List[B] canThrow E + def map[B, E](f: A => B throws E): List[B] throws E ``` -This assumes a type `A canThrow E` to indicate computations of type `A` that can throw an exception of type `E`. But in practice the overhead of the additional type parameters makes this approach unappealing as well. Note in particular that we'd have to parameterize _every method_ that takes a function argument that way, so the added overhead of declaring all these exception types looks just like a sort of ceremony we would like to avoid. +This assumes a type `A throws E` to indicate computations of type `A` that can throw an exception of type `E`. But in practice the overhead of the additional type parameters makes this approach unappealing as well. Note in particular that we'd have to parameterize _every method_ that takes a function argument that way, so the added overhead of declaring all these exception types looks just like a sort of ceremony we would like to avoid. But there is a way to avoid the ceremony. Instead of concentrating on possible _effects_ such as "this code might throw an exception", concentrate on _capabilities_ such as "this code needs the capability to throw an exception". From a standpoint of expressiveness this is quite similar. But capabilities can be expressed as parameters whereas traditionally effects are expressed as some addition to result values. It turns out that this can make a big difference! @@ -71,19 +71,32 @@ How can the ability be produced? There are several possibilities: Most often, the ability is produced by having a using clause `(using CanThrow[Exc])` in some enclosing scope. This roughly corresponds to a `throws` clause in Java. The analogy is even stronger since alongside `CanThrow` there is also the following type alias defined in the `scala` package: ```scala -infix type canThrow[R, +E <: Exception] = CanThrow[E] ?=> R +infix type $throws[R, +E <: Exception] = CanThrow[E] ?=> R ``` -That is, `R canThrow E` is a context function type that takes an implicit `CanThrow[E]` parameter and that returns a value of type `R`. Therefore, a method written like this: +That is, `R $throws E` is a context function type that takes an implicit `CanThrow[E]` parameter and that returns a value of type `R`. What's more, the compiler +will translate an infix types with `throws` as the operator to `$throws` applications +according to the rules +``` + A throws E --> A $throws E + A throws E₁ | ... | Eᵢ --> A $throws E₁ ... $throws Eᵢ +``` +Therefore, a method written like this: ```scala def m(x: T)(using CanThrow[E]): U ``` can alternatively be expressed like this: ```scala -def m(x: T): U canThrow E +def m(x: T): U throws E ``` -_Aside_: If we rename `canThrow` to `throws` we would have a perfect analogy with Java but unfortunately `throws` is already taken in Scala 2.13. - -The `CanThrow`/`canThrow` combo essentially propagates the `CanThrow` requirement outwards. But where are these abilities created in the first place? That's in the `try` expression. Given a `try` like this: +Multiple `CanThrow` capabilities can be combined in a single throws clause. For instance, the method +```scala +def m2(x: T)(using CanThrow[E1], CanThrow[E2]): U +``` +can alternatively be expressed like this: +```scala +def m(x: T): U throws E1 | E2 +``` +The `CanThrow`/`throws` combo essentially propagates the `CanThrow` requirement outwards. But where are these abilities created in the first place? That's in the `try` expression. Given a `try` like this: ```scala try @@ -126,16 +139,16 @@ You'll get this error message: |The ability to throw exception LimitExceeded is missing. |The ability can be provided by one of the following: | - A using clause `(using CanThrow[LimitExceeded])` - | - A `canThrow` clause in a result type such as `X canThrow LimitExceeded` + | - A `throws` clause in a result type such as `X throws LimitExceeded` | - an enclosing `try` that catches LimitExceeded | |The following import might fix the problem: | | import unsafeExceptions.canThrowAny ``` -As the error message implies, you have to declare that `f` needs the ability to throw a `LimitExceeded` exception. The most concise way to do so is to add a `canThrow` clause: +As the error message implies, you have to declare that `f` needs the ability to throw a `LimitExceeded` exception. The most concise way to do so is to add a `throws` clause: ```scala -def f(x: Double): Double canThrow LimitExceeded = +def f(x: Double): Double throws LimitExceeded = if x < limit then x * x else throw LimitExceeded() ``` Now put a call to `f` in a `try` that catches `LimitExceeded`: @@ -169,12 +182,12 @@ So the takeaway is that the effects as abilities model naturally provides for ef ## Gradual Typing Via Imports -Another advantage is that the model allows a gradual migration from current unchecked exceptions to safer exceptions. Imagine for a moment that `experimental.saferExceptions` is turned on everywhere. There would be lots of code that breaks since functions have not yet been properly annotated with `canThrow`. But it's easy to create an escape hatch that lets us ignore the breakages for a while: simply add the import +Another advantage is that the model allows a gradual migration from current unchecked exceptions to safer exceptions. Imagine for a moment that `experimental.saferExceptions` is turned on everywhere. There would be lots of code that breaks since functions have not yet been properly annotated with `throws`. But it's easy to create an escape hatch that lets us ignore the breakages for a while: simply add the import ```scala import scala.unsafeExceptions.canThrowAny ``` This will provide the `CanThrow` ability for any exception, and thereby allow -all throws and all other calls, no matter what the current state of `canThrow` declarations is. Here's the +all throws and all other calls, no matter what the current state of `throws` declarations is. Here's the definition of `canThrowAny`: ```scala package scala @@ -188,7 +201,8 @@ enable more fluid explorations of code without regard for complete exception saf To summarize, the extension for safer exception checking consists of the following elements: - - It adds to the standard library the class `scala.CanThrow`, the type `scala.canThrow`, and the `scala.unsafeExceptions` object, as they were described above. + - It adds to the standard library the class `scala.CanThrow`, the type `scala.$throws`, and the `scala.unsafeExceptions` object, as they were described above. + - It adds some desugaring rules ro rewrite `throws` types to cascaded `$throws` types. - It augments the type checking of `throw` by _demanding_ a `CanThrow` ability or the thrown exception. - It augments the type checking of `try` by _providing_ `CanThrow` abilities for every caught exception. diff --git a/docs/docs/reference/soft-modifier.md b/docs/docs/reference/soft-modifier.md index 1097eccec9ff..2b81e39afb04 100644 --- a/docs/docs/reference/soft-modifier.md +++ b/docs/docs/reference/soft-modifier.md @@ -5,7 +5,7 @@ title: Soft Keywords A soft modifier is one of the identifiers `opaque`, `inline`, `open`, `transparent`, and `infix`. -A soft keyword is a soft modifier, or one of `derives`, `end`, `extension`, `using`, `|`, `+`, `-`, `*` +A soft keyword is a soft modifier, or one of `derives`, `end`, `extension`, `throws`, `using`, `|`, `+`, `-`, `*` A soft modifier is treated as potential modifier of a definition if it is followed by a hard modifier or a keyword combination starting a definition (`def`, `val`, `var`, `type`, `given`, `class`, `trait`, `object`, `enum`, `case class`, `case object`). Between the two words there may be a sequence of newline tokens and soft modifiers. diff --git a/library/src-bootstrapped/scala/CanThrow.scala b/library/src-bootstrapped/scala/CanThrow.scala index 2ef2bcce0342..f6ee54edeca5 100644 --- a/library/src-bootstrapped/scala/CanThrow.scala +++ b/library/src-bootstrapped/scala/CanThrow.scala @@ -7,15 +7,17 @@ import annotation.{implicitNotFound, experimental} * a given of class `CanThrow[Ex]` to be available. */ @experimental -@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}") +@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 `throws` clause in a result type such as `X throws ${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 + * def f(): T throws Ex1 | Ex2 + * + * Used in desugar.throws. */ @experimental -infix type canThrow[R, +E <: Exception] = CanThrow[E] ?=> R +infix type $throws[R, +E <: Exception] = CanThrow[E] ?=> R @experimental object unsafeExceptions: diff --git a/tests/neg/safeThrowsStrawman.check b/tests/neg/safeThrowsStrawman.check index d0f0b8e60176..6bf1ecdae513 100644 --- a/tests/neg/safeThrowsStrawman.check +++ b/tests/neg/safeThrowsStrawman.check @@ -4,7 +4,7 @@ | The capability to throw exception scalax.Fail is missing. | The capability can be provided by one of the following: | - A using clause `(using CanThrow[scalax.Fail])` - | - A throws clause in a result type such as `X throws scalax.Fail` + | - A raises clause in a result type such as `X raises scalax.Fail` | - an enclosing `try` that catches scalax.Fail -- Error: tests/neg/safeThrowsStrawman.scala:27:15 --------------------------------------------------------------------- 27 | println(bar) // error @@ -12,5 +12,5 @@ | The capability to throw exception Exception is missing. | The capability can be provided by one of the following: | - A using clause `(using CanThrow[Exception])` - | - A throws clause in a result type such as `X throws Exception` + | - A raises clause in a result type such as `X raises Exception` | - an enclosing `try` that catches Exception diff --git a/tests/neg/safeThrowsStrawman.scala b/tests/neg/safeThrowsStrawman.scala index 08f169e91701..f9b7fda118f9 100644 --- a/tests/neg/safeThrowsStrawman.scala +++ b/tests/neg/safeThrowsStrawman.scala @@ -2,21 +2,21 @@ import language.experimental.erasedDefinitions import annotation.implicitNotFound object scalax: - @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 throws clause in a result type such as `X throws ${E}`\n - an enclosing `try` that catches ${E}") + @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 raises clause in a result type such as `X raises ${E}`\n - an enclosing `try` that catches ${E}") erased class CanThrow[-E <: Exception] - infix type throws[R, +E <: Exception] = CanThrow[E] ?=> R + infix type raises[R, +E <: Exception] = CanThrow[E] ?=> R class Fail extends Exception - def raise[E <: Exception](e: E): Nothing throws E = throw e + def raise[E <: Exception](e: E): Nothing raises E = throw e import scalax._ def foo(x: Boolean): Int = if x then 1 else raise(Fail()) // error -def bar: Int throws Exception = +def bar: Int raises Exception = raise(Fail()) @main def Test = diff --git a/tests/neg/safeThrowsStrawman2.scala b/tests/neg/safeThrowsStrawman2.scala index 80e5139b1f8d..55e5ef4aea2f 100644 --- a/tests/neg/safeThrowsStrawman2.scala +++ b/tests/neg/safeThrowsStrawman2.scala @@ -4,15 +4,15 @@ object scalax: erased class CanThrow[E <: Exception] type CTF = CanThrow[Fail] - infix type throws[R, E <: Exception] = CanThrow[E] ?=> R + infix type raises[R, E <: Exception] = CanThrow[E] ?=> R class Fail extends Exception - def raise[E <: Exception](e: E): Nothing throws E = throw e + def raise[E <: Exception](e: E): Nothing raises E = throw e import scalax._ -def foo(x: Boolean, y: CanThrow[Fail]): Int throws Fail = +def foo(x: Boolean, y: CanThrow[Fail]): Int raises Fail = if x then 1 else raise(Fail()) def bar(x: Boolean)(using CanThrow[Fail]): Int = diff --git a/tests/neg/saferExceptions.check b/tests/neg/saferExceptions.check index 97cd423746c6..3a83341488f7 100644 --- a/tests/neg/saferExceptions.check +++ b/tests/neg/saferExceptions.check @@ -1,26 +1,26 @@ --- Error: tests/neg/saferExceptions.scala:14:16 ------------------------------------------------------------------------ -14 | case 4 => throw Exception() // error +-- Error: tests/neg/saferExceptions.scala:12:16 ------------------------------------------------------------------------ +12 | 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` + | - A `throws` clause in a result type such as `X throws 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 +-- Error: tests/neg/saferExceptions.scala:17:46 ------------------------------------------------------------------------ +17 | def baz(x: Int): Int throws 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 `throws` clause in a result type such as `X throws java.io.IOException` + | - an enclosing `try` that catches java.io.IOException | - | The following import might fix the problem: + | The following import might fix the problem: | - | import unsafeExceptions.canThrowAny + | import unsafeExceptions.canThrowAny | diff --git a/tests/neg/saferExceptions.scala b/tests/neg/saferExceptions.scala index df6feb2d4a65..3ef2f8bb0067 100644 --- a/tests/neg/saferExceptions.scala +++ b/tests/neg/saferExceptions.scala @@ -4,9 +4,7 @@ object test: class Failure extends Exception - def bar(x: Int): Int - `canThrow` Failure - `canThrow` IOException = + def bar(x: Int): Int throws Failure | IOException = x match case 1 => throw AssertionError() case 2 => throw Failure() // ok @@ -15,5 +13,5 @@ object test: 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 + def foo(x: Int): Int throws Exception = bar(x) + def baz(x: Int): Int throws Failure = bar(x) // error diff --git a/tests/pos/reference/saferExceptions.scala b/tests/pos/reference/saferExceptions.scala index f444a22d8053..04dbd5fbfd87 100644 --- a/tests/pos/reference/saferExceptions.scala +++ b/tests/pos/reference/saferExceptions.scala @@ -5,7 +5,7 @@ class LimitExceeded extends Exception val limit = 10e9 -def f(x: Double): Double canThrow LimitExceeded = +def f(x: Double): Double throws LimitExceeded = if x < limit then x * x else throw LimitExceeded() @main def test(xs: Double*) = diff --git a/tests/run/safeThrowsStrawman.scala b/tests/run/safeThrowsStrawman.scala index 8ddb594b787a..d9bd4dc227e6 100644 --- a/tests/run/safeThrowsStrawman.scala +++ b/tests/run/safeThrowsStrawman.scala @@ -3,19 +3,19 @@ import language.experimental.erasedDefinitions object scalax: erased class CanThrow[-E <: Exception] - infix type throws[R, +E <: Exception] = CanThrow[E] ?=> R + infix type raises[R, +E <: Exception] = CanThrow[E] ?=> R class Fail extends Exception - def raise[E <: Exception](e: E): Nothing throws E = throw e + def raise[E <: Exception](e: E): Nothing raises E = throw e import scalax._ -def foo(x: Boolean): Int throws Fail = +def foo(x: Boolean): Int raises Fail = if x then 1 else raise(Fail()) def bar(x: Boolean)(using CanThrow[Fail]): Int = foo(x) -def baz: Int throws Exception = foo(false) +def baz: Int raises Exception = foo(false) @main def Test = try diff --git a/tests/run/safeThrowsStrawman2.scala b/tests/run/safeThrowsStrawman2.scala index 1fa38257c177..d7af087a1690 100644 --- a/tests/run/safeThrowsStrawman2.scala +++ b/tests/run/safeThrowsStrawman2.scala @@ -3,19 +3,19 @@ import language.experimental.erasedDefinitions object scalax: erased class CanThrow[-E <: Exception] - infix type throws[R, +E <: Exception] = CanThrow[E] ?=> R + infix type raises[R, +E <: Exception] = CanThrow[E] ?=> R class Fail extends Exception - def raise[E <: Exception](e: E): Nothing throws E = throw e + def raise[E <: Exception](e: E): Nothing raises E = throw e private class Result[T]: var value: T = scala.compiletime.uninitialized - def try1[R, E <: Exception](body: => R throws E)(c: E => Unit): R = + def try1[R, E <: Exception](body: => R raises E)(c: E => Unit): R = try2(body)(c) {} - def try2[R, E <: Exception](body: => R throws E)(c: E => Unit)(f: => Unit): R = + def try2[R, E <: Exception](body: => R raises E)(c: E => Unit)(f: => Unit): R = val res = new Result[R] try given CanThrow[E] = ??? @@ -30,11 +30,11 @@ object scalax: import scalax._ -def foo(x: Boolean): Int throws Fail = +def foo(x: Boolean): Int raises Fail = if x then 1 else raise(Fail()) def bar(x: Boolean)(using CanThrow[Fail]): Int = foo(x) -def baz: Int throws Exception = foo(false) +def baz: Int raises Exception = foo(false) @main def Test = try1 { diff --git a/tests/run/saferExceptions.scala b/tests/run/saferExceptions.scala index b08c23c7e20c..c28509ddf7be 100644 --- a/tests/run/saferExceptions.scala +++ b/tests/run/saferExceptions.scala @@ -17,7 +17,7 @@ def foo(x: Int) = case ex: Exception => 4 case ex: Throwable => 5 -def bar(x: Int): Int canThrow Exception = +def bar(x: Int): Int throws Exception = x match case 1 => throw AssertionError() case 2 => throw Fail() From f5b81259c6f0564983aea1ab37e853c1b93ccf0d Mon Sep 17 00:00:00 2001 From: Martin Odersky Date: Mon, 26 Jul 2021 11:01:31 +0200 Subject: [PATCH 5/6] Go back to "capability" ... instead of "ability". Two reasons: - it's more standard - it's also more correct. According to https://writingexplained.org/capability-vs-ability-difference#When_to_Use_Capability a capability is a yes or no proposition, whereas an ability is a matter of degree. --- .../tools/dotc/transform/TypeUtils.scala | 2 +- docs/docs/reference/experimental/canthrow.md | 58 +++++++++---------- library/src-bootstrapped/scala/CanThrow.scala | 4 +- tests/neg/saferExceptions.check | 8 +-- 4 files changed, 35 insertions(+), 37 deletions(-) diff --git a/compiler/src/dotty/tools/dotc/transform/TypeUtils.scala b/compiler/src/dotty/tools/dotc/transform/TypeUtils.scala index 5a8727e4f90d..7a3da6ad4bde 100644 --- a/compiler/src/dotty/tools/dotc/transform/TypeUtils.scala +++ b/compiler/src/dotty/tools/dotc/transform/TypeUtils.scala @@ -28,7 +28,7 @@ object TypeUtils { * 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. + * a capability to do so. */ def isCheckedException(using Context): Boolean = self.derivesFrom(defn.ExceptionClass) diff --git a/docs/docs/reference/experimental/canthrow.md b/docs/docs/reference/experimental/canthrow.md index 4f28cdd6cf1f..989d766a4e1e 100644 --- a/docs/docs/reference/experimental/canthrow.md +++ b/docs/docs/reference/experimental/canthrow.md @@ -1,6 +1,6 @@ --- layout: doc-page -title: CanThrow Abilities +title: CanThrow Capabilities author: Martin Odersky --- @@ -42,7 +42,7 @@ So the dilemma is that exceptions are easy to use only as long as we forgo stati However, a programming language is not a framework; it has to cater also for those applications that do not fit the framework's use cases. So there's still a strong motivation for getting exception checking right. -## From Effects To Abilities +## From Effects To Capabilities Why does `map` work so poorly with Java's checked exception model? It's because `map`'s signature limits function arguments to not throw checked exceptions. We could try to come up with a more polymorphic formulation of `map`. For instance, it could look like this: @@ -53,22 +53,20 @@ This assumes a type `A throws E` to indicate computations of type `A` that can t But there is a way to avoid the ceremony. Instead of concentrating on possible _effects_ such as "this code might throw an exception", concentrate on _capabilities_ such as "this code needs the capability to throw an exception". From a standpoint of expressiveness this is quite similar. But capabilities can be expressed as parameters whereas traditionally effects are expressed as some addition to result values. It turns out that this can make a big difference! -Going to the root of the word _capability_, it means "being _able_ to do something", so the "cap" prefix is really just a filler. Following Conor McBride, we will use the name _ability_ from now on. +## The CanThrow Cabability -## The CanThrow Ability - -In the _effects as abilities_ model, an effect is expressed as an (implicit) parameter of a certain type. For exceptions we would expect parameters of type +In the _effects as capabilities_ model, an effect is expressed as an (implicit) parameter of a certain type. For exceptions we would expect parameters of type `CanThrow[E]` where `E` stands for the exception that can be thrown. Here is the definition of `CanThrow`: ```scala erased class CanThrow[-E <: Exception] ``` -This shows another experimental Scala feature: [erased definitions](./erased-defs). Roughly speaking, values of an erased class do not generate runtime code; they are erased before code generation. This means that all `CanThrow` abilities are compile-time only artifacts; they do not have a runtime footprint. +This shows another experimental Scala feature: [erased definitions](./erased-defs). Roughly speaking, values of an erased class do not generate runtime code; they are erased before code generation. This means that all `CanThrow` capabilities are compile-time only artifacts; they do not have a runtime footprint. -Now, if the compiler sees a `throw Exc()` construct where `Exc` is a checked exception, it will check that there is an ability of type `CanThrow[Exc]` that can be summoned as a given. It's a compile-time error if that's not the case. +Now, if the compiler sees a `throw Exc()` construct where `Exc` is a checked exception, it will check that there is a capability of type `CanThrow[Exc]` that can be summoned as a given. It's a compile-time error if that's not the case. -How can the ability be produced? There are several possibilities: +How can the capability be produced? There are several possibilities: -Most often, the ability is produced by having a using clause `(using CanThrow[Exc])` in some enclosing scope. This roughly corresponds to a `throws` clause +Most often, the capability is produced by having a using clause `(using CanThrow[Exc])` in some enclosing scope. This roughly corresponds to a `throws` clause in Java. The analogy is even stronger since alongside `CanThrow` there is also the following type alias defined in the `scala` package: ```scala infix type $throws[R, +E <: Exception] = CanThrow[E] ?=> R @@ -96,7 +94,7 @@ can alternatively be expressed like this: ```scala def m(x: T): U throws E1 | E2 ``` -The `CanThrow`/`throws` combo essentially propagates the `CanThrow` requirement outwards. But where are these abilities created in the first place? That's in the `try` expression. Given a `try` like this: +The `CanThrow`/`throws` combo essentially propagates the `CanThrow` requirement outwards. But where are these capabilities created in the first place? That's in the `try` expression. Given a `try` like this: ```scala try @@ -106,7 +104,7 @@ catch ... case exN: ExN => handlerN ``` -the compiler generates abilities for `CanThrow[Ex1]`, ..., `CanThrow[ExN]` that are in scope as givens in `body`. It does this by augmenting the `try` roughly as follows: +the compiler generates capabilities for `CanThrow[Ex1]`, ..., `CanThrow[ExN]` that are in scope as givens in `body`. It does this by augmenting the `try` roughly as follows: ```scala try erased given CanThrow[Ex1] = ??? @@ -136,8 +134,8 @@ You'll get this error message: ``` 9 | if x < limit then x * x else throw LimitExceeded() | ^^^^^^^^^^^^^^^^^^^^^ - |The ability to throw exception LimitExceeded is missing. - |The ability can be provided by one of the following: + |The capability to throw exception LimitExceeded is missing. + |The capability can be provided by one of the following: | - A using clause `(using CanThrow[LimitExceeded])` | - A `throws` clause in a result type such as `X throws LimitExceeded` | - an enclosing `try` that catches LimitExceeded @@ -146,7 +144,7 @@ You'll get this error message: | | import unsafeExceptions.canThrowAny ``` -As the error message implies, you have to declare that `f` needs the ability to throw a `LimitExceeded` exception. The most concise way to do so is to add a `throws` clause: +As the error message implies, you have to declare that `f` needs the capability to throw a `LimitExceeded` exception. The most concise way to do so is to add a `throws` clause: ```scala def f(x: Double): Double throws LimitExceeded = if x < limit then x * x else throw LimitExceeded() @@ -175,10 +173,10 @@ Everything typechecks and works as expected. But wait - we have called `map` wit println(xs.map(x => f(x)(using ctl)).sum) catch case ex: LimitExceeded => println("too large") ``` -The `CanThrow[LimitExceeded]` ability is passed in a synthesized `using` clause to `f`, since `f` requires it. Then the resulting closure is passed to `map`. The signature of `map` does not have to account for effects. It takes a closure as always, but that -closure may refer to abilities in its free variables. This means that `map` is +The `CanThrow[LimitExceeded]` capability is passed in a synthesized `using` clause to `f`, since `f` requires it. Then the resulting closure is passed to `map`. The signature of `map` does not have to account for effects. It takes a closure as always, but that +closure may refer to capabilities in its free variables. This means that `map` is already effect polymorphic even though we did not change its signature at all. -So the takeaway is that the effects as abilities model naturally provides for effect polymorphism whereas this is something that other approaches struggle with. +So the takeaway is that the effects as capabilities model naturally provides for effect polymorphism whereas this is something that other approaches struggle with. ## Gradual Typing Via Imports @@ -186,7 +184,7 @@ Another advantage is that the model allows a gradual migration from current unch ```scala import scala.unsafeExceptions.canThrowAny ``` -This will provide the `CanThrow` ability for any exception, and thereby allow +This will provide the `CanThrow` capability for any exception, and thereby allow all throws and all other calls, no matter what the current state of `throws` declarations is. Here's the definition of `canThrowAny`: ```scala @@ -194,7 +192,7 @@ package scala object unsafeExceptions: given canThrowAny: CanThrow[Exception] = ??? ``` -Of course, defining a global ability like this amounts to cheating. But the cheating is useful for gradual typing. The import could be used to migrate existing code, or to +Of course, defining a global capability like this amounts to cheating. But the cheating is useful for gradual typing. The import could be used to migrate existing code, or to enable more fluid explorations of code without regard for complete exception safety. At the end of these migrations or explorations the import should be removed. ## Scope Of the Extension @@ -203,24 +201,24 @@ To summarize, the extension for safer exception checking consists of the followi - It adds to the standard library the class `scala.CanThrow`, the type `scala.$throws`, and the `scala.unsafeExceptions` object, as they were described above. - It adds some desugaring rules ro rewrite `throws` types to cascaded `$throws` types. - - It augments the type checking of `throw` by _demanding_ a `CanThrow` ability or the thrown exception. - - It augments the type checking of `try` by _providing_ `CanThrow` abilities for every caught exception. + - It augments the type checking of `throw` by _demanding_ a `CanThrow` capability or the thrown exception. + - It augments the type checking of `try` by _providing_ `CanThrow` capabilities for every caught exception. That's all. It's quite remarkable that one can do exception checking in this way without any special additions to the type system. We just need regular givens and context functions. Any runtime overhead is eliminated using `erased`. ## Caveats -Our ability model allows to declare and check the thrown exceptions of first-order code. But as it stands, it does not give us enough mechanism to enforce the _absence_ of -abilities for arguments to higher-order functions. Consider a variant `pureMap` +Our capability model allows to declare and check the thrown exceptions of first-order code. But as it stands, it does not give us enough mechanism to enforce the _absence_ of +capabilities for arguments to higher-order functions. Consider a variant `pureMap` of `map` that should enforce that its argument does not throw exceptions or have any other effects (maybe because wants to reorder computations transparently). Right now we cannot enforce that since the function argument to `pureMap` can capture arbitrary -abilities in its free variables without them showing up in its type. One possible way to -address this would be to introduce a pure function type (maybe written `A -> B`). Pure functions are not allowed to close over abilities. Then `pureMap` could be written +capabilities in its free variables without them showing up in its type. One possible way to +address this would be to introduce a pure function type (maybe written `A -> B`). Pure functions are not allowed to close over capabilities. Then `pureMap` could be written like this: ``` def pureMap(f: A -> B): List[B] ``` -Another area where the lack of purity requirements shows up is when abilities escape from bounded scopes. Consider the following function +Another area where the lack of purity requirements shows up is when capabilities escape from bounded scopes. Consider the following function ```scala def escaped(xs: Double*): () => Int = try () => xs.map(f).sum @@ -240,16 +238,16 @@ But if you try to call `escaped` like this val g = escaped(1, 2, 1000000000) g() ``` -the result will be a `LimitExceeded` exception thrown at the second line where `g` is called. What's missing is that `try` should enforce that the abilities it generates do not escape as free variables in the result of its body. It makes sense to describe such scoped effects as _ephemeral abilities_ - they have lifetimes that cannot be extended to delayed code in a lambda. +the result will be a `LimitExceeded` exception thrown at the second line where `g` is called. What's missing is that `try` should enforce that the capabilities it generates do not escape as free variables in the result of its body. It makes sense to describe such scoped effects as _ephemeral capabilities_ - they have lifetimes that cannot be extended to delayed code in a lambda. # Outlook -We are working on a new class of type system that supports ephemeral abilities by tracking the free variables of values. Once that research matures, it will hopefully be possible to augment the language so that we can enforce the missing properties. +We are working on a new class of type system that supports ephemeral capabilities by tracking the free variables of values. Once that research matures, it will hopefully be possible to augment the language so that we can enforce the missing properties. And it would have many other applications besides: Exceptions are a special case of _algebraic effects_, which has been a very active research area over the last 20 years and is finding its way into programming languages (e.g. Koka, Eff, Multicore OCaml, Unison). In fact, algebraic effects have been characterized as being equivalent to exceptions with an additional _resume_ operation. The techniques developed here for exceptions can probably be generalized to other classes of algebraic effects. -But even without these additional mechanisms, exception checking is already useful as it is. It gives a clear path forward to make code that uses exceptions safer, better documented, and easier to refactor. The only loophole arises for scoped abilities - here we have to verify manually that these abilities do not escape. Specifically, a `try` always has to be placed in the same computation stage as the throws that it enables. +But even without these additional mechanisms, exception checking is already useful as it is. It gives a clear path forward to make code that uses exceptions safer, better documented, and easier to refactor. The only loophole arises for scoped capabilities - here we have to verify manually that these capabilities do not escape. Specifically, a `try` always has to be placed in the same computation stage as the throws that it enables. Put another way: If the status quo is 0% static checking since 100% is too painful, then an alternative that gives you 95% static checking with great ergonomics looks like a win. And we might still get to 100% in the future. diff --git a/library/src-bootstrapped/scala/CanThrow.scala b/library/src-bootstrapped/scala/CanThrow.scala index f6ee54edeca5..ce518bff19ec 100644 --- a/library/src-bootstrapped/scala/CanThrow.scala +++ b/library/src-bootstrapped/scala/CanThrow.scala @@ -2,12 +2,12 @@ package scala import language.experimental.erasedDefinitions import annotation.{implicitNotFound, experimental} -/** An ability class that allows to throw exception `E`. When used with the +/** A capability 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. */ @experimental -@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 `throws` clause in a result type such as `X throws ${E}`\n - an enclosing `try` that catches ${E}") +@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 `throws` clause in a result type such as `X throws ${E}`\n - an enclosing `try` that catches ${E}") erased class CanThrow[-E <: Exception] /** A helper type to allow syntax like diff --git a/tests/neg/saferExceptions.check b/tests/neg/saferExceptions.check index 3a83341488f7..06c5bcc1a547 100644 --- a/tests/neg/saferExceptions.check +++ b/tests/neg/saferExceptions.check @@ -1,8 +1,8 @@ -- Error: tests/neg/saferExceptions.scala:12:16 ------------------------------------------------------------------------ 12 | case 4 => throw Exception() // error | ^^^^^^^^^^^^^^^^^ - | The ability to throw exception Exception is missing. - | The ability can be provided by one of the following: + | The capability to throw exception Exception is missing. + | The capability can be provided by one of the following: | - A using clause `(using CanThrow[Exception])` | - A `throws` clause in a result type such as `X throws Exception` | - an enclosing `try` that catches Exception @@ -14,8 +14,8 @@ -- Error: tests/neg/saferExceptions.scala:17:46 ------------------------------------------------------------------------ 17 | def baz(x: Int): Int throws Failure = bar(x) // error | ^ - | The ability to throw exception java.io.IOException is missing. - | The ability can be provided by one of the following: + | The capability to throw exception java.io.IOException is missing. + | The capability can be provided by one of the following: | - A using clause `(using CanThrow[java.io.IOException])` | - A `throws` clause in a result type such as `X throws java.io.IOException` | - an enclosing `try` that catches java.io.IOException From 9b35e0b4ab6599034952143a140547399ad435c3 Mon Sep 17 00:00:00 2001 From: Martin Odersky Date: Thu, 26 Aug 2021 12:41:48 +0200 Subject: [PATCH 6/6] Address review comments - Move $throws to scala.runtime - Add comment --- compiler/src/dotty/tools/dotc/ast/Desugar.scala | 6 ++++-- compiler/src/dotty/tools/dotc/core/Definitions.scala | 1 + library/src-bootstrapped/scala/CanThrow.scala | 10 +--------- library/src-bootstrapped/scala/runtime/$throws.scala | 11 +++++++++++ 4 files changed, 17 insertions(+), 11 deletions(-) create mode 100644 library/src-bootstrapped/scala/runtime/$throws.scala diff --git a/compiler/src/dotty/tools/dotc/ast/Desugar.scala b/compiler/src/dotty/tools/dotc/ast/Desugar.scala index c8cbec56fe78..a71fc3d40e92 100644 --- a/compiler/src/dotty/tools/dotc/ast/Desugar.scala +++ b/compiler/src/dotty/tools/dotc/ast/Desugar.scala @@ -1261,13 +1261,15 @@ object desugar { makeOp(left, right, Span(left.span.start, op.span.end, op.span.start)) } - /** Translate throws type `A throws E1 | ... | En` + /** Translate throws type `A throws E1 | ... | En` to + * $throws[... $throws[A, E1] ... , En]. */ def throws(tpt: Tree, op: Ident, excepts: Tree)(using Context): AppliedTypeTree = excepts match case InfixOp(l, bar @ Ident(tpnme.raw.BAR), r) => throws(throws(tpt, op, l), bar, r) case e => - AppliedTypeTree(cpy.Ident(op)(tpnme.THROWS), tpt :: excepts :: Nil) + AppliedTypeTree( + TypeTree(defn.throwsAlias.typeRef).withSpan(op.span), tpt :: excepts :: Nil) /** Translate tuple expressions of arity <= 22 * diff --git a/compiler/src/dotty/tools/dotc/core/Definitions.scala b/compiler/src/dotty/tools/dotc/core/Definitions.scala index a2dfcf794f38..667dcf913ee3 100644 --- a/compiler/src/dotty/tools/dotc/core/Definitions.scala +++ b/compiler/src/dotty/tools/dotc/core/Definitions.scala @@ -834,6 +834,7 @@ class Definitions { CanEqualClass.companionModule.requiredMethod(methodName) @tu lazy val CanThrowClass: ClassSymbol = requiredClass("scala.CanThrow") + @tu lazy val throwsAlias: Symbol = ScalaRuntimePackageVal.requiredType(tpnme.THROWS) @tu lazy val TypeBoxClass: ClassSymbol = requiredClass("scala.runtime.TypeBox") @tu lazy val TypeBox_CAP: TypeSymbol = TypeBoxClass.requiredType(tpnme.CAP) diff --git a/library/src-bootstrapped/scala/CanThrow.scala b/library/src-bootstrapped/scala/CanThrow.scala index ce518bff19ec..4da00b8a76ac 100644 --- a/library/src-bootstrapped/scala/CanThrow.scala +++ b/library/src-bootstrapped/scala/CanThrow.scala @@ -10,15 +10,7 @@ import annotation.{implicitNotFound, experimental} @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 `throws` clause in a result type such as `X throws ${E}`\n - an enclosing `try` that catches ${E}") erased class CanThrow[-E <: Exception] -/** A helper type to allow syntax like - * - * def f(): T throws Ex1 | Ex2 - * - * Used in desugar.throws. - */ -@experimental -infix type $throws[R, +E <: Exception] = CanThrow[E] ?=> R - @experimental object unsafeExceptions: given canThrowAny: CanThrow[Exception] = ??? + diff --git a/library/src-bootstrapped/scala/runtime/$throws.scala b/library/src-bootstrapped/scala/runtime/$throws.scala new file mode 100644 index 000000000000..af35f7402ca3 --- /dev/null +++ b/library/src-bootstrapped/scala/runtime/$throws.scala @@ -0,0 +1,11 @@ +package scala.runtime +import annotation.experimental + +/** A helper type to allow syntax like + * + * def f(): T throws Ex1 | Ex2 + * + * Used in desugar.throws. + */ +@experimental +infix type $throws[R, +E <: Exception] = CanThrow[E] ?=> R