Skip to content

Commit 2dd54ee

Browse files
committed
Add error handling strawman
There is so far no standard way in Scala to address a class of situations that's often called "error handling". In these situations we have two possible outcomes representing success and failure. We would like to process successful results further and propagate failures outward. The aim is to find a "happy path" where we deal with success computations directly, without having to special-treat failing results at every step. There are many different ways in use in Scala to express these situations. - Express success with normal results and failure with exceptions. - Use `Option`, where success is wrapped in `Some` and failure is `None`. - Use `Either`, where success is wrapped in `Right` and failure is wrapped in `Left`. - Use `Try`, where success is wrapped in `Success` and failure (which must be an exception) is wrapped in `Failure`. - Use nulls, where success is a non-null value and failure is `null`. - Use a special `IO` monad like `ZIO`, where errors are built in. Exceptions propagate silently until they are handled in a `catch`. All other failure modes require either ad-hoc code or monadic lifting into for comprehensions to propagate. `nulls` can only be treated with ad-hoc code and they are not very popular in Scala so far. Exceptions should only be used if the failure case is very unlikely (rule of thumb: in less than 0.1% of cases). Even then they are often shunned since they are clunky to use and they currently undermine type safety (the latter point would be addressed by the experimental `saferExceptions` extension). That said, exceptions have their uses. For instance, it's better to abort code that fails in a locally unrecoverable way with an exception instead of aborting the whole program with a panic or `System.exit`. If we admit that not all error handling scenarios should be handled with exceptions, what else should we use? So far the only choices are ad-hoc code or monadic lifting. Both of these techniques suffer from the fact that a failure is propagated out of only a single construct and that major and pointless acrobatics are required to move out failures further. This commit sketches a systematic and uniform solution to this problem. It essentially provides a way to establish "prompts" for result types and a way to return to a prompt with an error value using a method called `_.?`. This is somewhat similar to Rust's `?` postfix operator, which seems to work well. But there are is an important difference: Rust's ? returns from the next-enclosing function or closure whereas our `?` method returns to an enclosing prompt, which can be further outside. This greatly improves the expressiveness of `?`, at the price of a more complex execution semantics. For instance, here is an implementation of a "traverse"-like operation, converting a `List[Option[T]]` into a `Option[List[T]]`. ```scala def traverse[T](xs: List[Option[T]]): Option[List[T]] = optional: xs.map(_.?) ``` Here, `optional` is the prompt for the `Option` type, and `.?` maps `Option[T]` to the underlying type `T`, jumping to the enclosing `optional` prompt in the case a `None` is encountered. You can think of it as a safe version of `Option`'s `get`. This function could not be written like this in Rust since `_.?` is a closure so a `None` element would simply be mapped to itself. Also unlike for Rust, Scala's technique is library-based. Similar prompt/return pairs can be defined for many other types. This commit also defines a type `Result` which is similar to Rust's that supports the pattern. `Result` will hopefully replace `Either`, which is in my opinion an abomination for error handling. All these constructs can be defined in terms of a lower-level construct where one can simply return a result to an enclosing boundary, without any distinction between success and failure. The low-level construct is composed of a `boundary` prompt and a `break` method that returns to it. It can also be used as a convenient replacement for non-local returns. On the JVM, the break operation is in general implemented by throwing a special `Break` exception, similar to how non-local returns were handled before. However, it is foreseen to implement an optimization pass that replaces `Break` exceptions by gotos if they are caught in the same scope without intervening functions. Dotty already has a `LabeledBlock` tree node that can be used for this purpose. This means that local return to prompts are about as fast as in Rust whereas non-local returns in Scala are a bit slower than local returns, whereas in Rust they are impossible. We plan to do benchmarks to quantify the cost more precisely.
1 parent d3070f6 commit 2dd54ee

File tree

4 files changed

+157
-0
lines changed

4 files changed

+157
-0
lines changed

tests/run/errorhandling/Result.scala

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
package dotty.util
2+
import boundary.Label
3+
4+
abstract class Result[+T, +E]
5+
case class Ok[+T](value: T) extends Result[T, Nothing]
6+
case class Err[+E](value: E) extends Result[Nothing, E]
7+
8+
object Result:
9+
extension [T, E](r: Result[T, E])
10+
11+
/** `_.?` propagates Err to current Label */
12+
transparent inline def ? (using Label[Err[E]]): T = r match
13+
case r: Ok[_] => r.value
14+
case err => break(err.asInstanceOf[Err[E]])
15+
16+
/** If this is an `Err`, map its value */
17+
def mapErr[E1](f: E => E1): Result[T, E1] = r match
18+
case err: Err[_] => Err(f(err.value))
19+
case ok: Ok[_] => ok
20+
21+
/** Map Ok values, propagate Errs */
22+
def map[U](f: T => U): Result[U, E] = r match
23+
case Ok(x) => Ok(f(x))
24+
case err: Err[_] => err
25+
26+
/** Flatmap Ok values, propagate Errs */
27+
def flatMap[U](f: T => Result[U, E]): Result[U, E] = r match
28+
case Ok(x) => f(x)
29+
case err: Err[_] => err
30+
31+
/** Simlar to `Try`: Convert exceptions raised by `body` to `Err`s.
32+
* In principle, `Try[T]` should be equivalent to `Result[T, Exception]`.
33+
* Note that we do not want to catch and reify all Throwables.
34+
* - severe JVM errors that make continuation impossible should not be reified.
35+
* - control throwables like `boundary.Break` should not be caught. We want
36+
* them to return from a `Result`.
37+
* (Generally, the focus on `Throwable` in Scala libraries is a mistake.
38+
* Use `Exception` instead, as it was meant to in Java.)
39+
*/
40+
def apply[T](body: => T): Result[T, Exception] =
41+
try Ok(body)
42+
catch case ex: Exception => Err(ex)
43+
end Result
44+
45+
/** A prompt for `_.?`. It establishes a boundary to which `_.?` returns */
46+
object respond:
47+
transparent inline def apply[T, E](inline body: Label[Err[E]] ?=> T): Result[T, E] =
48+
boundary(Ok(body))
49+

tests/run/errorhandling/Test.scala

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import dotty.util.*
2+
3+
/** boundary/break as a replacement for non-local returns */
4+
def indexOf[T](xs: List[T], elem: T): Int =
5+
boundary:
6+
for (x, i) <- xs.zipWithIndex do
7+
if x == elem then break(i)
8+
-1
9+
10+
def breakTest() =
11+
println("breakTest")
12+
assert(indexOf(List(1, 2, 3), 2) == 1)
13+
assert(indexOf(List(1, 2, 3), 0) == -1)
14+
15+
/** traverse becomes trivial to write */
16+
def traverse[T](xs: List[Option[T]]): Option[List[T]] =
17+
optional(xs.map(_.?))
18+
19+
def optTest() =
20+
println("optTest")
21+
assert(traverse(List(Some(1), Some(2), Some(3))) == Some(List(1, 2, 3)))
22+
assert(traverse(List(Some(1), None, Some(3))) == None)
23+
24+
/** A check function returning a Result[Unit, _] */
25+
inline def check[E](p: Boolean, err: E): Result[Unit, E] =
26+
if p then Ok(()) else Err(err)
27+
28+
/** Another variant of a check function that returns directly to the given
29+
* label in case of error.
30+
*/
31+
inline def check_![E](p: Boolean, err: E)(using l: boundary.Label[Err[E]]): Unit =
32+
if p then () else l.break(Err(err))
33+
34+
/** Use `Result` to convert exceptions to `Err` values */
35+
def parseDouble(s: String): Result[Double, Exception] =
36+
Result(s.toDouble)
37+
38+
def parseDoubles(ss: List[String]): Result[List[Double], Exception] =
39+
respond:
40+
ss.map(parseDouble(_).?)
41+
42+
/** Demonstrate combination of `check` and `.?`. */
43+
def trySqrt(x: Double) = // inferred: Result[Double, String]
44+
respond:
45+
check(x >= 0, s"cannot take sqrt of negative $x").?
46+
math.sqrt(x)
47+
48+
/** Instead of `check(...).?` one can also use `check_!(...)`.
49+
* Note use of `mapErr` to convert Exception errors to String errors.
50+
*/
51+
def sumRoots(xs: List[String]) = // inferred: Result[Double, String]
52+
respond:
53+
check_!(xs.nonEmpty, "list is empty") // direct jump
54+
val ys = parseDoubles(xs).mapErr(_.toString).? // direct jump
55+
ys.reduce((x, y) => x + trySqrt(y).?) // need exception to propagate `Err`
56+
57+
def resultTest() =
58+
println("resultTest")
59+
assert(sumRoots(List("1", "4", "9")) == Ok(6))
60+
assert(sumRoots(List("1", "-2", "4")) == Err(s"cannot take sqrt of negative -2.0"))
61+
assert(sumRoots(List()) == Err("list is empty"))
62+
assert(sumRoots(List("1", "3ab")) == Err("java.lang.NumberFormatException: For input string: \"3ab\""))
63+
64+
@main def Test =
65+
breakTest()
66+
optTest()
67+
resultTest()

tests/run/errorhandling/break.scala

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
package dotty.util
2+
import scala.util.control.ControlThrowable
3+
4+
object boundary:
5+
6+
class Break[T](val label: Label[T], val value: T) extends ControlThrowable
7+
8+
class Label[T] extends ControlThrowable:
9+
transparent inline def break(value: T): Nothing = throw Break(this, value)
10+
11+
transparent inline def apply[T <: R, R](inline body: Label[T] ?=> R): R =
12+
val local = Label[T]()
13+
try body(using local)
14+
catch case ex: Break[_] if ex.label eq local =>
15+
ex.value.asInstanceOf[T]
16+
17+
end boundary
18+
19+
object break:
20+
transparent inline def apply[T](value: T)(using l: boundary.Label[T]): Nothing =
21+
l.break(value)
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
package dotty.util
2+
import boundary.Label
3+
4+
/** A mockup of scala.Option */
5+
abstract class Option[+T]
6+
case class Some[+T](x: T) extends Option[T]
7+
case object None extends Option[Nothing]
8+
9+
object Option:
10+
/** This extension should be added to the companion object of scala.Option */
11+
extension [T](r: Option[T])
12+
transparent inline def ? (using label: Label[None.type]): T = r match
13+
case Some(x) => x
14+
case None => label.break(None)
15+
16+
/** A prompt for `Option`, which establishes a boundary which `_.?` on `Option` can return */
17+
object optional:
18+
transparent inline def apply[T](inline body: Label[None.type] ?=> T): Option[T] =
19+
boundary(Some(body))
20+

0 commit comments

Comments
 (0)