Suggestion: if-comprehensions

Yes, you did.

I just coded this as .~ (because I already use .? to mean exit early with error, while here we want explicitly to exit without error).

You can check it out and play with it here, if you want:

I have added a for-comprehension equivalent which is, I think, way more bulky and harder to understand, once you get the idea behind .~.

Switching the for-comprehensions to if-comprehensions would help (I added a comment with a possible if-comprehension syntax), but fundamentally the lack of ability to span different types of might-have-a-value makes it less elegant than it could be.

Note that the construct that I wrote bypasses all the monad transformer stuff or the manual .toOptioning everywhere. It’s a bit…trippy, admittedly.

But, anyway, if the point is only that it’s worth considering a library solution, I think this should demonstrate that there is adequate power and brevity. Familiarity might be a bit lacking, but sometimes new constructs are powerful precisely because they express a helpful concept in a compact way that previously you couldn’t.

I’m not entirely sure I’ll adopt this as part of my standard library addon; I generally want explicit errors. But it was a fun exercise, and it turns out to be pretty powerful.

(Note that I did the && sequencing too.)

(Note also that the attempt code creates zero surplus objects, unlike the for comprehension which creates roughly two per comprehension in the for.)

(I’m not sure I like the .~ on Boolean. It’s kind of hard to read. Something like ensure(boolean) would read better.)

So there’s been quite a few comments along the lines of “this only works for Options”, “this privileges Option”, and “for -comprehensions are more general, this is only a special case”.

Options are special, they should be special, and they are a case worth specializing for:

  1. They are how we deal with null.
  2. 0..1 is different in kind not degree from 0..N, so for comprehensions are not more general, they are different.

Options are pervasive in Scala, yet their usage is still cumbersome.

Adding if-comprehensions and def foo(s: String?) callable with foo(myString) and foo(myStringOpt: _?) as I suggested years ago (please ignore the elvis operator second part of that suggestion) would go a long way towards making Options in Scala more ergonomic.

And that is a goal worth pursuing.

They aren’t the only way we deal with null. Problems that amount to unflattened Option[Option[T]] are not unusual, and data structures to represent that ergonomically come into play. If this new pattern happens, it should support those: they share the concept of “might or might not contain a value”. Option isn’t unique, just more common.

Seriously, I get the desire here. But Scala generalizes over Monads instead of privileging List for good reason; I’m not convinced that this is all that qualitatively different. I’m intrigued by the line of thought, but it feels over-specific in a somewhat un-Scala way.

5 Likes

If there’s a base class for 0..1 a la Traversable or Iterable for 0..N I’m all for it.

Why a base class? That’s not how for works, and it’s generally not the way Scala thinks…

Metaphorically speaking then - is there a corresponding 0…1 construct to whatever 0…N construct for accepts?

Not as such, but there isn’t one for for either - it just works for anything that has the appropriate functions. (map, flatMap, withFilter) for simply desugars into calls to those functions.

So the question is, what are the functions needed in order to synthesize this mooted variation of if? I’d guess probably isDefined and get, but there might well be other ways to formulate it.

2 Likes

That sounds like a great approach!

That would have the advantage of playing nicely with name-based extractors, which key off isEmpty and get

Well, the construct

if a <- aO && b then c(a) else d

is, with Option, exactly equivalent to

(for { a <- aO; if b } yield c(a)).getOrElse(d)

so that could just be made explicit. (If there’s an else, it’s getOrElse; if not, it’s just a for without a yield.)

There are two reasons that might not be ideal. One is partial function calls. One would think that

if x = pf(a) then foo(x)

would make a lot of sense, but it doesn’t work without lifting in the for case.

Secondly, the for encoding enforces a monadic structure, but since an if comprehension isn’t trying to preserve the monad anyway, even if there is one, the enforcement adds unnecessarily burdensome restrictions.

Thing is, for comprehension combines expressions of a type F into an expression of the same type F. Thus, we “bind” from Option into a new Option, or from List to a List, or from Future to a Future. This suggestion is to use them to combine options into boolean, which is too bespoke.

But we can still transform

if {comprehension} then f

to

for {comprehension*} f

where comprehension and comprehension* are related by replacing && with sequencing (;), and statements that have neither the form a <- z nor a = z with if p instead of just plain p.

Additionally

if {comprehension} f
else g

becomes

(for comprehension* do f).getOrElse(g)

and

if {comprehension*} then f
else if {comprehension2*} then g
else h

becomes

(for comprehension* do f)
.orElse(for comprehension2* do g)
.getOrElse(h)

with the same completely mechanical type-free desugaring that is already done with for.

It might only be useful for Option right now in the getOrElse form. But you can add extension methods to Try or whatever else to get the full capability.

So at least if we view if-comprehensions as sugar on a for, I don’t think the lack of type information is a problem. It’s just a mechanical transformation. Whether it actually transforms first to for-land and then to calls to flatMap etc, or just transforms directly to calls to flatMap etc. doesn’t really matter. The point is that the same style of transformation is adequate for the stated desired functionality.

Does the for-ification also work with more complex if-constructs like if (a <- aOpt || b <- bOpt)?

I guess that would need to be transformed to something like for(c <- aOpt.orElse(bOpt))?

I’m not clear on if it would work in the general case - how would eg if(a <- aOpt || foo || b <- bOpt) be handled?

1 Like

Maybe you tell us how this is supposed to work?

if (a <- aOpt || foo || b <- bOpt) { ...um...what goes here? }

I don’t see what the use is in naming a or b because you can’t know which one you get (and they might not be the same type). So you can’t actually use them.

At that point, you don’t gain anything over

if (aOpt.isDefined || foo || bOpt.isDefined) { ... }

do you?

So I don’t think || makes sense in a comprehension.

If either a or b is acceptable, you can

if (x <- aOpt.orElse(bOpt)) foo(x)

That is an excellent point.

Thoughts on if(a <- aOpt && !b <- bOpt)?

We should probably either not allow it, or make b available in the else branch.

You can’t make b available in the else branch because the reason you are in there can be that aOpt.isDefined returned false

This is one of the reasons that I think the parrallel with booleans doesn’t generalize well

1 Like

The only problem with this approach is that it elevates Option from a standard library element to a syntactical element of the language.

The same is true for flatMap, withFilter, map, and foreach, but they are much more widely defined than getOrElse. get isn’t acceptable for use, as it returns T in some interfaces and Option[T] in others. getOrElse is probably the defined target method here. Either, Try, and Option all have that. The Map interfaces have that as well, but they require a key as well as a default.

Are we planning on issuing the transformation for any type that contains a getOrElse method? Does the type also need flatMap, and map as well?

I’m ok with targeting getOrElse for syntactic sugar as long as it would apply to more than just (pun intended) Option.

Can’t this be built—if at all—on top of match case? IIUC it is intended as a slightly shorter syntax for a (potentially nested) match expression. So just expand it to the longer match expression. And then you only need the methods which already have a special status because of name based matching.

1 Like

Foo for thought: in the language we’re building, one is going to be are able to write:

if aOpt is Some(a) and bOpt is Some(b) and a.foo() == b.bar() then 
   // Use a & b...
else ...

Some other cool examples:

fun add(x, y) =
  if x is Some(xv) and y is Some(yv)
  then Some(xv + yv)
  else None

fun nonZero(list) =
  list is Nil or
    list is x :: xs and x != 0 and nonZero(xs)

fun findFirst(list, p) =
  if list is
    Nil then None
    x :: xs and
      p(x) then Some(x)
      else findFirst(xs, p)

fun zipWith(f, xs, ys) =
  if xs is Cons(x, xs)
    and ys is Cons(y, ys)
    and zipWith(f, xs, ys) is Some(tail)
    then Some(Cons(f(x, y), tail))
  else if xs is Nil
    and ys is Nil
    then Some(Nil)
  else None

fun mapPartition(f, xs) = if xs is
  Nil then (Nil, Nil)
  x :: xs and mapPartition(f, xs) is (l, r) and f(x) is
    Left(v) then (v :: l, r)
    Right(v) then (l, v :: r)

if x <=
  31 then "invisible"
  57 and x >= 48 then "digit"
  90 and x >= 65 then "uppercase"
  122 and x >= 97 then "lowercase"
else "symbol"
4 Likes

No, because if you have a sum type you don’t know which branch to match on. Also, you have to finish your typing pass first so you even know what the branches are.

If it’s methods, you can do a purely textual rewrite, and then if the methods aren’t available, give an error message. (Did you know, for example, that you can–at least in Scala 3 where I checked it–write flatMap and withFilter and so on as extension methods and it still works?)