Optics from a different perspective

Why lenses don’t compose backwards

“How do I get to Aberdeen from here?”

“If I were going to Aberdeen, I wouldn’t start from here.”

— Old joke

I’m not sure that “lenses compose backwards” is still a live view. That said, I think this view is tied to some broader misunderstandings that are easy to fall into. The fact is that while lenses/optics are a really useful abstraction the historical (and often pedagogical) route leading up to them is really odd. We usually start by talking about getters and setters, but those are actually a somewhat awkward aspect of modern optics. My goal here is to present a different route to lenses that I think more directly captures the intuition behind them.

Semantic Editor Combinators

The starting point for this approach is an old Conal Elliott blog post about what he calls Semantic Editor Combinators. The basic idea is that when we think about functions like:

map   :: (a -> b) -> [a]   -> [b]
first :: (a -> b) -> (a,c) -> (b,c)
head  :: (a -> a) -> [a]   -> [a]
age   :: (Int -> Int) -> Person -> Person

we shouldn’t think of them as two argument functions that happen to be curried as a quirk of Haskell, but as genuinely single argument functions that transform an “edit” from operating on a “smaller” structure to operating on a “larger” structure. Furthermore, we should be perfectly happy to compose these: map . first . map . first takes an edit on a small structure and transforms it into an edit on a (much) larger structure.

It’s worth noting that SECs can be monomorphic (like age), polymorphic but type preserving (like head), or polymorphic and type changing (like map and first).

It’s also worth noting that—although it doesn’t show up in the types—some SECs call their argument function multiple times (like map), and some only once (like first).

Adding effects

One shortcoming of SECs is that in a pure language like Haskell a decent chunk of the functions you’re going to want to call don’t have simple types, but types with some kind of “effect-carrying” wrapper: a -> f b. If we want to use something like an SEC on these we’re going to need to do something about the effect. If we focus for the moment on single target SECs it should be reasonably intuitive that we need this wrapper type to be a functor, so that after running the argument function we can do whatever we need to put the result back in our larger type. This gives types like:

first :: Functor f => (a -> f b) -> (a,c) -> f (b,c)
head  :: Functor f => (a -> f a) -> [a]   -> f [a]
age   :: Functor f => (Int -> f Int) ->
                    Person -> f Person

which you may recognise as the “van Laarhoven” representation of lenses.

Effects on multiple targets

Extending the above to SECs which have multiple targets to be edited is simple enough: we replace the Functor constraint with Applicative, so that the results of the multiple calls can be stuck together. The convention is to call one of these a Traversal, as opposed to Lens which affects only one target. Why not simply forget about lenses and use the Applicative constraint (or even the Monad constraint that might seem obvious as an effect type) for everything? The answer—as we’ll see when we return to the topic of getters and setters—is that there are certain effects we might want to use that are Functor (or Applicative) but not Applicative (or Monad).

Operators

Operators in optics libraries have a bit of a messy reputation, and I’m not going to evangelise for them here. The way that operators (and similar functions) tend to work is:

  1. Construct a small (possibly effectful) edit function e.g. (+ 2)
  2. Apply the optic to that function to get a larger edit function
  3. (Optional) Embed that larger edit function in something e.g. the State monad

You can make perfectly good use of optics without these operators by simply applying the optics as functions to your own small edit functions, although there are some pain points that we will cover (and to some extent solve) later.

Getters and setters

This is where we rejoin the traditional route to thinking about optics. Setters are pretty obvious, they are simply applying the SEC to an edit function const x. Getters are much stranger in this approach. We start from the idea of an inner edit function with a logging effect, which logs whatever arguments the edit is called with. If all we care about is this logging the effect wrapper can even throw away the result, and short circuit the construction of the larger result type:

data Const c a = Const { getConst :: c }
instance Functor (Const c) where
    fmap _ (Const c) = Const c
get l = getConst . l Const

We can easily extend this to traversals by logging a list of targets, or only the first target. Under Haskell’s typeclass magic we can even use the “single target” get function above on a traversal provided the target type is monoidal, but that is in no way an essential part of optics

Profunctor optics

One obvious irritation of the move to effectful SECsin languages where the effect is represented with a type constructor is that we can no longer easily apply them to simple functions, but instead have to do a dance with the Identity effect. If we want to use edits of the form g a -> b or g a -> f b it is often possible to contort our optics into the right shape, but doing so tends to rely on some pretty obtuse operators. The trick here is to notice that all of these are of the form p a b for some p, and define our concept of an optic that way. Useful optics are clearly going to require constraints on the type p, the question is how those constraints map onto different types of optic.

Indexed optics

Changelog