Search code examples
scalalensesmonocle-scala

Filtering Lists in Scala's Monocle


Given the following code:

case class Person(name :String)
case class Group(group :List[Person])

val personLens = GenLens[Person]
val groupLens = GenLens[Group]

how can i "filter" out certain Persons from the selection, NOT by index but by a specific property of Person, like:

val trav :Traversal[Group, Person] = (groupLens(_.group) composeTraversal filterWith((x :Person) => /*expression of type Boolean here */))

I only found the filterIndex function, which does only include elements from the list based on the index, but this is not what I want.

filterIndex takes a function of type: (Int => Boolean)

and I want:

filterWith (made up name), that takes a (x => Boolean), where x has the type of the element of the list, namely Person in this short example.

This seems so practical and common that I assume somebody has thought about that and i (with my, i must admit limited understanding of the matter) don't see why it can't be done.

Am I missing this functionality, is it not implemented yet or just plainly impossible for whatever reason (please do explain if you have the time)?

Thank you.


Solution

  • A bad version

    I'll start with a naive attempt to write something like this. I'm using a simple list version here, but you could get fancier (with Traverse or whatever) if you wanted.

    import monocle.Traversal
    import scalaz.Applicative, scalaz.std.list._, scalaz.syntax.traverse._
    
    def filterWith[A](p: A => Boolean): Traversal[List[A], A] =
      new Traversal[List[A], A] {
        def modifyF[F[_]: Applicative](f: A => F[A])(s: List[A]): F[List[A]] =
          s.filter(p).traverse(f)
      }
    

    And then:

    import monocle.macros.GenLens
    
    case class Person(name: String)
    case class Group(group: List[Person])
    
    val personLens = GenLens[Person]
    val groupLens = GenLens[Group]
    
    val aNames = groupLens(_.group).composeTraversal(filterWith(_.name.startsWith("A")))
    
    val group = Group(List(Person("Al"), Person("Alice"), Person("Bob")))
    

    And finally:

    scala> aNames.getAll(group)
    res0: List[Person] = List(Person(Al), Person(Alice))
    

    It works!


    Why it's bad

    It works, except…

    scala> import monocle.law.discipline.TraversalTests
    import monocle.law.discipline.TraversalTests
    
    scala> TraversalTests(filterWith[String](_.startsWith("A"))).all.check
    + Traversal.get what you set: OK, passed 100 tests.
    + Traversal.headOption: OK, passed 100 tests.
    ! Traversal.modify id = id: Falsified after 2 passed tests.
    > Labels of failing property: 
    Expected List(崡) but got List()
    > ARG_0: List(崡)
    ! Traversal.modifyF Id = Id: Falsified after 2 passed tests.
    > Labels of failing property: 
    Expected List(ᜱ) but got List()
    > ARG_0: List(ᜱ)
    + Traversal.set idempotent: OK, passed 100 tests.
    

    Three out of five isn't very good.


    A slightly better version

    Let's start over:

    def filterWith2[A](p: A => Boolean): Traversal[List[A], A] =
      new Traversal[List[A], A] {
        def modifyF[F[_]: Applicative](f: A => F[A])(s: List[A]): F[List[A]] =
          s.traverse {
            case a if p(a) => f(a)
            case a => Applicative[F].point(a)
          }
      }
    
    val aNames2 = groupLens(_.group).composeTraversal(filterWith2(_.name.startsWith("A")))
    

    And then:

    scala> aNames2.getAll(group)
    res1: List[Person] = List(Person(Al), Person(Alice))
    
    scala> TraversalTests(filterWith2[String](_.startsWith("A"))).all.check
    + Traversal.get what you set: OK, passed 100 tests.
    + Traversal.headOption: OK, passed 100 tests.
    + Traversal.modify id = id: OK, passed 100 tests.
    + Traversal.modifyF Id = Id: OK, passed 100 tests.
    + Traversal.set idempotent: OK, passed 100 tests.
    

    Okay, better!


    Why it's still bad

    The "real" laws for Traversal aren't encoded in Monocle's TraversalLaws (at least not at the moment), and we additionally want something like this to hold:

    For any f: A => A and g: A => A, t.modify(f.compose(g)) should equal t.modify(f).compose(t.modify(g)).

    Let's try it:

    scala> val graduate: Person => Person = p => Person("Dr. " + p.name)
    graduate: Person => Person = <function1>
    
    scala> val kill: Person => Person = p => Person(p.name + ", deceased")
    kill: Person => Person = <function1>
    
    scala> aNames2.modify(kill.compose(graduate))(group)
    res2: Group = Group(List(Person(Dr. Al, deceased), Person(Dr. Alice, deceased), Person(Bob)))
    
    scala> aNames2.modify(kill).compose(aNames2.modify(graduate))(group)
    res3: Group = Group(List(Person(Dr. Al), Person(Dr. Alice), Person(Bob)))
    

    So we're out of luck again. The only way our filterWith could actually be lawful is if we promise never to use it with an argument to modify that might change the result of the predicate.

    This is why filterIndex is legit—its predicate takes as an argument something that modify can't touch, so you can't break the t.modify(f.compose(g)) === t.modify(f).compose(t.modify(g)) law.


    Moral of the story

    You could write an unlawful Traversal that does unlawful filtering stuff and use it all the time and it's pretty likely that it will never hurt you and that nobody will ever think you are a horrible person. So go for it, if you want. You'll probably never see a filterWith in a decent lens library, though.