Skip to content

Add error handling strawman #16550

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

Closed
wants to merge 5 commits into from

Conversation

odersky
Copy link
Contributor

@odersky odersky commented Dec 18, 2022

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 either success or 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 one 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, one could argue: 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]].

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 directly be taken as the closure's result, that is, it would be mapped to itself.

Also unlike in the case of 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 the one in Rust that also supports this 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.

@arturopala
Copy link
Contributor

I like the simplicity and extendibility of the proposed solution. It will make it easy to work with the existing types denoting success/failure.

I wonder whether we could have compiler support to infer the boundary based on the method return type to make the following snippet valid:

def traverse[T](xs: List[Option[T]]): Option[List[T]] =
  xs.map(_.?)

def parseDoubles(ss: List[String]): Result[List[Double], Exception] =
    ss.map(parseDouble(_).?)

@majk-p
Copy link

majk-p commented Dec 19, 2022

I really like the idea of improving error handling and I appreciate the work being done in this direction. I'm just afraid that this could complicate things. As mentioned in the original post we have a number of ways to handle errors already. I'm worried that introducing a new way of handling errors will get us to the point from the xkcd Standards

image

Source: https://xkcd.com/927/

I'd like to see how this could work with the existing codebase, like how could we use instead of/together with ZIO or EitherT.

@adamw
Copy link
Contributor

adamw commented Dec 20, 2022

While Either might not be perfect, it's very commonly used for error-handling right now (next to exceptions). Even, or maybe especially, in purely-functional codebases, including ones based on ZIO: if a method it pure, it uses Either, if a method is effectful, it uses ZIO. So while we might try to introduce a better construct (Result), I think it would be great to make working with Eithers more convenient as well: it would be an immediate win, replacing for-comprehensions when they are clunky. But looking at the code, nothing prohibits that, "just" needs to be added to stdlib.

Apart from that, I also have a question - should Labels be used directly? That is, would this be considered valid code:

def sth[T](xs: List[Option[T]]) = xs.map(_.?)

Because of the necessary Label implicit, the type wouldn't infer (same problem as with context functions). So should this construct be lexically-constrained, that is the "proper usage" would require all ? to be syntactically nested inside a prompt?

@odersky
Copy link
Contributor Author

odersky commented Dec 20, 2022

@adamw Yes, no problem adding the same mechanism to Either (even though it's still an abomination 😁 as an error handling construct). Labels are a first class construct. sth could also be written like this:

def sth[T](xs: List[Option[T]])(using boundary.Label[None.type]): T = xs.map(_.?)

But doing this feels a bit artificial to me. I feel it more natural to return an Option[T] instead.

@odersky
Copy link
Contributor Author

odersky commented Dec 20, 2022

As usual, the hardest part is naming, and I am not at all sure we have found the best solution yet. So far we have for prompt and return construct:

  • boundary / break for simple returns without error path
  • optional / _.? for options
  • respond / _.? for results

Other suggestions are welcome!

@arturopala
Copy link
Contributor

arturopala commented Dec 20, 2022

What about adding uncertain?

object Either:
  /** This extension should be added to the companion object of scala.Either */
  extension [L,R](r: Either[L,R])
    transparent inline def ? (using label: Label[Left[L]]): R = r match
      case Right(x) => x
      case y => label.break(y)

/** A prompt for `Either`, which establishes a boundary which `_.?` on `Either` can return */
object uncertain:
  transparent inline def apply[L,R](inline body: Label[Left[L]] ?=> R): Either[L,R] =
    boundary(Right(body))

@arturopala
Copy link
Contributor

or alternative as a boundary name for Either

@adamw
Copy link
Contributor

adamw commented Dec 20, 2022

@odersky It would be nice to be able to extract code fragments which use ?. Or should we rely on IDEs, to suggest / auto-fix such cases, by adding the prompt & then inferring the correct Result/Option type?

Btw. I know that it might be impossible to do in general, but some form of inference for implicit parameters/context functions would be really useful (esp. for context functions to gain more adoption).

Btw 2. on a syntactical level, this does look similar to @raulraja's proposal, although less general. It could handle the Eithers and Options, but not the Futures.

@alexandru
Copy link

Forgive my 2 cents, but upon seeing this:

def traverse[T](xs: List[Option[T]]): Option[List[T]] =
  optional(xs.map(_.?))

It made absolutely no sense to me, and I'm a senior developer with experience in about a dozen programming languages (except for Rust, since it was mentioned). And that's because, in my mind, anonymous lambdas (or functions in general) are incompatible with break statements that have an effect in the outer scope (taking Dijkstra's advice to heart, I considered GOTOs harmful, and never looked back). Also that map is the applicative functor map, so it has certain laws that don't include "things that throw".

Either isn't necessarily good, because it relies on flatMap for composition, which is awkward, but it's IMO less awkward than this, because at least there we can speak of function composition that has algebraic laws (IMO).

I second the suggestion to look into Kotlin-like coroutines, as at least that way the mechanism is more general. Or at least give it a hard NO / THUMBS DOWN, before introducing more competing ways to do the same thing.

Also, it's good keeping in mind that non-local returns aren't bad because of performance issues. They are bad because people misunderstand how return in an anonymous function works. And I can see the same confusing mental model happening for this construct, too.

@sjrd
Copy link
Member

sjrd commented Dec 20, 2022

I think the coding style does not help there. I would also be confused with it. I would personally write

def traverse[T](xs: List[Option[T]]): Option[List[T]] =
  optional {
    xs.map(_.?)
  }

@odersky
Copy link
Contributor Author

odersky commented Dec 20, 2022

@sjrd Yes, I personally would write

optional:
  xs.map(_.?)

but then I shied away from it because I did not want to open another significant indentation debate here.

@odersky
Copy link
Contributor Author

odersky commented Dec 20, 2022

@sjrd Btw could you take a look at the test failure in this PR? I have no idea why no where the JS test fails.

@sjrd
Copy link
Member

sjrd commented Dec 20, 2022

@sjrd Btw could you take a look at the test failure in this PR? I have no idea why no where the JS test fails.

Probably the error message string Err(s"cannot take sqrt of negative -2.0")). The double -2.0 prints as "-2" in JS, not "-2.0". Use a double value with a decimal place such as -2.5 to make the test portable.

@odersky
Copy link
Contributor Author

odersky commented Dec 20, 2022

Also, it's good keeping in mind that non-local returns aren't bad because of performance issues. They are bad because people misunderstand how return in an anonymous function works. And I can see the same confusing mental model happening for this construct, too.

Interestingly, I now get very strong opinions from the other side, where people think that non-local returns are the most natural thing and that their absence is sorely missed. So everybody has their own opinion. But note that the situation is different here since we have a prompt. That by itself indicates that this is not a run-of-the-mill return statement.

@sjrd
Copy link
Member

sjrd commented Dec 20, 2022

Also, you shouldn't rely on the particular error message "java.lang.NumberFormatException: For input string: \"3ab\"". This is not specified in the JavaDoc, so even another JDK than yours can make the test fail. You can test that the exception is a NumberFormatException, but not what the message is.

@raulraja
Copy link

raulraja commented Dec 20, 2022

I believe what Scala is missing and needs to stay relevant is the succinct syntax for async computations like Future[A] => A. Continuations solve this as proposed in https://contributors.scala-lang.org/t/pre-sip-suspended-functions-and-continuations/5801.

We showed there that other types with either a continuation + mutable reference or a simple ControlThrowable can implement F[A] => A for many types.

Something very similar to what this PR does is maybe already possible. We do something similar in the library we are creating to work with our continuation proposal as we explore easier idioms for different types in Scala.

https://github.com/47deg/TBD/blob/8196b2ec54319aee7e96f9ad81ef8bc42a429de4/scala-fx/src/test/scala/fx/BindTests.scala

Here we can mix short-circuiting computations of different types, and we use context functions and using requirement
to carry the ability to Control implicitly and simplify the return types of functions, removing Either and allowing the user to throw typed values.

  property("Short-circuiting with Either.Left") = forAll { (n: Int, s: String) =>
    val effect: Control[String | None.type] ?=> Int =
      Left[String, Int](s).bind + Option(n).bind
    run(effect) == s
  }

Short-circuiting computations can already implement F[A] => A. Here the operator name is bind, not ?, but that is irrelevant. For all these options and other effect blocks to be truly ergonomic, they would need to allow threading async effects naturally through inline or suspend if that was supported.

https://github.com/47deg/TBD/blob/8196b2ec54319aee7e96f9ad81ef8bc42a429de4/scala-fx/src/main/scala/fx/Bind.scala
https://github.com/47deg/TBD/blob/8196b2ec54319aee7e96f9ad81ef8bc42a429de4/scala-fx/src/main/scala/fx/Continuation.scala

Most of these abstractions, like Control come from our team's work for Arrow 2.0.
https://github.com/arrow-kt/arrow/blob/84ce8cb9573648dacbfc5ea839b594f5a7d4a4db/arrow-libs/core/arrow-core/src/commonMain/kotlin/arrow/core/continuations/Fold.kt

We are proposing suspending functions or tight integration with continuations as a solution because we can solve many problems more than just error handling. We can implement F[A] => A where desired, for example, for Futures, and the user can also implement the same operation for any other type they wish.

def await[A](future: Future[A])(using Suspend): A =
  summon[Suspend].suspendContinuation { (c: Continuation[A]) =>
    future.onComplete {
      case Success(value) => c.resume(Success(value))
      case Failure(exception) => c.resume(Failure(exception))
      // cancelation concerns omitted for brevity
    }
  }

Ultimately our goal is to be able to describe the effects of different types like this:

def getCountryCodeDirect(futurePerson: Future[Person])
     (using Suspend, StructuredConcurrency, Control[NotFound.type | None.type]): String =
  val person = futurePerson.bind
  val address = person.address.bind
  val country = address.country.bind
  country.code.bind

Here our function returns String, and we can compose that without burdening the caller on how to deal with the Future[Either[NotFound.type, Option[String]]] or similar type that this would return in Scala today if we depend on flatMap.

We can do that in kotlin and others languages like modern Java with LOOM. Current solutions based on macros or libraries like monadless and dotty-cps-async are still expression based and still force you to return boxed types.

Besides the proposal we sent in the PRE-SIP, are there other solutions or plans for Scala or the upcoming Caprese research efforts to include the ability of suspension or some capability that would allow us to implement Future[A] => A in a non-blocking way?

@diesalbla
Copy link

diesalbla commented Dec 21, 2022

This is a positive step and an improvement for using wrapper values.

The new construct (or library primitives) intuitively resembles try-catch blocks. The optional call, like the try keyword, lays down a "net" in which any mistakes that may happen are stopped and dealt with, in the optional case by returning a None, in the Either case by returning a Left. Within the inner block, it is now possible to use a direct style. Much like try-catch blocks, this new constructs helps to "separate business logic from execution concerns", and this separation can be nested and composed through the stack of calls (via the implicit parameters).

This makes it a great alternative to for comprehensions, which are just syntax sugar of flatMap chains. Since optional and ? notation are just plain functions, it seems that they should be easy to combine with other constructs such as local methods or control structures. Is that the case?

@armanbilge
Copy link
Contributor

def traverse[T](xs: List[Option[T]]): Option[List[T]] =
  optional(xs.map(_.?))

This looks very similar to what we can do with dotty-cps-async :)

//> using lib "com.github.rssh::dotty-cps-async::0.9.12"

import cps.*

given CpsMonad[Option] with CpsMonadInstanceContext[Option] with
  def pure[A](a: A): Option[A] = Some(a)
  def map[A, B](fa: Option[A])(f: A => B): Option[B] = fa.map(f)
  def flatMap[A, B](fa: Option[A])(f: A => Option[B]): Option[B] = fa.flatMap(f)

def traverse(xs: List[Option[Int]]): Option[List[Int]] =
  async[Option] { 
    xs.map(await(_))
  }

@main def main =
  println(traverse(List(Some(1), None, Some(3))))

(Using a type parameter for traverse didn't compile for some reason, see dotty-cps-async/dotty-cps-async#63).

I think it would be excellent for CPS to be officially supported in Scala 3. Further reading:
https://gist.github.com/djspiewak/741c60cff4959feb5272d88306595771

@kubukoz
Copy link
Contributor

kubukoz commented Dec 21, 2022

If this doesn't require changes in the language, I would suggest making it an (official) library, kind of like scala-xml. If people turn out to use it a lot (judged by community feedback / github project stats), it could become part of the stdlib.

Personally I don't like having yet another solution to the problem, which also doesn't consider other flavors of the problem like Future/IO/other monads (unlike the suspended functions proposal).

Interestingly, I now get very strong opinions from the other side, where people think that non-local returns are the most natural thing and that their absence is sorely missed. So everybody has their own opinion.

Everybody having an opinion is why we have this problem in the first place. You said it yourself:

There is so far no standard way in Scala to address a class of situations that's often called "error handling".

If we're supposed to address it, I think we need to be opinionated :)

@ScalaWilliam
Copy link
Contributor

I'd question the use of symbols like .? - always find them highly confusing especially in languages like PHP and TypeScript. Having explicit words, and only words, would be much more welcoming and comprehensible to the eye (at the cost of conciseness - but I'd argue too much conciseness hurts readability).

I thought Scala's async/await was reasonable, as is ThoughtWorks's library each: https://github.com/ThoughtWorksInc/each . F#'s computation expressions do something similar, though I'm not a fan again of symbols like '!' - https://learn.microsoft.com/en-us/dotnet/fsharp/language-reference/computation-expressions

@bmeesters
Copy link

I like the initiative to look at error handling again. IMO the new Result type is better than Either. However - as said a few times now - I think the approach needs to work with popular approaches such as Futures, ZIO and Cats Effect. If not, I don't think this is going to be widely adopted, and we have yet another way of handling errors that is not universally used and might increase Scala's perceived complexity.

@deusaquilus
Copy link
Contributor

deusaquilus commented Dec 23, 2022

Since everyone is putting in their "two-cents" I'm going to go in on this with @bmeesters, @kubukoz, and @majk-p. I don't think this is necessary, clear, or useful (and if I'm wrong, it should be tested out as a library). Also, it doesn't help me at all with null-row fallback cases in Quill that I need to handle (and they are the vast majority of my life's problems) and I imagine Slick (and probably Spark) are in a similar place.

...and I'm sorry @alexandru and @raulraja but your Kotlin-inspired solutions would not help any existing Scala LINQ-system use-cases (Quill, Slick, Spark, etc...) either.

@bertlebee
Copy link

bertlebee commented Dec 23, 2022

For now, I'd personally like to see a lot more effort going into fixing all the compiler crashes and tooling support for existing features (especially optional/fewer braces) than continuing to introduce more features to the language.

On 3.2.1 I'm still getting frequent crashes in certain parts of my code.

Edit: I was expecting crashes for 3.0.x, but thought that 3.1.x should be pretty stable. We're at 3.2.x and my expectations for 3.3.x are unfortunately not much higher.

@deusaquilus
Copy link
Contributor

deusaquilus commented Dec 23, 2022

💯 @robmwalsh!!
The stability and tooling stories of Scala 3 are still in a very bad state. Every single new language feature and/or change makes me cringe.

@kostaskougios
Copy link

kostaskougios commented Dec 23, 2022

I voted thumbs down for this one, there are many reasons for this but main reason is that any attempt I've seen so far to do error handling (Either, Try, ZIO) is a lot more complicated that throwing exceptions. And especially in projects at work (all places I've worked on) where the above abstractions resulted in quite messy code for no good reason. And a lot of conflicting opinions between developers of which one to use.

For this one, I can point to a few flaws:

  • I didn't understand the code on the provided example:
def traverse[T](xs: List[Option[T]]): Option[List[T]] =
  optional(xs.map(_.?))

how does this read? It is not clear that a List(Some(1),None,Some(2)) will return None. Where is that logic specified in the single line of implementation?

I suppose this is just an example but it is a good example of how .? can be abused. And that abuse will be a daily thing in commercial projects (as is for the Try/Either/ZIO etc)

  • Will we have to pass Label's if we want to use this pattern across different methods? I would like to see how this looks if there are 2+ different .? usages on the same code.

@odersky
Copy link
Contributor Author

odersky commented Dec 24, 2022

Also, it doesn't help me at all with null-row fallback cases in Quill that I need to handl

Can you describe what these cases are?

I didn't understand the code on the provided example:

def traverse[T](xs: List[Option[T]]): Option[List[T]] =
  optional(xs.map(_.?))

how does this read? It is not clear that a List(Some(1),None,Some(2)) will return None. Where is that logic specified in the single line of implementation?

Maybe I should have picked another example. This one is the shortest to demonstrate the feature, but it is as a consequence very dense. Let's analyze the code in detail, and I am using indent syntax now to make it clearer:

optional:
  xs.map(_.?)

.? "peels off" the Some from a list element, so it is essentially like an Option.get. But unlike get it will return a None to the enclosing optional prompt if the element is not defined. So xs.map(_.?) peels off all Some wrappers from the list elements. The optional prompt re-wraps with Some in case of success, and returns None in case where a .? returned to it.

If I rewrite code with exceptions, it looks similar:

try
  Some(xs.map(_.get))
catch case ex: NoSuchElementException =>
  None

This shows that the mechanism is similar to exceptions, but there are three important differences:

  • It can be quite a bit faster (to be clear how much, we need to do some detailed benchmarking)
  • It's a lot less boilerplate
  • It's a lot more precise. Note in the exception example above, I have to ensure manually that the exception thrown by get is indeed a NoSuchElementException and nothing else. If that was not the case, the exception would go unhandled. By contrast, this strawman ensures that every error will be handled, since you cannot do a .? without an enclosing prompt (*). Conversely, the try-catch could also check other NoSuchElement exceptions in its body, not just the one issued from get. Again, I need to do a manual code search to rule that out. By contrast, the .? always returns to the lexically enclosing prompt.

(*) To be 100% safe, this needs capture checking, but the same holds for exceptions as well.

I should also say that .? shines with Result even more than with Option, but since Result is also new I did not want to introduce too much in the intro to this PR. But it's all in the code.

I have not seen any new errors about optional/fewer braces reported against the compiler in a while. If you perceive them, you need to file issues, otherwise nothing will be done about them. I can assure you that these issues would get priority as they always have. I don't think I have ever seen an error in that category that was not fixed immediately.

@odersky
Copy link
Contributor Author

odersky commented Dec 24, 2022

Will we have to pass Label's if we want to use this pattern across different methods? I would like to see how this looks if there are 2+ different .? usages on the same code.

Methods is not the problem, the prompt can be as far outwards as you wish. If you want to return to a prompt of the same class which is not the immediately enclosing one, that's probably an anti-pattern. It's like returning to a try which is higher up in the stack than the one that handles your exceptions. You can't do that without major contortions either. But it's possible to code it up using explicit labels. The most direct way would be like this. (I am using break instead of .? for illustration but it works the same for both):

boundary: outerLabel ?=>
  ...
  boundary:
    ...
    break(using outerLabel)

@diesalbla
Copy link

diesalbla commented Dec 24, 2022

any attempt I've seen so far to do error handling (Either, Try, ZIO) is a lot more complicated that throwing exceptions. And especially in projects at work (all places I've worked on) where the above abstractions resulted in quite messy code for no good reason

Exceptions can be a blunt tool. In Scala they are untyped, so an expression does not indicate which exceptions may its evaluation raise. Types like Either, Result, or ZIO, do represent those potential "failure modes" in the type. Another concern is that exceptions are not so just about "error handling", but about resource safety as well. The proposed solution has a major advantage over Java-like exceptions: it does not need to annotate or handle every single method in a call, if it is in scope.

you want to return to a prompt of the same class which is not the immediately enclosing one, that's probably an anti-pattern. It's like returning to a try which is deeper in the stack than the one that handles your exceptions

Would that break encapsulation, by allowing the outer prompt to alter the behaviour of the inner prompt, which may be part of a self-contained library?


Looking at the conversation so far, we may be looking at the "error handling" problem from different perspectives. The problem itself could be:

Most programs consist of a main sequence or set of operations, which are related through data dependencies (one's output being input for another) and control dependencies (one's output determines if another one is evaluated). Along this "happy path" there are some points where certain conditions on the program's state, intermediate results, or environment, have to be satisfied. If a check is not satisfied, the evaluation must leave the main path and finish with a "failed" result to the caller.

This is, to a degree, separate from notions of concurrency, blocking I/O, resource safety, streaming, or making it easier to work with "programs as values" datatypes. Some comments above look at this as if seeking a way to do all of these, whereas the proposed feature (ISTM) deals with "error handling" alone. Now, such a feature is useful and relevant to two audiences:

  • Undergraduate students of computer science who are learning Scala as a first or second programming language. To these, Scala should show how a state-of-the-art programming language with implicit parameters can deal and represent error handling.
  • Early career programmers and those starting with Scala, who may be accustomed to other languages or "paradigms". These may be unaccustomed to composing programs through flatMap, map or ifM combinators, but are used to checked exceptions. For these, the idea of an outer prompt opening a "safe scope" in which errors are dealt with, is quite familiar.

For these audiences, exceptions are (as mentioned) too blunt, and IO datatypes are (despite their advocates best efforts) too cumbersome. This proposal presents a way of dealing with error-handling that: allows writing in a direct style, that is to say, to write code that focuses on the control and data dependencies of the operations, and puts error-handling aside. To put it in another way: if you look at this feature from up the ladder, you may find this too small; but for those currently climbing up, this is a nice step, a bit above and instead of exceptions, in which they can accomplish much while sparing them from having to deal with with monads or for comprehensions.

Not so much playing "hide the monad", but rather "let the monad wait".

@sjrd
Copy link
Member

sjrd commented Dec 24, 2022

you want to return to a prompt of the same class which is not the immediately enclosing one, that's probably an anti-pattern.

It's actually completely sound and fine to do that. In fact, IIRC some pattern matches with alternatives are compiled that way. Tail recursive methods are also arguably compiled like that, except the outer boundary happens to be the full method, so we use a return instead, but that's equivalent to returning from a boundary that encloses the full method body (and in Scala.js, that is indeed how return statements are compiled, since the Scala.js IR has no return).

@adamw
Copy link
Contributor

adamw commented Jan 3, 2023

We don't need fibers for 99% of the projects and certainly current frameworks introduce a lot of complexity.

@kostaskougios My experiences might differ, but I'd say the opposite: any project that handles incoming http requests, or processes messages from a kafka queue, etc., involves a number of threads/fibers running concurrently. They might be well hidden, but they are there, and how well they work determines how your system behaves overall. Another data point to the utility of light-weight fibers is that Oracle just invested many years in delivering Loom.

Handling an individual request/messages might be mostly sequential - that's true. But we want not only to be ready for today, but also for tomorrow. I'm not a fortune teller, and it might turn out that most code ends up being sequential after all, but I'd rather expect for more concurrent code, following the trend of an increasing number of cores available locally, and the number of distributed microservices that have to be called.

I think that historically doing any kind of concurrent processing was quite hard - and that's what libraries such as cats or ZIO aim to solve. They put emphasis on safety, so that we can do a .parallelMap just the same as we would do .map. And that safety is crucial if we want to use concurrency without fear. We might have developed a reflex to avoid using concurrency just because it involves new failure modes which have to be handled, new potential for leaks etc.

current frameworks introduce a lot of complexity

I wouldn't agree, and I don't think it does the cats/ZIO libraries justice. They have a learning curve, they require programming using monadic, instead of direct style, but in exchange a lot of hard problems becomes easy. The discussion diverges now and again into the area of combining their strong sides with direct style just because we'd like to have both, but if that's possible is still an open problem :)

I don't agree that exceptions are always bad. For unrecoverable errors I don't think it is worth increasing the complexity of the code to make sure those errors are handled

Agreed, exceptions aren't universally bad, but they are not universally good either. Unrecoverable errors definitely should be handled by exceptions.

By the way: the fileHandle.fold example is better than an if (fileHandle == 0), as it forces you (at the type level) to check if opening the file succeeded. In the if case you have to resort to test, linters, or discipline to make sure that you always check the return value for errors. Whether this is a recoverable or unrecoverable error is a business-logic-level decision. That's also why you have .toInt and .toIntOption (though you should always use the latter one ;) )

@odersky
Copy link
Contributor Author

odersky commented Jan 3, 2023

@armanbilge

Another problem is actually one of being able to prevent cancelation/interruption, which is essential for guaranteeing safe release of resources.

Other people use try-finally for that. And elsewhere in the thread it is argued that it is a problem that Java applications can suppress interruption, so I am not quite sure what holds.

But we have strayed very far from Result here and I am not sure what the two have to do with each other. Maybe the argument is that we need monadic abstractions in the specific use case of expressive user-level scheduled concurrency so no point in investing in powerful & convenient direct style language constructs? First, I think the jury is still out on that. The only language level argument I have seen is the one of granularity. If we cannot ensure that an interrupt request is eventually honored, we might have a problem. But I am not sure how important that problem is, and it might also be solvable by other means. For instance, the compiler could insert an isInterrupted poll request on every back edge of the program flow graph. Others will have to figure that out. The rest of the arguments seem to come down to the problem that we don't have sufficient hooks for customizing existing schedulers so we have to write our own. But that can also be done for direct style programs, it seems. At least I would not rule that out categorically today.

@adamw

I think we're not OK with exceptions today, that's why we are searching for better error-handling mechanisms. That's partly why we use Either instead of throw

We are searching for better error handling mechanisms; we have that in common. But for the moment I would by far prefer exceptions over Either. So it would be good to find a third way we could both agree on. I have proposed Result, but you find it "mediocre". Can you say what you would like to see instead? Result is very popular in Rust so I think the burden of proof that we can do better is on you here 😄

@armanbilge
Copy link
Contributor

@odersky thanks for the response. I agree that things have become convoluted ...

Actually I asked what I hope is a simple question in #16550 (comment):

Or, if this [proposal] is not a special case of CPS actually, then I would appreciate an example that demonstrates how it is different (and apologies that I missed it).

The idea being, if this turns out to be a special-case of CPS, then perhaps we should consider implementing CPS? And if this proposal is different from CPS, I would personally find it very helpful to see how it is different. So far all the examples seem possible to implement with dotty-cps-async.

@odersky
Copy link
Contributor Author

odersky commented Jan 3, 2023

The idea being, if this turns out to be a special-case of CPS, then perhaps we should consider implementing CPS? And if this proposal is different from CPS, I would personally find it very helpful to see how it is different. So far all the examples seem possible to implement with dotty-cps-async.

Unwinding the call stack is a very special case that could be addressed with CPS. But you don't need CPS since our runtimes already have exceptions which can do the job just as well. CPS would go much further. If it comes at some point, there should be no problem to integrate the kind of error handling I have proposed with it.

@kostaskougios
Copy link

@adamw I would think multiple kafka consumers on the same topic would work for most projects.

Lets consider the fileHandle.fold issue again, you are right that if the return type is a Try (Either, Result etc) then it forces you to check for failure. That is a good thing when you want to check for failure. But a bad thing if you don't. If I had to create the open() method I would lean towards returning FileHandle straight away, if someone wants to handle failure they can always wrap it in a Try (I am pretty sure you would go the other way around).

I think we pretty much agree on the exceptions, not sure about the link you provided regarding toIntOption. The link is about a use case where a lot of invalid ints are expected. In that case yes ofcourse toIntOption is preferable as in that case the code would need to figure out what to do with the invalid numbers. The most common usecase though that I've seen is a number been in the form of a string and we just need to do a toInt to convert it (without a try-catch).

@armanbilge
Copy link
Contributor

armanbilge commented Jan 3, 2023

Unwinding the call stack is a very special case that could be addressed with CPS. But you don't need CPS since our runtimes already have exceptions which can do the job just as well. CPS would go much further.

Thank you for that helpful clarification! So if I understand correctly, CPS is general enough to implement the sort of error handling proposed in this PR, as well as support direct syntax for existing techniques such as IO monads (which projects such as cats-effect-cps and zio-direct are already exploring).

To quote you from #16550 (comment):

There are two broad and competing approaches to evolve Scala.

From my perspective, introducing CPS could go a long way towards unifying these approaches , since a common style of direct syntax with prompts could be used with both. That would have my vote :)

@adamw
Copy link
Contributor

adamw commented Jan 3, 2023

We are searching for better error handling mechanisms; we have that in common. But for the moment I would by far prefer exceptions over Either. So it would be good to find a third way we could both agree on. I have proposed Result, but you find it "mediocre". Can you say what you would like to see instead?

Sorry if this sounded too harsh :)

Don't get me wrong, I'm not a die-hard fan of Eithers. They have their uses, as do exceptions. Sometimes its better to use one, sometimes the other. Usually along the lines of recoverable/unrecoverable errors. Or depending on the type of code you write - is it a simple script/app, or service implementing complex business rules, where it's valuable to track the exact errors that might happen.

As for the proposal, there are two parts: the prompt/return mechanism, and Result. As for prompt/return, it's a library addition which doesn't impact the broader ecosystem: it can be very useful locally, and to be useful doesn't require libraries to integrate with it in any way. I think the construct might be misleading for beginners as its incorrect to use it with Futures or lazily-evaluated data structures, but otherwise it looks nice and I would use it if it would be available out-of-the-box with implementations for Option, Either, Try.

As for Result, that's a different story. It is an improved version of Either (and that's of course good), but to be really useful, the ecosystem would have to adopt it, and support accepting/returning Results, so that conversion in both ways aren't needed on every second expression. So I'd say it brings little benefit (some improvement over Either, but not radical), for a big cost.

I'm afraid I don't have a better solution. I don't like providing criticism without a proposal, but I hope that my perspective as maintainer of some libraries maybe allows me to do that. In sttp, for instance, we already have variants of backends for Try and Either. In tapir, we accept Either as the result of an endpoint's server logic. Each of those would have to be duplicated to provide good programmer experience. I don't mind the effort, but it would be best if it was for a substantially better solution.

And looking for this better solution brings us now and again to discussions around effect systems. Error handling is a crucial component of cats-effect / ZIO / direct-style programming, so I don't think these two can be discussed separately. After all, effects might fail in many (intermittent, unpredictable) ways, and retries, timeouts (and hence cancellation), fiber hierarchies, etc. all boil down to error handling. Maybe we are looking for solutions for different use-cases, prompt/return being just fine for smaller apps, while for larger ones you need more principled error handling.

I'm myself trying to understand and find the tradeoffs between monadic and direct-style programming (as witnessed by probably too many blogs). It does seem it's a choice between ergonomics and safety, which are hard to compare, so maybe in the end it will turn out the answer is simpky "it depends" ;). But one of Scala's strengths is that it can support both styles

@wjoel
Copy link
Contributor

wjoel commented Jan 3, 2023

Result is very popular in Rust so I think the burden of proof that we can do better is on you here 😄

It's pretty decent in Rust, but it was also the blessed error handling solution from the start, being in the standard library from (almost?) day one, and supported by special syntax. Even if it was a bad idea (I don't think it is), it would still be popular because of that.

Still, it is not without its own set of problems, which is why the Rust community came up with thiserror and anyhow (and others) to make them better: https://www.lpalmieri.com/posts/error-handling-rust/#the-error-type-is-not-enough
If Scala is really going to go in this direction and include Result in the standard library, it would be wise to study those other lessons from Rust.

I share the concerns voiced by others here about transplanting Rust's Result into Scala - unlike Rust, we're not starting from scratch, so we should have a better story for how to make this "The" way to handle errors, otherwise it will indeed just be one more option (perhaps making it "The" way to handle errors in a new/updated standard library, along with solutions for how to make it easily replace Either, exceptions, etc. would work... maybe). Having to manually type out different prompt names already gives it a quite different nature from Rust's ?, in my opinion. Importing and using lower case objects in a DSL fashion like this feels weirdly unpleasant to me, but maybe I could get used to it.

I'm very happy to see the conversation started by this, however. 😄

@diesalbla
Copy link

diesalbla commented Jan 3, 2023

As for Result, that's a different story. It is an improved version of Either (and that's of course good), but to be really useful, the ecosystem would have to adopt it, and support accepting/returning Results, so that conversion in both ways aren't needed on every second expression. So I'd say it brings little benefit (some improvement over Either, but not radical), for a big cost.

There are also two further complications, though:

  • Either is not just a failure-as-data type, it is also used to represent mutually exclusive choice between equally valid sources. Thus, we may not want to remove it completely.
  • Migrations are costly not just because of the effort to change source code, but also because we need to keep binary-compatibility with libraries whose public API uses Either to represent errors (the Decoder of circe comes to mind).

What I am noticed, though, is that the cause of the problem is that one data-type (Either) is to a use (failure-as-data) other than what it was intended for (symmetric disjoint union). For such use case, we could just use opaque types. We should deprecate any right-biased method in Either, and move them to the opaque type wrapper. Here is a rough (Scala-2 syntax) sketch for that:

opaque type Attempt[+A, +E] = Either[E, A]

extension [A, E](e: Attempt[A, E]) 
  def map[B](f: A => B): Attempt[F, B] = 
  def flatMap[F <: E, B](chance: A => Attempt[F, B]): Attempt[F, B] = 
}
object Fine {
  def apply[A](a: A): Attempt[A, Nothing] = Right(a)
  def unapply[A](e: Attempt[S, E]): Option[A] = 
    e match { case Fine(a) => Some(a) ; case _ => None }
}
object Fail {
  def apply[E](e: E): Attempt[Nothing, E] = Left(e)
  def unapply[E](e: Attempt[S, E]): Option[Attempt[S, E]] 
}

This, together with the deprecation of map and flatMap or filter in Either, could push people towards using these aliases instead of the Left and Right directly. Bit clunky, admittedly, but it may serve to hide Either from sight.

This would work, provided that the statement "Scala 3 Opaque type aliases provide type abstractions without any overhead.", in the documentation states), also implies that the Java Bytecode generated uses the underlying data-type in lieu of the opaque one.

@ScalaWilliam
Copy link
Contributor

ScalaWilliam commented Jan 3, 2023

@adamw @kostaskougios indeed, using IO by name is uncommon in Akka/Spark types of workflows. Having worked plenty with both Spark (3 years) and and cats-effect (4 years), I had noticed it that while the lack of "IO everywhere" makes prototyping and getting results in Spark far easier; the more you go outside DataSets, DataFrames into RDDs and strict evaluation (.cache()), the more hours you spend figuring out why Spark is simply running out of memory. DataFrames and RDDs are indeed read-only versions of IO with IO-less edges (which you need to be careful of). Likewise, Akka Materializers and FS2 streams also share lots of parallels in the sense that you get the best efficiency when you fuse a computation graph together. Spark developers say "avoid .cache()", cats-effect developers say "avoid .unsafeRunSync()"; it's pretty much the same thing. Spark's internals tell you all about computation graphs, optimizations and analyses, that are possible through semantic analysis.

The software that survives the best is one that coaxes you into working declaratively but providing easy escapes into the strict world. It's about discipline combined with ergonomics, which I find Scala hugely strong at.

I find something is off with the error handling strawman - is respond, a side-effecting word the right choice, and is .? the right symbol? F#'s computation expressions still confuse me to this day with their symbols, I feel that whatever going on in there is too implicit.

Regarding Either being an error type: a distinction needs to be made very clear between exceptional cases and non-exceptional invalid cases: a user may enter age of 17, which is really a validation error, but if the database is unavailable, that is really an exception. In my IO code I will avoid mixing validations of things I want to show to the user, from exceptional cases (the likelihood of them low, hence "exception").

The current way of doing IO and Eithers, requiring a bit of weird syntax, can easily and sufficiently be satisfied with monadic[Container] { x.each + y.each } in the style of ThoughtWorks' https://github.com/ThoughtWorksInc/each - that is more native in the language and not in macro form. Where I think Labels/goto's would be useful is in defining DSLs where you perhaps want to do more adventurous things unachievable with monadic+each. I would therefore propose:

  • monadic+each native support, possibly enabled by:
  • advanced label/flow controls as in the strawman

I don't think we should have only one or the other because they are for really different use cases: one of them is a fundamental building block, like the material that is used to make cement, whereas the other is more like cement used in its final application.

Exceptions are exceptional by definition, and are not errors. I would not consider them part of "error handling".

@odersky odersky force-pushed the add-errorhandling-strawman branch from a0c51b9 to e66dc31 Compare January 4, 2023 07:28
@odersky
Copy link
Contributor Author

odersky commented Jan 4, 2023

I have now added a validation capability to the Result type. It's a PoC, intended to show that it's possible in principle, and which can still undergo changes. It ties in seamlessly into the Scala 3 tuple abstractions. In Scala 3,

A_1 *: A_2 *: ... A_n *: EmptyTuple

is the tuple (A_1, ..., A_n). Likewise, if R_1, ..., R_n are Results then

R_1 *: R_2 *: ... R_n *: Result.empty

is the tuple result Ok((V_1, ..., V_n)) if all results R_i are ok, of the form Ok(V_i), or Err(es) with the list of errors es encountered otherwise.

@odersky odersky force-pushed the add-errorhandling-strawman branch from e66dc31 to 0452a35 Compare January 4, 2023 07:51
@odersky
Copy link
Contributor Author

odersky commented Jan 4, 2023

@adamw Thank you for the detailed explanation of the tradeoffs. I have now broken out boundary/break as a separate PR: #16612. We should discuss issues of naming and semantics there. This one should be the least controversial since it is a strictly better replacement of scala.util.control.NonLocalReturns and scala.util.control.Breaks.

About the rest you make a good distinction that the .? operator is completely independent of its carrier types, and that introducing .? is orthogonal to introducing a Result type. I agree that it will be painful to update libraries to support another error handling type. But I still think it's important to introduce a better alternative to Either that can also do validation. So many complex patterns in Scala could be eliminated! So, even if the transition is painful I believe it's better to start now and introduce Result as an @experimental addition to the library. This means people can try it out, we can gather feedback, and at the same time we can still withdraw it or change it.

@mpilquist
Copy link
Contributor

@odersky Should we hold comments on Result until it's moved out of the tests directory? There's a lot of prior work that should influence the design but I don't want to clutter this PR with that stuff. Happy to wait if preferred.

Also, could you respond to this question from earlier in the thread?

How would type class instances be found for [X] =>> Result[X, E] -- e.g., given [E]: Monad[[X] =>> Result[X, E]]? Would there be some change to unification that would allow that instance to be resolved?

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.
@odersky odersky force-pushed the add-errorhandling-strawman branch from 0452a35 to 9f455a6 Compare January 4, 2023 17:56

abstract class Result[+T, +E]
case class Ok[+T](value: T) extends Result[T, Nothing]
case class Err[+E](value: E) extends Result[Nothing, E]
Copy link
Contributor

@ScalaWilliam ScalaWilliam Jan 5, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would it be more natural to call this Bad (or Failed) rather than Err? As Err(or) is not an opposite or an antonym of Ok. It also makes it feel like value E should extend Exception or Error; alternatively it also sounds like something you'd see in the Linux kernel or Kubernetes source code.

Read out loud the following:

  • "This is an Ok Result"
  • "This is an Err Result"
  • "This is a Bad Result"
  • "This is a Failed Result"

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  • "This is a Fine Result"
  • "This is a Fail Result".

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@ScalaWilliam , type E should stay open, a lot of the times it won't be an exception but rather a case class or a string.
i.e.
if nameStr.isBlank then Err("name is empty") else Ok(nameStr)

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@ScalaWilliam

Scalactic uses Good and Bad, "good" is the direct antonym of "bad". On the other side, using Bad for Result might cause some conflicts in code using Scalactic, or at least lead to some confusion. While this should not drive the design of the standard library, I think it's worth considering what are the names used in popular libraries. I did not see Ok or Err in any other validation library, and their meaning seems already obvious.

Scalactic's Bad and cats' Validated are not bound to Exception or any other error type, I think Err should do the same if we want it to replace them.

def indexOf[T](xs: List[T], elem: T): Int =
boundary:
for (x, i) <- xs.zipWithIndex do
if x == elem then break(i)
Copy link
Contributor

@ScalaWilliam ScalaWilliam Jan 5, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It really feels like we're closer to having Python generator syntax, which is fantastic! Would love to see a strawman for this if it is actually possible with the existing Scala 3 capabilities.

In Python you can do things like this, which read naturally and return a generator, an equivalent to a Scala/Java Iterator:

def example():
  yield "We'll give you all the numbers divisible by 3 or 2, with those by 3 as priority"
  for i in range(1, 1000):
    if i % 3 == 0:
      yield ("%d is divisible by 3" % i)
    elif i % 2 == 0:
      yield ("%d is even" % i)

image

The yields can live anywhere, and you can easily return or break out.

And of course you can use this generator in another for-loop, or even manually consume it. They terminate through an exception called StopIteration:
image

However, I had not seen a solution to clean-up/finalization in generators. This is where libraries like FS2 really shine because you have guaranteed clean-up of resources. I'm willing to put up with a more rigid coding experience in order to get that guarantee, but if there is a way to achieve the best of both worlds it would be really fantastic.

What's great about generators is that you write them in a push-style (yield), but consume them in a pull style (next()/for).

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

but if there is a way to achieve the best of both worlds it would be really fantastic.

@ScalaWilliam you mean like this? :)

//> using lib "com.github.rssh::dotty-cps-async::0.9.14"
//> using lib "co.fs2::fs2-core::3.4.0-56-9e373bd-SNAPSHOT"
//> using repository "https://s01.oss.sonatype.org/content/repositories/snapshots/"
//> using option "-Ykind-projector"

import cats.effect.*
import fs2.*
import cps.*

given [O]: CpsMonad[Pull[IO, O, *]] with CpsMonadInstanceContext[Pull[IO, O, *]] with
  def pure[A](a: A): Pull[IO, O, A] = Pull.pure(a)
  def map[A, B](fa: Pull[IO, O, A])(f: A => B): Pull[IO, O, B] = fa.map(f)
  def flatMap[A, B](fa: Pull[IO, O, A])(f: A => Pull[IO, O, B]): Pull[IO, O, B] = fa.flatMap(f)

def generator: Pull[IO, String, Unit] =
  async[Pull[IO, String, *]] {
    await(
      Pull.acquire[IO, Unit](
        IO.println("acquired resource"),
        (_, _) => IO.println("released resource")
      ): Pull[IO, String, Unit]
    )

    (1 to 20).map { i =>
      if i % 3 == 0 then
        await(Pull.output1[IO, String](s"$i is divisible by 3"))
      if i % 2 == 0 then
        await(Pull.output1[IO, String](s"$i is even"))
    }
  }

@main def main =
  import cats.effect.unsafe.implicits.global
  generator.stream.foreach(IO.println(_)).compile.drain.unsafeRunSync()
acquired resource
2 is even
3 is divisible by 3
4 is even
6 is divisible by 3
6 is even
8 is even
9 is divisible by 3
10 is even
12 is divisible by 3
12 is even
14 is even
15 is divisible by 3
16 is even
18 is divisible by 3
18 is even
20 is even
released resource

I'm a broken record, but CPS is really really awesome!

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I find this really strange that the resource release code magically happens at the end of the block, even though the "nested" code is in the same block, (i.e not in some explicit envelope)

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't disagree :) mixing resources with generators is somewhat "advanced", because the resource needs to stay alive for the lifetime of the generator, and the lifetime of the generator is determined by some downstream consumer of it.

That is to say: the generator itself is the "envelope", since we don't know what goes "inside" until the user actually consumes it (and does whatever).

So in the example above, bringing a resource into the generator process keeps it alive and makes it available for the entire lifetime of the generator (as determined by the consumer).

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@armanbilge that is impressive, I did not expect it would even be possible in fs2. Even experimenting with vars inside this just works.

Are you able to also do an fs2 Pipe that would 'await' for an input, and conditionally produce outputs? Possibly allocating a resource in the middle as well like you had declared.

To make it very simple, is it possible to write the equivalent of .filter(_ % 3 == 0) in this style as well?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@ScalaWilliam Thanks!

Even experimenting with vars inside this just works.

It might, but it would be safer to use await(Pull.eval(IO.ref(...))) to make mutable vars, and update them with await(Pull.eval(ref.set(...))) 😄


Are you able to also do an fs2 Pipe that would 'await' for an input, and conditionally produce outputs?

Great question, we've been discussing this topic on the Typelevel Discord. So first of all, you can definitely do this with the Stream monad:

//> using lib "com.github.rssh::dotty-cps-async::0.9.14"
//> using lib "co.fs2::fs2-core::3.4.0-56-9e373bd-SNAPSHOT"
//> using repository "https://s01.oss.sonatype.org/content/repositories/snapshots/"
//> using option "-Ykind-projector"

import cats.effect.*
import fs2.*
import cps.*

given CpsMonad[Stream[IO, *]] with CpsMonadInstanceContext[Stream[IO, *]] with
  def pure[A](a: A): Stream[IO, A] = Stream.emit(a)
  def map[A, B](fa: Stream[IO, A])(f: A => B): Stream[IO, B] = fa.map(f)
  def flatMap[A, B](fa: Stream[IO, A])(f: A => Stream[IO, B]): Stream[IO, B] = fa.flatMap(f)

def pipe(in: Stream[IO, Int]): Stream[IO, Int] =
  async[Stream[IO, *]] {
    val i = await(in)
    if i % 3 == 0 then
      await(Stream.emit[IO, Int](i))
    else
      await(Stream.empty: Stream[IO, Int])
  }

@main def main =
  import cats.effect.unsafe.implicits.global
  Stream.emits(1 to 20).through(pipe).foreach(IO.println(_)).compile.drain.unsafeRunSync()

However it's not yet possible to do this in the Pull monad, which is ideal for writing "generators".

There are a few ways to transform a Pull[IO, Output, State] datatype. My original example relied on flatMap, which gives you access to the current State.

def flatMap[A, B](pullA: Pull[IO, Out, A])(f: A => Pull[IO, Out, B]): Pull[IO, Out, B]

Then, CPS transforms await to flatMap:

val a = await(pullA)
await(f(a))

Pull also offers a method flatMapOutput, which gives you access to the current Out.

def flatMapOutput(in: Pull[IO, Out, Unit])(f: Out => Pull[IO, Out, Unit]): Pull[IO, Out, Unit]

It would be interesting if we could add e.g. an awaitNext(...) syntax that transforms to flatMapOutput under-the-hood:

val i = awaitNext(in)
if i % 3 == 0 then
  await(Pull.output1(i))

I don't know enough about the inner-workings of CPS to know if this is feasible. But it seems like it could be :)

@odersky
Copy link
Contributor Author

odersky commented Jan 6, 2023

@mpilquist

Should we hold comments on Result until it's moved out of the tests directory? There's a lot of prior work that should influence the design but I don't want to clutter this PR with that stuff. Happy to wait if preferred.

I am happy either way. We can continue to discuss here or wait until there's a PR for an experimental Result type in the library.

How would type class instances be found for [X] =>> Result[X, E] -- e.g., given [E]: Monad[[X] =>> Result[X, E]]? Would there be some change to unification that would allow that instance to be resolved?

I'd need to know more details to be able to give an answer. What is a concrete scenario that you think should work? One possible technique might use aliases. Partial unification in Scala 3 takes alias types into account. So if you define

type ResultR[E, O] = Result[O, E]

and then define the givens in terms of ResultR that should keep the E fixed and infer the O.

@mpilquist
Copy link
Contributor

mpilquist commented Jan 6, 2023

I'd need to know more details to be able to give an answer. What is a concrete scenario that you think should work?

I'd want all the derived combinators from the cats type classes to work with Result -- e.g., sequence, traverse, flatTraverse, etc. I imagine we'd support sequence and traverse as methods on Result companion object given their ubiquity. But I'd want seamless interop with the cats type classes in both monomorphic call sites (because the cats type classes provide tons of useful methods -- e.g., void, as, whenA, unlessA, etc.) as well as polymorphic call sites.

Would a type alias approach then also require the type aliases to be used at the call site? That seems like a deal breaker to me and would lead some folks to avoid Result entirely (or lead library authors to look for a Scala 3 version of the old Unapply trick, which I think we can all agree would be terrible 😄).

Scastie Example

import cats.Monad

enum Result[+A, +E]:
  case Ok[+A](value: A) extends Result[A, Nothing]
  case Err[+E](value: E) extends Result[Nothing, E]

  def map[B](f: A => B): Result[B, E] =
    this match
      case Ok(a) => Ok(f(a))
      case e: Err[?] => e

  def flatMap[B, E2 >: E](f: A => Result[B, E2]): Result[B, E2] =
    this match
      case Ok(a) => f(a)
      case e: Err[?] => e

object Result:
  def monad[E]: Monad[[X] =>> Result[X, E]] = new:
    def pure[A](a: A) = Ok(a)
    def flatMap[A, B](fa: Result[A, E])(f: A => Result[B, E]) = fa.flatMap(f)
    def tailRecM[A, B](a: A)(f: A => Result[Either[A, B], E]) = ???

  type Reverse[+E, +A] = Result[A, E]
  given [E]: Monad[[X] =>> Reverse[E, X]] = Result.monad

// Usage
import cats.syntax.all.*

val resultsEither = List(Right(1), Right(2), Right(3))
val consolidatedEither = resultsEither.sequence

val results = List(Result.Ok(1), Result.Ok(2), Result.Ok(3))

// Can we make this compile without adding a type annotation?
val consolidated = results.sequence

// This works but requires ascribing with the alias
val consolidatedAscribed = (results: List[Result.Reverse[String, Int]]).sequence

@kostaskougios
Copy link

kostaskougios commented Jan 11, 2023

As others noted, we have 3 things here:

  1. Label/break
  2. Result
  3. .?

Result will be a good addition to scala-library. It will allow scala-lib to properly report errors and also other libraries would be able to do so without depending on external validation libs. This is something that is certainly missing from scala-lib. There will be a migration period where i.e. methods returning Either[A,ERR] or Try would have to be deprecated in favor of those returning Result but eventually the deprecated methods can be dropped. And Result could straight away have a deprecated toEither and toTry to help migration.

I've been playing around with the api and even as is right now is helpful and looks easy to read and use to me.

So food for thoughts:

  • the csv parsing/validation example

a) Without error reporting, just convert valid csv rows and ignore invalid ones:

  private def parsePerson(name: String, age: String, salary: String): Option[Person] =
    optional {
      Person(name, age.toIntOption.?, salary.toDoubleOption.?)
    }

b) with error reporting :

  private def validate(nameStr: String, ageStr: String, salaryStr: String) =
    (if nameStr.isBlank then Err("name is empty") else Ok(nameStr)) *:
      Result(ageStr.toInt, "age is invalid") *:
      Result(salaryStr.toDouble, "salary is invalid") *:
      Result.empty

  private def parsePerson(nameStr: String, ageStr: String, salaryStr: String) =
    respond {
      val (name, age, salary) = validate(nameStr, ageStr, salaryStr).?
      Person(name, age, salary)
    }

or alternatively:

  private def parsePersonAlt(nameStr: String, ageStr: String, salaryStr: String) =
      validate(nameStr, ageStr, salaryStr).map { (name, age, salary) =>
        Person(name, age, salary)
      }
  • a config parsing example that does a bit thorough validation just to see what complexities arise when more complex validation is requred. This example is similar to cats-validation example. I've added a dsl to this to make it a bit more idiomatic. attemt(something that may fail).fail( error value ) results in an Ok or Err.
  case class ParsedConfig(url: String, port: Int)

  sealed abstract class ConfigError
  final case class MissingConfig(field: String) extends ConfigError
  final case class InvalidPort(port: String) extends ConfigError
  final case class ParseError(field: String) extends ConfigError

  private def toResult(m: Map[String, String]) =
    def checkUrl = for
      u <- attempt(m("url")).fail(MissingConfig("Url is missing"))
      r <- if u.startsWith("http://") then u.ok else ParseError("url must be http://...").err
    yield r
    def checkPort =
      for
        pStr <- attempt(m("port")).fail(MissingConfig("Port is missing"))
        p <- attempt(pStr.toInt).fail(InvalidPort("Port must be a number"))
        r <- if p > 0 then p.ok else ParseError("Port must be >0").err
      yield r

    checkUrl *: checkPort *: Result.empty

  def parseWithErrorReporting(m: Map[String, String]) =
    respond {
      val (url, port) = toResult(m).?
      ParsedConfig(url, port)
    }

again alternativelly the last method can be :

  def parseWithErrorReportingAlt(m: Map[String, String]) =
    toResult(m).map { (url, port) =>
      ParsedConfig(url, port)
    }

@odersky
Copy link
Contributor Author

odersky commented Jan 21, 2023

The test cases in this PR have migrated to #16612, which implements the boundary/break abstraction sketched here. None of the other elements of this proposal are implemented yet outside of tests. This will need continued discussion and work to mature.

@odersky odersky closed this Jan 21, 2023
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.