Pre-SIP: improve for-comprehensions functionality

Improving for-comprehensions and adding alternative sugar for monadic constructs are two separate tasks, because I think you’re not proposing to remove for-comprehensions altogether.

Special alternative sugar for monads already exists:
GitHub - ThoughtWorksInc/Dsl.scala: A framework to create embedded Domain-Specific Languages in Scala
http://monadless.io/
GitHub - pelotom/effectful: A syntax for type-safe effectful computations in Scala

For-comprehensions can be improved independently.

return in Scala returns from a method, so if it’s used inside a closure then it does throw an exception: scala.runtime.NonLocalReturnControl. This extra sugar adds third overload for return so perhaps something like return! would be less ambiguous.

2 Likes

Yeah don’t worry, I’m not suggesting we use that syntax - that is just an example of how F# solved this problem.

def next: A given Monad[A]

Are you sure you are talking about a monad, not a comonad? There’s no next in monad. But comonad’s ε is probably what you need, no?

I didn’t say that, Eugene did.

That said (heh), .next in his example is not a method at all, and the statement is not an expression, but it’s a macro that generates a flatMap call, and binds name of the lhs of the (what looks like an) assignment to the flatMap parameter name.

1 Like

There are a few attempts to implement this with macros and compiler plugins. I have tried Dsl.scala written by @yangbo . Here is an example of how this looks like.

1 Like

In companies where I worked and works we did and we do use for for monads.
In my view, it’s what for is good for.

And no, we don’t use cats (mostly). We have almost mathematical reasons not to use cats.

So, I hope, other people would say the same: for is pretty much what you expect, especially if you saw it in Haskell.

1 Like

Guys, I think we have got a bit sidetracked from the original discussion.

Can a moderator split out the discussion about alternate kinds of comprehension syntax if it’s not too much trouble?

4 Likes

There’s now an implementation of this feature in Allow `given` bindings in patterns by odersky · Pull Request #7194 · lampepfl/dotty · GitHub (which sits on top of Trial balloon for fixes to given syntax by odersky · Pull Request #7150 · lampepfl/dotty · GitHub which trials some changes to the syntax of givens).

2 Likes

I’m a fan of a lot of the ideas thrown around in the thread. While we’re discussing, my 2c would be a few concrete changes to for-comps which I think should be feasible and not break existing code.

  1. Clean up anonymous binds, e.g., _ <- IO(println("foo")). These are a big pet peeve of mine and I think the current look makes code a lot harder to read. Two potential ways to do this would be 1) the Haskell way of IO(println("foo")) where the bind is inferred for monadic values on their own line, and 2) reusing the do keyword to write do IO(println("foo")). I think the former is cleaner but I’m not sure if it would introduce changes to parsing. The latter has the downside of do appearing with different meanings in two nearby places (here and at the end of a foreach-style comprehension).

  2. Add yield <- as suggested by @oleg-py at the top of the thread. Seems to me like a simple solution with no new keywords, but I can imagine how it might seem unintuitive to some. To help with that, I propose another change to make things more “orthogonal”:

  3. Allow yield and yield <- inside of the comprehension. For an example, consider tree traversal:

case class Tree(value: Int, children: List[Tree])

def preorderTraverse(tree: Tree) = for {
  yield tree.value
  child <- tree.children
} yield <- preorderTraverse(child)

def postorderTraverse(tree: Tree) = for {
  yield <- tree.children.flatMap(postorderTraverse)
} yield tree.value

Both yield and yield <- mid-comprehension would desugar into an associative combine of the yielded value(s) and the remainder of the computation. Mid-comprehension yield then requires a monadic point, and if empty is present as well then it should form a monoid with combine for guards to behave as expected. This is all similar to MonadPlus laws in Haskell. It would require standardizing these ops in the stdlib, of course. And there might have to be special error reporting for type mismatches between different yields.

Anyway, this clarifies that yield is used in the same sense as in coroutines. The resulting expressions would be pretty similar to generators familiar to Python users, though with clunkier control flow. In this comparison, yield <- is analogous to Python’s yield from. I think together these changes would bring a fair amount of the benefit of the ideas suggested in this thread while minimizing changes to syntax. Could think about adding an applicative/zip “and” binding in the style of those other languages’ comprehensions as well.

In addition to that it would be nice to not have to do the yield () at all, so being able to write simply:

def loop: IO[Unit] =
  for {
    IO(println("Doing periodic things"))
    IO.sleep(1.second)
    loop
  }

Much like you don’t need to write the () in

def foo: Unit = { 1; () }
5 Likes

While I also like the idea of removing the requirement of yield at the end (and it seems it would conveniently have the same semantics as an omitted do, so wouldn’t mess with the regularity of the syntax), it seems slightly tricky. If you just infer a missing yield (), the expanded version of your abbreviated loop code would have the same problem of generating garbage objects pointed out by OP, but—even worse—it would happen silently.

It seems to me that doing this properly would require a bigger change in program semantics where the last value in the for-comprehension could count as the returned value. But then, especially if yield <- is allowed at the end, this just creates 2 ways of doing the same thing, since you could always (modulo application of point) move the last value between the comprehension and the final yield. So I think the only reasonable way of implementing this change would be to go full Haskell do-notation and get rid of yield altogether. But that’s a big breaking change for little benefit where adding yield <- would do the job just fine.

1 Like

I’d go as far as to say we could have

def loop(i: Int): IO[Int] =
  for {
    IO(println("Doing periodic things"))
    IO.sleep(1.second)
    if(i > 0) loop(i - 1) else IO.pure(42)
  }

just like that

5 Likes

That would be confusing for nested monads. Also are each statements returning a monad?

While we’re at it, why not use a different keyword for monadic comprehensions. for never made any sense in my mind.
I sometimes feel like the conflation of keywords in Scala comes from the desire to make the syntax appear simple. For-yield is one such case IMHO. the yield keyword wouldn’t even be needed if we used a different keyword than for as far as I can tell?
This could also lead the way for applicative syntax to be added later.

Come to think of it, is this secretly why we got rid of do ... while? do would be the obvious candidate to replace for-yield, possibly with a do parallel ... variant for applicative?

2 Likes

Yes, freeing up do for use with for was one of the motivations to eliminate do...while. Here’s Scala 3:

scala> for x <- 1 to 3
     | do println(x)
1
2
3