As part of my astrological forays, I built a little library that can traverse an interval of time and find significant events (changes in moon phase, a planet going retrograde/direct, ingresses into zodiac signs, transits, eclipses.) The data produced is necessarily verbose: you get back a sequence of events, and each type of event carries information about itself; in some cases, said information can be composed of further complex data (e.g. a Transit can tell you all the phases of application/separation it goes through in the interval, and each phase has a beginning and end.) To have to go through a sequence like this and pattern match and deconstruct in different ways depending on your use case seemed like a lot of annoying boilerplate that I, as a library author, could do better to help with than to just give you the types to pattern match, some good names, and a firm handshake. And this is when I realized what's so great about a popular, albeit intimidating, concept in the Haskell ecosystem: optics: the ability to build greatly upon "cheap" abstractions.
Getting (to) it
I have to admit, I've been one of the attention-challenged developers that gazes upon the Sacred UML and despairs, and both very practical tutorials that devolve into very clever combinator golf and strongly reasoned but impenetrable category theoretic approaches have left me in a state of mild panic.
I watched a few videos, read a few articles and book chapters that mostly helped me cobble together a working knowledge of optics/lenses, but beyond the "it's sort of just getters and setters for Haskell innit" notion that allows you to dig deep into some
JSON or put together a couple diagrams or swagger docs, I didn't feel like I got it. On the one hand, why did
the extremely pedestrian notion of thing.property.property.property = new_value
feel
so contrived? On the other hand, it felt like it needed to be more "fancy" to survive in the world of pure, lazy, functional programming.
It wasn't until I was sitting around thinking about how I can allow users of my library to go through a sequence of product values composed of further product values with some sum values in the middle there that the value proposition landed: I don't need a big ol' dependency like lens
to give you a few optics that you can then grab
and do optics things with. And you don't need lens
either: you can use microlens
or another smaller library, or write a few little functions, and suddenly you have very concise, composable access to just the data you need, and a whole vocabulary of cool combinators to grab from as your use cases evolve (and you eventually end up bringing in lens
.) And that is only possible because the abstractions of optics can be represented with a few type aliases (or profunctors, more on that later,) that is: it's already there in the language, we don't need to agree on a library. And this very concrete need, not needing to bring in big dependencies, makes it a very cost-effective abstraction: you don't need to invest a lot of your dependency budget, but you can get a lot of possibilities of re-combination out of it.
Providing some baby optics
After reading this excellent blog post about writing one's own lenses, as well as the microlens README, the relude implementation and last but not least kmett's own wiki on the matter, I realized that, to provide the kind of basic optics that my library could benefit from (i.e. monomorphic lenses and prisms... or something close to that,) I literally only needed a couple of type aliases:
{-# LANGUAGE RankNTypes #-}
module Almanac.Internal.Lens where
-- | General lens: can change the type of the container.
-- from: https://hackage.haskell.org/package/lens-5.1/docs/Control-Lens-Lens.html#t:Lens
type Lens s t a b = forall f. Functor f => (a -> f b) -> s -> f t
-- | Monomorphic (simple) 'Lens': can't change the type when setting
type Lens' s a = Lens s s a a
-- | General Traversal
-- from: https://hackage.haskell.org/package/lens-5.1/docs/Control-Lens-Type.html#t:Traversal
type Traversal s t a b = forall f. Applicative f => (a -> f b) -> s -> f t
-- | Monomorphic 'Traversal'
type Traversal' s a = Traversal s s a a
And to "create" lenses, I could generalize the process with a simple helper function:
-- | Create a 'Lens'' from a getter and setter
simpleLens :: (s -> a) -> (s -> a -> s) -> Lens' s a
simpleLens getter setter f s = setter s <$> f (getter s)
The types in my library are either big records composed of further records, or sum types, so lenses for the former make sense -- for the famous/infamous looks.like.java
nested access uses cases. For the case where one wants to focus on one of different options in a sum type, the optics literature would posit a Prism
: however, if you go looking, you'll find that a bona-fide Prism
is defined in terms of type classes from profunctors
:
type Prism s t a b =
forall p f. (Choice p, Applicative f) =>
p a (f b) -> p s (f t)
Which would allow one to use all combinators that can be used on Prisms... but it seemed like for my prospective use case, focused more on reading/getting than in writing/setting the pseudo-prisms that are actually Traversal
s as proposed by microlens were more than enough. With the above simpleLens
helper and the "Traversals misused as Prisms" concession, I was ready to provide a few optics. For example:
eventL :: Lens' ExactEvent Event
eventL =
simpleLens get set
where
get = event
set e evt' = e{event=evt'}
exactitudeMomentsL :: Lens' ExactEvent [UTCTime]
exactitudeMomentsL =
simpleLens get set
where
get = exactitudeMoments
set e exc' = e{exactitudeMoments = exc'}
_LunarPhaseInfo :: Traversal' Event LunarPhaseInfo
_LunarPhaseInfo f (LunarPhase info) = LunarPhase <$> f info
_LunarPhaseInfo _ evt = pure evt
Allow writing code such as:
it "finds all lunar phases in November 2021" $ do
let nov2021 = UTCTime (fromGregorian 2021 11 1) 0
dec2021 = UTCTime (fromGregorian 2021 12 1) 0
q = mundane
(Interval nov2021 dec2021)
[QueryLunarPhase]
expectedPhases =
[
(WaningCrescent,"2021-11-01T13:28:27.314121723175Z"),
(NewMoon,"2021-11-04T21:14:36.684200763702Z"),
(WaxingCrescent,"2021-11-08T02:31:28.868464529514Z"),
(FirstQuarter,"2021-11-11T12:46:02.566146254539Z"),
(WaxingGibbous,"2021-11-15T07:27:48.519482016563Z"),
(FullMoon,"2021-11-19T08:57:27.984892129898Z"),
(WaningGibbous,"2021-11-23T12:50:27.58442312479Z"),
(LastQuarter,"2021-11-27T12:27:40.648325085639Z")
] & map (second mkUTC)
exactPhases <- runQuery q >>= eventsWithExactitude
let digest = (summarize <$> exactPhases) ^.. traversed . _Just
summarize evt =
let phase = evt ^? eventL._LunarPhaseInfo.lunarPhaseNameL
firstExact = evt ^? exactitudeMomentsL._head
in (,) <$> phase <*> firstExact
digest `shouldBe` expectedPhases
That is: run the query, and try to find all phases for which we were able to calculate moments of exactitude (not all events are guaranteed to get their moments of exactitude calculated -- a transit may be happening, but not become exact in the examined interval); for all of those, get the name of the phase. The alternative is a ton of pattern matching. Arguably, one still needs to know the "shape" of things (what data constitutes an event, and a lunar phase,) but the resulting code is concise, reusable in smaller blocks, and can be put together with existing combinators and optics into existing types (like the _Just
or _head
prisms) -- vs. you having to write your own pseudo-optics by pattern matching every time you want to "look into" a particular path in the data: this kind of boilerplate evaporates.
Granted, a previous incarnation of the above code in specific is less dense:
extractMoonPhaseInfo :: Seq ExactEvent -> [(LunarPhaseName, UTCTime)]
extractMoonPhaseInfo evts =
fmap summarize evts & toList & catMaybes
where
summarize (ExactEvent (LunarPhase LunarPhaseInfo{lunarPhaseName}) (firstExact:_)) =
Just (lunarPhaseName, firstExact)
summarize _ = Nothing
-- setup is the same ...
-- it "....
exactPhases <- runQuery q >>= eventsWithExactitude
let digest = extractMoonPhaseInfo exactPhases
-- digest `shouldBe` ...
But, in my view, it's also less modular: I needed a separate helper function to isolate the deep pattern-matching, and the building blocks (i.e. "here's how you get the phase name", and "here's how you get the first moment of exactitude") were less obvious candidates for use in other situations in the very same test suite, which meant having to figure out ad-hoc "optics" to look into the bits of data that I cared about for each test case, as opposed to just composing existing optics.
So, with a couple of type aliases, and some elbow grease (didn't want to introduce a fancy TemplateHaskell
internal module to call simpleLens
for me) to provide the obvious lenses and "prisms", I can now allow the deep-nested access in the "dialect" of optics that some users prefer (others can continue using the less esoteric approach of pattern matching and judicious helper functions; and whomever chooses this, doesn't have to carry a big ol' lens
dependency that they're not using.)
Practical needs met, I still felt like the matter of "can't really provide real Prisms" hid some interesting discoveries, so I went looking for some literature on this whole optics thing.
Down the profunctors rabbit hole
Reading the lens
package haddocks didn't really get me very far: there's a nice symmetry to the definitions of Lens
and Traversal
reproduced above, and Prism
, with its weird Choice
constraint, looked almost offensively out of place. Fortunately, I had heard in passing that even though the most popular library, lens
, uses a representation of optics called the van Laarhoven encoding, all optics could also be represented as profunctors without losing the "you can get them for free out of existing concepts" elegance, so I grabbed a couple of papers and articles:
- Profunctor Optics: the categorical view
- What you needa know about Yoneda
- Profunctor optics: modular data accessors
It was that last paper that really laid everything in a manner comprehensible to me: they first define some optics in the more naïve, "data" encoding, and then they introduce the notion of Profunctor
as a generalization of functions:
-- | A @Profunctor a b@ is a "transformer" that knows how to "read" and "write"
-- into a value.
class Profunctor p where
dimap :: (a' -> a) -> (b -> b') -> p a b -> p a' b'
The instance of functions as profunctors somewhat cemented the "intuition" of profunctors for me:
-- | f can be understood as a preprocessor (which is why it's contravariant:
-- has to output whatever h expects,) and g is a post-processor
-- (which is why it's covariant, it takes what h produces)
instance Profunctor (->) where
dimap f g h = f >>> h >>> g
In that a dimap
operation on a profunctor h
, given a function f
that can
produce inputs to h
, and a function g
that can consume outputs from h
, gives us a new profunctor
that "wraps" the original one. The paper does an excellent job in pacing this and all subsequent definitions, I can't do it justice here without reproducing the whole thing, so please go read it!
And then, to finally land on
these very symmetrical definitions of Lens
, Traversal
and Prism
, which, being profunctors, can compose with each other (We'll define Cartesian
and CoCartesian
later):
type Optic p a b s t = p a b -> p s t
-- | 'Adapter' is @Iso@ in @Control.Lens@
type AdapterP a b s t
= forall p. Profunctor p
=> Optic p a b s t
-- | 'Cartesian' in the paper is the same as @Strong@ in @profunctors@
type LensP a b s t
= forall p. Cartesian p
=> Optic p a b s t
-- | 'CoCartesian' == @Choice@ from @profunctors@
type PrismP a b s t
= forall p. CoCartesian p
=> Optic p a b s t
-- | 'Monoidal' == @Traversing@ (?)
type TraversalP a b s t
= forall p. (Cartesian p, CoCartesian p, Monoidal p)
=> Optic p a b s t
Which rely on the following family of profunctors:
-- | Strength with respect to product types.
-- @profunctors@ calls this @Strong@,
-- also says it's the "generalizing Star of a strong Functor"
class Profunctor p => Cartesian p where
first :: p a b -> p (a,c) (b,c)
second :: p a b -> p (c,a) (c,b)
-- | Known in @profunctors@ as @Choice@,
-- "generalizing CoStar of a Functor that's strong in Either"
class Profunctor p => CoCartesian p where
left :: p a b -> p (Either a c) (Either b c)
right :: p a b -> p (Either c a) (Either c b)
class Profunctor p => Monoidal p where
par :: p a b -> p c d -> p (a, c) (b, d)
empty :: p () ()
That is: all of these, and more, are optics with different constraints for the profunctors involved; and, being profunctors, an Optic
itself can be seen as a lifting of a "transformer" of components a
to b
, p a b
, into a "transformer" of whole structures s
to t
, p s t
. And they combine nicely to produce new optics like affine traversals (where a Lens and Prism meet,) to solve concrete needs such as "how to get the first element of an optional pair". I put together code from the paper as I was reading along, plus my very poorly named/thought out exploratory examples, in a gist, if you want to play with it and check out some examples.
As I was putting together the gist above, it's very interesting to see how some operators from the Control.Category
and Control.Arrow
modules can be used to write some profunctor instances: a certain rhyming that intimates that in the same vein that Arrow
aims to generalize at a very deep level some patterns of programming, Profunctor
can be seen as building upon that same ethos (one can even say that "Arrow is just a Strong Category, anyway", though that's arguable.)
An extended example
One train of thought that I want to hop onto for a bit, is the paper's demonstration of the modularity of profunctor optics, vs. the less generally applicable "concrete" optics, which I think embodies the essence of these kind of "high-yield" abstractions.
Let's say we want to write a concrete Lens
into the first component of a pair:
-- | Concrete lens
data Lens a b s t = Lens {view :: s -> a, update :: (b,s) -> t}
-- | Act on the first component
_1 :: Lens a b (a,c) (b,c)
_1 =
Lens v u
where
v (a, c)= a
u (b, (a,c)) = (b,c)
Which works fine. However, let's say that we're now dealing with a pair whose first component is itself a pair. We want something like _1_1 :: Lens a b ((a,c),d) ((b,c),d)
. Since the concrete representation of Lens
is not a function (or a Category
,) we can't simply say that _1_1 = _1 . _1
. So we must give up and pattern match once
again:
_1_1 :: Lens a b ((a,c),d) ((b,c),d)
_1_1 =
Lens v u
where
v ((a,c),d) = a
u (b, ((a,c),d)) = ((b,c),d)
The van Laarhoven encoding doesn't suffer from this:
-- | Almost the same definition as from Control.Lens, but with @s t a b@
-- changed to @a b s t@ to agree with the profunctor optics paper:
type LensVL a b s t = forall f. Functor f => (a -> f b) -> s -> f t
-- | little helper to build lenses
lens :: (s -> a) -> (s -> b -> t) -> LensVL a b s t
lens sa sbt afb s = sbt s <$> afb (sa s)
_1VL :: LensVL a b (a,c) (b,c)
_1VL =
lens v u
where
v (a,c) = a
u (a,c) b = (b,c)
_1_1VL :: LensVL a b ((a,c),d) ((b,c),d)
_1_1VL = _1VL . _1VL
And neither does the profunctor encoding:
-- | 'Cartesian' here == 'Strong' in @profunctors@
class Profunctor p => Cartesian p where
first :: p a b -> p (a,c) (b,c)
second :: p a b -> p (c,a) (c,b)
type LensP a b s t = forall p. Cartesian p => Optic p a b s t
-- | Using left-to-right composition and 'fanout' from Control.Category/Arrow,
-- plus 'dimap' from 'Profunctor':
_1P :: LensP a b (a,c) (b,c)
_1P = first >>> dimap (fst &&& id) (second snd)
_1_1P :: LensVL a b ((a,c),d) ((b,c),d)
_1_1P = _1P . _1P
Things get even more interesting once one gets prisms into the mix. Let's say we want to have a prism into an optional value:
data Prism a b s t = Prism {match :: s -> Either t a, build :: b -> t}
the :: Prism a b (Maybe a) (Maybe b)
the =
Prism m b
where
m Nothing = Left Nothing
m (Just a) = Right a
b b' = Just b'
If we wished to use the
and _1
together with this "concrete" encoding, as we've seen before when trying to compose _1
after itself,
we can't use the existing functions that we wrote for each sub-problem, and have to write the combination anew, by pattern matching and building up to the components of a new data type, the AffineTraversal
:
-- | Thanks to: https://github.com/hablapps/DontFearTheProfunctorOptics/blob/master/ProfunctorOptics.md#profunctor-affine
-- and: https://artyom.me/lens-over-tea-5
data AffineTraversal a b s t
= AffineTraversal { preview :: s -> Either t a, set :: (b,s) -> t}
_the_1 :: AffineTraversal a b (Maybe (a,c)) (Maybe (b,c))
_the_1 =
AffineTraversal p s
where
p Nothing = Left Nothing
p (Just (a,c)) = Right a
s (b, Just (a,c)) = Just (b,c)
s (b, Nothing) = Nothing
_1_the :: AffineTraversal a b (Maybe a, c) (Maybe b, c)
_1_the =
AffineTraversal p s
where
p (Nothing, c) = Left (Nothing, c)
p (Just a, c) = Right a
s (b, (Just a, c)) = (Just b,c)
s (b, (Nothing,c)) = (Nothing, c)
In the case of profunctor optics, we can get to affine traversals by simple composition,
the new optic arising unladen as yet another "Optic
with certain constraints" type alias:
class Profunctor p => CoCartesian p where
left :: p a b -> p (Either a c) (Either b c)
right :: p a b -> p (Either c a) (Either c b)
type PrismP a b s t
= forall p. CoCartesian p
=> Optic p a b s t
theP :: PrismP a b (Maybe a) (Maybe b)
theP = right >>> dimap (maybe (Left Nothing) Right) (either id Just)
-- | optic into the first component of an optional pair
-- >>> the_1P (^2) (Just (3, True))
-- Just (9,True)
the_1P :: (Cartesian p, CoCartesian p) => Optic p a b (Maybe (a, c)) (Maybe (b, c))
the_1P = theP . _1
-- | optic onto the optional first component of a pair:
-- >>> _1_theP (^2) (Just 2, False)
-- (Just 4,False)
_1_theP :: (Cartesian p, CoCartesian p) => Optic p a b (Maybe a, c) (Maybe b, c)
_1_theP = _1 . theP
If we want to put a name to the resulting optic:
-- | Optic with 0 or 1 targets.
type AffineTraversalP a b s t
= forall p. (Cartesian p, CoCartesian p)
=> Optic p a b s t
-- | optic into the first component of an optional pair
-- >>> the_1P (^2) (Just (3, True))
-- Just (9,True)
the_1P :: AffineTraversalP a b (Maybe (a, c)) (Maybe (b, c))
the_1P = theP . _1
-- | optic onto the optional first component of a pair:
-- >>> _1_theP (^2) (Just 2, False)
-- (Just 4,False)
_1_theP :: AffineTraversalP a b (Maybe a, c) (Maybe b, c)
_1_theP = _1 . theP
In the van Laarhoven encoding, we can also easily compose a Prism and a Lens:
type PrismVL a b s t
= forall p f. (CoCartesian p, Applicative f)
=> p a (f b) -> p s (f t)
prism :: (s -> Either t a) -> (b -> t) -> PrismVL a b s t
prism seta bt = dimap seta (either pure (fmap bt)) . right
theVL :: PrismVL a b (Maybe a) (Maybe b)
theVL =
prism m b
where
m Nothing = Left Nothing
m (Just a) = Right a
b b' = Just b'
the_1VL :: Applicative f => (a -> f b) -> Maybe (a, c) -> f (Maybe (b, c))
the_1VL = theVL . _1VL
_1_theVL :: Applicative f => (a -> f b) -> (Maybe a, c) -> f (Maybe b,c)
_1_theVL = _1VL . theVL
However, if were to put a name to the resulting optic, as described much better elsewhere, we would be forced, given the Applicative
constraint inherited from the traditional definition of Prism
, to recognize it as a Traversal
:
type TraversalVL a b s t = forall f. Applicative f => (a -> f b) -> s -> f t
the_1VL :: TraversalVL a b (Maybe (a, c)) (Maybe (b, c))
the_1VL = theVL . _1VL
_1_theVL :: TraversalVL a b (Maybe a, c) (Maybe b,c)
_1_theVL = _1VL . theVL
Which isn't quite right, as it's slightly overpowered: a Traversal
can have many
targets that it can act upon sequentially (hence the Applicative
), but we really only need to target zero or one components of a value that we may act upon or update. If we had an alternative formulation of Prism
, with the controversial Pointed
class:
class Pointed p where
point :: a -> p a
type PrismVL' a b s t
= forall p f. (CoCartesian p, Functor f, Pointed f)
=> p a (f b) -> p s (f t)
prism' :: (s -> Either t a) -> (b -> t) -> PrismVL' a b s t
prism' seta bt = dimap seta (either point (fmap bt)) . right
theVL' :: PrismVL' a b (Maybe a) (Maybe b)
theVL'=
prism' m b
where
m Nothing = Left Nothing
m (Just a) = Right a
b b' = Just b'
_the_1VL' :: (Functor f, Pointed f) => (a -> f b) -> Maybe (a, c) -> f (Maybe (b, c))
_the_1VL' = theVL' . _1VL
_1_theVL' :: (Functor f, Pointed f) => (a -> f b) -> (Maybe a, c) -> f (Maybe b, c)
_1_theVL' = _1VL . theVL'
And if we were to put a name to the resulting optic:
type AffineTraversalVL a b s t
= forall f. (Functor f, Pointed f)
=> (a -> f b) -> s -> f t
_the_1VL' :: AffineTraversalVL a b (Maybe (a, c)) (Maybe (b, c))
_the_1VL' = theVL' . _1VL
_1_theVL' :: AffineTraversalVL a b (Maybe a, c) (Maybe b, c)
_1_theVL' = _1VL . theVL'
I find that quite neat: just like Pointed
is right there between Functor
and Applicative
, an AffineTraversal
, which can focus on a part of a whole that may not be there, is right there between acting upon one target either reading or setting a part of a whole (Lens
) and one of mutually exclusive alternatives which can also construct the whole (Prism
) (and not beyond, all the way to Traversal
, which can act over many targets at once.) It's interesting, though, how in both the van Laarhoven encoding and the profunctor encoding, a hierarchy arises and one can use a Traversal
, say, to do the job of an AffineTraversal
or a Prism
, which are "below" in the hierarchy -- much like one can use a power tool to open a can of tomato sauce: possible, but some of the power
is being "wasted," and one could make a (conceptual) mess. Just because I think it's pretty, the also extremely excellent Glassery
article shows several optics in their hierarchical relationship as a diagram
The paper of course doesn't cover all possible bases -- it stays true to its mission of focusing on profunctor optics; they hint at the existence of Affine Traversals but don't land on the concept by name, and they intimate in passing an impossibility with Costar
and Choice
that I asked on Reddit about, because it seemed to contradict another intimation found in the profunctors
library
Coming down from the profunctors trip
Apart from providing a more solid foundation to my notion of the biggest selling point for optics being the modularity they provide to other types, and how this kind of very generic but very applicable concept can dispense with loads of boilerplate, it also does an excellent job in providing a practical equivalence between "concrete" ("data") encodings of optics, and profunctor optics. The "what you needa know about yoneda" paper does a great job in proving equivalence between the profunctor and van laarhoven encodings, as does this talk by Bartosz Milewski. I even found code in Control.Lens to transform between representations!
One somewhat abstract lesson here is: it's interesting how the story of concrete encodings being easy to grok but not very
helpful once one wants to compose optics with each other contrasts with the more "rarefied" encodings that do allow composability: the van Laarhoven encoding uses existing type classes to arm each
optics representation with power coming from more general concepts (Functor
for focusing on one component, like Lenses
; Applicative
for focusing on many,) but it suffers from some legacy issues by engaging the existing type class hierarchy going up from there: though it can go very far by not using anything that's not in base
, it eventually has to rely on Profunctor
anyway for Prism
and others; the Profunctor
encoding builds upon a different hierarchy with Profunctor
at its base, which is conceptually extremely clean, but in the current state of affairs, requires pulling in the heavy profunctors
package.
Another, more concrete lesson, is that just like my "pattern match every single time" motivating story can get tedious, concrete optics buckle under the weight of their lack of generality; the more "abstract" encodings provide a vocabulary to elide boilerplate tasks -- this is a conclusion drawn by the profunctor optics paper, too, and they point to other interesting work in this same vein, like the Scrap your Boilerplate concept: purely mechanical work on data shouldn't pollute most business logic!
Also, by seeing how Traversal
relates to Prism
and AffineTraversal
, I feel mostly okay about my library exporting Traversal
s where it should be exporting Prism
s -- though if profunctors
ever land in base
, or if a "transformer" arises in my library as I refine it and thus necessitates pulling in the profunctors
package, I'd like to refactor my pseudo-Prisms: I like the notion of the power of the
abstraction being commensurate to the use case!
Libraries
Apart from vitrea
, which is more of an academic foray into the category theoretical implications of the profunctors encoding, and the very excellent optics
library which includes indexed optics, I've found a couple implementations that use profunctors, by well-regarded haskellers:
prolens
by Kowainik -- zero dependencies: they define their own profunctor classesfresnel
by robrix (offused-effects
fame) -- provides a much larger set of optics, depending onprofunctors
and friends.- Unrelated to profunctors, I was glad to find a couple of Clojure approximations:
traversy
, which admits to only providing Traversals, andspecter
, which seems like it arose independently of optics theory.
Further reading
Once again, the Profunctor Optics
paper by Pickering, Gibbons and Wu is a fantastic resource, but these others, in no particular order, also served in writing this article (and, no doubt, as I learn much more about what optics can bring to the table: )
- Oleg Grenrus's Glassery and Affine Traversals
- Lens over Tea
- microlens
- Mario Roman's
vitrea
optics library, using the profunctor encoding -- he also coauthored a paper on the category theoretical foundation of profunctor optics. - Well-Typed's
optics
library, with very thorough documentation -- the announcement has pretty diagrams! - The superb introduction to optics in Purescript, which uses the profunctor encoding.
- Reddit discussion on Profunctors
- Don't fear the profunctor optics