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, like5
, to output, like15
. - We can bolt something to the output.
(b) => ((a) => a * 3)(b) - 5
takes our original function and, for the same input5
, now returns10
instead of15
. - We can convert input.
(b) => ((a) => a * 3)(b + 1)
takes our original function and, for the same input5
, now returns18
instead of15
. - (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 theConst
profunctor - the
Const
profunctor, when used in List's implementation oftraverse
, starts with a pure const (getting anmempty
, or aNothing
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 calledSetter
, and whose magic we've seen in "The Plus One Server." - The Forget profunctor, that accepts
a
's and "forgets them" in a recepticle calledr
, ignoring the outputb
. A forget that mapsa
to itself is called aGetter
, akaForget identity
. The general operation is called aFold
.
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!
Top comments (2)
Awesome article! Thank you!
This is Gold❤️