-
Notifications
You must be signed in to change notification settings - Fork 1.1k
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
Add error handling strawman #16550
Conversation
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
|
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 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 |
While Apart from that, I also have a question - should
Because of the necessary |
@adamw Yes, no problem adding the same mechanism to 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 |
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:
Other suggestions are welcome! |
What about adding
|
or |
@odersky It would be nice to be able to extract code fragments which use 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 |
Forgive my 2 cents, but upon seeing this:
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
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 |
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(_.?)
} |
@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. |
@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 |
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. |
Also, you shouldn't rely on the particular error message |
I believe what Scala is missing and needs to stay relevant is the succinct syntax for async computations like We showed there that other types with either a continuation + mutable reference or a simple 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. Here we can mix short-circuiting computations of different types, and we use context functions and 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 https://github.com/47deg/TBD/blob/8196b2ec54319aee7e96f9ad81ef8bc42a429de4/scala-fx/src/main/scala/fx/Bind.scala Most of these abstractions, like 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 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 We can do that in kotlin and others languages like modern Java with LOOM. Current solutions based on macros or libraries like 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 |
This is a positive step and an improvement for using wrapper values. The new construct (or library primitives) intuitively resembles try-catch blocks. The This makes it a great alternative to |
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 I think it would be excellent for CPS to be officially supported in Scala 3. Further reading: |
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).
Everybody having an opinion is why we have this problem in the first place. You said it yourself:
If we're supposed to address it, I think we need to be opinionated :) |
I'd question the use of symbols like 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 |
I like the initiative to look at error handling again. IMO the new |
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. |
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. |
💯 @robmwalsh!! |
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:
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)
|
Can you describe what these cases are?
def traverse[T](xs: List[Option[T]]): Option[List[T]] =
optional(xs.map(_.?))
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(_.?)
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:
(*) To be 100% safe, this needs capture checking, but the same holds for exceptions as well. I should also say that 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. |
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 boundary: outerLabel ?=>
...
boundary:
...
break(using outerLabel) |
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
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:
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:
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". |
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 |
@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
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 :)
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 |
Other people use But we have strayed very far from
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 |
@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):
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. |
@adamw I would think multiple kafka consumers on the same topic would work for most projects. Lets consider the 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). |
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 To quote you from #16550 (comment):
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 :) |
Sorry if this sounded too harsh :) Don't get me wrong, I'm not a die-hard fan of As for the proposal, there are two parts: the prompt/return mechanism, and As for 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 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 |
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 I share the concerns voiced by others here about transplanting Rust's I'm very happy to see the conversation started by this, however. 😄 |
There are also two further complications, though:
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 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 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. |
@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 ( 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 Regarding The current way of doing IO and Eithers, requiring a bit of weird syntax, can easily and sufficiently be satisfied with
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". |
a0c51b9
to
e66dc31
Compare
I have now added a validation capability to the A_1 *: A_2 *: ... A_n *: EmptyTuple is the tuple R_1 *: R_2 *: ... R_n *: Result.empty is the tuple result |
e66dc31
to
0452a35
Compare
@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 About the rest you make a good distinction that the |
@odersky Should we hold comments on Also, could you respond to this question from earlier in the thread?
|
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.
0452a35
to
9f455a6
Compare
|
||
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] |
There was a problem hiding this comment.
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"
There was a problem hiding this comment.
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".
There was a problem hiding this comment.
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)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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) |
There was a problem hiding this comment.
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)
The yield
s 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
:
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
).
There was a problem hiding this comment.
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!
There was a problem hiding this comment.
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)
There was a problem hiding this comment.
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).
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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 :)
I am happy either way. We can continue to discuss here or wait until there's a PR for an experimental
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 |
I'd want all the derived combinators from the cats type classes to work with 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 😄). 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 |
As others noted, we have 3 things here:
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:
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)
}
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)
} |
The test cases in this PR have migrated to #16612, which implements the |
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.
Option
, where success is wrapped inSome
and failure isNone
.Either
, where success is wrapped inRight
and failure is wrapped inLeft
.Try
, where success is wrapped inSuccess
and failure (which must be an exception) is wrapped inFailure
.null
.IO
monad likeZIO
, 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 orSystem.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 aOption[List[T]]
.Here,
optional
is the prompt for theOption
type, and.?
mapsOption[T]
to the underlying typeT
, jumping to the enclosingoptional
prompt in the case aNone
is encountered. You can think of it as a safe version ofOption
'sget
. This function could not be written like this in Rust since_.?
is a closure so aNone
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 replaceEither
, 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 aboundary
prompt and abreak
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 replacesBreak
exceptions by gotos if they are caught in the same scope without intervening functions. Dotty already has aLabeledBlock
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.