Direct style (Rust) instead of for comprehensions

Instead of using regular Scala code to treat with effects, we use for comprehensions or flatMaps, so we have two pain points:

  • We cannot use common control structures.
  • We use another and more limited syntax instead of just regular code.

I want to propose using the same mechanism as Rust, the question operator mark (Operator expressions - The Rust Reference). So, we just type “?” when a function returns an effect, and we don’t have to use for comprehensions.

As example, instead of this code:

// Example from https://github.com/zio/zio-direct#branching-and-looping-support
Database.open.flatMap { db =>
  def whileFun(): ZIO[Any, Throwable, Unit] =
    db.hasNextRow().flatMap { hasNextRow =>
      if (hasNextRow)(
        db.lockNextRow().flatMap { lockNextRow =>
          if (!lockNextRow)
            db.nextRow().map(nextRow => doSomethingWith(nextRow))
          else
            ZIO.succeed(waitT())
        }
      ).flatMap(_ => whileFun())
      else
        ZIO.unit
    }
  whileFun()
}

We’d have the following:

val db = Database.open()?

while (db.hasNextRow()?) {
  if (db.lockNextRow()?) {
    val nextRow = db.nextRow()?
    doSomethingWith(nextRow)
  }
  else {
    waitT()
  }
}

There are advances in this topic, for example, zio-direct (GitHub - zio/zio-direct: Direct-Style Programming for ZIO), but they are DSLs, so they are limited to the supported transformations and we cannot use “just Scala”.

The question operator mark can be implemented in custom types (std::ops::Carrier - Rust).

Having effects is good, but using them with classic approaches or DSLs creates ugly code and doesn’t attract people to Scala.

1 Like

Note that this is already quite easy if you have a Result[T, Throwable]:

extension [T](res: Either[U, Throwable]) {
  def ? : T = res.fold(e => throw e, x => x)
}

object Result {
  def Try[T](body : => T): Either[Throwable, T] = try { x = body; Right(x) } catch { case NonFatal(e) => Left(e) }
}

The only problem is that you have to wrap every method that uses.? in Result.Try { }. The Rust compiler enforces this for you. It would be fairly easy to write a compiler plugin to enforce the same thing I believe.

In dotty-cps-async (where you use ‘just scala’) exists a short syntax for await as unary_!. enabled by import syntax, so now it’s possible to write:

import cps.*
import cps.syntax.*


def dbFun() = async[YouEffect] {
   val  db =   ! Database.open()
   while( await(db.hasNextRow()) ) {
         if ( await(db.lockNextRow() ) {
             val nextRow = ! db.nextRow()
             doSomethingWith(nextRow)
         }else{
             whaitT()
         }
   }
}

Here I leave await in places, where ! can be read as boolean expression.
It’s still verbose, from my point of view.
To go further, we can next alternatives:

  • Automatic coloring
import cps.*
import cps.automaticColoring.given
import scala.language.implicitConversions


def  dbFun() = async[YouEffect] {
   val  db =   Database.open()
   while( db.hasNextRow() ) {
         if ( db.lockNextRow() ) {
             val nextRow =  db.nextRow()
             doSomethingWith(nextRow)
         }else{
             whaitT()
         }
   }
}

(but it’s too hight-level for many cases because developers now need to look at function definitions to restore typing in mind)

Then this code can be written as:

import cps.*


def dbFun() = async[YouEffect] {
   val  db  <-  Database.open()
   while( await(db.hasNextRow()) ) {
         if ( await(db.lockNextRow() ) {
             val nextRow <-  db.nextRow()
             doSomethingWith(nextRow)
         }else{
             whaitT()
         }
   }
}

1 Like

Similar to @adampauls’ suggestion, this would work out of the box:

import scala.util.*

extension [T](xo: Try[T])
  def ? : T = xo match
    case Success(x) => x
    case Failure(ex) => throw ex

def parse(str: String): Try[Int] =
  Try:
    str.toInt

def parse2(str1: String, str2: String): Try[Int] =
  Try:
    val x = parse(str1).?
    val y = parse(str2).?
    x + y

@main def Test =
  println(parse2("12", "1"))
  println(parse2("2ax", "44"))

Differences to the Rust version:

  • Need to enclose the whole method body with Try instead of the final result with Ok. That’s not a big deal. In my mind, the Try version reads even slightly better.
  • .?” instead of “?” since Scala does not have postfix operators anymore. This could be fixed by throwing in extra syntax.
  • Try is less flexible than Result in Rust. I think we should add a Result type to the stdlib as soon as we can. Either does not cut it.
  • Control logic based on exceptions rather than branches. This is probably the most problematic aspect. First, exceptions extract a performance penalty (it can be quite small, if stacktraces are suppressed, but still…). Second, we intermingle exceptions with ?. An exeption thrown by a ? would be caught by an enclosing try and conversely Try would catch any exception, not just the ones thrown by ?.

So, I am not sure whether we want to go down that route. We could stick with just exceptions, that would make it clearer what goes on. Or we could invest in a special operator ? that gets compiled to returns instead of exceptions.

1 Like

eh…

import scala.util.*

def parse2(str1: String, str2: String): Int =
    val x = str1.toInt
    val y = str2.toInt
    x + y

@main def Test =
  Try:
    println(parse2("12", "1"))
    println(parse2("2ax", "44"))

There you go, exactly the same.

Your .? buys you exactly nothing, given scala’s unchecked exceptions plus the fact that you are just rethrowing.

Aren’t postfix operators generally deprecated in Scala 3? When writing zio-direct I wanted to use effect! instead of effect.run to call the effects but then I saw that you need to use import language.postfixOps so that doesn’t work. Could symbolic postfix ops be allowed in some cases?

2 Likes

I was intentionally ruling out unchecked exceptions, since that amounts to cheating. But you are right, if we want to make the Try/? pattern safe we’d need to check exceptions in the language. That might come at some point… [CanThrow Capabilities]

I do exactly this in my own code, and it is wonderful.

It’s fast, clear, composable–there are really no downsides. For me, at least, it blows every other error-handling method out of the water (for routine use–in specialized cases you can need other things, such as when there are resources to clean up, or you may need to combine it with some sort of Using clause).

If you want to try it out, you can find it in my kse library for Scala 2.12–it works by inserting a return statement using a macro (which works because of nonlocal returns).

In mill: ivy"com.github.ichoran::kse:0.11" in dependencies.

import kse.flow._ to get it to work. Here’s an example, using my Ok instead of Either (because Ok has a favored Yes branch = Right, and because Yes[Y] is typed as Ok[Nothing, Y] unlike Either which keeps both type parameters for Right…super awkward in practice).

// Setup: define functions that have correct and error branches
scala> import kse.flow._
import kse.flow._

scala> val zipPattern = """(\d\d\d\d\d)""".r
zipPattern: scala.util.matching.Regex = (\d\d\d\d\d)

scala> def parseZip(s: String) = s match {
  case zipPattern(digits) => Yes(digits.toInt)
  case _ => No("Not a zip: " + s)
}
parseZip: (s: String)kse.flow.Ok[String,Int]

scala> val phonePatternUS = """(\d\d\d)-(\d\d\d)-(\d\d\d\d)""".r
phonePatternUS: scala.util.matching.Regex = (\d\d\d)-(\d\d\d)-(\d\d\d\d)

scala> def parsePhone(s: String) = s match {
  case phonePatternUS(area, pre, post) => Yes((area.toInt, pre.toInt*10000 + post.toInt))
  case _ => No(s"Not a phone number: " + s)
}
parsePhone: (s: String)kse.flow.Ok[String,(Int, Int)]

So, now what, some for (Yes(y) <- parseZip(s)-type thing to monadically thread through both zips and phone numbers?

No! That’s the magic of .?:

scala> def parse2(s: String, t: String): Ok[String, (Int, (Int, Int))] =
    Yes((parseZip(s).?, parsePhone(t).?))
parse2: (s: String, t: String)kse.flow.Ok[String,(Int, (Int, Int))]

// That's it!  You just write the answer you want
// except decorate things that might fail with `.?`
// Could it get any easier?!

scala> parse2("fish", "salmon")
res0: kse.flow.Ok[String,(Int, (Int, Int))] = No(Not a zip: fish)

scala> parse2("12345", "salmon")
res1: kse.flow.Ok[String,(Int, (Int, Int))] = No(Not a phone number: salmon)

scala> parse2("12345", "987-654-3210")
res2: kse.flow.Ok[String,(Int, (Int, Int))] = Yes((12345,(987,6543210)))

And you can’t return the wrong type:

scala> def parseWrong(s: String): Ok[Int, (Int, Int)] = Yes(parsePhone(s).?)
<console>:15: error: type mismatch;
 found   : kse.flow.Ok[String,(Int, Int)]
 required: kse.flow.Ok[Int,?]
       def parseWrong(s: String): Ok[Int, (Int, Int)] = Yes(parsePhone(s).?)

I have not yet published the both improved-and-worse version for Scala 3. It’s improved because the macro was less fragile for Scala 3.1 (but it broke for 3.2, haven’t fixed it yet), and worse because you can’t merely type a return type–I wasn’t able to get the macro to read off the type information correctly (maybe my mistake?), so it looks like the following (note the required Or.Ret, making things clear but ugly; note also that I’ve changed from Ok[N, Y] to Y Or N, where Or is a union type with unboxed success type Is and boxed failure type Alt, courtesy of opaque types in Scala 3!..but…anyway):

scala> def parse2(s: String, t: String): (Int, (Int, Int)) Or String =
     |   Or.Ret{
     |     (parseZip(s).?, parsePhone(t).?)
     |   }
     | 
def parse2(s: String, t: String): kse.flow.Or[(Int, (Int, Int)), String]
                                                                                
scala> parse2("fish", "salmon")
val res0: kse.flow.Or[(Int, (Int, Int)), String] = Alt(Not a zip: fish)
                                                                                
scala> parse2("12345", "salmon")
val res1: kse.flow.Or[(Int, (Int, Int)), String] = Alt(Not a phone number: salmon)
                                                                                
scala> parse2("12345", "987-654-3210")
val res2: kse.flow.Or[(Int, (Int, Int)), String] = (12345,(987,6543210))

When Scala drops nonlocal returns, assuming that still will happen, it will have to rely on inserting its own code after hopefully successfully detecting whether to throw a stackless exception or return directly. I am really, really not looking forward to that, because at the moment .? is probably my favorite single feature, and coming to the right conclusion about stackless exceptions seems very hard outside of the compiler.

Anyway, gigantic thumbs up to .?. Please, however, if anyone picks this up for a standard feature, don’t implement it in a more limited way than I have–mine works with Try, with Either, with Option, with Or/Ok…anything where there’s a clear “oh, that other type”, you can toss upstream with .?.

(For bonus points: I added a .?+(f: A => B) to switch the type on the way out. Yeah, you could first map, then use .?, but .?+ is clearer.)

2 Likes

About non-local returns: Does ? work as expected in Rust closures?

I.e. Scala equivalent:

  def f = 
     xs.map (x => 
       val a = parse(x)?
       a + 1
    )

would the ? exit function f? That would mean we need non-local returns back, with all their fragility. The alternative would be to forbid an occurrence of ? at this point. What does Rust do?

It just propagates the result to closure return type.
Ie the return can be either Result[Iterable, Error]/Iterable[Result[,]]. If its used inside a loop body then it will return the error result from the function that contains the loop.
Why not do early returns with exception with no stacktrace under the hood?
On the sidenote why not just add a monadic val operator a la leans “let x ← expr” or ocaml “let* x = expr” to scala in a form of “val x ← expr”? I feel like it would clear the need to abuse for comprehensions and remove some of the footguns associated with monadic code and for comprehensions

I fail to see how rust improved over something like java’s checked exceptions. It seems to me Result[T] (can’t remember rust snytax) and ? is equivalent to declaring your method as throws Exception and simply calling methods that throw it. Java will make you handle the exception by either rethrowing - which in rust would be calling it with ? - or using try-catch - with in rust would be unwrap I think.
I mean, yeah java’s exception handling is not composable, but rust’ isn’t either because the type system hates generic unsized types last I checked.

Except result is generic on errors too. Unwrap() panics, the closest thing is exception in java. Question mark operator is essentially a pattern match + early return in case of error.

The way I would phrase it is that for comprehensions are useful and proven in industry (both in Scala and Haskell), and they are useful quite separate from other current and future features of the Scala programming language. However, they have a number of well-understood drawbacks, one of which is that they represent a separate style for imperative code, when used in imperative DSLs like Flow (persistent workflows in Scala), Quill (LINQ in Scala), or things like ZIO (concurrency).

Many features of Scala have been improved in Scala 3, but for the most part, for comprehensions are the same as they have always been, with most of the drawbacks they have always had.

@rssh has explored some alternatives, and the OP, @Andreu, proposes one possibility in this thread.

Without currently commenting on these alternatives, I think it’s useful to establish some goals for what a successor to for comprehensions might look like. I might propose the following:

  1. Preserve the known benefits of for comprehensions
    1. Flexibile for any functional DSLs that embed imperative steps, including server-side DSLs like LINQ, stateful workflows, and “app-side” DSLs like concurrency (ZIO), etc.
    2. Well-known, well-studied, and proven tool for functional DSLs, extensively used in many libraries
    3. Lexically-scoped
    4. Deterministic, unambiguous de-sugaring
  2. Address the known deficiencies of for comprehensions in Scala 2.x, including:
    1. Incompatible with tail calls
    2. Incompatible with irrefutable destructuring
    3. Poor error messages
  3. Improve on the ergonomics of for comprehensions
    1. Control flow support
    2. Pattern matching support
    3. Try/catch support (stretch goal?)

A list of non-goals might be:

  1. To narrow the scope to any other problem than for comprehensions (e.g. to specialize a proposal for “async”, about to be made obsolete by Loom)
  2. To in any way relate to any sort of effect tracking
  3. To connect the proposal to continuations, which is really a separate concept and not related to for comprehensions

If there’s consensus on goals and non-goals, I am sure the question of whether such an improvement to for comprehensions fits within an extension method(s), a macro, or core improvements to the language itself.

9 Likes

Rust only returns from the most local scope, which is fine if absolutely everything works with Result (and because it’s Rust it generally does) and if there’s no significant extra cost (and because it’s Rust, there generally isn’t).

So if you have a closure |x| { let y = foo(x)?; bar(y) } then the error branch type of foo and bar had better match, and you’ll get the error branch result early from the closure. (It’s equivalent here to |x| foo(x).and_then(|y| bar(y)), where and_then is Rust’s flatMap.)

The problem with this is that you then can’t do any interesting processing inside constructs that don’t anticipate errors. For instance, you can’t try to map over a list of files and bail out when you hit the first one that can’t be read.

So, actually, the Scala non-local return solution is substantially more powerful, and you need that power more often because we’re probably not going to rewrite the entire Scala library ecosystem to use, for instance, my unboxed-success Or. (Even if we did, Rust still wins: the type union approach still requires an extra instanceof check, which is usually more expensive than Rust’s bitflag check.)

Non-local returns are–if you use sensible error-handling constructs instead of trying to roll your own catch-throwable thing–only a major problem when they cross thread boundaries, aren’t they? In particular, I don’t see any real advantage in a “hey, pay attention!” loan pattern like breakable over the idea that actually, yes, you can jump around non-locally anywhere. Needing a witness (ephemeral given, I suppose) that it’s cool seems like a reasonable thing to have. Not having support at all is disappointing, precisely because of the steep requirement to distinguish when you don’t even need to emit a throw vs. when you do.

Critically, this kind of thing is extremely powerful, and hard to accomplish cleanly without non-local return inserted as needed by the compiler:

def process(inputs: Array[Path], output: Path): Foo Or Boo =
  val out = selectCanonicalTarget(output).?
  val stuff = inputs.fold(Foo.zero)((a, x) => a + read(x).?)
  out.write(stuff)
  Is(stuff)

Indeed, in Rust I constantly avoid using their higher-order constructs in favor of manually traversing iterators precisely because I then can exit early with ?. The amount of cognitive overhead saved by, say, map is less than the amount you have to spend to figure out how to deal with errors and not do pointless work when an error has already been hit.

The non-local return issue isn’t really any worse than the try-catch issue; it’s the same issue, after all! The only question is whether it catches people by surprise.

If .? becomes a common pattern, it’s much less likely to catch people by surprise.

Anyway, the biggest issue is that closures then need to come in safe and unsafe colors if the compiler is going to help catch mistakes in usage across threads, and this is true independent of whether there are non-local returns.

// Bad
def oops: Either[String, Int] =
  val x = mightFail.?
  val fy = Future{ mightAlsoFail(x).? }
  handleTimeout(Await result fy)
// Also bad
def oops: Either[String, Int] =
  try
    val x = mightThrow()
    val fy = Future{ mightAlsoThrow(x) }
    handleTimeout(Await result fy)
  catch case e: MyError => Left(reportError(e))

Anyway, I definitely recognize the danger, but it seems like an incredibly powerful feature to throw away in order to only somewhat reduce the kind of danger that is always lurking there anyway.

If it has to be enabled by Or.Ret or returnable or something, fine, that’s not too big a sacrifice. But not having a good way to mix, without you needing to pay attention, local and non-local returns kind of shifts it from awesome to meh, another choice among many.

1 Like

Checked exceptions are extremely expensive. Rust’s ? is practically free. Even stackless exceptions have an overhead 10-100x higher than Rust’s Result, and ordinary exceptions are more like 1000-10000x higher.

Plus you have handy functions on Result to help you do common tasks, which you don’t necessarily have with exceptions.

So that means it’s like checked exceptions, except usable for ordinary control flow instead of not.

It assigns the error to the output of map. This is some code we can play with (using the question operator mark and the try block):

Playground: Rust Playground

#![feature(try_blocks)]

use std::num::ParseIntError;

fn main() {
    f();
}

fn f() -> () {
    let xs = vec!["1", "2", "3"];
    
    let _result_using_question_mark_operator: Result<Vec<i32>, ParseIntError> = xs.iter()
        .map(|x| {
            let a = parse(x)?;
            Ok(a + 1)
        }).collect();
    
    let _result_using_try_block: Result<Vec<i32>, ParseIntError> = xs.iter()
        .map(|x| try {
            let a = parse(x)?;
            a + 1
        }).collect();
    
    println!("This message is displayed");
}

fn parse(str: &str) -> Result<i32, ParseIntError> {
    let num: Result<i32, ParseIntError> = str.parse();
    num
}

Note: Try in Scala is for exceptions, but in Rust is for types and ?.
https://doc.rust-lang.org/beta/unstable-book/language-features/try-blocks.html

For Scala users, it’s important to note that in Rust, try does not catch exceptions (“panics”, in Rust, though a panic is not normally expected to be handled; it generally means you’re dead).

Rather, because ? is so awesome, it gives a new scope out of which ? returns (i.e. less than the whole function). (On success it automatically wraps the result as a success, i.e. Ok(_).)

1 Like

I wonder if it’s time to revive the spirit of Scala-Virtualized, which had explored overloadable control structures (while-loops, if-then-else, pattern matching, …) and other language extensions. With extension methods now being part of the language, and macros having stabilized, notably inline, Scala3 and lightweight virtualization may be a good starting point to provide the right foundations to better support effects.

2 Likes

Meh, that’s a problem to be solved by the JVM, not the language syntax. Exceptions are not going anywhere in java as a platform.

Even stackless exceptions have an overhead 10-100x higher than Rust’s Result

Do these have overhead compared to normal object instantiation? because last time I benchmarked this, they don’t, and if they don’t, then you are struggling against the nature of the JVM and allocations. Let JIT or escape analysis do its job or wait until the platform improves, or move away from the platform.

I think you are conflating two things here @Ichoran, the JVM as a platform and its intrinsics, and language design to model effects. For instance, if the right™ linguistic tool was found for scala and it is natural/native on the jvm, I wouldn’t hold it against it if it were slower than rust.

I’m fine accepting that the JVM has different performance/convenience tradeoffs than Rust. It does, and I still mostly write Scala because only in rare cases is the performance unacceptable. That’s not the issue. The issue is being needlessly careless with performance to the point where people who need performance actively avoid the feature.

Anyway, the point is equally much that ? is an extremely useful way to propagate errors, and it is that part which is relevant to Scala. .? isn’t automatic, so it warns you where your failures are (unlike exceptions, that just magically smash through things); but it’s very minimal, so you barely have to think about it when you don’t want to.

That is the more important consideration: it helps you write better code.

(Depending on details, yes, even stackless exceptions can be more expensive than passing back an allocated error type. But, anyway, you did specifically ask how Rust improved over something like Java’s checked exceptions, and one of the big advantages is performance, even if Scala cannot fully recapture the same advantage. I was just answering your question!)

1 Like