ADT through the looking glass - lenses, prims and other optics
Algebraic data structures and invaluable in functional programming. With immutable data structures reasoning about the code is easy. Concurrency requires little effort as you don’t have to be worried about locking, mutexes and semaphores - nobody can change your data anyway. If you need to update something you can just create an updated copy and use it for now on. Except if you have a nested structure and need to update something deep, deep inside.
Getter and Setter
Let us examine some real-world scenario. We have a program, that loads values from config files, but allow to override them with parameters passed on program startup.
final case class S3Config(bucket: String, region: String)
final case class AppConfig(s3: S3Config)
object Main {
def main(args: Array[String]): Unit = {
val app = updateConfigWithArgs(
loadConfigFromFiles,
args
)
// use config
}
def loadConfigFromFiles: AppConfig = ???
def updateConfigWithArgs(app: AppConfig,
args: Array[String]): AppConfig = ???
}
(We’re gonna be hard on yourselves and don’t use libraries like scopt, because this is just an example and we won’t handle complex cases).
def updateConfigWithArgs(app: AppConfig,
args: Array[String]): AppConfig =
// create (n arg, n+1 arg) pairs
// handle them as potential --arg value pair
(args.toList zip args.toList.tail).foldLeft(app) {
case (app, ("--bucket", bucket)) =>
app.copy(app.s3.copy(bucket = bucket))
case (app, ("--region", region)) =>
app.copy(app.s3.copy(region = region))
case (app, _) => app
}
So, even with such a simple nesting, we have a bit of boilerplate. If there were more levels of nesting things would look nasty. Meanwhile, OOP programmers using mutable data structures would do something like:
app.s3.bucket = bucket
app.s3.region = region
or
app.s3.setBucket(bucket)
app.s3.setRegion(region)
and called it a day. It would be nice if we could have something like OOP’s setters for immutable data structures. Well, .copy
is like that, but it doesn’t compose. We need something that composes. Let’s draft an interface for such setter:
trait Setter[S, A] {
def modify(f: A => A): S => S
def set(a: A): S => S
}
This interface covers our 2 main use cases: setting some constant value and updating a value using some function. In our example we would need 2 Setters
:
val setBucket = new Setter[AppConfig, String] {
def modify(f: String => String): AppConfig => AppConfig =
app => app.copy(app.s3.copy(bucket = f(app.s3.bucket)))
def set(a: String): AppConfig => AppConfig =
modify(_ => a)
}
val setRegion = new Setter[AppConfig, String] {
def modify(f: String => String): AppConfig => AppConfig =
app => app.copy(app.s3.copy(region = f(app.s3.region)))
def set(a: String): AppConfig => AppConfig =
modify(_ => a)
}
def updateConfigWithArgs(app: AppConfig,
args: Array[String]): AppConfig =
(args.toList zip args.toList.tail).foldLeft(app) {
case (app, ("--bucket", bucket)) =>
setBucket.set(bucket)(app)
case (app, ("--region", region)) =>
setRegion.set(region)(app)
case (app, _) => app
}
Slightly better, but we still need to create the whole trait, which itself is kind of a pain. Could we make it more composable?
trait Setter[S, A] { self =>
def modify(f: A => A): S => S
def set(a: A): S => S = modify(_ => a)
def compose[B](setter: Setter[A, B]): Setter[S, B] =
new Setter[S, B] {
def modify(f: B => B): S => S =
(setter.modify _).andThen(self.modify _)(f)
}
}
When you combine functions
f: A => B
andg: B => C
in Scala, you have 2 options:f andThen g: A => C
org compose f: A => C
- the latter simulates mathematical , which is defined as .I decided to use
andThen
(for implementation) as more intuitive, but in libraries you might expect thatandThen
will have another name, whilecompose
will behave just like in functions - so don’t be surprised if the order of operands will be flipped.Interestingly though,
(A => A) => (S => S)
combined with(B => B) => (A => A)
into(B => B) => (S => S)
behaves likecompose
so I should name the method just that. (Thanks to Mateusz Górski for pointing it out!)
We could even create some utility for creating setters:
object Setter {
def apply[S, A](update: (A => A) => (S => S)): Setter[S, A] =
new Setter[S, A] {
def modify(f: A => A): S => S = update(f)
}
}
and use it to define our setters:
val setS3 = Setter[AppConfig, S3Config] { f =>
app => app.copy(s3 = f(app.s3))
}
val setBucket = Setter[S3Config, String] { f =>
s3 => s3.copy(bucket = f(s3.bucket))
}
val setRegion = Setter[S3Config, String] { f =>
s3 => s3.copy(region = f(s3.region))
}
and use them:
(setS3 combine setBucket).set(bucket)(app)
(setS3 combine setRegion).set(region)(app)
Now it is composable, but still some boilerplate, we just exchanged one boilerplate into another. If we reused the setters, then it is some gain, but writing them manually is annoying.
However, there is some advantage to the way we express setting operation right now. The code is so simple and unambiguous in implementation that we could provide some macros or shapeless magic.
object Setter {
// do you believe in magic?
def apply[S, A](f: S => A) = macro SetterMacros.impl
}
val setS3 = Setter[AppConfig, S3Config](_.s3)
val setBucket = Setter[S3Config, String](_.bucket)
val setRegion = Setter[S3Config, String](_.region)
There are libraries that already do this (we’ll get to them later on), so, for now, let’s just assume that this boilerplate can be generated for us.
Since there is a setter, there should be a getter too.
trait Getter[S, A] { self =>
def get(s: S): A
def andThen[B](getter: Getter[A, B]): Getter[S, B] =
new Getter[S, B] {
def get(s: S): B =
(self.get _).andThen(getter.get _)(s)
}
}
object Getter {
def apply[S, A](f: S => A): Getter[S, A] =
new Getter[S, A] {
def get(s: S): A = f(s)
}
}
Normally, using getters in specific code has not many advantages. More in libraries and other examples of generic or even generated code.
If we take a look at how setter would be generated (basically passing how to get the attribute out of object), we should be able to generate getter as well. Perhaps, we could even generate something that is both getter and setter at the same time.
Getters and setters do not compose with each other. Getter
+Setter
and Setter
+Getter
are the only pairs of structures described today that do not compose.
Lens
Lenses are things we use to take a closer look at something, focus something smaller. That metaphor is why the combination of a getter and a setter is called lens
:
trait Lens[S, A] { self =>
def modify(f: A => A): S => S
def set(a: A): S => S = modify(_ => a)
def get(s: S): A
def combine[B](lens: Lens[A, B]): Lens[S, B] =
new Lens[S, B] {
def modify(f: B => B): S => S =
(lens.modify _).andThen(self.modify _)(f)
def get(s: S): B =
(self.get _).andThen(lens.get _)(s)
}
}
The most interesting part here is compose
. Look how one modify
is composed by putting lens
before self
and in self
before lens
in get
. It looks a little bit like a profunctor. Could we make it more explicit?
case class Functions[A, B, C, D](f1: A => B, f2: C => D) {
def dimap[A0, D1](f: A0 => A)
(g: D => D1): Functions[A0, B, C, D1] =
Functions(f andThen f1, f2 andThen g)
}
class Lens[S, A](
val functions: Functions[A => A, S => S, S, A]
)
extends Getter[S, A]
with Setter[S, A] {
def modify(f: A => A): S => S = functions.f1(f)
def get(s: S): A = functions.f2(s)
def andThen[B](lens: Lens[A, B]): Lens[S, B] =
new Lens(functions.dimap(lens.functions.f1)
(lens.functions.f2))
}
Of course, it would be too messy to maintain code like that (also inefficient), so nobody implements lenses this way, but we should be aware that hey can be implemented using profunctors, and we could use some profunctor type class to compose them. (It also implies that getter could be implemented using some functor and setter using some contravariant functor).
Removing boilerplate for Lenses
How can we use lenses easily? If we want to reuse code and need something that compiles fast, we can take a look at Monocle:
import monocle.Lens
import monocle.macros.GenLens
val setS3 : Lens[AppConfig, S3Config] =
GenLens[AppConfig](_.s3)
val setBucket : Lens[S3Config, String] =
GenLens[S3Config](_.bucket)
val setRegion : Lens[S3Config, String] =
GenLens[S3Config](_.region)
(setS3 composeLens setBucket).set(bucket)(app)
(setS3 composeLens setRegion).set(region)(app)
This generates the right implementation for your case classes, that you will have to compose before using. You can also try generating things in one go:
import monocle.macros.syntax.lens._
app.lens(_.s3.bucket).set(bucket)
app.lens(_.s3.region).set(region)
For ad hoc lenses usage shapeless works equally well (though it might be harsher on your compiler):
import shapeless._
lens[AppConfig].s3.bucket.set(app)(bucket)
lens[AppConfig].s3.region.set(app)(region)
Personally, I use lenses for updating configs and nested domain models.
Prism
Lenses work great when we have product types (or at least something, that could pretend to be one). But what should be done when we have sum types?
sealed trait ConfigValue extends Product with Serializable
final case class StringVal(value: String) extends ConfigValue
final case class IntVal(value: Int) extends ConfigValue
// forget about lenses
Lens[ConfigValue, StringVal] compose Lens[StringVal, String]
// they won't work!
Lenses are used to focus on some part of your data, but you cannot focus if you don’t know if the data is even there! And with sum types, you might have several possibilities to consider!
If we stuck to our optics metaphor (and we will), we could think that we need something that would do for sum type something like light dispersion does to light. A prism of a sort.
trait Prism[S, A] {
def reverseGet(a: A): S
def getOption(s: S): Option[A]
def modifyOption(f: A => A): S => Option[S] =
s => getOption(s).map(f).map(reverseGet)
}
As we can see, this basically provides us with a type-safe way of upcast (reverseGet
) and downcast (getOption
). We could even use it to update value inside our polymorphic structure if it happens to contain the expected subtype (modifyOption
). Let’s try to implement it!
val setStringVal = new Prism[ConfigValue, StringVal] {
def reverseGet(a: StringVal): ConfigValue = a
def getOption(s: ConfigValue): Option[StringVal] = {
case a: StringVal => Some(a)
case _ => None
}
}
We could use e.g. partial function or extractors to provide S => Option[A]
mapping, and then pass A => S
lifting.
object Prism {
def apply[S, A](pf: PartialFunction[S, A])(f: A => S) =
new Prism[S, A] {
def reverseGet(a: A): S = f(a)
def getOption(s: S): Option[A] =
Option(s).collect(pf)
}
}
val setStringVal =
Prism[ConfigValue, StringVal] {
case a: StringVal => a
} { a => a }
Nice! We only have one problem. StringValue
is a product type, so modifying it would require lenses. We also haven’t said anything about composing prisms and lenses. So, let’s define the composition!
trait Prism[S, A] { self =>
...
def combine[B](prism: Prism[A, B]): Prism[S, B] =
new Prism[S, B] {
def reverseGet(a: A): S =
(prism.reverseGet _).andThen(self.reverseGet _)(s)
def getOption(s: S): Option[B] =
self.getOption(s).flatMap(prism.getOption)
}
def combine[B](prism: Lens[A, B]): Prism[S, B] =
new Prism[S, B] {
def reverseGet(a: B): S =
??? // ooops!!!
def getOption(s: S): Option[B] =
self.getOption(s).map(lens.get)
}
}
Hmm, it seems we have a blocker here. How to define reverseGet
in a meaningful way when we compose Prism
and Lens
into Prism
? Well, we can’t. We also cannot compose them into Lens
because get
value would have to be Option[B]
while the type Lens[S, B]
would require B
. A pity! They are quite similar and we would definitely benefit from being able to set
and modify
something that has coproduct in its path!
Removing boilerplate for Prisms
Before we move on just a short look at how Monocle, helps us get rid of boilerplate:
import monocle._
import monocle.macros.GenPrism
val setStringVal: Prism[ConfigValue, StringVal] =
GenPrism[ConfigValue, StringVal]
For now, I’m now showing shapeless’ prism example. I’ll explain why in a moment.
Optional
Lenses and prisms are 2 different things. But we could define something that is some sort of compromise between Lens
and Prism
interfaces. We have a feeling (correct), that modify
and set
could be implemented for both products and coproducts. On the other hand, reverseGet
(upcast) makes sense only for sum types/subtypes. get
only works with products so we could use getOption
always and just wrap value with Some
. (To be exact: if we knew that value is always there, we would have a product without sum types involved, so we could just use Lens
. If we are considering this new thing, then we have a sum type and the value might be absent).
Let’s draft the new interface! Because it’s like lenses but with support for things that might not be there, let’s call it Optional
:
trait Optional[S, A] {
def modify(f: A => A): S => S
def set(a: A): S => S = modify(_ => a)
def getOption(s: S): Option[A]
}
This has to compose with both Lens
es and Prism
s:
trait Optional[S, A] { self =>
...
def combine[B](optional: Optional[A, B]): Optional[S, B] =
new Optional[S, B] {
def modify(f: B => B): S => S =
(optional.modify _).andThen(self.modify _)(f)
def getOption(s: S): Option[B] =
self.getOption(s).flatMap(optional.getOption)
}
def combine[B](lens: Lens[A, B]): Optional[S, B] =
new Optional[S, B] {
def modify(f: B => B): S => S =
(lens.modify _).andThen(self.modify _)(f)
def getOption(s: S): Option[B] =
self.getOption(s).map(lens.get)
}
def combine[B](prism: Prism[A, B]): Optional[S, B] =
new Optional[S, B] {
def modify(f: B => B): S => S =
self.modify { a =>
prism
.getOption(a)
.map(f)
.map(prism.reverseGet)
.getOrElse(a)
}
def getOption(s: S): Option[B] =
self.getOption(s).flatMap(prism.getOption)
}
}
Optional
can also be used as a result of the composition of Lens
es with Prism
s or Prism
s with Lens
es:
trait Lens[S, A] { self =>
...
def combine[B](prism: Prism[A, B]): Optional[S, B] =
new Optional[S, B] {
def modify(f: B => B): S => S =
self.modify { a =>
prism
.getOption(a)
.map(f)
.map(prism.reverseGet)
.getOrElse(a)
}
def getOption(s: S): Option[B] =
(self.get _).andThen(prism.getOption)(s)
}
}
trait Prism[S, A] { self =>
...
def combine[B](lens: Lens[A, B]): Optional[S, B] =
new Optional[S, B] {
def modify(f: B => B): S => S =
(lens.modify _).andThen(self.modify _)(f)
def getOption(s: S): Option[B] =
self.getOption(s).map(lens.get)
}
}
With that, we are now able to compose optics describing config values.
val setStringVal: Prism[ConfigValue, StringVal]
val setValue: Lens[StringVal, String]
val composed: Optional[ConfigValue, String] =
(setStringVal andThen setValue)
composed.set("x")(config): ConfigValue
composed.getOption(config): Option[String]
Implementations
This is a good moment to stop and take a closer look at how things are implemented in libraries. The first thing that you’ll notice, when you look into Monocle or Scalaz implementation of lenses (Monocle and Scalaz) and prisms (Monocle only - Scalaz do not implement Prisms) does not have 2 type parameters - they have 4, which are later on fixed in type alias to 2:
trait PPrism[S, T, A, B] { ... }
trait PLens[S, T, A, B] { ... }
type Prism[S, A] = PPrism[S, S, A, A]
type Lens[S, A] = PLens[S, S, A, A]
The idea here is like this: you have a S => A
lens/prism. You treat it like a bifunctor [S, A]
, where you pass in 2 functions S => T
and A => B
. So modify
becomes (A => B) => (S => T)
, reverseGet
becomes B => T
etc. Authors wanted to have bimap
and lens/prosm operations in one data structure which is why they ended up with PLens
/PPrism
. Then Lens
and Prism
became just a special (but common) case, when bifunctor has identity
as function applied to both S => T
(S =:= T
) and A => B
(A =:= B
). Optional
is also implemented this way:
trait POptional[S, T, A, B] { ... }
type Optonal[S, A] = POptional[S, S, A, A]
This is quite important information if you want to read the source code and don’t get insane - in many cases fragments that are impossible to comprehend, becomes much easier to parse if you substitute T
with S
, B
with A
, try to understand the code in simpler case, and then imagine that the optics you just analyzed are also (bi)mapped.
Shapeless makes a slightly different approach to optics. While Monocle makes a strict separation between lens, prism and optional, shapeless gets rid of optional and unifies interfaces of lens and prism - actually, the only difference between Lens[S, A]
and Prism[S, A]
is that the former’s get
returns A
while the latter’s Option[A]
. They also don’t introduce 4 parameter version of optics, so the whole code is much easier to read/write if you are new to the concept.
import shapeless._
prism[ConfigValue][StringVal].value.get(cfg) // Option[String]
Usage is simple - start with lens[A]
/prism[A]
(this is actually irrelevant, a distinction is just for readability reasons) and then use .fieldName
to create Lens
or [Subtype]
to create Prism
. It will naturally chain optics, with no extra effort.
A similar thing we have with Circe’s optics:
// example from Circe Optiocs docs
import io.circe.optics.JsonPath._
val _phoneNum =
root.order.customer.contactDetails.phone.string
val phoneNum: Option[String] = _phoneNum.getOption(json)
Notice, that this way you are able to e.g. migrate one JSON format into the other without having to decode into the ADT. So you could receive events/requests in the older format, check version, run all migrations from received version to the current one, and keep case class
es and case object
s only for the current schema.
Traversal
Optional works under the assumption, that there are 0 or 1 smaller elements we can focus on. It might be an element of a product type or subtype of a coproduct. However, it won’t be fully useful if we’ll try to focus on elements of e.g. List[Int]
. A list can have any number of elements: 0, 1, 100, 1000… If we wanted to be able to get
or getOption
them, it wouldn’t make much sense (well, we could get head
or last
element as Option
, but surely not all). We might want to modify
them, though.
Before we define an interface that will be a stripped down Optional
, we should notice one thing. When it comes to a collection, we can filter or expand it without messing up its type. We couldn’t do it with lenses (an element of a product type is not guaranteed to be a collection so we cannot filter/expand it, we can only create a new value of the same type). Same with prisms - coproduct is already defined, and we can only create a new value of some subtype (assuming, that the subtype matches the expected for which we provided handling). Finally, optional as a common part of prisms and lenses cannot support filtering nor expanding as well.
How could our new optics handle it? Well, it can act similarly to a monad - but working on a A => F[A]
:
trait Traversal[S, A] {
def modifyF[F[_]: Applicative](f: A => F[A]): S => F[S]
def modify(f: A => A): S => S =
modifyF[Id](f)
def set(a: A): S => S =
modify(_ => a)
def foldMap[M: Monoid](f: A => M)(s: S): M =
modifyF[Const[M, ?]](a => Const(f(a)))(s).getConst
def fold(s: S)(implicit ev: Monoid[A]): A =
foldMap(identity)(s)
}
where Const
is
// Tagged type of a kind - B is a tag and
// there is no actual value of this type here.
// It is a functor when it comes to A parameter.
case class Const[A, B](getConst: A)
Example:
def listTraversal[A] = new Traversal[List[A], A] {
def modifyF[F[_]: Applicative](
f: A => F[A]
): List[A] => F[List[A]] =
_.foldRight(F[Applicative].pure(List.empty[A])) {
case (a, fListA) =>
val fFun: F[A => List[A] => List[A]] =
F[Applicative].pure(a1 => list => a1 :: list)
val fInt: F[Int] = f(a)
fFun.ap(fA).ap(fListA)
}
}
// queries database, therfore asynchronous
val getSupervisorId: EmployeeId => Task[EmployeeId]
val employeeIds: List[EmployeeId]
val supervisorIds: Task[List[EmployeeId]] =
listTraversal[Task]
.modifyF(getSupervisorId)(employeeIds)
case class Customer(signed: LocalDateTime)
val customers: List[Customer]
implicit val earlierDate: Monoid[LocalDateTime]
val earliestSignup =
listTraversal[Customer].foldMap(_.signed)(customers)
Nice - we can use Traversal
optics to do things like Task.sequence
! That is, as long as you don’t change the type. If not that little details it would be very similar to Traverse
type class (in a way both are concerned about the same thing, just in a different context).
Well, to be honest… you can change the type. As you might still remember, all the previous optics were defined as POptics[S, T, A, B]
with 4 type parameters, where Optics[S, A]
were aliases for POptics[S, S, A, A]
. The same is true about Traversal
:
trait PTraversal[S, T, A, B] {
def modifyF[F[_]: Applicative](f: A => F[B]): S => F[T]
def modify(f: A => B): S => T =
modifyF[Id](f)
def set(a: A): S => T =
modify(_ => a)
...
}
type Traversal[S, A] = PTraversal[S, S, A, A]
def listPTraversal[A, B] =
new PTraversal[List[A], List[B], A, B] { ... }
def fetchUser: UserId => Future[User]
val userIds: List[UserId]
val usersF: Future[List[User]] =
listPTraversal[UserId, User].modifyF(fetchUser)(userIds)
Traversal
is more generic than Optional
, so it composes with everything that Optional
composes. This might help us understand why it makes sense for all optics to have 4 type parameters (for S => T
and A =>B
mappings) - this way we can always compose them. When used standalone, such mappings aren’t obviously useful when it comes to Lens
, Prism
and Optional
- we can always do optics’ modify
and result in mapping in 2 steps, but Traversal
shows when it becomes much clearer to do it in one step.
Fold
If all we want to do is folding the result with foldMap
, we don’t need a whole Traversal
.
trait Fold[S, A] {
def foldMap[M: Monoid](f: A => M)(s: S): M
}
And sometimes you cannot create one when you compose optics. In particular: when you compose many optics with getter you cannot reliably get something that handles everything required of Lens
/Prism
/Optional
/Traverse
(especially, if you consider the 4 type-parameter variants). In such cases, the result of a composition can be Fold
.
Getter
combined with Traversal
/Optional
/Prism
gives Fold
.
Iso
So far we used optics to focus on some smaller part of a bigger whole - it was some element of a whole element (lens), some type of a sum type (prism) or uniform way to perform both of these at once. Now, we will take a look at something from a different perspective.
Mathematics define bijection as a two-way function. Formally, it has to map to all elements of its domain (function on, surjection) and each argument assigned has a different returned value (function 1-1, injection). If you can define a bijection, you can perform an operation and safely reverse it - it basically ensures the existence of .
But still, it just means that the data could have 2 different representations when we send it. The behavior associated with these forms might differ so e.g. if you have a bijection between char
and BitSet
of fixed size, you can translate back and forth. However, if we decide that e.g. combining two char
s means addition modulo 256, whole adding BitSet
s would be and/or/xor on corresponding fields, then you cannot rely on instinct, that f(c1 + c1) = f(c1) + f(f2)
.
Transformation that preserves structure - acts according to that instinctive rule that f(c1 + c1) = f(c1) + f(f2)
- is called homomorphism. homos - same, morph - shape, so its name literally means that keeps shape the same. You will most often see it with combination with bijection. A homomorphic bijection is called isomorphism. iso - equal - suggest how we should think of isomorphisms. If you can map every element of one set into every element of another set, these mappings are 1-1 and preserve the relations between elements - we have no real reason to treat both sets at different things at all! The fact, that when we see something different when we investigate elements in detail, becomes just a point of view. As such, we can pick whichever point of view makes things easier for us at the moment, and don’t stick to one all the time.
Example: time can be stored both in an ISO 8601 format yyyy-mm-ddThh:mm:ss.ffffff
and using a number of seconds from Unix epoch (beginning of a Unix era). If you wanted to compare time instances or add some time ranges together, the representation should not matter to the result.
But the representation can pretty much affect how easy is to compute the result! Comparison of integers is much easier than parsing and interpreting strings and then performing some complex rules that would compare time zones, etc. It might be also relatively easy to convert that string to Unix epoch - at least easier than to compare two strings immediately - and then compare the integers.
We actually saw one structure, that given transformations in both ways would translate relations on one structure to relations on the other - an invariant functor. (Actually, the example of Unix epoch and DateTime
is taken from Cats documentation about Invariant
).
F[Long].imap[ZonedDateTime]
(s => ZonedDateTime.from(Instant.ofEpochSecond(s)))
(_.toEpochSecond)
Such bijection would be isomorphic and so the received F[ZonedDateTime]
map would preserve properties of F[Long]
. If F
would be Semigroup
, then we would add zoned times treating them as seconds from Unix epoch.
But with Invariant
we just used the isomorphism to turn one structure into another (equivalent!) representation. The morphism itself is hidden inside and we cannot make use of it anymore. If we wanted to be able to freely move from one representation to the other, we need some other data structure. What we would want it to do?
trait Iso[S, A] { self =>
def get(s: S): A
def reverseGet(a: A): S
def reverse: Iso[A, S] = new Iso[A, S] {
def get(s: A): S = self.reverseGet(s)
def reverseGet(a: S): A = self.get(a)
}
}
(Just like with everything else, you can expect that Monocle with implement PIso[S, T, A, B]
and have type Iso[S, A] = PIso[S, S, A, A]
).
Such interface does all that we want - it translates S
to A
, A
to S
, we can change which form is default and which one is reversed. Of course, we can also compose them.
trait Iso[S, A] { self =>
...
def combine[B](iso: Iso[A, B]): Iso[S, B] =
new Iso[S, B] {
def get(s: S):B =
(self.get _).andThen(iso.get _)(s)
def reverseGet(a: B):S =
(iso.reverseGet _).andThen(self.reverseGet _)(a)
}
}
It can also be combined with other optics we talked, but we’ll skip drafting all compositions here. Let’s just say that combining with:
Lens
/Prism
/Optional
will produceLens
/Prism
/Optional
,- swapping the order (e.g.
Setter
withIso
) won’t change the result type.
If we think about it, no big surprises here.
Of all libraries mentioned so far, only Monocle implements Iso
. Shapeless and Scalaz make use of isomorphism (Scalaz even has Iso
), but they don’t reify them into an optics implementation composable with other optics.
Table of composition
Scalaz has only Lens
es - they always compose (as long as type parameters match).
Shapeless has Lens
es and Prism
s, but in its implementation, there are hardly any differences between them, so they always compose.
Monocle implements whole range of optics, but their composition is a little more tricky, so I borrowed this table from the official documentation (as well as few examples before):
Fold | Getter | Setter | Traversal | Optional | Prism | Lens | Iso | |
---|---|---|---|---|---|---|---|---|
Fold | Fold | Fold | Fold | Fold | Fold | Fold | Fold | Fold |
Getter | Fold | Getter | - | Fold | Fold | Fold | Getter | Getter |
Setter | - | - | Setter | Setter | Setter | Setter | Setter | Setter |
Traversal | Fold | Fold | Setter | Traversal | Traversal | Traversal | Traversal | Traversal |
Optional | Fold | Fold | Setter | Traversal | Optional | Optional | Optional | Optional |
Prism | Fold | Fold | Setter | Traversal | Optional | Prism | Optional | Prism |
Lens | Fold | Getter | Setter | Traversal | Optional | Optional | Lens | Lens |
Iso | Fold | Getter | Setter | Traversal | Optional | Prism | Lens | Iso |
The docs also show the relationships between optics - what could be considered a more general version of something else. Notice, that this is not an inheritance relation - while Traversal
is more generic than Optional
it is not its supertype. This diagram though what we can expect when we combine two optics - usually you can just pick up the most specific thing both of them implements.
Summary
In this article we learned a bit about a family of data transformations, that helps us investigate insides and modify immutable data structures. We learned, that there are many kinds of them, each of them helps us in a different situation, and almost all of them compose together.
In order to learn when and how to use them, I would recommend starting with something simple like Shapeless’ lenses and prisms - they present the easiest way of creating optics deep looking into our ADT. Once we got better (or once shapeless becomes too slow), we can use Monocle and employ its macros and syntax to handle more complex cases with as little boilerplate as possible.