Suggestion: if-comprehensions

In such example it is very similiar :wink:

But fold can not use bool expression\ three-valued logic and it is not very good in long chains:

sif( a < b){
...
}else sif( b < c ){
...
}else foldIf( opt ){ o=>
...
}else{
...
}

Actualy this theme is all about syntatic sugar.

Edit:
I often prefer to use yield:

(for(v<-value) yield v).orElse(...)

Right, but that’s actually the source of discomfort for me with this proposal: it privileges Option in a way that doesn’t match the way for works.

I’d actually be more interested if this was based on syntax sugar or a type class, so that it could be generalized beyond Option.

(This isn’t purely academic: it’s not unusual to have real-world trinary values that distinguish between “empty” and “unknown”.)

3 Likes

It’s true, but I would advise exploring library-only alternatives first.

For example, I just coded something that lets you do the following:

val answer =
  attempt:
    val a = aOpt.?
    val b = bOpt.?
    bar(b, a).?     // bar(a, b) returns Boolean
    foo(a, b)
  .default:
    d

The ability of Scala 3 to create custom very low overhead (low runtime overhead and low syntactic overhead) syntax like this is pretty good. Yes, it doesn’t have the actual comprehension, so you have to type val a couple of times. But if you interpret .? as “get a value, or run a test, and bail out otherwise”, then it’s really compact and clear. And it’s a 100% library solution.

Don’t want to save the unpacked options? Fine. Just unpack them again:

val answer =
  attempt:
    bar(bOpt.?, aOpt.?).?; foo(aOpt.?, bOpt.?)
  .default:
    d

One-liner? Okay, you can:

val answer = attempt{ bar(bOpt.?, aOpt.?).?; foo(aOpt.?, bOpt.?) }.default(d)

Yes, it does take a little more getting used to than a new language construct, but the advantage is that you don’t have to convince anyone of anything to start using this. It just takes a bit of code. (And Scala 3.3 with boundary/break.)

If you want to sequence with && instead of ;, that’s easy enough too. Want to chain multiple .attempts? Also easy.

1 Like

Just to make sure I parsed your suggestion correctly, that construct would be equivalent to:

val answer = 
  if(a <- aOpt && b <- bOpt && bar(b, a)) foo(a, b)
  else d

I fully acknowledge that your .? construct and Scala’s capabilities are quite impressive, but that syntax just turned my brain inside out :wink:

1 Like

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