With new programming techniques come new problems and new patterns to solve them.
In functional programming, immutability is a must. As a consequence, whenever it is needed to modify the content of a data structure, a new instance with updated values is created. Depending on how complex the data structure is, creating a copy may be a verbose task.
To simplify the process, a set of functions, generically called Optics, have been designed to access/modify parts of a whole in an easy way. Those functions must obey some laws that make their behaviour predictable and intuitive (for instance, if we modify a value and then read it back, we should obtain the modified value).
This post presents some examples on how to use an Optics library in Scala called Monocle.
Monocle examples
To illustrate the use of Monocle, let’s start by creating a simple domain model:
import monocle.macros.Lenses sealed trait RoomTariff case class NonRefundable(fee: BigDecimal) extends RoomTariff case class Flexible(fee: BigDecimal) extends RoomTariff @Lenses("_") case class Hotel(name: String, address: String, rating: Int, rooms: List[Room], facilities: Map[String, List[String]]) @Lenses("_") case class Room(name: String, boardType: Option[String], price: Price, roomTariff: RoomTariff) @Lenses("_") case class Price(amount: BigDecimal, currency: String)
The annotation @Lenses generates automatically a lens for each attribute of the case class.
Let’s create an imaginary hotel:
val rooms = List( Room("Double", Some("Half Board"), Price(10, "USD"), NonRefundable(1)), Room("Twin", None, Price(20, "USD"), Flexible(0)) , Room("Executive", None, Price(200, "USD"), Flexible(0)) ) val facilities = Map("business" -> List("conference room")) val hotel = Hotel("Hotel Paradise", "100 High Street", 5, rooms, facilities)
And now the fun part.
Room changes based on the room position in the List
test("double price of even rooms") { val updatedHotel = (_rooms composeTraversal filterIndex{i: Int => i/2*2 == i} composeLens _price composeLens _amount modify(_ * 2)) (hotel) assert(updatedHotel.rooms(0).price.amount == hotel.rooms(0).price.amount * 2) assert(updatedHotel.rooms(1).price.amount == hotel.rooms(1).price.amount) assert(updatedHotel.rooms(2).price.amount == hotel.rooms(2).price.amount * 2) } test("set price of 2nd room") { val newValue = 12 val roomToUpdate = 1 assert(hotel.rooms(roomToUpdate).price.amount != newValue) val updatedHotel = (_rooms composeOptional index(roomToUpdate) composeLens _price composeLens _amount set newValue)(hotel) val updatedRoomList = (index[List[Room], Int, Room](roomToUpdate) composeLens _price composeLens _amount set newValue)(hotel.rooms) assert(updatedHotel.rooms(roomToUpdate).price.amount == newValue) assert(updatedRoomList(roomToUpdate).price.amount == newValue) }
Modifying a non-existing room
test("no changes are made when attempting to modify a non-existing room") { val newValue = 12 val roomToUpdate = 3 assert(hotel.rooms.length == 3) val updatedHotel = (_rooms composeOptional index(roomToUpdate) composeLens _price composeLens _amount set newValue)(hotel) assert(hotel == updatedHotel) } test("hotel 'disappears' when attempting to modify a non-existing room") { val newValue = 12 val roomToUpdate = 3 assert(hotel.rooms.length == 3) val updatedHotel = (_rooms composeOptional index(roomToUpdate) composeLens _price composeLens _amount setOption newValue)(hotel) assert(updatedHotel.isEmpty) }
Changing an optional value
test("set a value inside an Option") { val newValue = "New Board Type" val roomToUpdate = 0 assert(!hotel.rooms(roomToUpdate).boardType.contains(newValue)) val updatedHotel = (_rooms composeOptional index(roomToUpdate) composeLens _boardType composeOptional some.asOptional set newValue)(hotel) assert(updatedHotel.rooms(roomToUpdate).boardType.contains(newValue)) } test("no changes are made when attempting to modify an empty Option") { val newValue = "New Board Type" val roomToUpdate = 1 assert(hotel.rooms(roomToUpdate).boardType.isEmpty) val updatedHotel = (_rooms composeOptional index(roomToUpdate) composeLens _boardType composeOptional some.asOptional set newValue)(hotel) assert(updatedHotel.rooms(roomToUpdate).boardType.isEmpty) } test("hotel 'disappears' when attempting to modify an empty Option") { val newValue = "New Board Type" val roomToUpdate = 1 assert(hotel.rooms(roomToUpdate).boardType.isEmpty) val updatedHotel = (_rooms composeOptional index(roomToUpdate) composeLens _boardType composeOptional some.asOptional setOption newValue)(hotel) assert(updatedHotel.isEmpty) }
Changes with an applicative function
test("divide prices by 10"){ assert(hotel.rooms(0).price.amount == 10) assert(hotel.rooms(1).price.amount == 20) val updatedHotel = (_rooms composeTraversal each composeLens _price composeLens _amount modify(_ / 10))(hotel) assert(updatedHotel.rooms(0).price.amount == 1) assert(updatedHotel.rooms(1).price.amount == 2) } test("divide prices by 0"){ assert(hotel.rooms(0).price.amount == 10) assert(hotel.rooms(1).price.amount == 20) val updatedHotel = (_rooms composeTraversal each composeLens _price composeLens _amount).modifyF[Option](y => Try{y / 0}.toOption)(hotel) assert(updatedHotel.isEmpty) }
Modifying number of rooms
test("append a room"){ assert(hotel.rooms.length == 3) val newRoom = Room("Triple", None, Price(1, "USD"), Flexible(0)) val updatedHotel = (_rooms set _snoc(hotel.rooms, newRoom))(hotel) assert(updatedHotel.rooms.length == 4) assert(updatedHotel.rooms(3) == newRoom) } test("prepend a room"){ assert(hotel.rooms.length == 3) val newRoom = Room("Triple", None, Price(1, "USD"), Flexible(0)) val updatedHotel = (_rooms set _cons(newRoom, hotel.rooms))(hotel) assert(updatedHotel.rooms.length == 4) assert(updatedHotel.rooms(0) == newRoom) }
Using prisms to modify the room tariff
test("set prices of Flexible rooms"){ val prism = Prism.partial[RoomTariff, BigDecimal]{case Flexible(x) => x}(Flexible) val newValue = 100 assert(hotel.rooms(0).roomTariff == NonRefundable(1)) assert(hotel.rooms(1).roomTariff == Flexible(0)) assert(hotel.rooms(2).roomTariff == Flexible(0)) val updatedHotel = (_rooms composeTraversal each composeLens _roomTariff composePrism prism set newValue)(hotel) assert(hotel.rooms(0).roomTariff == updatedHotel.rooms(0).roomTariff) assert(updatedHotel.rooms(1).roomTariff == Flexible(newValue)) assert(updatedHotel.rooms(2).roomTariff == Flexible(newValue)) }
Manipulating a Map
test("modifying business facilities") { val updatedHotel = (_facilities composeLens at("business") set Some(List("")))(hotel) assert(updatedHotel.facilities("business") == List("")) } test("removing business facilities") { val updatedHotel = (_facilities composeLens at("business") set None)(hotel) val updatedFacilities = remove("business")(hotel.facilities) assert(updatedHotel.facilities.get("business").isEmpty) assert(updatedFacilities.get("business").isEmpty) } test("adding entertainment facilities") { val updatedHotel = (_facilities composeLens at("entertainment") set Some(List("satellite tv", "internet")))(hotel) assert(updatedHotel.facilities("entertainment") == List("satellite tv", "internet")) }
Folding over the room list
test("folding over room prices to add them up") { assert(hotel.rooms(0).price.amount == 10) assert(hotel.rooms(1).price.amount == 20) assert(hotel.rooms(2).price.amount == 200) assert((_rooms composeFold Fold.fromFoldable[List, Room] foldMap(_.price.amount))(hotel) == 230) }
Modifying rooms that meet specific criteria
val unsafePrism = UnsafeSelect.unsafeSelect[Room](_.name == "Double") test("double price of Double rooms using unsafe operation") { val updatedHotel = (_rooms composeTraversal each composePrism unsafePrism composeLens _price composeLens _amount modify (_ * 2)) (hotel) assert(hotel.rooms.filter(_.name == "Double").map(_.price.amount*2) == updatedHotel.rooms.filter(_.name == "Double").map(_.price.amount)) }
This last example makes use of an unsafe prism (it is unsafe because does not comply with all Prism laws). Let’s verify this statement by testing the laws:
val roomGen: Gen[Room] = for { name <- Gen.oneOf("Double", "Twin", "Executive") board <- Gen.option(Gen.alphaStr) price <- for{ price <- Gen.posNum[Double] currency <- Gen.oneOf("USD", "GBP", "EUR") } yield Price(price, currency) tariff <- Gen.oneOf(Gen.posNum[Double].map(NonRefundable(_)), Gen.posNum[Double].map(Flexible(_))) } yield Room(name, board, price, tariff) implicit val roomArb: Arbitrary[Room] = Arbitrary(roomGen) implicit val arbAA: Arbitrary[Room => Room] = Arbitrary{ for{ room <- roomGen } yield (_: Room) => room } checkAll("unsafe prism", PrismTests(unsafePrism))
When running the above test, the following tests fail:
- Prism.compose modify
- Prism.round trip other way
So, what is wrong? Let’s check the law “round trip other way”. Here’s its definition on PrismLaws:
def roundTripOtherWay(a: A): IsEq[Option[A]] = prism.getOption(prism.reverseGet(a)) <==> Some(a)
And this is how the law is broken:
val a = Room(Twin,None,Price(1.0,USD),Flexible(1.0)) val b = unsafePrism.reverseGet(a) = Room(Twin,None,Price(1.0,USD),Flexible(1.0)) val c = unsafePrism.getOption(b) = None None != Some(a)
So, our unsafePrism is unsafe when used to make changes on the attribute included in the predicate to create the prism.
All the above examples can be found on this repo.