Lens
A Lens
is an optic used to zoom inside a Product
, e.g. case class
, Tuple
, HList
or even Map
.
Lenses
have two type parameters generally called S
and A
: Lens[S, A]
where S
represents the Product
and A
an element inside of S
.
Let's take a simple case class with two fields:
case class Address(streetNumber: Int, streetName: String)
We can create a Lens[Address, Int]
which zooms from an Address
to its field streetNumber
by supplying a pair of functions:
get: Address => Int
replace: Int => Address => Address
import monocle.Lens
val streetNumber = Lens[Address, Int](_.streetNumber)(n => a => a.copy(streetNumber = n))
This case is really straightforward so we automated the generation of Lenses
from case classes using a macro:
import monocle.macros.GenLens
val streetNumber = GenLens[Address](_.streetNumber)
Once we have a Lens
, we can use the supplied get
and replace
functions (nothing fancy!):
val address = Address(10, "High Street")
// address: Address = Address(streetNumber = 10, streetName = "High Street")
streetNumber.get(address)
// res0: Int = 10
streetNumber.replace(5)(address)
// res1: Address = Address(streetNumber = 5, streetName = "High Street")
We can also modify
the target of Lens
with a function, this is equivalent to call get
and then replace
:
streetNumber.modify(_ + 1)(address)
// res2: Address = Address(streetNumber = 11, streetName = "High Street")
val n = streetNumber.get(address)
// n: Int = 10
streetNumber.replace(n + 1)(address)
// res3: Address = Address(streetNumber = 11, streetName = "High Street")
We can push the idea even further, with modifyF
we can update the target of a Lens
in a context, cf cats.Functor
:
def neighbors(n: Int): List[Int] =
if(n > 0) List(n - 1, n + 1) else List(n + 1)
import cats.implicits._ // to get all Functor instance
streetNumber.modifyF(neighbors)(address)
// res4: List[Address] = List(
// Address(streetNumber = 9, streetName = "High Street"),
// Address(streetNumber = 11, streetName = "High Street")
// )
streetNumber.modifyF(neighbors)(Address(135, "High Street"))
// res5: List[Address] = List(
// Address(streetNumber = 134, streetName = "High Street"),
// Address(streetNumber = 136, streetName = "High Street")
// )
This would work with any kind of Functor
and is especially useful in conjunction with asynchronous APIs,
where one has the task to update a deeply nested structure with the result of an asynchronous computation:
import scala.concurrent._
import scala.concurrent.ExecutionContext.Implicits._ // to get global ExecutionContext
def updateNumber(n: Int): Future[Int] = Future.successful(n + 1)
streetNumber.modifyF(updateNumber)(address)
// res6: Future[Address] = Future(Success(Address(11,High Street)))
Most importantly, Lenses
compose together allowing to zoom deeper in a data structure
case class Person(name: String, age: Int, address: Address)
val john = Person("John", 20, Address(10, "High Street"))
val address = GenLens[Person](_.address)
address.andThen(streetNumber).get(john)
// res7: Int = 10
address.andThen(streetNumber).replace(2)(john)
// res8: Person = Person(
// name = "John",
// age = 20,
// address = Address(streetNumber = 2, streetName = "High Street")
// )
Other Ways of Lens Composition
Is possible to compose few Lenses
together by using compose
:
GenLens[Person](_.name).replace("Mike") compose GenLens[Person](_.age).modify(_ + 1)
Same but with the simplified macro based syntax:
import monocle.macros.syntax.lens._
john.lens(_.name).replace("Mike").lens(_.age).modify(_ + 1)
(All Setter
like optics offer replace
and modify
methods that returns an EndoFunction
(i.e. S => S
) which means that we can compose modification using basic function composition.)
Sometimes you need an easy way to update Product
type inside
Sum
type - for that case you can compose Prism
with Lens
by using some
:
import monocle.macros.GenLens
case class B(c: Int)
case class A(b: Option[B])
val c = GenLens[B](_.c)
// c: Lens[B, Int] = repl.MdocSession$MdocApp$$anon$7@be1f3d6
val b = GenLens[A](_.b)
// b: Lens[A, Option[B]] = repl.MdocSession$MdocApp$$anon$8@d0f1671
b.some.andThen(c).getOption(A(Some(B(1))))
// res11: Option[Int] = Some(value = 1)
For more detailed view of the various optics composition see Optics
Lens Generation
Lens
creation is rather boiler platy but we developed a few macros to generate them automatically. All macros
are defined in a separate module (see modules).
import monocle.macros.GenLens
val age = GenLens[Person](_.age)
GenLens
can also be used to generate Lens
several level deep:
GenLens[Person](_.address.streetName).replace("Iffley Road")(john)
// res12: Person = Person(
// name = "John",
// age = 20,
// address = Address(streetNumber = 10, streetName = "Iffley Road")
// )
For those who want to push Lenses
generation even further, we created @Lenses
macro annotation which generate
Lenses
for all fields of a case class. The generated Lenses
are in the companion object of the case class:
import monocle.macros.Lenses
@Lenses case class Point(x: Int, y: Int)
val p = Point(5, 3)
Point.x.get(p)
// res13: Int = 5
Point.y.replace(0)(p)
// res14: Point = Point(x = 5, y = 0)
You can also add a prefix to @Lenses
in order to prefix the generated Lenses
:
@Lenses("_") case class PrefixedPoint(x: Int, y: Int)
val p = PrefixedPoint(5, 3)
PrefixedPoint._x.get(p)
// res15: Int = 5
Note: before using @Lenses
remember to activate macro annotations. See Getting started section for instructions.
Laws
A Lens
must satisfy all properties defined in LensLaws
from the core
module.
You can check the validity of your own Lenses
using LensTests
from the law
module.
In particular, a Lens
must respect the getReplace
law which states that if you get
a value A
from S
and
replace
it back in, the result is an object identical to the original one. A side effect of this law is that replace
must only update the A
it points to, for example it cannot increment a counter or modify another value.
def getReplace[S, A](l: Lens[S, A], s: S): Boolean =
l.replace(l.get(s))(s) == s
On the other hand, the replaceGet
law states that if you replace
a value, you always get
the same value back.
This law guarantees that replace
is actually updating a value A
inside of S
.
def replaceGet[S, A](l: Lens[S, A], s: S, a: A): Boolean =
l.get(l.replace(a)(s)) == a