Skip to content

Better handling of multiple exceptions for saferExceptions #13914

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 2 commits into from
Dec 10, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions compiler/src/dotty/tools/dotc/ast/Desugar.scala
Original file line number Diff line number Diff line change
Expand Up @@ -1265,6 +1265,8 @@ object desugar {
* $throws[... $throws[A, E1] ... , En].
*/
def throws(tpt: Tree, op: Ident, excepts: Tree)(using Context): AppliedTypeTree = excepts match
case Parens(excepts1) =>
throws(tpt, op, excepts1)
case InfixOp(l, bar @ Ident(tpnme.raw.BAR), r) =>
throws(throws(tpt, op, l), bar, r)
case e =>
Expand Down
23 changes: 14 additions & 9 deletions compiler/src/dotty/tools/dotc/typer/Typer.scala
Original file line number Diff line number Diff line change
Expand Up @@ -1749,15 +1749,20 @@ class Typer extends Namer
untpd.ref(defn.Predef_undefined))
.withFlags(Given | Final | Lazy | Erased)
.withSpan(expr.span)
val caps =
for
case CaseDef(pat, guard, _) <- cases
if Feature.enabled(Feature.saferExceptions) && pat.tpe.widen.isCheckedException
yield
checkCatch(pat, guard)
makeCanThrow(pat.tpe.widen)

caps.foldLeft(expr)((e, g) => untpd.Block(g :: Nil, e))
val caughtExceptions =
if Feature.enabled(Feature.saferExceptions) then
for
CaseDef(pat, guard, _) <- cases
if pat.tpe.widen.isCheckedException
yield
checkCatch(pat, guard)
pat.tpe.widen
else Seq.empty

if caughtExceptions.isEmpty then expr
else
val capabilityProof = caughtExceptions.reduce(OrType(_, _, true))
untpd.Block(makeCanThrow(capabilityProof), expr)

def typedTry(tree: untpd.Try, pt: Type)(using Context): Try = {
val expr2 :: cases2x = harmonic(harmonize, pt) {
Expand Down
81 changes: 46 additions & 35 deletions docs/docs/reference/experimental/canthrow.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,14 +87,27 @@ can alternatively be expressed like this:
```scala
def m(x: T): U throws E
```
Multiple `CanThrow` capabilities can be combined in a single throws clause. For instance, the method
Also the capability to throw multiple types of exceptions can be expressed in a few ways as shown in the examples below:
```scala
def m2(x: T)(using CanThrow[E1], CanThrow[E2]): U
def m(x: T): U throws E1 | E2
def m(x: T): U throws E1 throws E2
def m(x: T)(using CanThrow[E1], CanThrow[E2]): U
def m(x: T)(using CanThrow[E1])(using CanThrow[E2]): U
def m(x: T)(using CanThrow[E1]): U throws E2
```
can alternatively be expressed like this:

**Note 1:** A signature like
```scala
def m(x: T): U throws E1 | E2
def m(x: T)(using CanThrow[E1 | E2]): U
```
would also allow throwing `E1` or `E2` inside the method's body but might cause problems when someone tried to call this method
from another method declaring its `CanThrow` capabilities like in the earlier examples.
This is because `CanThrow` has a contravariant type parameter so `CanThrow[E1 | E2]` is a subtype of both `CanThrow[E1]` and `CanThrow[E2]`.
Hence the presence of a given instance of `CanThrow[E1 | E2]` in scope satisfies the requirement for `CanThrow[E1]` and `CanThrow[E2]`
but given instances of `CanThrow[E1]` and `CanThrow[E2]` cannot be combined to provide and instance of `CanThrow[E1 | E2]`.

**Note 2:** One should keep in mind that `|` binds its left and right arguments more tightly than `throws` so `A | B throws E1 | E2` means `(A | B) throws (Ex1 | Ex2)`, not `A | (B throws E1) | E2`.

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
Expand All @@ -105,17 +118,29 @@ catch
...
case exN: ExN => handlerN
```
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:
the compiler generates an accumulated capability of type `CanThrow[Ex1 | ... | Ex2]` that is available as a given in the scope of `body`. It does this by augmenting the `try` roughly as follows:
```scala
try
erased given CanThrow[Ex1] = ???
...
erased given CanThrow[ExN] = ???
erased given CanThrow[Ex1 | ... | ExN] = ???
body
catch ...
```
Note that the right-hand side of all givens is `???` (undefined). This is OK since
these givens are erased; they will not be executed at runtime.
Note that the right-hand side of the synthesized given is `???` (undefined). This is OK since
this given is erased; it will not be executed at runtime.

**Note 1:** The `saferExceptions` feature is designed to work only with checked exceptions. An exception type is _checked_ if it is a subtype of
`Exception` but not of `RuntimeException`. The signature of `CanThrow` still admits `RuntimeException`s since `RuntimeException` is a proper subtype of its bound, `Exception`. But no capabilities will be generated for `RuntimeException`s. Furthermore, `throws` clauses
also may not refer to `RuntimeException`s.

**Note 2:** To keep things simple, the compiler will currently only generate capabilities
for catch clauses of the form
```scala
case ex: Ex =>
```
where `ex` is an arbitrary variable name (`_` is also allowed), and `Ex` is an arbitrary
checked exception type. Constructor patterns such as `Ex(...)` or patterns with guards
are not allowed. The compiler will issue an error if one of these is used to catch
a checked exception and `saferExceptions` is enabled.

## An Example

Expand All @@ -133,17 +158,17 @@ def f(x: Double): Double =
```
You'll get this error message:
```
9 | if x < limit then x * x else throw LimitExceeded()
| ^^^^^^^^^^^^^^^^^^^^^
|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
|
|The following import might fix the problem:
|
| import unsafeExceptions.canThrowAny
if x < limit then x * x else throw LimitExceeded()
^^^^^^^^^^^^^^^^^^^^^
The capability to throw exception LimitExceeded is missing.
The capability can be provided by one of the following:
- Adding a using clause `(using CanThrow[LimitExceeded])` to the definition of the enclosing method
- Adding `throws LimitExceeded` clause after the result type of the enclosing method
- Wrapping this piece of code with a `try` block 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 capability to throw a `LimitExceeded` exception. The most concise way to do so is to add a `throws` clause:
```scala
Expand Down Expand Up @@ -179,20 +204,6 @@ closure may refer to capabilities in its free variables. This means that `map` i
already effect polymorphic even though we did not change its signature at all.
So the takeaway is that the effects as capabilities model naturally provides for effect polymorphism whereas this is something that other approaches struggle with.

**Note 1:** The compiler will only treat checked exceptions that way. An exception type is _checked_ if it is a subtype of
`Exception` but not of `RuntimeException`. The signature of `CanThrow` still admits `RuntimeException`s since `RuntimeException` is a proper subtype of its bound, `Exception`. But no capabilities will be generated for `RuntimeException`s. Furthermore, `throws` clauses
also may not refer to `RuntimeException`s.

**Note 2:** To keep things simple, the compiler will currently only generate capabilities
for catch clauses of the form
```scala
case ex: Ex =>
```
where `ex` is an arbitrary variable name (`_` is also allowed), and `Ex` is an arbitrary
checked exception type. Constructor patterns such as `Ex(...)` or patterns with guards
are not allowed. The compiler will issue an error if one of these is used to catch
a checked exception and `saferExceptions` is enabled.

## 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 `throws`. But it's easy to create an escape hatch that lets us ignore the breakages for a while: simply add the import
Expand Down
2 changes: 1 addition & 1 deletion library/src/scala/CanThrow.scala
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import annotation.{implicitNotFound, experimental}
* a given of class `CanThrow[Ex]` to be available.
*/
@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}")
@implicitNotFound("The capability to throw exception ${E} is missing.\nThe capability can be provided by one of the following:\n - Adding a using clause `(using CanThrow[${E}])` to the definition of the enclosing method\n - Adding `throws ${E}` clause after the result type of the enclosing method\n - Wrapping this piece of code with a `try` block that catches ${E}")
erased class CanThrow[-E <: Exception]

@experimental
Expand Down
14 changes: 7 additions & 7 deletions tests/neg/i13846.check
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,13 @@
-- Error: tests/neg/i13846.scala:7:9 -----------------------------------------------------------------------------------
7 | foo() // error
| ^
| The capability to throw exception ArithmeticException is missing.
| The capability can be provided by one of the following:
| - A using clause `(using CanThrow[ArithmeticException])`
| - A `throws` clause in a result type such as `X throws ArithmeticException`
| - an enclosing `try` that catches ArithmeticException
| The capability to throw exception ArithmeticException is missing.
| The capability can be provided by one of the following:
| - Adding a using clause `(using CanThrow[ArithmeticException])` to the definition of the enclosing method
| - Adding `throws ArithmeticException` clause after the result type of the enclosing method
| - Wrapping this piece of code with a `try` block that catches ArithmeticException
|
| The following import might fix the problem:
| The following import might fix the problem:
|
| import unsafeExceptions.canThrowAny
| import unsafeExceptions.canThrowAny
|
6 changes: 3 additions & 3 deletions tests/neg/i13864.check
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,9 @@
| ^
| The capability to throw exception Ex[Int] is missing.
| The capability can be provided by one of the following:
| - A using clause `(using CanThrow[Ex[Int]])`
| - A `throws` clause in a result type such as `X throws Ex[Int]`
| - an enclosing `try` that catches Ex[Int]
| - Adding a using clause `(using CanThrow[Ex[Int]])` to the definition of the enclosing method
| - Adding `throws Ex[Int]` clause after the result type of the enclosing method
| - Wrapping this piece of code with a `try` block that catches Ex[Int]
|
| The following import might fix the problem:
|
Expand Down
20 changes: 10 additions & 10 deletions tests/neg/saferExceptions.check
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,9 @@
| ^^^^^^^^^^^^^^^^^
| 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
| - Adding a using clause `(using CanThrow[Exception])` to the definition of the enclosing method
| - Adding `throws Exception` clause after the result type of the enclosing method
| - Wrapping this piece of code with a `try` block that catches Exception
|
| The following import might fix the problem:
|
Expand All @@ -14,13 +14,13 @@
-- Error: tests/neg/saferExceptions.scala:17:46 ------------------------------------------------------------------------
17 | def baz(x: Int): Int throws Failure = bar(x) // error
| ^
| 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
| The capability to throw exception java.io.IOException is missing.
| The capability can be provided by one of the following:
| - Adding a using clause `(using CanThrow[java.io.IOException])` to the definition of the enclosing method
| - Adding `throws java.io.IOException` clause after the result type of the enclosing method
| - Wrapping this piece of code with a `try` block 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
|
57 changes: 57 additions & 0 deletions tests/pos/i13816.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import language.experimental.saferExceptions

class Ex1 extends Exception("Ex1")
class Ex2 extends Exception("Ex2")

def foo1(i: Int): Unit throws Ex1 throws Ex2 =
if i > 0 then throw new Ex1 else throw new Ex2

def foo2(i: Int): Unit throws Ex1 | Ex2 =
if i > 0 then throw new Ex1 else throw new Ex2

def foo3(i: Int): Unit throws (Ex1 | Ex2) =
if i > 0 then throw new Ex1 else throw new Ex2

def foo4(i: Int)(using CanThrow[Ex1], CanThrow[Ex2]): Unit =
if i > 0 then throw new Ex1 else throw new Ex2

def foo5(i: Int)(using CanThrow[Ex1])(using CanThrow[Ex2]): Unit =
if i > 0 then throw new Ex1 else throw new Ex2

def foo6(i: Int)(using CanThrow[Ex1 | Ex2]): Unit =
if i > 0 then throw new Ex1 else throw new Ex2

def foo7(i: Int)(using CanThrow[Ex1]): Unit throws Ex2 =
if i > 0 then throw new Ex1 else throw new Ex2

def foo8(i: Int)(using CanThrow[Ex2]): Unit throws Ex1 =
if i > 0 then throw new Ex1 else throw new Ex2

def test(): Unit =
try
foo1(1)
foo2(1)
foo3(1)
foo4(1)
foo5(1)
foo6(1)
foo7(1)
foo8(1)
catch
case _: Ex1 =>
case _: Ex2 =>

try
try
foo1(1)
foo2(1)
foo3(1)
foo4(1)
foo5(1)
// foo6(1) // As explained in the docs this won't work until we find a way to aggregate capabilities
foo7(1)
foo8(1)
catch
case _: Ex1 =>
catch
case _: Ex2 =>