If monads are programmable semicolons (ways to chain operation), lenses are programmable dots (ways to delegate access to data). Other optics are largely generalizations of that pattern.
The problem most programmers would be familiar with is making an update inside a deeply nested immutable data structure. For example, suppose you want to update a user's billing address, and you have an immutable data structure that looks like this:
user { billingInfo: { card, address }, name, subscription { level, expiration }, status { level, since } }
The structure is immutable, so you can't make the update in place. On the other hand, you're only changing one field, so it would be wasteful to make a complete deep copy. The efficient way to create an updated instance is to create a new user instance and a new billingInfo instance while reusing the name, subscription, and status instances.
You can think of this as the equivalent of a setter for immutable data structures.
This is an artificial example, because the cost of making a deep copy of this user structure is probably not that bad, and the boilerplate to make an efficient update is not all that bad, either. You would use an optics library when 1) you need efficient updates and 2) it's worth investing a little effort to hide the boilerplate.
Optics also let you concisely express access into a deeply nested structure, the getter paired with the setter. In my experience, updates are the motivation for setting up optics, and concise access is a nice thing you get as a bonus.
Lens are the functional version of getters and setters. A lens takes a product type (struct, class, etc) and allows you to view or update part of it. Prisms are something similar for sum types (variants) that allow you to look at a value if it's present and err otherwise.
The optical analogy comes from how these operations resemble zooming in on structures with a magnifying glass and the entire family of related transformations is called optics.
So behind the scenes, every one of those statements will make a whole new user object with a whole new address object so that it remains immutable? And whether that will actually have any real-world performance impact is I guess entirely situational. Still, what happens if you do that with a big object graph?
Also, the original strong need for immutable data in the first place is safety under concurrency and parallelism?
This is in general how "mutations" are supposed to be done in a language like Scala (and is not unique to this library). Yes, Scala does have a set of mutable collections, but the immutable collections are heavily optimized to make creating a "new" collection with a mutation much cheaper than having to copy the entire collection.
Of course, copying a case class in order to change a field likely does require a full copy of the object, though since this is the JVM, things like strings can be shared between them.
Ultimately this pattern is... fine. Most uses don't end up caring about the extra overhead vs. that of direct mutation. I don't recall if the Scala compiler does this, but another optimization that can be used is to actually mutate an immutable object when the compiler knows the original copy isn't used anywhere else after the mutation.
> Also, the original strong need for immutable data in the first place is safety under concurrency and parallelism?
That's one of the uses, but multiple ownership in general is another, without the presence of concurrency.
On top of that, there's the general belief (which I subscribe to) that mutation introduces higher cognitive load on someone understanding the code. Immutable data is much easier to reason about.
Yes, behind the scenes every one of those statements will make a shallow copy of the object. But it isn't just that object necessarily. For example, if you modify a tree node, then not only does that node needs cloning, its parent does too (since the modified parent needs to point to the new node), and so on until the root, which results in h = O(log(n)) new objects to create an entirely new tree. (h is the height of the tree).
What you get out if it is (a) safety, (b) understandability, which are wonderful properties to have as long as the end result is performing adequately. Implementing concurrent tree or graph traversals under conventional mutation is painful; the Java collection libraries simply throw a ConcurrentModificationException.
The equivalent code for readonly traversals of immutable data structures is simplicity itself. You also get versioning and undo's for free.
There are many techniques like this within Scala that would never be feasible if it wasn't for the fact that the JVM is ridiculously fast. You could write the worst code imaginable and in many cases would still have better performance than Python, Javascript etc.
Every scala code base I have worked on, that wasnt written by small team of experts, turned into a huge pile of crap. A small squad of people that treat the language like a religion create an impenetrable masterpiece
A lot of work has been done in Scala 3 to simplify everything.
And with the arrival of virtual threads in the JVM there are new concurrency libraries e.g. Ox [1] and Gears [2] which remove the need to use FP concepts like monads. Which have been the major source of much of the complexity.
For all its problems it is a seriously under-rated platform especially Scala.js which IMHO is far better and simpler than Typescript.
This has nothing to do with optics, which is a branch of physics that studies the behavior and properties of light.
https://ncatlab.org/nlab/show/lens+%28in+computer+science%29
This is why I read the comments before I click on the link. :)
It’s a metaphor.
Yes, using Monocle for years now. So happy to have rich ecosystem of libraries in Scala land.
I haven't encountered this pattern before. Is there some more information on what problems this is designed to solve?
If monads are programmable semicolons (ways to chain operation), lenses are programmable dots (ways to delegate access to data). Other optics are largely generalizations of that pattern.
The problem most programmers would be familiar with is making an update inside a deeply nested immutable data structure. For example, suppose you want to update a user's billing address, and you have an immutable data structure that looks like this:
The structure is immutable, so you can't make the update in place. On the other hand, you're only changing one field, so it would be wasteful to make a complete deep copy. The efficient way to create an updated instance is to create a new user instance and a new billingInfo instance while reusing the name, subscription, and status instances.You can think of this as the equivalent of a setter for immutable data structures.
This is an artificial example, because the cost of making a deep copy of this user structure is probably not that bad, and the boilerplate to make an efficient update is not all that bad, either. You would use an optics library when 1) you need efficient updates and 2) it's worth investing a little effort to hide the boilerplate.
Optics also let you concisely express access into a deeply nested structure, the getter paired with the setter. In my experience, updates are the motivation for setting up optics, and concise access is a nice thing you get as a bonus.
Lens are the functional version of getters and setters. A lens takes a product type (struct, class, etc) and allows you to view or update part of it. Prisms are something similar for sum types (variants) that allow you to look at a value if it's present and err otherwise.
The optical analogy comes from how these operations resemble zooming in on structures with a magnifying glass and the entire family of related transformations is called optics.
Lenses make it easier to read and update members deep in a hierarchy of read-only data structures.
Also available for TypeScript:
https://gcanti.github.io/monocle-ts/
Also in F#: https://fsprojects.github.io/FSharpPlus/lens.html
So behind the scenes, every one of those statements will make a whole new user object with a whole new address object so that it remains immutable? And whether that will actually have any real-world performance impact is I guess entirely situational. Still, what happens if you do that with a big object graph?
Also, the original strong need for immutable data in the first place is safety under concurrency and parallelism?
This is in general how "mutations" are supposed to be done in a language like Scala (and is not unique to this library). Yes, Scala does have a set of mutable collections, but the immutable collections are heavily optimized to make creating a "new" collection with a mutation much cheaper than having to copy the entire collection.
Of course, copying a case class in order to change a field likely does require a full copy of the object, though since this is the JVM, things like strings can be shared between them.
Ultimately this pattern is... fine. Most uses don't end up caring about the extra overhead vs. that of direct mutation. I don't recall if the Scala compiler does this, but another optimization that can be used is to actually mutate an immutable object when the compiler knows the original copy isn't used anywhere else after the mutation.
> Also, the original strong need for immutable data in the first place is safety under concurrency and parallelism?
That's one of the uses, but multiple ownership in general is another, without the presence of concurrency.
On top of that, there's the general belief (which I subscribe to) that mutation introduces higher cognitive load on someone understanding the code. Immutable data is much easier to reason about.
Yes, behind the scenes every one of those statements will make a shallow copy of the object. But it isn't just that object necessarily. For example, if you modify a tree node, then not only does that node needs cloning, its parent does too (since the modified parent needs to point to the new node), and so on until the root, which results in h = O(log(n)) new objects to create an entirely new tree. (h is the height of the tree).
What you get out if it is (a) safety, (b) understandability, which are wonderful properties to have as long as the end result is performing adequately. Implementing concurrent tree or graph traversals under conventional mutation is painful; the Java collection libraries simply throw a ConcurrentModificationException. The equivalent code for readonly traversals of immutable data structures is simplicity itself. You also get versioning and undo's for free.
> actually have any real-world performance impact
There are many techniques like this within Scala that would never be feasible if it wasn't for the fact that the JVM is ridiculously fast. You could write the worst code imaginable and in many cases would still have better performance than Python, Javascript etc.
> Still, what happens if you do that with a big object graph?
The only thing that really matters here is how deep the graph is. Any unchanged object can just be reused as-is.
Every scala code base I have worked on, that wasnt written by small team of experts, turned into a huge pile of crap. A small squad of people that treat the language like a religion create an impenetrable masterpiece
A lot of work has been done in Scala 3 to simplify everything.
And with the arrival of virtual threads in the JVM there are new concurrency libraries e.g. Ox [1] and Gears [2] which remove the need to use FP concepts like monads. Which have been the major source of much of the complexity.
For all its problems it is a seriously under-rated platform especially Scala.js which IMHO is far better and simpler than Typescript.
[1] https://github.com/softwaremill/ox
[2] https://github.com/lampepfl/gears
Every <insert any language here> code base I have worked on, that wasnt written by small team of experts, turned into a huge pile of crap…
:-)
well aligned scala teams are a great thing, impenetrable code is not - maybe a poor choice of adjective?