title |
---|
24 Days of Hackage: profunctors |
Yesterday, Tom
showed us a different type of functor than the ordinary Haskell Functor
- the
contravariant functor. Today, Tom's going to guide us through another type of
functor - the profunctor.
Yesterday, we considered the intuition that functors are producers of output,
and contravariant functors are consumers of input - and both functors can be
adapted to work with different types. What about a datatype that represents both
an "adaptable consumer of input" and an "adaptable producer of output" at the
same time, i.e. some sort of "pipe" structure? This is exactly what a
Profunctor
instance is, and again the function arrow a -> b
gives us
our prototypical example of such a type. A Profunctor
has two type
parameters, a
and b
. The first can be thought of as an "input of type a
"
and can be adapted with a function of type c -> a
, like Contravariant
. The
second can be thought of as an "output of type b
" and can be adapted with a
function of type b -> d
, like Functor
. This gives us the following type
class:
class Profunctor p where
lmap :: (c -> a) -> p a b -> p c b
rmap :: (b -> d) -> p a b -> p a d
dimap :: (c -> a) -> (b -> d) -> p a b -> p c d
lmap
adapts the input (left hand type variable) and rmap
adapts the output
(right hand type variable). dimap
adapts them both at the same time.
Much like Functor
and Contravariant
, Profunctor
instances must satisfy some
laws. The laws that a Profunctor
must satisfy are a combination of the
Functor
and Contravariant
laws for its type parameters:
dimap id id = id
dimap (h' . h) (f . f') = dimap h f . dimap h' f'
Furthermore, rmap f
should be equivalent to dimap id f
, and lmap f
should
be equivalent to dimap f id
. Because of this, the minimal definition of a
Profunctor
instance is either specifying rmap
and lmap
in terms of
dimap
, or dimap
in terms of rmap
and lmap
.
For functions, the Profunctor
instance is:
instance Profunctor (->) where
lmap h g = g . h
rmap f g = f . g
dimap h f g = f . g . h
If you study this, you'll see that lmap
adapts a function g
by composing h
on the right (changing the "input"), while rmap
adapts g
by composing f
on
the left (changing the "output"). Using equational reasoning, we can easily
prove that this instance does indeed satisfy the laws:
dimap id id = \g -> id . g . id = \g -> g = id
dimap (h' . h) (f . f') = \g -> (f . f') . g . (h' . h)
= \g -> f . (f' . g . h') . h
= \g -> f . dimap h' f' g . h
= \g -> dimap h f (dimap h' f' g)
= dimap h f . dimap h' f'
So all is well.
The function arrow is the prototypical example of a profunctor, but it is also probably the most boring one. Here's a more interesting example: a datatype that represents the concept of a left fold.
data L a b = forall s. L (s -> b) (s -> a -> s) s
The left fold is a process that updates state according to its input, and can
transform this state into a final result. The datatype contains some value of
type s
representing the initial state, a map of type s -> a -> s
which reads
a value a
and updates the state accordingly, and a map of type s -> b
which
converts the final state into the return value. This is a Profunctor
or
"adaptable pipe with input of type a
and output of type b
". (You'll find
this definition in the folds
package).
instance Profunctor L where
dimap h f (L result iterate initial) =
L (f . result) (\s -> iterate s . h) initial
runFold :: L a b -> [a] -> b
runFold (L result iterate initial) = result . foldl iterate initial
Here's a left fold that represents a sum.
summer :: Num a => L a a
summer = L id (+) 0
testSummer :: Int
testSummer = runFold summer [1..10]
> testSummer
55
This is fine if we're summing a list of Num
instances, but can we use this to
fold other types of lists? Of course we can, and to do so we'll need to change
the input type to our fold. Here's an example of adapting the left fold to a
different input and output type.
lengther :: L String String
lengther = dimap length (\s -> "The total length was " ++ show s) summer
testLengther :: String
testLengther = runFold lengther ["24", "days", "of", "hackage", "!"]
> testLengther
"The total length was 16"
There are not many Profunctor
definitions on hackage, although they are used
in the internals of lens
.
Personally I have used them heavily in Opaleye,
a relation database EDSL. In Opaleye there are profunctors which act exactly
as the intuition about them suggests: they can be seen as consuming values of
one type and producing values of another. For example there is a Profunctor
instance for "consuming" the rows returned by a query running on PostgreSQL and
"producing" the equivalent values in Haskell.
Defining a Contravariant
or Profunctor
instance for your datatype can give
you more certainty about the correctness of your code, just like defining a
Functor
instance can. Unless I am much mistaken, parametricity implies that
there is at most one valid Contravariant
or Profunctor
instance for your
datatype. Thus defining such an instance cannot restrict you in any way, and
acts as an additional property for the type checker to check that your program
satisfies. If you expected your type to be contravariant in its argument, or a
profunctor in two arguments, but you can't write a definition to satisfy the
compiler then perhaps you have a bug in your type definition!
Have a look through your code and if you find suitable datatypes, give yourself
the gift of Contravariant
and Profunctor
instances this Christmas.
Thanks to merijn on the #haskell IRC channel who suggested the output/input/pipe adaptor analogy.