Combine collection of lenses
Asked Answered
L

1

9

Monocle is a great library (and not the only one) which implements lenses pattern, which is great if we have to change one field in huge nested object. Like in example http://julien-truffaut.github.io/Monocle/

case class Street(number: Int, name: String)
case class Address(city: String, street: Street)
case class Company(name: String, address: Address)
case class Employee(name: String, company: Company)

The following boilerplate

employee.copy(
  company = employee.company.copy(
    address = employee.company.address.copy(
      street = employee.company.address.street.copy(
        name = employee.company.address.street.name.capitalize // luckily capitalize exists
      )
    )
  )
)

Can easily be replaced with

import monocle.macros.syntax.lens._

employee
  .lens(_.company.address.street.name)
  .composeOptional(headOption)
  .modify(_.toUpper)

Which is great. As far as I understand, macros magic converts everything exactly to the same code as above.

However, what if I want to combine several actions? What if I want to change street name, address city and company name at the same time with one call? Like the following:

employee.copy(
  company = employee.company.copy(
    address = employee.company.address.copy(
      street = employee.company.address.street.copy(
        name = employee.company.address.street.name.capitalize // luckily capitalize exists
      ),
      city = employee.company.address.city.capitalize
    ),
    name = employee.company.name.capitalize
  )
)

If I just reuse lenses here, I would have the following code:

employee
  .lens(_.company.address.street.name).composeOptional(headOption).modify(_.toUpper)
  .lens(_.company.address.city).composeOptional(headOption).modify(_.toUpper)
  .lens(_.company.name).composeOptional(headOption).modify(_.toUpper)

Which will eventually be translated to THREE employee.copy(...).copy(...).copy(...) invocations, not just ONE employee.copy(...). How to make it better?

Furthermore, it would be really great to apply a sequence of operations. Like sequence of pairs Seq[(Lens[Employee, String], String => String)] where first element is a lens pointing to the correct field and the second one is a function which modifies it. It will help to build such sequence of operations from the outside. For the above example:

val operations = Seq(
  GenLens[Employee](_.company.address.street.name) -> {s: String => s.capitalize},
  GenLens[Employee](_.company.address.city) -> {s: String => s.capitalize},
  GenLens[Employee](_.company.name) -> {s: String => s.capitalize}
)

or something similar...

Longobard answered 16/8, 2017 at 12:59 Comment(0)
I
11

As far as I understand, macros magic converts everything exactly to the same code as above.

It doesn't.

This simple code:

employee.lens(_.name)
  .modify(_.capitalize)

Becomes something along the lines of that monstrosity *:

monocle.syntax.ApplyLens(employee,
    new monocle.PLens[Employee, Employee, String, String] {
      def get(e: Employee): String = e.name;
      def set(s: String): Employee => Employee = _.copy(s);
      def modify(f: String => String): Employee => Employee = e => e.copy(f(e.name))
    }
}).modify(_.capitalize)

Which is quite far from simple

employee.copy(name = employee.name.capitalize)

and includes three redundant objects (anonymous lens class, ApplyLens for syntax sugar and lambda returned from modify). And we skipped more by using capitalize directly instead of composing with headOption.

So no, there is no free dinner. Most of the time, however, it's good enough and nobody cares about extra lens objects and intermediate results.


Multiple operations

You can build a Traversal (collection lens) from multiple lenses if their types align (here it's Employee to String)

val capitalizeAllFields = Traversal.applyN(
  GenLens[Employee](_.name),
  GenLens[Employee](_.company.address.street.name),
  GenLens[Employee](_.company.address.city),
  GenLens[Employee](_.company.name)
).modify(_.capitalize)

This will still call copy multiple times. For efficiency, you can use Traversal.apply4 et al. varieties, which will require you to write that copy manually (and I'm too lazy to do it now).

Finally, if you want to apply various transformations to different types of fields, you are supposed to use the fact that modify and set return a function of type Employee => Employee. For your example that would be:

val operations = Seq(
  GenLens[Employee](_.company.address.street.name).modify(_.capitalize),
  GenLens[Employee](_.company.address.street.number).modify(_ + 42),
  GenLens[Employee](_.company.name).set("No Company Inc.")
)

val modifyAll = Function.chain(operations)

// does all above operations of course, with two extra copy calls
modifyAll(employee) 

* - this is simplified output from desugar in Ammonite-REPL. I skipped modifyF, btw

Inexpungible answered 16/8, 2017 at 14:3 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.