Tweag

Arrows, through a different lens

15 April 2021 — by Juan Raphael Diaz Simões

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 Monads. 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 Lenses 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 Lenses 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 Eithers 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 Arrows, 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!


  1. The fact that these hierarchies are separated is due to historical reasons.
About the authors
Juan Raphael Diaz Simões

If you enjoyed this article, you might be interested in joining the Tweag team.

This article is licensed under a Creative Commons Attribution 4.0 International license.

Company

AboutOpen SourceCareersContact Us

Connect with us

© 2024 Modus Create, LLC

Privacy PolicySitemap