-->

Filtering Lists in Scala's Monocle

2019-04-21 21:14发布

问题:

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 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 lists element, 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.

回答1:

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.