Feedback for my Pattern-Matching lib

I’m writing a small library for “Scala-like” pattern-matching in when-expressions. It’s not ready yet (especially collections need some more love), and I’m looking for feedback and criticism. Here is how it can look like:

data class Person(val firstName: String, val lastName: String, val age: Int)

val p = Person("Alice", "Cooper", 74)

when(match(p)) {
    "Alien"(any, any, any) -> println("Aliens!")
    "Person"("Mick", "Jagger", gt(70)) -> println("Mick Jagger!")
    "Person"("Alice", "Cooper", any) -> println("Alice Cooper!")
    else -> println("I don't know this guy")
}
1 Like

Looks interesting as a something that hacks through existing language.

One question: why "Person"(...) and not Person::class(...)? Would it be technically impossible? Do you consider it worse than a string?

Also, polluting global space with String.invoke() is pretty aggressive. I think it would be better to provide this operator through DSL. It could be hard to merge this with when though. Maybe something like:

p.match { when (it) {
   ...
}}

Not ideal, I agree.

edit: Or resign from using when entirely and do something like:

p.match {
    "Alien"(...) then { println() }
    "Person"(...) then { println() }
    ...
}

But then I think {} would be mandatory.

Thank you for the feedback!

I switched from String.invoke to KClass.invoke. The String version is a little shorter, but I think using a KClass feels a little more precise and safe (and actually simplifies the code). One thing I didn’t think of before is that renaming a class would spell disaster in the String version. Also, having invoke on the lesser used KClass feels not as unsafe as for String.

I thought about a seperate DSL imitating when, but I don’t see a big advantage, and the user has the burden of learning a new syntax, even if it looks similar. The only thing that might be prettier in an own DSL is the capture pattern, and I don’t think it’s worth it.

2 Likes

For the indexed access, I also tried to use reflection classes instead of String, and it works great. I think I’ll keep both:

when(match(p)) {
  startsWith("B")["firstName"] ->... // old String based access
  startsWith("C")[Person::firstName] -> ... // more type safe
}

If you can wrap your head around that you have to go reverse, you can even have “nested” access:
eg(5)[String::length][Person::firstName]

1 Like

Maybe this could be better:

eg(5) { person: Person -> person.firstName.length }
// or just simply
eg(5) { it.firstName.length }

I thought about something like this, but at this place I don’t know that I need to start from Person, so I could do only something like (Any) -> Any or need type annotations, which is not great. Further, it would probably mean to restructure large parts of the DSL, while [] is totally orthogonal to other features by using an extension method with receiver.

I’ll ponder over this, maybe there is another syntax to make it work, as it would be useful to have.

1 Like

Yes, I thing I have something I could live with. Using reified generics, this would work

when(match(person)) {
        eq(5) on { p:Person -> p.firstName.length } -> ...
        eq(5).on<Person>{ it.firstName.length } -> ...
        eq("Alice") on Person::firstName -> ...
        else -> ...
}

The second version can’t use infix, but I think all cases look okay-ish.

1 Like

I added a second style to the library using a similar syntax to @broot’s suggestion, it looks like this:

val result = match(p) {
    Person::class("Alice", "Smith", gt(3)) then { "It's Alice!" }
    otherwise { "someone else" }
}

Nice work! Does your library also detect if the pattern match block is not exhaustive, e.g. all possible combinations are covered by the match block? In Scala the compiler tells you that at compile time. Would be very useful if your library could also do that at least at runtime.

An example to show what I mean:

val option = Option[String]
val otherOption = Option[String]

(option, otherOption) match {
  case (Some(value1), Some(value2)) => println(s"Both options have values: $value1 and $value2")
  case (Some(value1), None)          => println(s"Only option has value: $value1")
  case (None, Some(value2))          => println(s"Only otherOption has value: $value2")
  case (None, None)                  => println("Neither option has a value")
}

In Scala the compiler would complain at compile time if any of the 4 cases above were missing.

It is very hard to detect exhaustiveness in a general way. In order to even attempt this, the patterns would need additional information about how they cover parts of a given type, but that would mean that custom patterns can’t be simply (A) → Boolean anymore. Further, because I would have this information only at runtime means that I need to allow users to write match bodies without otherwise, and throw a runtime exception if the matches are not exhaustive, which makes it much less useful. The only way to get around this would be writing a compiler plugin, which comes with its own heap of problems. So I’d rather focus on improving and extending the existing library (as I did with the recent addition of validations) instead of working on a very complicated and brittle feature that only looks nice on paper.

I really hope one day Kotlin gets pattern matching as a language feature, which would make exhaustiveness checks much more feasible.

1 Like

@Landei: I see your point and I also hope Kotlin gets pattern matching with check for exhaustiveness as Scala. Yes, at best it is put into the compiler and not in a library (although in most other cases I prefer things to be done the other way round) so that things can be verified at compile time.

The current application I work on pattern matching with check for exhaustiveness is a must, because of safety reasons. Otherthings things could go wrong in the real world (and not only with data).

1 Like