Suggestion: if-comprehensions

for-comprehensions are a great way to wade through both Options and conditionals and do something if they’re all populated and the conditions are satisfied.

However, I find myself oftentimes needing to also do something when they’re not populated & satisfied.

This leads to falling back on a very awkward match syntax, with no real or good substitute, especially if the logic in the for-comprehension is complex or there is more than one Option to extract.

Suggestion: expand the if-statement to allow for for-comprehension style Option-extraction.

In its simplest case:

if (a <- aOpt) {
   // Use a...
} else {
  // Else...
}

would be equivalent to:

aOpt match {
    case Some(a) => // Use a...
    case _ => // Else... 
}

But you could also write e.g.:

if (a <- aOpt && b <- bOpt && a.foo() == b.bar()) {
   // Use a & b...
} else {
  // Else...
}

which currently requires something like:

aOpt match {
    case Some(a) => bOpt match {
        case Some(b) if a.foo() == b.bar() => // Use a & b
        case _ => // Else 1...
    case _ => // Else 2... 
}

But that form is both difficult to decipher and has two else branches.

Some random points:

  1. In the if-comprehension, an Option extraction evaluates to true iff the Option.isDefined().

  2. if(!a <- aOpt) could be a legal statement, in which case it would evaluate to true iff Option.isEmpty(). a would not be available in the then-section of the if-statement, but would be available in the else-branch.

  3. Seqs would not be allowed - the if-statement can only handle Options.

  4. One could imagine a for (...) { } else {} style syntax, but that is a very non-intuitive extension. I’ve also found that when Seqs are involved there’s usually both a preamble & postamble to the for-loop, necessitating an outer gate: if (seq.nonEmpty && ...) { ... for (s <- seq) { ... } ... }. So there’s an impedance mismatch between the need and the capability.

  5. The match statement is extremely powerful and has great applications, but I find it being both verbose and hiding the intention for this particular type of conditional extraction.

  6. The if-comprehension has an intuitive, powerful, clear, concise and backwards-compatible/opt-in syntax and would add something quite useful to the language.

Thoughts?

1 Like

For clarification, what would make this approach unsuitable?

(aOpt, bOpt) match {
    case (Some(a), Some(b)) if a.foo() == b.bar() => // Use a & b
    case _ => // Else ... 
}
2 Likes

I figured someone would make a suggestion along those lines :wink:

Yes, it’s absolutely possible to do more-or-less convoluted zip-esque rephrasings of the match. While they’ll work, they still hide the intent.

I guess it comes down to “I’m trying to do something that’s more like an if than a match, and while shoehorning is possible, it’s still shoehorning”.

This is unsuitable when computing bOpt depends on the value of a, which is not uncommon when you are doing chained validations or similar logic

Notably, there is precedence for this kind of functionality in other languages. e.g. Python added the Walrus Operator which largely serves the same purpose in a limited manner. Swift has if let. Kotlin has flow-based type inference for if foo != null

This is reminiscent of if-let statements in Rust (if let - Rust By Example).

But honestly, in Rust I only use if-let in the simplest case: destructure one thing, and only act in that case. Otherwise I find match clearer even in Rust (where match is slightly less clear than in Scala, I find, because of the lack of the case keyword). (Rust also doesn’t allow more complex conditionals in the destructuring part of if-let.)

The nice thing about match statements is that they lay out everything in a very clear way, which is obscured by both for-comprehensions and if-else statements. I really think the best practice is to embrace match for this kind of thing.

3 Likes

Which is when you’d use a for-comprehension, anyway, no?

1 Like

Sure. As far as i understand it, basically this proposal is providing syntactic sugar for

val result = for(a <- aOpt; b <- bOpt; if c) yield foo(a, b)
result.getOrElse(d)

and letting you write it as

if(a <- aOpt && b <- bOpt && c) foo(a, b) else d

No more, no less. I’m not fully convinced that the convenience is worth the additional syntax, but it’s not unreasonable to say that Options are sufficiently common that they deserve a bit of special syntax. After all, other languages (Rust, Kotlin, Python, Swift, …) all have done so with their nullable/noneable/optional types, and in my experience it definitely smooths things out over juggling Option types and combinators in Scala.

1 Like

But for-comprehensions are that special syntax, except generalized. I guess having non-generalized syntax is a possibility.

I mean, the point of “special syntax” is that it is not generalized. Otherwise it’s just “syntax” :stuck_out_tongue:

Or at least the degree to which it’s specialized/generalized matters. “Syntax that works across all MonadFilters” is more specialized than “Syntax that works across all function calls”, but is less specialized than “Syntax that works across all Options”. If Options are common enough, even among the universe of all possible MonadFilters, it’s certainly conceivable they could deserve an even more specialized syntax just for them

Basically yes, assuming we don’t embrace the if(!a <- aOpt) extension.

But much like the onerous match, the equivalent for hides the intent and requires explicitly storing the intermediary result (or some creative parenthesis usage that probably wouldn’t pass code review :wink: )

What I like about the proposal is the clarity and explicitness. And I believe it’s quite intuitive since it rests on the parallel usage in the for-comprehension.

To be clear: I’d personally prefer it to using match even in the simple case - the match ends up quite verbose and with an extra level of indent if the leaf code is multiline. The if-comprehension has a minimum amount of text without losing clarity.

What if we introduce a extract operator <= with syntax Pattern <= value and result a boolean value.
Then we generalize the problem.

Some use case:

//1.
if (Some(a) <= aOpt && Some(b) <= bOpt && a.foo() == b.bar()) {
   // Use a & b...
} else {
  // Else...
}



//2.
val list: List[Any] = ???
if ( h @ Some(i : Int) :: _ <= list && i > 10) {
    // do something with h and i
}

//3.

val text = "user@address"
if (s"${user}@${addr}" <= text && addr == "address") {
    // ....
}

// and more

BTW: We already have <- and -> in scala, and we have => without <=, I think it is OK to add <= for symmetry.

1 Like

<= already exists as the less-than operator, so that may be very confusing. And probably impossible for the parser to see the difference, since Some(a) can have a <= (extension) method.

But I like that this is at least a general solution that is not specialized to only Option. And you could do it with <- instead of <=.

2 Likes

I find this proposal quite elegant, though I’m not convinced it is worth it

For me there is a tension in this case between

  1. Making the intent clearer
  2. Having the language easy to learn (simpler and fewer mechanisms)

More syntax is always a burden to new users, not only to learn what it does, but also when to use it
We already have some issues with match vs combinators vs for-comprehension, so for me this would add one more case to consider in this debate

Given this, I believe for this to be worthwhile, we would need to find a unique mechanism that allows both to express if- and for-comprehension, maybe something like a <- aOpt creating a conditional binding or something like that

This is a small change, but I believe !a <- aOpt introducing bindings in the else (and else if) branch to be really surprising, and un-scala-like(?), so I would advise to drop it

In case the condition is a <- aOpt || b <- bOpt what can you do in the body of the if branch ?

All in all, I think this is a really neat ideas, I don’t think it’s usable as-is, but it forces us to find-out why, and in this process will probably make us move forward, thank you for the proposal !

3 Likes

Here is yet another idea:

dslIf(aOpt){ v=>
} else {

}

where dslIf is declared as:

def dslIf[T,R](condition: Option[T])(body:(T)=>R)else(body: =>R)

IMHO: It would be very usefull dsl :wink:

In my mind Scala is the language of taking things to their logical conclusion. My favorite example of that is string interpolation. Scala didn’t just phone it in like most other languages - it offers fully user-definable interpolators with tremendous power. I have used it to great effect that would be hard-to-impossible to replicate in other languages.

For the questions at hand I would decompose them as follows:

  1. Since a <- aOpt evaluates to either true or false, it is just a regular conditional in the if-statement. And any old conditional can be negated. So taking that to it’s logical conclusion means we must support a !a <- aOpt construct, regardless of any considerations of the availability of a in either branch.

  2. Whatever the rest of the logical statement is in the if, a <- aOpt is either decidedly true, decidedly false or ambiguous, for each of the then & else branches. Whenever it is decidedly true, there would be a loss if it weren’t made available in that branch. So we must make it available in any branch where it’s decidedly true.

Now I’m going to draw an arbitrary line in the sand and say that the analysis of true/false/ambiguous does not need to extend beyond what’s inside the if(...). It’s of course possible to create monstrosities like:

val silly = aOpt.isDefined
if (a <- aOpt || silly) { ... }

In this case a would not be available inside either branch. The example gets even worse if we write:

if (a <- aOpt || aOpt.isDefined) { ... }

I’m totally fine with a not being available here either.

I.e. effectively making the rule “|| present => ambiguous => a not available”.

If we can refine that further then that would be great, but it’s a comparatively small win.

Isn’t that basically Option.fold[B](ifEmpty: => B)(f: (A) => B): B with the order of arguments reversed?

fold is great but doesn’t allow additional conditions.

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