\[ \newcommand{\view}{\textrm{get}} \newcommand{\set}{\textrm{set}} \]
This is a brain-dump of how I think about lenses in isolation. I will be using the lens laws directly in the category of sets with functions. So the arguments are meaningful for all lens implementations. Mathematical parts may seem opaque if you are not used this kind of technical writing but this should not deceive you. The content is not deep and I am sure all of these are known to experts. However I haven’t seen these arguments written down like this anywhere else.
Update: Found it! Unifying Set-Based, Delta-Based and Edit-Based Lenses by Michael Johnson and Robert Rosebrugh has essentialy the ame content. Also, found a paper by the same authors treating lenses as Grothendieck opfibrations Lenses, fibrations and universal translations. So why read this post instead of these papers? Well, probably for the fibration formulation inspired lens constructions implemented in Haskell.
Let us recall the definition of a lens expressed in terms of its usual api, namely the functions \({\rm view}\) and \({\rm set}\).
Definition: Let \(S\) and \(A\) be sets. A lens from \(S\) to \(A\) is a tuple \[ \langle \view\colon S\to A,\, \set\colon S\to A\to S \rangle \] satisfying the following identities:
In this post I will derive a different and a little bit more geometric characterization of lenses. But before that we need two preliminary notions.
The first is the notion of a fiber of a function. Given a function \(\pi\colon S\to A\) and an element \(a\in A\), we define the fiber of \(\pi\) above \(a\) to be \[ \pi^{-1}(a) = \{s\in S | \pi(s) = a\}. \] Note that \(S\) is a disjoint union of fibers of \(\pi\). We denote the set of fibers of \(\pi\) by \(S/\pi\). So \(S/\pi\) is the set of equivalence classes of the relation defined by \(a_1\equiv a_2\) if and only if \(\pi(a_1)=\pi(a_2)\). We will also view \(S/\pi\) as a category whose objects are the equivalence classes and whose morphisms are the functions.
The second one is the notion of a codiscrete groupoid. Let \(A\) be any set. Define \(\mathcal{E}(A)\), the codiscrete groupoid on \(A\), to be the category whose objects are the elements of \(A\) and for any two elements \(a,b\in A\) the set \({\rm Hom}(a,b)\) has precisely one element. We will denote this element by \(\epsilon_{a,b}\). Note that \(\mathcal{E}(A)\) is a groupoid, that is, all morphisms are isomorphisms. Indeed \(\epsilon_{a,a} = {\rm Id}_a\) and \(\epsilon_{a,b}^{-1}\) is given by \(\epsilon_{b,a}\).
Now we can give our definition.
Definition: Let \(S\) and \(A\) be sets. A lens fibration from \(S\) to \(A\) is a tuple \[ \langle \pi\colon S\to A,\, F\colon\mathcal{E}(A)\to S/\pi \rangle \] where
Here is our claim: Let \(S\) and \(A\) be sets with \(S\neq\emptyset\). Then for a lens from \(S\) to \(A\) we can construct a lens fibration from \(S\) to \(A\). Similary, for a lens fibration from \(S\) to \(A\) we can construct a lens from \(S\) to \(A\). Moreover these constructions are inverse to each other.
Let’s begin. Fix \(S\) and \(A\) as in the claim.
Lens to fibration: Given a lens \(\langle \view,\, \set \rangle\) from \(S\) to \(A\) let us define \(\pi = \view\). First we need to show that \(\pi\) is surjective. Pick an \(s\in S\). For any \(a\in A\) we have \[ a = \view\,(\set\,s\,a) = \pi(\set\,s\,a). \] by the second lens rule. Thus \(a\) is in the image of \(\pi\). Now let us define the functor \(F\). We do not have a choice on the action of \(F\) on the objects of \(\mathcal{E}(A)\): we must have \(F(a)=\pi^{-1}(a)\). For morphisms, we define \(F(\epsilon_{a,b})\colon\pi^{-1}(a)\to\pi^{-1}(b)\) by the rule \[ F(\epsilon_{a,b})(s) = \set\,s\,b. \] Let us first check that \(F(\epsilon_{a,b})\) does send the fiber above \(a\) to the fiber above \(b\). Let \(s\in\pi^{-1}(a)\). Then \[ \pi(F(\epsilon_{a,b})(s))=\pi(\set\,s\,b)= \view\,(\set\,s\,b) = b \] by the second lens rule. So \(F(\epsilon_{a,b})\) sends \(s\) to \(\pi^{-1}(b)\).
Now let us check the functor laws.
Fibration to lens: We will go in the opposite direction. Let \(\langle \pi,\, F \rangle\) be a lens fibration. Let \(\view = \pi\) and let \[ \set\,s\,a = F(\epsilon_{\pi(s),a})(s) \] for \(s\in S\) and \(a\in A\). Note that this makes sense since \(s\) is in the domain of \(F(\epsilon_{\pi(s),a})\), namely \(\pi^{-1}(\pi(s))\). Now let us prove the lens laws.
I will not prove that these constructions are inverse to each other –LaTeX is time consuming– but at this point it should not be too difficult to produce a proof.
A few observations are in order. Let \(\langle \pi,\, F \rangle\) be a lens fibration. Then all fibers of \(\pi\) are isomorphic because \(\mathcal{E}(A)\) is a connected groupoid and functors preserve isomorphisms. This also means that for any \(a\in A\), \(S\) is isomorphic to \(A\times\pi^{-1}(a)\).
One can also view the notion of a lens fibration as a special case of a groupoid action defined, say, in section 10.4 of Topology and Groups by Ronald Brown. The orbits of this action are the equivalence classes of the equivalence relation on \(S\) defined by \[ s \equiv t \;\;\;\text{ if and only if }\;\;\; F(\epsilon_{\pi(s),\pi(t)})(s)=t. \] Each orbit intersects with all fibers exactly once thus restricting \(\pi\) to any one of these orbits gives a bijection onto \(A\). So in addition to the “vertical” decomposition of \(S\) into fibers, we also have a “horizontal” decomposition into orbits.
This is a lot to take in. So let’s draw a picture explaining the situation.
Here the big rectangle is the set \(S\) decomposed into blue fibers. The light orange lines are the orbits.
It is time to see some examples with real code.
I posted the code in this section as a gist if you want to play with it.
Example 1: Lens on Either
Let us begin with the simplest nontrivial example. Assume that \(A\) has only two elements. Then \(S\) is a disjoint union of two fibers so we
can model \(S\) by Either
. Let us
also model \(A\) by a custom data type
called Position
.
data Position = OnTheLeft | OnTheRight
deriving Show
Now if we have an isomorphism between two types a
and b
then we can use them as fibers and
define a lens from Either a b
to
Position
. The
implementation is straightforward.
lensFromIso :: Iso' a b -> Lens' (Either a b) Position
= lens getter setter
lensFromIso f where
= \case
getter Left _ -> OnTheLeft
Right _ -> OnTheRight
=
setter s a case (s, a) of
Left a, OnTheRight) ->
(Right $ a ^. f
Right b, OnTheLeft) ->
(Left $ b ^. re f
->
_ s
To simplify things further, let us take a
and b
to be Bool
. Then we
can describe all lens structures with signature Lens' (Either Bool Bool) Position
because we know all automorphisms of Bool
:
parallel :: Lens' (Either a a) Position
= lensFromIso simple
parallel
crossed :: Lens' (Either Bool Bool) Position
= lensFromIso $ iso not not crossed
These lenses have the same getter but their setters are different. Drawing pictures of these lenses should clarify the choice of names.
Actually parallel
is a more
traditional lens in disguise. Note that we have the following
isomorphism:
data WithPosition a = WithPosition
_value :: a,
{ _position :: Position
deriving Show
} 'WithPosition
makeLenses '
productToSum :: Iso' (WithPosition a) (Either a a)
= iso fromProduct fromSum
productToSum where
= \case
fromSum Left a -> WithPosition a OnTheLeft
Right a -> WithPosition a OnTheRight
WithPosition a pos) =
fromProduct (case pos of
OnTheLeft -> Left a
OnTheRight -> Right a
which is an instance of the isomorphism \(a\times 2 \cong a + a\). Using this isomorphism we can define
position' :: Lens' (WithPosition a) Position
= productToSum . parallel position'
It is easy to see that position
and position'
are the same lens.
Example 2: Sum without Either
In the previous example we modeled a disjoint union as a sum type. It may be the case that decomposition into a sum is not explicitly expressed in the type. Now let us see an example of that. This time we will start with the picture:
The fibers are the even and odd numbers. As in the case of Position
we
will define a custom type to model \(A\).
data Parity = Even | Odd
deriving Show
parity :: Lens' Int Parity
= lens getter setter
parity where
=
getter n if even n then Even else Odd
=
setter n p case (getter n, p) of
Even, Odd) -> n + 1
(Odd, Even) -> n - 1
(-> n _
Note that in this example the fibers are not types in Haskell –maybe
in Liquid Haskell, hmm…– but we can still define a lens very much like
lensFromIso
.
Example 3: Pairs as fibrations
Now let us remove our restriction on the size of \(A\). We can define
lensFromIsoFamily :: (a -> a -> b -> b) -> Lens' (a, b) a
= lens getter setter
lensFromIsoFamily functor where
= a
getter (a, _b) = (a2, functor a1 a2 b) setter (a1, b) a2
Here (a2, functor a1 a2 b)
corresponds to \(F(\epsilon_{a_1,
a_2})(s)\) where \(s = (a_1,
b)\). We expect functor a a = id
and functor a2 a3 . functor a1 a2 = functor a1 a3
to hold for this to be a lawful lens.
For a concrete example let us consider the plane with a given coordinate system.
type Coordinate = Double
type Plane = (Coordinate, Coordinate)
standardX :: Lens' Plane Coordinate
= lensFromIsoFamily f
standardX where
= id
f _ _
skewedX :: Lens' Plane Coordinate
= lensFromIsoFamily f
skewedX where
= b + a1 - a2 f a1 a2 b
Here standardX
is the same as
the lens _1
specialized to
pairs. You can check that the f
in the second example satisfies the functor laws.
Example 4: Using singletons over a finite focus
All these examples are fine but they have a common drawback. After
using \(\set\) on any of these lenses
the value forgets what was set. For instance I can produce a value by
the expression Left 3 & parallel .~ OnTheRight
which I know to be Right 3
,
however when I want to consume tat value I still need to pattern match
against both Left
and Right
. The
only way to get around this problem is to allow set
to change the type of the
structure it acts on. So simple lenses will not cut it.
As an example we will model temperature with unit Fahrenheit or Celsius.
data Unit = Celsius | Fahrenheit
deriving Show
'Unit]
genSingletons ['
newtype Temperature (u :: Unit) = Temperature { rawValue :: Double }
deriving Show
fahrenheightToCelsiusIso :: Iso' Double Double
= iso toFahrenheit fromFahrenheit
fahrenheightToCelsiusIso where
= (c * (9/5)) + 32
toFahrenheit c = (f - 32) * (5/9)
fromFahrenheit f
getUnit :: SUnit u -> Temperature u -> SUnit u
= s
getUnit s _
setUnit :: SUnit u -> Temperature u -> SUnit v -> Temperature v
Temperature v) sV =
setUnit sU (case (sU, sV) of
SCelsius, SFahrenheit) ->
(Temperature $ v ^. fahrenheightToCelsiusIso
SFahrenheit, SCelsius) ->
(Temperature $ v ^. re fahrenheightToCelsiusIso
->
_ Temperature v
unit :: SingI u => Lens (Temperature u) (Temperature v) (SUnit u) (SUnit v)
= lens (getUnit sing) (setUnit sing) unit
Note that everything went one level up: fibers are types and the
fibration is over a kind. If we want to we can even implement Celsius
and Fahrenheit lenses in terms of unit
.
newtype AsCelsius = AsCelsius { getCelsisus :: Double }
deriving Show
newtype AsFahrenheit = AsFahreheit { getFahrenheit :: Double }
deriving Show
celsiusMono :: Lens' (Temperature 'Celsius) AsCelsius
= lens getter setter
celsiusMono where
= AsCelsius . rawValue
getter Temperature _) (AsCelsius c) = Temperature c
setter (
fahrenheitMono :: Lens' (Temperature 'Fahrenheit) AsFahrenheit
= lens getter setter
fahrenheitMono where
= AsFahreheit . rawValue
getter Temperature _) (AsFahreheit f) = Temperature f
setter (
celsius :: SingI u => Lens' (Temperature u) AsCelsius
= lens getter setter
celsius where
=
getter t & unit .~ SCelsius
t & view celsiusMono
=
setter t c & unit .~ SCelsius
t & celsiusMono .~ c
& unit .~ sing
fahrenheit :: SingI u => Lens' (Temperature u) AsFahrenheit
= lens getter setter
fahrenheit where
=
getter t & unit .~ SFahrenheit
t & view fahrenheitMono
=
setter t f & unit .~ SFahrenheit
t & fahrenheitMono .~ f
& unit .~ sing
Example 5: Using singletons over an infinite focus
The last example is a little on the nerd-snipe side. We will consider
a fibration over type level positive integers where the fiber above
n
is streams with chunk size
n
. Here is the code.
data Sized (n :: Nat) (a :: Type) where
Null :: Sized Zero a
(:::) :: a -> Sized n a -> Sized ('S n) a
infixr 5 :::
toElement :: Sized One a -> a
::: Null) = a
toElement (a
fromElement :: a -> Sized One a
= a ::: Null
fromElement a
sizedToList :: Sized n a -> [a]
Null = []
sizedToList ::: rest) = a : sizedToList rest
sizedToList (a
instance Show a => Show (Sized n a) where
show = show . sizedToList
type ChunkedStream (n :: Nat) (a :: Type) = Str.Stream (Sized n a)
decomposeIntoSingles :: Sized n a -> [Sized One a]
Null = []
decomposeIntoSingles ::: rest) = (a ::: Null) : decomposeIntoSingles rest
decomposeIntoSingles (a
splitSized ::
SNat n ->
Str.Stream (Sized One a) ->
Sized n a, Str.Stream (Sized One a))
(@(Str.Cons a rest) =
splitSized sN strcase sN of
SZ ->
Null, str)
(SS pred ->
let (taken, remaining) = splitSized pred rest
in (toElement a ::: taken, remaining)
mkChunkIso :: SNat ('S n) -> Iso' (ChunkedStream One a) (ChunkedStream ('S n) a)
= iso toNChunk toOneChunk
mkChunkIso sN where
=
toNChunk str let (taken, remaining) = splitSized sN str
in taken <:> toNChunk remaining
Str.Cons a rest) =
toOneChunk (`Str.prefix` toOneChunk rest
decomposeIntoSingles a
getChunkSize :: SNat n -> ChunkedStream n a -> SNat n
= sN
getChunkSize sN _
setChunksize :: SNat ('S n) -> ChunkedStream ('S n) a -> SNat ('S m) -> ChunkedStream ('S m) a
= nToM strN
setChunksize sN strN sM where
= oneToM . nToOne
nToM = view $ re $ mkChunkIso sN
nToOne = view $ mkChunkIso sM
oneToM
chunkSize ::
SingI n =>
Lens (ChunkedStream ('S n) a) (ChunkedStream ('S m) a) (SNat ('S n)) (SNat ('S m))
= lens (getChunkSize $ SS sing) (setChunksize $ SS sing) chunkSize
Here Sized
is your
garden variety length-indexed list but I had to implement my own version
since nix told me that the package is broken and I really don’t want to
deal with it right now.
To give an idea of what is happening, here is a small repl interaction:
ex1 :: ChunkedStream One Int
= fromElement <$> Str.iterate (+1) 0
ex1
sample :: Show a => ChunkedStream n a -> String
= show $ Str.take 10 str
sample str
{-
> :t (ex1 ^. chunkSize)
(ex1 ^. chunkSize) :: SNat ('S ZSym0)
> sample ex1
"[[0],[1],[2],[3],[4],[5],[6],[7],[8],[9]]"
> sample (ex1 & chunkSize .~ SS (SS SZ))
"[[0,1],[2,3],[4,5],[6,7],[8,9],[10,11],[12,13],[14,15],[16,17],[18,19]]"
-}