Functional Design Patterns in Scala: 2. Lenses
Case classes provide a convenient way of working with immutable objects in scala. Manipulating fields within them can be tedious, however. In this post, we will look at how lenses can be used to abstract over this complexity while preserving the benefits of immutability.
Consider the following example:
case class Address(line1: String, line2: String, postcode: String)
val a = Address("221B", "Baker St", "NW1 6XE")
To update the first line of a particular address, we can simply make a copy of it, overriding the relevant value:
println(a.copy(line1 = "221A"))
// Address(221A,Baker St,NW1 6XE)
To verify that the original value a
has not changed as a result of this operation:
println(a)
// Address(221B,Baker St,NW1 6XE)
For shallow updates, using this technique works well. However, suppose the address field is part of some more complex structure; perhaps we have accounts that have an owner, each of whom has an address:
case class Account(ID: Int, owner: Person)
case class Person(name: String, address: Address)
val p = Person("Sherlock Holmes", a)
val acc1 = Account(1, p)
Updating the address for account acc1
then becomes:
val acc2 = acc1.copy(
owner = acc1.owner.copy(
address = acc1.owner.address.copy(
line1 = "221A")))
which we can verify works as expected:
println(acc2)
// Account(1,Person(Sherlock Holmes,Address(221A,Baker St,NW1 6XE)))
Nesting calls to copy
in this fashion quickly leads to code that is verbose and difficult to
maintain. Fortunately, there is a solution to this problem in the world of functional programming:
lenses. Here, we will use monocle‘s GenLens
macro to rewrite our example.
import $ivy.`com.github.julien-truffaut::monocle-core:1.4.0`, monocle.Lens
import $ivy.`com.github.julien-truffaut::monocle-macro:1.4.0`, monocle.macros.GenLens
val owner: Lens[Account, Person] = GenLens[Account](_.owner)
val address: Lens[Person, Address] = GenLens[Person](_.address)
val line1: Lens[Address, String] = GenLens[Address](_.line1)
The code above defines three lenses, which we can use as ‘getters’ and ‘setters’ for the fields they relate to:
println(owner.get(acc1))
// Person(Sherlock Holmes,Address(221A,Baker St,NW1 6XE))
println(line1.set("221A")(a))
// Address(221A,Baker St,NW1 6XE)
At face value, this provides little benefit over the previous approach, since we still need a reference to an address in order to set the first line of it. However, the real power of lenses is that they compose. For example, we can construct a lens that allows us to set the first line of an account owner’s address directly from a reference to the account itself:
val lens: Lens[Account, String] = owner composeLens address composeLens line1
With this in place, we can now write
println(lens.get(acc1))
// 221B
println(lens.set("221A")(acc1))
// Account(1,Person(Sherlock Holmes,Address(221A,Baker St,NW1 6XE)))
…and we are done.
To dig a little deeper into the subject, suppose now that the domain model changes and account owners are no longer required to provide their address:
case class Person(name: String, maybeAddress: Option[Address])
Here, we can no longer use lenses, since we can’t always be guaranteed to get a value back.
Instead, we can use monocle’s Optional
as a way of querying and modifying an owner’s
address. We supply two functions as constructor arguments: these will be used as the
‘getter’ and ‘setter’ respectively:
import $ivy.`com.github.julien-truffaut::monocle-core:1.4.0`, monocle.Optional
val address = Optional[Person, Address](_.maybeAddress)(a => _.copy(maybeAddress = Some(a)))
We can check that this provides access to the address field for an account owner as follows:
val p1 = Person("John Watson", None)
val p2 = Person("Sherlock Holmes", Some(a))
println(address.getOption(p1))
// None
println(address.getOption(p2))
// Some(Address(221B,Baker St,NW1 6XE))
println(address.set(a)(p1))
// Person(John Watson,Some(Address(221B,Baker St,NW1 6XE)))
We can compose Lenses and Optionals using composeOptional
. Note that the result of this is
always an Optional
:
val optional: Optional[Account, Address] = owner composeOptional address
As before, this enables us to manipulate the nested addresses in our updated account type directly:
val acc2 = Account(2, p1)
println(optional.getOption(acc2))
// None
println(optional.set(a)(acc2))
// Account(2,Person(John Watson,Some(Address(221B,Baker St,NW1 6XE))))
The examples presented here only scratch the surface of what lenses (and the wider topic of optics) are capable of. If you are interested in learning more, I would recommend checking out the Monocle docs and experimenting further. The code for all the examples used in this post is available as a GitHub Gist.