DEV Community

loading...
Meeshkan

Profunctors are everywhere!

mikesol profile image Mike Solomon Originally published at meeshkan.com ・18 min read

Profunctors are a really useful abstraction. It took me a while to understand what they are, but once I did, I found myself using them in contexts as diverse as digital signal processing, server building and data mining.

In this article, I'd like to share the intuition I've built up about profunctors. The way I learned these things is by studying how profunctors can be used to create optics. By the end of the article, I hope you'll see the value of using profunctors and optics in your day-to-day work!

What's a profunctor?

In short, a profunctor fulfills three criteria:

  • It has input and output.
  • You can bolt on something to the "output" end to modify the output.
  • You can bolt on something to the "input" end to convert input.

If this sounds like a function, it's because functions are profunctors! Let's see how.

  • A function has input and output. In JavaScript, (a) => a * 3 maps input, like 5, to output, like 15.
  • We can bolt something to the output. (b) => ((a) => a * 3)(b) - 5 takes our original function and, for the same input 5, now returns 10 instead of 15.
  • We can convert input. (b) => ((a) => a * 3)(b + 1) takes our original function and, for the same input 5, now returns 18 instead of 15.
  • (bonus) We can convert input and modify output at the same time. (b) => ((a) => a * 3)(b + 1) - 5

Profunctors are not just functions. Profunctors are a general way to conceive of IO. This is why I find them so useful. As programmers, we deal with IO on a micro-level (ie chaining functions) and a macro-level (ie chaining audio plugins) all the time.

In PureScript, the function that performs the operation above is called dimap and it works like this:

dimap input output profunctor == new_profunctor

The four JS examples above can be written in PureScript as follows:

dimap identity identity ((*) 3)
dimap identity (flip (-) 5) ((*) 3)
dimap ((+) 1) identity ((*) 3)
dimap ((+) 1) (flip (-) 5) ((*) 3)

For the remainder of the article, we'll focus on one profunctor that I'll affectionately call "The Plus One Server". It takes a number and returns the number + 1. In JavaScript, (a) => a + 1. In Python lambda a: a + 1. In Brainfuck >+[-<+>]<. You get the idea.

Let's say that we've written "The Plus One Server" (aka ((+) 1.0)) and we now learn that it needs to accept and return a string. The temptation may be to do something like this:

const original = (a) => a + 1.0
const modified = (a) => `${original(parseFloat(a))}`
modified("3.1416") // "4.1416"

Thinking profunctorially, we can do it like this in PureScript:

original = ((+) 1.0)
modified = dimap readFloat show original
modified "3.1416" -- "4.1416"

readFloat changes a string to a number, show changes a number to a string, and ((+) 1) is my profunctor.

The difference between the JS and PureScript versions is subtle but important. The first sends us on the path to spaghetti code, and the second leads to organizing things in a clean, maintainable way.

To see why, imagine that the boss comes along and she says "Hey, coder person, your plus-one server needs to accept UTF8 byte strings and return byte strings." No prob, profunctors to the rescue!

original = ((+) 1.0)
modified = dimap readFloat show original
bossMadeMe = dimap fromUTF8 toUTF8 modified

In the JS version, we'd have to constantly dip into the internals of the function to make changes, whereas here, we just bolt converters onto both ends and we're done with it.

Let's now imagine that the boss, ever fickle, decides to keep the output format as String. No problem!

original = ((+) 1.0)
modified = dimap readFloat show original
bossMadeMe = dimap fromUTF8 identity modified

So far, I've glossed over the profunctor-ness (profunctor-iality? profunctor-tude?) of ((+) 1.0), but it's time to address that. Let's see how dimap is implemented for functions:

class Profunctor p where
  dimap :: forall s t a b. (s -> a) -> (b -> t) -> p a b -> p s t

instance profunctorFunction :: Profunctor Function where
  dimap input output function = output <<< function <<< input

At Meeshkan, we work a lot with IO (we're in the business of testing servers), so having "the server typeclass" (aka Profunctor) is invaluable as we build up mock servers from primitives.

Our first profunctor optic - Iso

If you look at the signature of dimap, you can read it a few different ways. One way is that it returns a profunctor p s t. But another way, and one that is closer to that of map, is that it returns a function between profunctors p a b -> p s t. When s == t and a == b, another way to look at dimap (s -> a) (a -> s) is that it has the potential to create an isomorphism between s and a, ie converting a map to a list of tuples and back again. In this case, and if there is no funny business (ie deleting entries), the two can be used interchangeably.

In lens-land, dimap is called iso for this reason.

iso :: forall p s t a b. Profunctor p => (s -> a) -> (b -> t) -> p a b -> p s t
iso = dimap

I'll also rebrand the signature p a b -> p s t as an Iso so that it's easier to understand the contract. Meaning that if you give me (s -> a) -> (b -> t), I'll give you back a p a b -> p s t.

type Iso s t a b = forall p. Profunctor p => p a b -> p s t

iso :: forall s t a b. (s -> a) -> (b -> t) -> Iso s t a b
iso = dimap

In general, a profunctor optic is a function with the signature p a b -> p s t.

You may be wondering, "Why are there two names for that thing profunctors do - dimap and iso?" The reason is because the profunctor of implementation of optics came about after optics were invented. The genius of the profunctor implementation is that one of the classic optics - Iso - is the dimap function.

dimap readFloat show ((+) 1.0) "3.1416" -- "4.1416"
iso readFloat show ((+) 1.0) "3.1416" -- "4.1416"

A stronger profunctor

Now, imagine that "The Plus One Server" is getting used more and, as is natural with servers, we want to hook it up to other services. While other "more sophisticated" services have some sort of logging, our mighty ((+) 1.0) lacks logging capabilities. So how can "The Plus One Server" take logs from upstream services and pass them to downstream services?

(dimap
  (\(Tuple s myLog) -> Tuple (readFloat s) myLog)
  ((Tuple b myLog) -> Tuple (show b) myLog)
  ((+) 1.0))
  "3.1416" -- craaasssshhh

Wouldn't it be nice if we could just ignore the log? We'd need to beef up our profunctor with a bit more muscle so it can "carry" the log in addition to the payload. In short, we need to make our server stronger.

class Profunctor p <= Strong p where
  first :: forall a b c. p a b -> p (Tuple a c) (Tuple b c)
  second :: forall a b c. p a b -> p (Tuple c a) (Tuple c b)

And now, let's make The Plus One Server strong.

dimap
  (\(Tuple s myLog) -> Tuple (readFloat s) myLog)
  ((Tuple b myLog) -> Tuple (show b) myLog)
  (first ((+) 1.0))
  (Tuple "3.1416" "someLog") -- Tuple "4.1416" "someLog"

The function first upgrades ((+) 1.0) to carry the log from the input to the output.

So what does this have to do with profunctor optics? What if we want to carry a map back to the incoming object?

mapBackHome :: String -> Tuple Number (Number -> String)
mapBackHome s = Tuple (readFloat s) show

In the example above, show is our "map back" from readFloat. It tells us how to convert the input to the output. We call a function between strong profunctors a Lens when it carries a map back home.

lens :: forall p s t a b. Strong p => (s -> Tuple a (b -> t)) -> p a b -> p s t
lens inputWithMap server =
  dimap
    inputWithMap
    ((Tuple b backHome) -> backHome b)
    (first server)

Here's the type of Lens.

type Lens s t a b = forall p. Strong p => p a b -> p s t

So we could have written the above definition as:

lens :: forall s t a b. Strong p => (s -> Tuple a (b -> t)) -> Lens s t a b
lens inputWithMap server =
  dimap
    inputWithmap
    ((Tuple b backHome) -> backHome b)
    server

Because optics are functions, we can compose them. Let's see how that works with lenses and our server.

mapBackToString :: String -> Tuple Number (Number -> String)
mapBackToString s = Tuple (readFloat s) show

mapBackToUTF8 :: ByteString -> Tuple String (String -> ByteString)
mapBackToUTF8 s = Tuple (fromUTF8 s) toUTF8

(lens mapBackToUTF8 -- lens 1
  <<< lens mapBackToString) -- lens 2
  ((+) 1.0) -- server
  (withOctets pack [0xAB, 0xCD]) -- "4.1416"

Even though lenses allow a different output type, for the purposes of this tutorial, we're using Lens s s a a, which is defined as Lens' in purescript-profunctor-lenses. In general, when you "zoom out" from a lens, you want to get back to where you started, so Lens' is enough for most cases.

Although we're using our hand-rolled lens function above, it is exactly the same one as in purescript-profunctor-lenses with a slightly different name. They call it lens', and they use lens as a helper function to build lens'. Try subbing lens above out for Data.Lens.lens' and see for yourself!

Give us a choice!

So "The Plus One Server" is the talk of the town, and other devs are starting to notice. Some are a little bit angry because they've never asked for a ((+) 1.0) and now it is part of their stack. A colleague approaches us and says:

Congrats on your accomplishment with "The Plus One Server." But now it's everywhere downstream and I don't work with numbers. Can you just ignore my input when you see it?

Aiming to please, we see what we can do!

-- how I'll use The Plus One Server
dimap
  (\s -> Left (readFloat s))
  (either show identity)
  ((+) 1.0)
  "3.1416" -- craaasssshhh
-- how my colleague's data will pass through the server
dimap
  (\s -> Right "Hello")
  (either show identity)
  ((+) 1.0)
  "3.1416" -- craaasssshhh

How do we deal with this? We have a choice between two data types, and our server needs to be a profunctor that can adapt accordingly.

class Profunctor p <= Choice p where
  left :: forall a b c. p a b -> p (Either a c) (Either b c)
  right :: forall a b c. p a b -> p (Either c a) (Either c b)

Armed with Choice, we can build a server that can act on our data and pass through our colleague's data:

-- how i'll use The Plus One Server
(dimap (\s -> Left (readFloat s)) (either show identity) (left ((+) 1.0))) "3.1416" -- == "4.1416"
-- how my colleague will use my server
(dimap (\s -> Right "Hello") (either show identity) (left ((+) 1.0))) "3.1416" -- == "Hello"

Another choice we can make is whether to attempt the computation at all. If our server can process the information, great, and if not, we provide a sensible default.

sensibleDefault :: String -> Either String Number
sensibleDefault s = if isNaN (readFloat s) then Right s else Left (readFloat s)

In lens-land, this is called a Prism.

prism :: forall p s t a b. Choice p => (s -> Either t a) -> (b -> t) -> p a b -> p s t
prism to fro server = dimap to (either identity fro) (right server)

And we can use it for our server like this:

prism sensibleDefault show ((+) 1.0) "3.1416" -- == "4.1416"
prism sensibleDefault show ((+) 1.0) "not a number" -- == "not a number"

So prisms give us a choice thanks to the Choice profunctor

But what about security?

"The Plus One Server" is so hot that hackers have noticed it and are trying to reverse engineer it to understand its inner workings and exploit its vulnerabilities. The boss, dismayed, starts saying stuff like "we need to lock this thing down" and "does anyone here know about end-to-end encryption?"

Your colleagues scramble to implement a crude form of encryption:

dimap
  (\s password -> readFloat s))
  (\locked -> show (locked "passw0rd"))
  ((+) 1.0)
  "3.1416" -- craaasssshhh

So some upstream service "locks" our operation with a password, and they later unlock it with their super-secure password "passw0rd". Of course, we have no clue what to do with a function that takes a password and produces a number. Our humble server can only work with numbers! Unless we are armed with a Closed profunctor.

class Profunctor p <= Closed p where
  lock :: forall a b x. p a b -> p (x -> a) (x -> b)

A way to think about lock is that it delays application of our function until the point when a password is provided. Another way to imagine it is that it sends the function to the end-user and lets them apply it with their password. I would guess that this is the exact algorithm WhatsApp uses for their end-to-end encryption.

Back to "The Plus One Server", it can now handle password-protected data:

(dimap (\s password -> readFloat s)) (\locked -> show (locked "passw0rd")) (lock ((+) 1.0))) "3.1416" -- == "4.1416"

In functional-programming land, passwords can be anything. Strings, ints, aaannnd..... functions! Yes, passwords can be functions - but why would one do this?

A password "unlocks" our lock, so a function unlocking something would mean that it provides information for a computation to continue. For example, think about image processing. We have a killer algorithm to do something like Gaussian blur or masking, but we don't want to know how the user applies it to their picture. Their use of our algorithm is private, and this use is encoded in a function. We'll call this form of password-protection with a function a Grate.

-- Here, (s -> a) will be our "function" password
grate :: (((s -> a) -> b) -> t) -> p a b -> p s t
grate unlock server = dimap (\s f -> f s) unlock (lock server)

The types going to dimap are a bit hard to follow, so let's write dimap just using its types:

dimap
  (s -> (s -> a) -> a) -- input
  (((s -> a) -> b) -> t) -- output
  (p ((s -> a) -> a) ((s -> a) -> b)) -- internal profunctor
  -- yields p s t

We see that, all the way through, (s -> a) is our password for the closed profunctor. Or, subbing (s -> a) into the definition of Closed, we have:

class Profunctor p <= Closed p where
-- remembering that
-- lock :: forall a b x. p a b -> p (x -> a) (x -> b)
-- when using a function (s -> a), polymorphism turns this into
   lock :: p a b -> p ((s -> a) -> a) ((s -> a) -> b)

If your brain is hurting, rest assured that that's as heady as it gets. Now we can actually use our grate to do some password-protected art with the ((+) 1.0) server.

data RGB = RGB Number Number Number

red :: RGB -> Number
red (RGB r g b) = r

green :: RGB -> Number
green (RGB r g b) = g

blue :: RGB -> Number
blue (RGB r g b) = b

myFilter :: ((RGB -> Number) -> Number) -> RGB
myFilter = \f -> RGB (f green) (f blue) (f red)

grate mySecretFilterApplication ((+) 1.0) (RGB 1.0 2.0 3.0) -- == (RGB 4.0 2.0 3.0)

Our ((+) 1.0) server did its magic without ever knowing the inner workings of your algorithm. The "passwords" here were green, blue and red. When mixed with our ((+) 1.0), they produced a new pixel.

In general, when thinking about password protecting something or, more generally, hiding the application of an algorithm like the one above, closed profunctors and grates are your friend!

Make me a Star

We've now deemed "The Plus One Server" to be so useful that we want to use it with exotic optics that we've never even heard of. So far, we have only been dealing with optics that accept one profunctor - the function. But there are lots of nice profunctors, and we'd like to work with them somehow.

One such profunctor is the Kleisli profunctor. It is a profunctor with a side effect mixed in. So if you take the Function profunctor we've seen before a -> b, a Kleisli profunctor is that with a little something extra like write to a log, read from an environment or launch a rocket. The signature for a Kleisli profunctor, also called a Star, is:

newtype Star f a b = Star (a -> f b)

instance functorStar :: Functor f => Functor (Star f a) where
  map f (Star g) = Star (map f <<< g)

instance profunctorStar :: Functor f => Profunctor (Star f) where
  dimap i o (Star ft) = Star (map o <<< ft <<< i)

Here, f represents the side effect and b represents the output value. This could be something like Log Int or Array String or RocketLauncher Number.

I don't know why they call it Star, but it seems like a reasonable choice. I tried singing "Twinkle Twinkle Little Kleisli" to my kid and it didn't really work, so let's stick with Star.

Anyway, the boss wants to use an optic from Kleisli-Land, and we're responsible for getting it to integrate with our system. Our profunctor that makes a foray into Kleisli-land is called Wander.

class Profunctor p <= Wander p where
  wander :: forall s t a b. StarOptic s t a b -> Optic p s t a b

The first argument is an optic of star profunctors. The return value is also an optic, but one that can be used by any profunctor that implements Wander. Under the hood, our profunctor call the Kleisli (Star) optic. That means that it'll have to know how to add and remove a side effect - adding when it enters Kleisli-land and removing when it leaves.

The actual signature for Wander in purescript is a bit different. I'm not sure why they made it this way, but it is isomorphic to the one above and gets the job done:

-- real signature
class Profunctor p <= Wander p where
  wander :: forall s t a b. (forall f. (a -> f b) -> s -> f t) -> p a b -> p s t

To make this more concrete, let's look at the implementation of Wander for the Function profunctor.

instance wanderFunction :: Wander Function where
  wander kleisli ourfunc input = unwrap ((kleisli (Identity <<< ourfunc)) input)
  -- you can also use this, which is more concise
  -- wander t = alaF Identity t

For example, let's imagine that we got a Kleisli function that lifted a side effect from one operation to another. This is common when, for example, an operation fails for a part of a whole and you need to mark the whole as "failed".

type Client = { name :: String, balance :: Number}

actOnBalance :: forall f. Applicative f => (Number -> f Number) -> Person -> f Person
actOnBalance f p = { name: p.name, balance: _ } <$> f p.balance

When we try using with "The Plus One Server", it will predictably crash.

actOnBalance ((+) 1.0) { name: "Mike", balance: 0.0 } -- crasssshhhh

But if we make ((+) 1.0) effectful, it is able to wander into Kleisli-land by using Identity under the hood, as we saw above.

wander actOnBalance ((+) 1.0) { name: "Mike", balance: 0.0 } -- == { name: "Mike", balance: 0.0 }

Luckily, there is a whole category of functions that behave like actOnBalance. They're called traverse, and they work on types that implement the Traversable typeclass.

The basic contract with something implementing Traversable is that, if you have a container of values and you can produce a side effect for each value in the container, then you can accumulate the side effects. For example, if you have a List of Writer Int (meaning integers we're keeping some sort of log about), then we can get a Writer (List Int) by accumulating the logs for each Int.

Let's look at the signature for traverse:

traverse :: forall a b m t. Traversable t => Applicative m => (a -> m b) -> t a -> m (t b)

It is exactly the same as the first argument to wander, with s == t a and t == t b.

This means that we can create a utility for anything implementing Traversable.

traversal = wander traverse

Now, let's use it with our friend ((+) 1.0).

traversal ((+) 1.0) [1.0,2.0,3.0] -- [2.0, 3.0, 4.0]

Remembering that we can compose optics, let's use it with the lens from above:

(traversal
  <<< lens (\s -> Tuple (readFloat s) show))
  ((+) 1.0)
  ["1.0","2.0","3.0"] -- ["2.0", "3.0", "4.0"]

In summary, a profunctor implementing Wander allows us to take a stroll in Kleisli-Land, and it can be used in lots of different contexts. We saw two of them above - actOnBalance and the mega-useful traversal. Wander will be even more useful when we talk about folds.

Folding up values

So far, our workhorse profunctor has been the function, and even when we wandered into Kleisli-land, we did so to import our results back to the world of functions. Remembering that optics are functions between profunctors, when we give a Function to an optic as an argument, we call that a Setter. This is how "The Plus One Server" worked - it is a function (profunctor) that we passed to an optic (function acting on profunctors) and it set something on the inside of a larger structure (ie an array of integers in a traversal or a RGB channel in a grate).

While this metaphor is helpful, it is admittedly like a bad monad tutorial (sorry), in that it specializes the idea of profunctors too narrowly. Profunctors are a generalized way to reason about I/O, and there's more to I/O than just functions.

One thing that comes up a lot with I/O is intentionally suppressing the output. For example, you can ignore the output value and instead return something else. In Purescript, this is called Forget.

newtype Forget r a b = Forget (a -> r)

instance forgetfulProfunctor :: Profunctor (Forget r) where
  dimap f _ (Forget z) = Forget (z <<< f)

instance forgetfulStrong:: Strong (Forget r) where
  first (Forget z) = Forget (z <<< f <<< fst)
  second (Forget z) = Forget (z <<< f <<< snd)


dimap (const true) (const 42) (identity) "3.1416" -- 42
dimap (const true) (const 42) (Forget identity) "3.1416" -- Forget true

We catch the input (true) and forget about the output.

A Fold is a profunctor optic that is evaluated with a Forget profunctor. A Getter is a special fold that uses the identity function as its accumulator. Let's build up view defined in purescript-profunctor-lenses step by step.

lens i = dimap i ((Tuple b f) -> f b) <<< first
lens (\a -> Tuple (readFloat a) show) (Forget identity) "3.1416" -- Forget 3.1416
forgetForget (Forget f) = f
forgetForget $ lens (\a -> Tuple (readFloat a) show) (Forget identity) "3.1416" -- 3.1416
view profunctor = forgetForget (profunctor (Forget identity))
view $ lens (\a -> Tuple (readFloat a) show) (Forget identity) "3.1416" -- 3.1416

Our view function below is isomorphic to the one you'll find in purescript-profunctor-lenses. It's quite concise!

The issue with Forget is that it can't forget something that doesn't exist. For example, let's try to make Forget an instance of Choice.

instance forgetChoice :: Choice (Forget r) where
  left (Forget f) = either (Left <<< f) ??? -- craaashhhh
  right (Forget f) = either ??? (Right <<< f)  -- craaashhhh

The problem is that we can't pass through a b because we're forgetting it - we just have a and r. So how can we get around this? When we don't have an a for (a -> r), we need to be able to pull an r out of thin air. What is the typeclass of things where you can pull one out of thin air? The monoid! The function to do that is mempty

mempty :: String -- == ""
mempty :: List -- Nil
mempty :: Array -- []
mempty :: Maybe Int -- Nothing

So as long as r above is a monoid, we can have our Choice profunctor.

instance forgetChoice :: Monoid r => Choice (Forget r) where
  left (Forget f) = either (Left <<< f) (Right mempty)
  right (Forget f) = either (Right mempty) (Right <<< f)

The same trick works for Wander but on a functor application level. Again, r will be a monoid, and the Const functor will "pull an r out of thin air" if not provided one.

instance wanderForget :: Monoid r => Wander (Forget r) where
  wander f (Forget r) = Forget (alaF Const f r)

instance applicativeConst :: Monoid a => Applicative (Const a) where
  pure _ = Const mempty -- "out of thin air"

So, when you try to preview a value from an empty list using a lens library as we do below, you get Nothing because:

  • the Wander instance of forget uses the Const profunctor
  • the Const profunctor, when used in List's implementation of traverse, starts with a pure const (getting an mempty, or a Nothing in this case)
  • as there is nothing to smoosh, it returns Nothing
preview traversed (Nil :: List Int) -- Nothing

Going back to Kleisli-land, the Const functor is the side effect that allows us to Wander into a Star profunctor (in this case, the function traversal), get its benefits, and bring them back home to our optic.

tl;dr

When building a server (when using a profunctor), here are some cool optics:

  • An iso is a profunctor whose input and output are isometric to some other value.
  • A lens is strong 💪 enough to drill down into a value and carry a map so we know how to get back up.
  • A prism gives us a choice 🤷‍♂️ between a value and a sensible default if we can't process the value.
  • A traversal allows us to wander 🏃‍♀️ into Kleisli-land and back to do our bidding
  • A grate allows us to lock 🔒 our optic and unlock it with a key, where the key is a function

And as optics are just functions from profunctor to profunctor, here are two profunctors you can use with optics:

  • The Function profunctor (a -> b), which is called Setter, and whose magic we've seen in "The Plus One Server."
  • The Forget profunctor, that accepts a's and "forgets them" in a recepticle called r, ignoring the output b. A forget that maps a to itself is called a Getter, aka Forget identity. The general operation is called a Fold.

Remember, these are just representations of the concept of optics. There are many ways to build up optics, and the profunctor one is one way. It is my favorite way because it gives you an expressive vocabulary to solve problems. At Meeshkan, we use custom optics and profunctors all over the place for different business cases, and the nice thing about the PureScript community is that we can share these solutions and get their feedback.

In this post, I've tried to build up intuition about what profunctors are and show how lenses are derived from them. Usually, when I use a library like purescript-profunctor-lenses, I'm happy just to use the library without understanding what's going on under the hood. However, the concepts used to implement profunctor lenses are so useful/universal that they're worth exploring in their own right. I hope they help you too as you solve new and exciting challenges!

Discussion (0)

pic
Editor guide