Optics are abstractions to update immutable data structures elegantly. One form of optics is Lens (or lenses, depending on the library implementation). A Lens is a functional reference that can focus (hence the name) into a structure and read, write, or modify its target:
typealias GB = Int
data class Memory(val size: GB)
data class MotherBoard(val brand: String, val memory: Memory)
data class Laptop(val price: Double, val motherBoard: MotherBoard)
fun main(args: Array<String>) {
val laptopX8 = Laptop(500.0, MotherBoard("X", Memory(8)))
val laptopX16 = laptopX8.copy(
price = 780.0,
motherBoard = laptopX8.motherBoard.copy(
memory = laptopX8.motherBoard.memory.copy(
size = laptopX8.motherBoard.memory.size * 2
)
)
)
println("laptopX16 = $laptopX16")
}
To create a new Laptop value from an existing one, we need to use several nested copy methods and references. In this example, it isn't as bad but you can imagine that in a more complex data structure, things can overgrow into madness.
Let's write our very first Lens values:
val laptopPrice: Lens<Laptop, Double> = Lens(
get = { laptop -> laptop.price },
set = { price -> { laptop -> laptop.copy(price = price) } }
)
The laptopPrice value is a Lens<Laptop, Double> that we initialize using the function Lens<S, T, A, B> (actually Lens.invoke). Lens takes two functions as parameters, as get: (S) -> A and set: (B) -> (S) -> T.
As you can see, set is a curried function so that you can write your set like this:
import arrow.optics.Lens
val laptopPrice: Lens<Laptop, Double> = Lens(
get = { laptop -> laptop.price },
set = { price: Double, laptop: Laptop -> laptop.copy(price = price) }.curried()
)
Which, depending on your preferences, can be easier to read and write.
Now that you have your first lens, it can be used to set, read, and modify a laptop's price. Not too impressive, but the magic of lenses is combining them:
import arrow.optics.modify
val laptopMotherBoard: Lens<Laptop, MotherBoard> = Lens(
get = { laptop -> laptop.motherBoard },
set = { mb -> { laptop -> laptop.copy(motherBoard = mb) } }
)
val motherBoardMemory: Lens<MotherBoard, Memory> = Lens(
get = { mb -> mb.memory },
set = { memory -> { mb -> mb.copy(memory = memory) } }
)
val memorySize: Lens<Memory, GB> = Lens(
get = { memory -> memory.size },
set = { size -> { memory -> memory.copy(size = size) } }
)
fun main(args: Array<String>) {
val laptopX8 = Laptop(500.0, MotherBoard("X", Memory(8)))
val laptopMemorySize: Lens<Laptop, GB> = laptopMotherBoard compose motherBoardMemory compose memorySize
val laptopX16 = laptopMemorySize.modify(laptopPrice.set(laptopX8, 780.0)) { size ->
size * 2
}
println("laptopX16 = $laptopX16")
}
We created laptopMemorySize combining lenses from Laptop all the way to memorySize; then, we can set laptop's price and modify its memory.
Despite how cool lenses are, it looks like a lot of boilerplate code. Fear not, Arrow can generate those lenses for you.