Optics: a hands-on introduction in Scala
Optics: purely functional abstractions to manipulate immutable objects
Here’s a dummy 👶 domain model:
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, salary: Int)
defined class Street
defined class Address
defined class Company
defined class Employee
Case classes in Scala are used to define immutable and algebraic data types. They have built-in support for: - pattern matching - comparison by structure - copy
method which helps for immutable updates
Let’s define an employee 👩:
val employee = Employee("Monica", Company("Bestmile", Address("Lausanne", Street(58, "rhodanie"))), 100)
employee: Employee = Employee(Monica,Company(Bestmile,Address(Lausanne,Street(58,rhodanie))),100)
Now let’s say we want to upper case the street name. Here’s how we can do it using copy
:
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
)
)
)
)
res3: Employee = Employee(Monica,Company(Bestmile,Address(Lausanne,Street(58,Rhodanie))),100)
Pretty convoluted 🙄. That’s when the Lens
optic becomes useful, as we’ll see next.
Lens
: magnifying glass 🔍 for immutable data structures
Lens
is like a zoom for data structures. Let’s import the monocle
library version of Lens
first (we’ll look at scalaz
later):
import monocle.Lens
import monocle.macros.GenLens
import monocle.Lens
import monocle.macros.GenLens
Let’s define a lens
on the company
field of Employee
:
val companyLens: Lens[Employee, Company] = GenLens[Employee](_.company)
companyLens: Lens[Employee, Company] = $sess.cmd5Wrapper$Helper$anon$1@49802b39
GenLens
is a helper scala macro provided by monocle.macros.GenLens
. A macro is code running at compilation time and emitting code for the actual compilation stage. It emits creation code for our companyLens
object by introspecting the passed-in selection lambda function.
Here’s what we can now do with our lens:
val foobarCompany = Company("FooBar", Address("Lausanne", Street(58,"rhodanie")))
companyLens.set(foobarCompany)(employee)
foobarCompany: Company = Company(FooBar,Address(Lausanne,Street(58,rhodanie)))
res6_1: Employee = Employee(Monica,Company(FooBar,Address(Lausanne,Street(58,rhodanie))),100)
Note that Lens.set
is a curried method, i.e. it defines two parameter lists: 1. value to be set 1. object to set it onto
Therefore, one could also do:
val foobarCompanySetter = companyLens.set(foobarCompany)
foobarCompanySetter: Employee => Employee = <function1>
And we get a function which can be applied to many employees to set their company to foobar:
foobarCompanySetter(employee)
// foobarCompanySetter(bob)
// foobarCompanySetter(paul)
// etc.
res8: Employee = Employee(Monica,Company(FooBar,Address(Lausanne,Street(58,rhodanie))),100)
🤔 Our initial objective actually was to uppercase the street name. Let’s see how this can help us get there.
We can define in a similar way lenses for the address
field of Company
, for the street
field of Address
, and the name
field of Street
:
val addressLens = GenLens[Company](_.address)
val streetLens = GenLens[Address](_.street)
val streetNameLens = GenLens[Street](_.name)
addressLens: Lens[Company, Address] = $sess.cmd9Wrapper$Helper$anon$1@72dfba8e
streetLens: Lens[Address, Street] = $sess.cmd9Wrapper$Helper$anon$2@2bf5fb3d
streetNameLens: Lens[Street, String] = $sess.cmd9Wrapper$Helper$anon$3@1eb2bad4
We now have the pieces to compose a lens to zoom 🔍 into the street name, all the way from the outside of an employee (so to speak 🤭):
val employeeStreetNameLens = companyLens composeLens addressLens composeLens streetLens composeLens streetNameLens
employeeStreetNameLens: monocle.PLens[Employee, Employee, String, String] = monocle.PLens$anon$1@fd5814b
or expressed in a more compact (but also more cryptic) syntax:
val employeeStreetNameLens = companyLens ^|-> addressLens ^|-> streetLens ^|-> streetNameLens
employeeStreetNameLens: monocle.PLens[Employee, Employee, String, String] = monocle.PLens$anon$1@1a18e104
We can now apply this to our employee!
employeeStreetNameLens.modify(_.capitalize)(employee)
res12: Employee = Employee(Monica,Company(Bestmile,Address(Lausanne,Street(58,Rhodanie))),100)
🍾🥂
Here’s the same example with scalaz
:
import scalaz.Lens._
import scalaz.@>
import scalaz.Lens._
import scalaz.@>
val companyL: Employee @> Company = lensu((employee, company) => employee.copy(company = company), _.company)
val addressL: Company @> Address = lensu((company, address) => company.copy(address = address), _.address)
val streetL: Address @> Street = lensu((address, street) => address.copy(street = street), _.street)
val streetNameL: Street @> String = lensu((street, name) => street.copy(name = name), _.name)
companyL: Employee @> Company = scalaz.LensFunctions$anon$5@7e479cf2
addressL: Company @> Address = scalaz.LensFunctions$anon$5@351133fe
streetL: Address @> Street = scalaz.LensFunctions$anon$5@4b7b15ab
streetNameL: Street @> String = scalaz.LensFunctions$anon$5@171e38e
Composition is carried out with the 🐟 >=>
operator (Kleisli composition with Lens
):
val employeeStreetNameL = companyL >=> addressL >=> streetL >=> streetNameL
employeeStreetNameL: scalaz.LensFamily[Employee, Employee, String, String] = scalaz.LensFamilyFunctions$anon$4@255b80bb
employeeStreetNameL.mod(_.capitalize, employee)
res16: Employee = Employee(Monica,Company(Bestmile,Address(Lausanne,Street(58,Rhodanie))),100)
Back to monocle
: we can even zoom-in further than the street name, directly into the String
type onto the first char.
Since a String
can be empty, the first char is optional. We can use headOption
, a special optic for such cases:
import monocle.function.Cons.headOption
val deepLens = companyLens ^|-> addressLens ^|-> streetLens ^|-> streetNameLens ^|-? headOption
import monocle.function.Cons.headOption
deepLens: monocle.POptional[Employee, Employee, Char, Char] = monocle.POptional$anon$1@11cd893d
deepLens.modify(_.toUpper)(employee)
res18: Employee = Employee(Monica,Company(Bestmile,Address(Lausanne,Street(58,Rhodanie))),100)
Note that using monocle
’s various functions and macros under the syntax
namespace, one can create lenses directly on the object:
import monocle.macros.syntax.lens._
employee.lens(_.company.address.street.name).composeOptional(headOption).modify(_.toUpper)
import monocle.macros.syntax.lens._
res19_1: Employee = Employee(Monica,Company(Bestmile,Address(Lausanne,Street(58,Rhodanie))),100)
Beyond lenses
There are different kinds of optics in monocle
. They almost all compose with each other. Here’s a diagram of all the types, from the most general to the most specific.
Iso
: a “mirror” for immutable structures
Iso
converts elements of type S
into elements of type A
when S
has the same “shape” as A
. Consider the following case class:
case class Person(name: String, age: Int)
defined class Person
Person
is equivalent to a tuple (String, Int)
and a tuple (String, Int)
is equivalent to Person
. We can define “bridges” between types of the same shape that way:
case class Person(name: String, age: Int)
case class PersonParam(newName: String, newAge: Int)
import monocle.macros.GenIso
// iso to tuple for both types
val personToTuple = GenIso.fields[Person]
val personParamToTuple = GenIso.fields[PersonParam]
// param -> tuple -> person
val paramToPerson = personParamToTuple composeIso personToTuple.reverse
// applied on Monica
paramToPerson.get(PersonParam("Monica", 21))
// res0: Person = Person(Monica,21)
Prism
💎 : pick a color from the 🌈
A Prism
is an optic used to select part of a Sum
type (also known as Coproduct
), typically a sealed trait
.
It’s a type with two parameters Prism[S, A]
: S
is the Sum
, A
a part of the Sum
.
Here’s an example Command
sealed trait:
case class Coordinates(x: Double, y: Double)
sealed trait Command
case class DriveToPickup(target: Coordinates, passengers: Int) extends Command
case class DriveToStation(stationCoordinates: Coordinates) extends Command
case class DriveToDropoff(target: Coordinates, passengers: Int) extends Command
case object EmergencyStop extends Command
case object EmergencyResume extends Command
defined class Coordinates
defined trait Command
defined class DriveToPickup
defined class DriveToStation
defined class DriveToDropoff
defined object EmergencyStop
defined object EmergencyResume
Now let’s say we want to define a generic logging function which logs coordinates involved in Command
instances:
def logAnyCoordinates(command: Command) = ???
That would be the usual implementation:
def logAnyCoordinatesUsual(command: Command) = {
val coordinates =
command match {
case DriveToPickup(c, _) => Some(c)
case DriveToDropoff(c, _) => Some(c)
case DriveToStation(c) => Some(c)
case _ => None
}
println(coordinates.map(c => s"x=${c.x}, y=${c.y}").getOrElse(""))
}
defined function logAnyCoordinatesUsual
logAnyCoordinatesUsual(DriveToDropoff(Coordinates(1.0, 2.0), 2))
logAnyCoordinatesUsual(EmergencyStop)
logAnyCoordinatesUsual(DriveToStation(Coordinates(5.0, 2.0)))
logAnyCoordinatesUsual(EmergencyResume)
logAnyCoordinatesUsual(DriveToDropoff(Coordinates(42, 42), 42))
x=1.0, y=2.0
x=5.0, y=2.0
x=42.0, y=42.0
That’s ok, but here are some drawbacks:
- This is evolution-fragile: if the
Sum
changes shape, code needs to be touched. - This sort of selection code is likely to end up duplicated in various places depending on the needs of each method.
- It only describes one thing: extracting the value from the
Sum
type
But most importantly: it’s super boring code! 😴 😝 Let’s look at another, more generic way to do this using a combination of Prism
and Lens
.
First, we need some imports and a helper method (which unfortunately isn’t part of monocle)
import monocle._
import monocle.Optional
import monocle.macros.{GenLens, GenPrism}
implicit class RichOptional[A, B](self: Optional[A, B]) {
def merge(optional: Optional[A, B]): Optional[A, B] =
Optional[A, B](a => self.getOption(a).orElse(optional.getOption(a)))(b => a => self.setOption(b)(a).getOrElse(optional.set(b)(a)))
}
import monocle._
import monocle.Optional
import monocle.macros.{GenLens, GenPrism}
defined class RichOptional
Using a combination of Prism
and Lens
, we can define an Optional
which acts as sort of pointer on all coordinates in the Command
sealed trait:
val commandCoordinatesOptional =
GenPrism[Command, DriveToPickup] ^|-> GenLens[DriveToPickup](_.target) merge
GenPrism[Command, DriveToStation] ^|-> GenLens[DriveToStation](_.stationCoordinates) merge
GenPrism[Command, DriveToDropoff] ^|-> GenLens[DriveToDropoff](_.target)
commandCoordinatesOptional: Optional[Command, Coordinates] = monocle.Optional$anon$6@7276fe7a
The combination of Prism
then “zoom-in” using ^|->
and Lens
leads to an Optional
, an optic that represents a value which can be there or not. Then we use horizontal composition between Optional
instances with merge
.
We can now rewrite our logging function to take advantage of this:
def logAnyCoordinatesCool(command: Command) = {
println(commandCoordinatesOptional.getOption(command).map(c => s"x=${c.x}, y=${c.y}").getOrElse(""))
}
defined function logAnyCoordinatesCool
logAnyCoordinatesCool(DriveToDropoff(Coordinates(1.0, 2.0), 2))
logAnyCoordinatesCool(EmergencyStop)
logAnyCoordinatesCool(DriveToStation(Coordinates(5.0, 2.0)))
logAnyCoordinatesCool(EmergencyResume)
logAnyCoordinatesCool(DriveToDropoff(Coordinates(42, 42), 42))
x=1.0, y=2.0
x=5.0, y=2.0
x=42.0, y=42.0
Even more 🆒 is the ability to update the coordinates for all! We can now define:
def offsetByOne(coordinates: Coordinates) = coordinates.copy(x = coordinates.x + 1, y = coordinates.y + 1)
def offsetCoordinatesByOne(command: Command) = commandCoordinatesOptional.modify(offsetByOne)(command)
defined function offsetByOne
defined function offsetCoordinatesByOne
offsetCoordinatesByOne(DriveToDropoff(Coordinates(1.0, 2.0), 2))
offsetCoordinatesByOne(EmergencyStop)
offsetCoordinatesByOne(DriveToStation(Coordinates(5.0, 2.0)))
offsetCoordinatesByOne(EmergencyResume)
offsetCoordinatesByOne(DriveToDropoff(Coordinates(42, 42), 42))
res8_0: Command = DriveToDropoff(Coordinates(2.0,3.0),2)
res8_1: Command = EmergencyStop
res8_2: Command = DriveToStation(Coordinates(6.0,3.0))
res8_3: Command = EmergencyResume
res8_4: Command = DriveToDropoff(Coordinates(43.0,43.0),42)
Traversal
➿: many eyes see it all 🕷️
Traversal
can focus into all elements inside of a container (e.g. List
, Vector
, Option
). In more generic terms, it allows to focus from a type S
into 0 to n values of type A
.
For instance, let’s say each vehicle keeps a journal 📒 of commands:
case class Vehicle(name: String, id: Int)
case class VehicleCommandsJournal(vehicle: Vehicle, commands: List[Command])
defined class Vehicle
defined class VehicleCommandsJournal
Here’s a journal with some commands:
val journal = VehicleCommandsJournal(
Vehicle("Navia1", 1),
List(
DriveToPickup(Coordinates(1.0, 2.0), 2),
DriveToDropoff(Coordinates(5.0, 3.0), 2),
DriveToStation(Coordinates(5.0, 2.0)),
EmergencyStop
))
journal: VehicleCommandsJournal = VehicleCommandsJournal(Vehicle(Navia1,1),List(DriveToPickup(Coordinates(1.0,2.0),2), DriveToDropoff(Coordinates(5.0,3.0),2), DriveToStation(Coordinates(5.0,2.0)), EmergencyStop))
Let’s see if we can carry out our coordinates offset stunt. First, we need some more imports:
import monocle.Traversal
import scalaz.std.list.listInstance // we need scalaz Traverse type class instance for List in scope
import monocle.Traversal
import scalaz.std.list.listInstance // we need scalaz Traverse type class instance for List in scope
We can now define a traversal for commands in the journal:
val commandsLens = GenLens[VehicleCommandsJournal](_.commands) // first the lens
val commandsCoordinatesTraversal = commandsLens ^|->> fromTraverse[List, Command, Command] // compose with the commands traversal
commandsLens: monocle.package.Lens[VehicleCommandsJournal, List[Command]] = $sess.cmd12Wrapper$Helper$anon$1@446a2d03
commandsCoordinatesTraversal: PTraversal[VehicleCommandsJournal, VehicleCommandsJournal, Command, Command] = monocle.PTraversal$anon$2@7d066293
We can use this Traversal
instance to define an offset function which works on VehicleCommandsJournal
:
def offsetAllCoordinatesByOne(journal: VehicleCommandsJournal) =
commandsCoordinatesTraversal.modify(offsetCoordinatesByOne)(journal)
defined function offsetAllCoordinatesByOne
And now let’s apply it to our small journal
:
offsetAllCoordinatesByOne(journal)
res14: VehicleCommandsJournal = VehicleCommandsJournal(Vehicle(Navia1,1),List(DriveToPickup(Coordinates(2.0,3.0),2), DriveToDropoff(Coordinates(6.0,4.0),2), DriveToStation(Coordinates(6.0,3.0)), EmergencyStop))
🍻🍻🍻
Typeclasses
We’ve seen how to zoom-in on elements based on structure using Lens
and other optics. We can also focus on elements based on data using monocle
’s various typeclasses.
To illustrate this, let’s define a structure which maps employees 👥👥 per company 🏭:
case class EmployeesPerCompany(employees: Map[Company, List[Employee]])
defined class EmployeesPerCompany
We have two companies around here:
val bm = Company("Bestmile", Address("Lausanne", Street(58, "rhodanie")))
val pmi = Company("Phil International", Address("Lausanne", Street(50, "Rhodanie")))
bm: Company = Company(Bestmile,Address(Lausanne,Street(58,rhodanie)))
pmi: Company = Company(Phil International,Address(Lausanne,Street(50,Rhodanie)))
And various employees:
val employeesPerCompany = EmployeesPerCompany(List(
Employee("Monica", bm, salary = 100),
Employee("Bob", bm, salary = 90),
Employee("Alice", bm, salary = 110),
Employee("Alfred", bm, salary = 105),
Employee("Picsou", pmi, salary = 1000),
Employee("Donald", pmi, salary = 2000))
.groupBy(_.company))
employeesPerCompany: EmployeesPerCompany = EmployeesPerCompany(
Map(
Company(Phil International,Address(Lausanne,Street(50,Rhodanie))) -> List(
Employee(Picsou,Company(Phil International,Address(Lausanne,Street(50,Rhodanie))),1000),
Employee(Donald,Company(Phil International,Address(Lausanne,Street(50,Rhodanie))),2000)
),
Company(Bestmile,Address(Lausanne,Street(58,rhodanie))) -> List(
Employee(Monica,Company(Bestmile,Address(Lausanne,Street(58,rhodanie))),100),
...
Notice the injustice 🤑💸? In the world of optics, it’s easy to correct this. We first need a lens on the salary
field of Employee
:
val salaryLens = GenLens[Employee](_.salary)
salaryLens: monocle.package.Lens[Employee, Int] = $sess.cmd32Wrapper$Helper$anon$1@710493f1
Then some imports:
import monocle.function.At.at // to get At typeclass
import monocle.function.Each.each // to get Each typeclass
import monocle.std.map._ // to get Map typeclass instance for At
import monocle.std.list._ // to get List typeclass instance for Each
import monocle.function.At.at // to get At typeclass
import monocle.function.Each.each // to get Each typeclass
import monocle.std.map._ // to get Map typeclass instance for At
import monocle.std.list._ // to get List typeclass instance for Each
We can now do:
val corrected = (employeesPerCompany.lens(_.employees)
^|-> at(pmi)
^|->> each ^|->> each
^|-> salaryLens
).modify(_ / 10)
corrected: EmployeesPerCompany = EmployeesPerCompany(
Map(
Company(Phil International,Address(Lausanne,Street(50,Rhodanie))) -> List(
Employee(Picsou,Company(Phil International,Address(Lausanne,Street(50,Rhodanie))),100),
Employee(Donald,Company(Phil International,Address(Lausanne,Street(50,Rhodanie))),200)
),
Company(Bestmile,Address(Lausanne,Street(58,rhodanie))) -> List(
Employee(Monica,Company(Bestmile,Address(Lausanne,Street(58,rhodanie))),100),
...
Of course, let’s not forget 😏:
(corrected.lens(_.employees)
^|-> at(bm)
^|->> each ^|->> each
^|-> salaryLens
).modify(_ * 10)
res35: EmployeesPerCompany = EmployeesPerCompany(
Map(
Company(Philip Morris International,Address(Lausanne,Street(50,Rhodanie))) -> List(
Employee(Picsou,Company(Philip Morris International,Address(Lausanne,Street(50,Rhodanie))),100),
Employee(Donald,Company(Philip Morris International,Address(Lausanne,Street(50,Rhodanie))),200)
),
Company(Bestmile,Address(Lausanne,Street(58,rhodanie))) -> List(
Employee(Monica,Company(Bestmile,Address(Lausanne,Street(58,rhodanie))),1000),
...
For more information
Documentation
Examples:
- https://github.com/julien-truffaut/Monocle/tree/master/example/src/test/scala/monocle
- https://gist.github.com/dragisak/fa8d6c297f20c1348e11 (scalaz)
Exercises:
For Javascripters:
Ramda
- http://randycoulman.com/blog/2016/07/12/thinking-in-ramda-lenses/
- https://github.com/ramda/ramda-lens