Enabling boolean partial function application in pattern matching arguments

A shorthand syntactic sugar to skip the need for binding and an extra if.

From this:

case class Foo(str: String, int: Int)
val foo: Foo = ???

foo match
  case Foo(_.startsWith("hello"), _ < 0) =>

The compiler will do this:

case class Foo(str: String, int: Int)
val foo: Foo = ???

foo match
  case Foo(str, int) if str.startsWith("hello") && int < 0 =>

Is this worth doing?

3 Likes

I’dd say yes. It is intuitive and concise.

case Foo(_ < 0) =>

already means

case Foo(<(_, 0)) =>

just like

case Foo(_ :: Nil) =>

means

case Foo(::(_, Nil)) =>

maybe change that to:

case class Foo(str: String, int: Int)
val foo: Foo = ???

foo match
  case Foo(if _.startsWith("hello"), if _ < 0) =>

?
then it could be disambiguated with the rules that @sjrd shown.

2 Likes

Honestly, I find it a bit superfluous, the extra binding doesn’t seem like such a burden to me

And the current syntax has the benefit of being very clear about what it does

Which I’d argue is not the case of the following

(In the context of this forum post, it might be obvious, but in a real codebase less so)

5 Likes

I agree. The original idea was good, but if that is not possible somehow, and adaptations are needed, the gain becomes less clear quickly.

I like the general direction.

Only that I’m not sure it’s really worth it.

(OTOH Rust uses pattern destructuring in all kinds of interesting places, resulting in quite “funny” syntax.)

How about if the pattern were even terser?

foo match
  case Foo(.startsWith("hello"), < 0) => ???

But like said, I’m not sure this isn’t “too magic”. (Also there is no precedent for such syntax in Scala, and I think also not elsewhere.)

So yes, having shorter pattern guards would be somehow cool, but maybe not worth it. IDK

I could go for

x match { case C(_, _) if _ == "" && _ == 0 => }

or

def check(s: String, n: Int): Boolean = ???
x match { case C(_, _) if check => }
x match { case C(_, 42) if _.startsWith("prefix") => }

but it’s the kiss of death if I like a syntax.

1 Like

Well, what if we start from

foo match
  case Foo(str.startsWith("hello"), int < 0) => str.dropRight(-int)

That clearly would break existing rules even worse, and isn’t fixable by using some other term (e.g. .) in place of _. I don’t like that the magic expansion can’t be used unless you never need to speak of the terms again.

At least with ordinary underscore expansion, the rewriting is in-place.

          _.length + _.size
(x, y) => x.length + y.size

But with this, the rewriting involves moving code around considerably more, including changing apparent scope (in vs out of ()):

case Foo(         _.length > 0,   _.isDefined) =>
case Foo(x, y) if x.length > 0 && y.isDefined  =>

So I think if it was going to work, we’d need an intermediate expansion that would allow declaration as well. For instance, akin to @Sporarum’s idea:

{: x if x.startsWith("Hello") :}

where {: and :} is a “permit variable binding only when in this set” operation, and could be elided if it was syntactically clear without. I’ll assume hereafter that it would be syntactically unambiguous, though I’m not certain.

Then at least it would simply be direct shorthand:

case Foo(     _.startsWith("hello"),      _ < 0) =>
case Foo(x if x.startsWith("hello"), y if y < 0) =>

But honestly, even then I don’t like it much. It seems irregular and provides minimal savings, and I’m not sure it reads better even to an expert, and to a non-expert I’m pretty sure it’s harder to read.

Indeed, I might support the embedded if syntax without the _ syntax, because that is kind of nice in that reasoning can be more local. (But it is kind of not-nice in that it makes it harder to pick out which new term names are being introduced.)

note that in my syntax the if changes argument processing mode from deconstruction to checking predicate, so you could also write:

case class Foo(str: String, int: Int)
val foo: Foo = ???
def checkStr(str: String): Boolean = ???
val checkInt: Int => Boolean = ???

foo match
  // case Foo(if _.startsWith("hello"), if _ < 0) =>
  case Foo(if checkStr, if checkInt) =>

so that would work even if you want to deduplicate predicates.

the whole idea of inline predicates would allow to merge cases which have identical bodies, but incompatible subtypes and thus predicates, e.g.:

sealed trait MyType
case class SubType1(str: String) extends MyType
case class SubType2(int: Int) extends MyType

val foo: Foo = ???

foo match {
  case SubType1(if _.startsWith("hello")) | SubType2(if _ < 0) =>
    // common body
}

without inline predicates such merging is not possible directly (or maybe it’s just too late and i’m missing something :slight_smile: ).

1 Like

Purely from a teaching perspective:

Best to leave the current syntax as it is I think.

Clarity is good, and too much sugar can be confusing. Learners already have difficulties with _; I would first teach cases without any _s and then say “when we are not using that bound value on the right hand side, we can communicate the idea that it’s not used by putting a _ there”.

cases are about destructuring / “unapplying” and ifs are about checking conditions, best to keep them conceptually separate. Many learners get condition checking but do not grasp destructuring / unapplying (since it’s a more mathematical / syntactic concept). So this would make things more confusing by mixing two concepts even more. Often beginners see cases as the same as if/else chains.

I would say to learners that, if they are using many cases with ifs in them, maybe they should reconsider their code. Sometimes they mean to write condition checking code, but because they learned this “cool new thing” they put many unnecessary cases with ifs everywhere.

I probably would not even advocate for the current syntax:

case Foo(str, int) if str.startsWith("hello") && int < 0 =>

but instead suggest

case Foo(str, int) => 
  if str.startsWith("hello") && int < 0 then ...
  else ...

which is much clearer. But the current syntax (above) is a reasonable middle ground. The new syntax is not much of a gain.

One can argue that “you can just teach several levels of sugar gradually, so they use it only when they’re ready”, but the “too much sugar” issue comes up a lot in Scala for learners (you need to spend a lot of time on Coursera forums first-hand to understand this). Especially the conflation of pattern matching and partial functions. Newcomers tend to put { case ... => } everywhere as a crutch, and cannot figure out how to write a “normal” total function literal.

Not the point of the discussion I know, but Foo(_.startsWith("hello"), ... can be accomplished with Foo(s"hello$rest", ....

2 Likes

It’s not the same thing. The if within a case is a guard condition that prevents from entering the case block. If the condition is false then the next case statement is checked.

Nonetheless, after the comment by @sjrd indeed there is a problem in the proposal, and I do not see how to make it better, so yes we should leave this syntax as-is.

3 Likes

I agree. I assume that this case is special and rare enough to not have its own special de-sugaring rules, thus avoiding “yet-another-thing-to-learn-and-remember”.

so maybe make a trade: deprecate very obscure variants of “extractor objects” which are in fact predicates and replace them with inline predicates marked by if as in my solution above in this thread?

currently we have (in both scala 2 and scala 3) Extractor Objects | Tour of Scala | Scala Documentation :

The return type of an unapply should be chosen as follows:

  • If it is just a test, return a Boolean. For instance case even().

they are pretty unwieldy to use directly, but with a helper trait they can be defined in not very boilerplate’y way. example Scastie - An interactive playground for Scala. :

// testing 'extractor objects' demo

// helper trait to define testing 'extractor objects' succintly
trait Predicator[T] {
  def unapply(subject: T): Boolean
}

val even: Predicator[Int] = _ % 2 == 0
val odd: Predicator[Int] = _ % 2 != 0

for (input <- Seq(1, 2, 3)) {
  input match {
    case even() => println(s"$input is even")
    case odd() => println(s"$input is odd")
    case _ => sys.error("unreachable")
  }
}

output:

1 is odd
2 is even
3 is odd

i’ve never seen such types of extractor objects (de facto predicators) in the wild - probably because they are so unwieldy. compact inline if predicates would probably get significantly more traction.

I’d wager it’s just because they are not more useful than the standard way

case Foo(even(), odd()) =>
// vs
case Foo(x, y) if even(x) && odd(y) =>

One big difference is the first does not bind whereas the second does, we can however make the first bind like so:

case Foo(x@even(), y@odd()) =>

I think this is a little bit harder to read

The standard way has the upside that it separates “binding expressions” from “testing expressions”:

case a < b if true => // scrut is constructed with a < b (puts a and b in scope)
case _ if a < b =>    // it is true that a < b (reads a and b from scope)
1 Like

yes, that’s good, but also it has a big downside of not working when we have multiple patterns in one match case. both proposed inline if predicates and current testing ‘extractor objects’ work even with multiple patterns in one match case.

that’s a really weird syntax. no idea how to comment on it.

anyway, here’s an example of match case with multiple patterns and predicates Scastie - An interactive playground for Scala. :

// testing 'extractor objects' demo

// helper trait to define testing 'extractor objects' succintly
trait Predicator[T] {
  def unapply(subject: T): Boolean
}

val even: Predicator[Int] = _ % 2 == 0
val odd: Predicator[Int] = _ % 2 != 0

val long: Predicator[String] = _.length >= 4
val short: Predicator[String] = _.length < 4

for ((word, index) <- Seq("the", "quick", "brown", "fox", "jumps", "over", "the", "lazy", "dog").zipWithIndex) {
  println(s"matching ${(word, index)}")
  (word, index) match {
    case (short(), even()) | (long(), odd()) => println("matched a case with multiple patterns and predicates")
    case _ => println("no match this time")
  }
}
println()

sealed trait MyType
case class MyInt(raw: Int) extends MyType
case class MyString(raw: String) extends MyType

for (input <- Seq[MyType](MyInt(8), MyString("quick"), MyInt(1), MyString("we"))) {
  println(s"matching $input")
  input match {
    case MyInt(even()) | MyString(short()) => println("matched a case with multiple patterns and predicates")
    case _ => println("no match this time")
  }
}

outputs:

matching (the,0)
matched a case with multiple patterns and predicates
matching (quick,1)
matched a case with multiple patterns and predicates
matching (brown,2)
no match this time
matching (fox,3)
no match this time
matching (jumps,4)
no match this time
matching (over,5)
matched a case with multiple patterns and predicates
matching (the,6)
matched a case with multiple patterns and predicates
matching (lazy,7)
matched a case with multiple patterns and predicates
matching (dog,8)
matched a case with multiple patterns and predicates

matching MyInt(8)
matched a case with multiple patterns and predicates
matching MyString(quick)
no match this time
matching MyInt(1)
no match this time
matching MyString(we)
matched a case with multiple patterns and predicates

I think it’s not that weird if we use something that makes sense:

case head :: tail => // scrut is constructed with a :: b (puts a and b in scope)

But I agree that symbolic constructors are not used that much now, but that feature was at the core of Scala when it was created !
Therefore it would be really hard to deprecate (And I’m sure it’s also used in clever ways to this day)

This is outside the scope of this discussion, but I’d love a “pass-through”, maybe something like:

case MyInt(even())     =V
case MyString(short()) => println("matched a case with multiple patterns and predicates")

Or even

case MyInt(even())     =↓
case MyString(short()) => println("matched a case with multiple patterns and predicates")

I’m half joking with these, I’d like the functionality, but the syntax makes me feel uneasy

They would allow us to use the ifs normally:

case MyInt(x) if x % 2 == 0      =V
case MyString(y) if y.length < 4 =>
  println("matched a case with multiple patterns and predicates")

anyway, it’s weird that an elegant solution of inline if predicates is rejected, but scala 2 and scala 3 both have extremely unpopular and clumsy testing extractor objects, i.e. the ones pointed by me here:

Not clever, but apparently preferred (that day) over guard:

case (x @ Optionlike()) :: _ =>

or

case ValDef(mods, name, tt @ build.SyntacticEmptyTypeTree(), rhs) =>

Not sure how clever it is, but case a < b if true => is possible today (at least in Scala 2, and probably in Scala 3 as well).

It’s worth noting that the if true is entirely irrelevant, so I’ve omitted it:

import cats.Order
import cats.syntax.all._

object < {
    def unapply[A: Order](p: (A, A)): Option[(A, A)] = 
        if (p._1 < p._2) p.some else none
}

List(1 -> 2, 2 -> 1, 1 -> 1).foreach {
    case a < b => println(s"$a < $b")
    case (a, b) => println(s"$a >= $b")
}

This produces the output you’d expect:

1 < 2
2 >= 1
1 >= 1