Our previous posts on computational pipelines, such as those introducing Funflow and Porcupine, show that Arrows are very useful for data science workflows.
They allow the construction of effectful and composable pipelines whose structure is known at compile time, which is not possible when using Monad
s.
However, Arrows may seem awkward to work with at first. For instance,
it’s not obvious how to use lenses to access record fields in Arrows.
My goal in this post is to show how lenses and other optics can be used in Arrow-based workflows. Doing so is greatly simplified thanks to Profunctor optics and some utilities that I helped add to the latest version of the lens library.
Optics on functions
We’re used to think of lenses in terms of getters and setters, but I’m
more interested today in the functions over
and traverseOf
.
-- We will use this prefix for the remaining of the post.
-- VL stands for Van Laarhoven lenses.
import qualified Control.Lens as VL
-- Transform a pure function.
over :: VL.Lens s t a b -> (a -> b) -> (s -> t)
-- Transform an effectful function.
traverseOf :: VL.Traversal s t a b -> (a -> m b) -> (s -> m t)
We would like to use similar functions on Arrow-based workflows, something like
overArrow :: VL.Lens s t a b -> Task a b -> Task s t
However, the type of lenses:
type Lens s t a b = forall f. Functor f => (a -> f b) -> s -> f t
doesn’t make it very obvious how to define overArrow
.
On the other hand, Arrows come equipped with functions first
and second
:
first :: Task b c -> Task (b, d) (c, d)
second :: Task b c -> Task (d, b) (d, c)
which very much feel like specialised versions of overArrow
for the
lenses
_1 :: VL.Lens (b, d) (c, d) b c
_2 :: VL.Lens (d, b) (d, c) b c
so maybe there is a common framework that can take both of these into account? The answer is yes, and the solution is lenses — but lenses of a different type.
Profunctor optics
There is an alternative and equivalent formulation of optics, called Profunctor optics, that works very well with Arrows
.
Optics in the Profunctor
framework have the following shape:
type Optic p s t a b = p a b -> p s t
with more precise optics such as Lens
being obtained by imposing constraints to p
coming from the different Profunctor classes.
In other words, an Optic
is precisely a higher-order function acting on some profunctor.
Because every Arrow is also a Profunctor1, the shape
of an Optic
is precisely what is needed to act on Arrows! Moreover, like the optics of the lens
library, profunctor optics can be composed like regular functions, with (.)
.
The lens
library now includes a module containing functions that convert between standard and profunctor optics, which makes using them very convenient.
In the following sections, we will go through the use and the intuition of the most common optics: Lens
, Prism
and Traversal
.
But first, let’s import the compatibility module for profunctor optics:
-- PL for Profunctor Lenses
import Control.Lens.Profunctor as PL
Lenses
Standard lenses are all about products — view
, for example, is used to deconstruct records:
view _fst :: (a, b) -> a
Therefore, it makes sense for Profunctor lenses to also talk about products.
Indeed, that is exactly what happens, through the Strong
type class:
class Profunctor p => Strong p where
first' :: p a b -> p (a, c) (b, c)
second' :: p a b -> p (c, a) (c, b)
With profunctor optics, a Lens
is defined as follows:
type Lens s t a b = forall p. Strong p => p a b -> p s t
Every Arrow
satisfies the Strong
class.
If we squint, we can rewrite the type of these functions as:
first' :: Lens' (a,c) (b,c) a b
second' :: Lens' (c,a) (c,b) a b
That is, a Strong
profunctor is equipped with lenses to reach
inside products.
One can always convert a record into nested pairs and act on them using Strong
— the Lens
just makes this much more convenient.
But how do we build a Lens
?
Besides writing them manually, we can also use all Lens
es from lens
:
PL.fromLens :: VL.Lens s t a b -> Lens s t a b
which means we can still use all the lenses we know and love. For example, one can apply a task to a tuple of arbitrary size:
PL.fromLens _1 :: Task a b -> Task (a,x,y) (b,x,y)
Summarizing, a Strong
profunctor is one we can apply lenses to.
Since every Arrow
is also a Strong
profunctor, one can use Lens
es with them.
Prisms
Standard prisms are all about sums — preview
, for example, is used to deconstruct sum-types:
view _Left :: Either a b -> Maybe a
Therefore, it makes sense for Profunctor prisms to also talk about sums.
Indeed, that is exactly what happens, through the Choice
type class:
class Profunctor p => Choice p where
left' :: p a b -> p (Either a c) (Either b c)
right' :: p a b -> p (Either c a) (Either c b)
With profunctor optics, a Prism
is defined as follows:
type Prism s t a b = forall p. Choice p => p a b -> p s t
Every ArrowChoice
satisfies the Choice
class.
Once more, we can rewrite the type of these functions as:
left' :: Prism (Either a c) (Either b c) a b
right' :: Prism (Either c a) (Either c b) a b
That is, a Choice
profunctor is equipped with prisms to discriminate sums.
One can always convert a sum into nested Either
s and act on them using Choice
— the Prism
just makes this much more convenient.
But how do we build a Prism
?
We can also use any prisms from lens
with a simple conversion:
PL.fromPrism :: VL.Prism s t a b -> Prism s t a b
For example, one can execute a task conditionally, depending on the existence of the input:
PL.fromPrism _Just :: Action a b -> Action (Maybe a) (Maybe b)
Summarizing, a Choice
profunctor is one we can apply prisms to.
Since every ArrowChoice
can be a Choice
profunctor, one can uses prisms with them.
Traversals
Standard traversals are all about Traversable
structures — mapMOf
, for example, is used to execute effectful functions:
mapMOf traverse readFile :: [FilePath] -> IO [String]
Therefore, it makes sense for Profunctor traversals to also talk about these traversable structures.
Indeed, that is exactly what happens, through the Traversing
type class:
class (Choice p, Strong p) => Traversing p where
traverse' :: Traversable f => p a b -> p (f a) (f b)
With profunctor optics, a Traversal
is defined as follows:
type Traversal s t a b = forall p. Traversing p => p a b -> p s t
There is no associated Arrow
class that corresponds to this class, but many Arrow
s, such as Kleisli
, satisfy it.
We can rewrite the type of this functions as:
traverse' :: Traversable f => Traversal (f a) (f b) a b
That is, a Traversing
profunctor can be lifted through Traversable
functors.
But how do we build a Traversal
?
We can also use any Traversal
from lens
with a simple conversion:
PL.fromTraversal :: VL.Traversal s t a b -> Traversal s t a b
For example, one can have a task and apply it to a list of inputs:
PL.fromTraversal traverse :: Action a b -> Action [a] [b]
Conclusion
Using Arrows does not stop us from taking advantage of the Haskell ecosystem.
In particular, optics interact very naturally with Arrows, both in their classical and profunctor formulations.
For the moment, the ecosystem is still lacking a standard library for Profunctor optics, but this is not a show stopper — the lens
library itself has most of the tools we need.
So the next time you are trying out Funflow or Porcupine, don’t shy away from using lens
!
- The fact that these hierarchies are separated is due to historical reasons.↩
About the author
If you enjoyed this article, you might be interested in joining the Tweag team.