Applicative style programming with profunctors
Quite unusually, this post is not written in Literate Haskell.
Applicative style
Applicative functors allow less expressiveness than monads, but are more general: every monad is an applicative functor.
A commonly mentionned benefit of programming with applicative
functors is that they allow more optimizations to take place,
in particular because the applicative product (<*>)
can
execute its two arguments “in parallel”.
<*>) :: Applicative f => f (a -> b) -> f a -> f b (
In contrast, the monadic bind (>>=)
expects an arbitrary function
as its second argument, which is quite opaque.
This forces a sequential execution, where the first operand must be evaluated
so that the second one can be applied to its result.
>>=) :: Monad f => f a -> (a -> f b) -> f b (
Applicative style relies on the Applicative
and Alternative
type
classes; the former is actually a superclass of the latter.
Can we use this style to program bidirectionally with profunctors?
In fact, my previous posts already show how to generalize Applicative
.
Here we shall focus on extending that generalization to work with
Alternative
.
Outline of naive parsers and printers
A parser typically uses Alternative
to parse a sum type, with one
branch for each constructor.
data Term
= Var Int
| Lambda Int Term
| App Term Term
parseTerm :: Parser Term
parseTerm
= parseVar
<|> parseLambda
<|> parseApp
A printer decides the branch to take by pattern matching.
printTerm :: Term -> String
printTerm t =
case t of
Var _ -> printVar t
Lambda _ _ -> printLambda t
App _ _ -> printApp t
Bidirectional programming
Let us imagine we have invertible parsers for each alternative,
merging parseVar
and printVar
, etc.
data IParser x a
var, lambda, app :: IParser Term Term
The final parser would look like this:
term :: IParser Term Term
term
= isVar =? var
<|> isLambda =? lambda
<|> isApp =? app
with some yet unknown operator (=?)
and functions isVar
, isLambda
,
isApp
.
Filter
Each branch should filter the input term, such that if it doesn’t have the right constructor, then the current branch fails and control flows to the next branch.
Filtering can obviously enough be done with a boolean predicate.
isVar (Var _) = True
isVar _ = False
...
Then, we expect a signature similar to the standard filter
on lists.
The naive solution is just to put this in a type class.
class Profunctor p => Filterable p where
=?) :: (x -> Bool) -> p x a -> p x a (
But this lacks the elegance of previous “high-level” abstractions.
Instead, consider that Profunctor
gives us lmap
.
lmap :: (y -> x) -> p x a -> p y a
The combination of lmap
and (=?)
(filter) is in fact equivalent to:
filterMap :: (y -> Maybe x) -> p x a -> p y a
The type of partial functions y -> Maybe x
is essentially what the
Invertible Syntax Descriptions paper uses to work with its own redefinition of
Alternative
(removing the Applicative
superclass constraint),
as one component of “partial isomorphisms”.
filterMap
should satisfy some laws:
filterMap (f >=> g) = filterMap f . filterMap g
filterMap pure = id
So filterMap
actually represents a functor; its domain is the Kleisli
category associated with the Maybe
monad.
Contravariant functors
We shall generalize Profunctor
. Focusing on the first type parameter of
p
, we have that p
must be a contravariant functor, from some arbitrary
category associated with p
, here called First p
. In comparison,
Profunctor
specializes it to the Hask
category of pure functions
(First p = (->)
).
The Category
type class can be found in Control.Category
, in base
.
The type
syntax is allowed here by the TypeFamilies
extension,
allowing one to write type-level functions to some extent.
class Category (First p) => Contravariant p where
type First p :: * -> * -> *
lmap :: First p y x -> p x a -> p y a
Maybe
In the case of an applicative parser, its instance may use the
Kleisli Maybe
category to allow mappings to fail:
newtype Kleisli m y x = Kleisli (y -> m x)
instance Monad m => Category (Kleisli m)
instance Contravariant IParser where
type First IParser = Kleisli Maybe
lmap :: Kleisli Maybe y x -> IParser x a -> IParser y a
lmap = (...)
A derived function can take care of unwrapping the Kleisli
newtype in
Haskell.
filterMap
:: (Contravariant p, First p ~ Kleisli m, Monad m)
=> (y -> m x) -> p x a -> p y a
filterMap = lmap . Kleisli
Pure functions
Of course, there are profunctors which cannot fail, the obvious one being
the function type (->)
.
instance Contravariant (->) where
type First (->) = (->)
lmap :: (y -> x) -> (x -> a) -> (y -> a)
lmap f g = g . f
However, Contravariant
may seem like too big of a generalization. In
particular, we have lost the ability to map a pure function in general when the
domain First p
is not (->)
.
Arrows
We can use the fact that pure functions can still be lifted
as Kleisli arrows.
One fitting structure is arrows, as found in Control.Arrow
, in base
,
it is situated somewhere between applicative functors and monads on the
abstraction ladder, but we are more particularly interested in one
method it provides: arr :: Arrow p => (y -> x) -> p y x
.
=.)
(:: (Contravariant p, Arrow (First p))
=> (y -> x) -> p x a -> p y a
=.) = lmap . arr (
There may be interesting non-arrow categories for bidirectional programming with profunctors, but I can’t think of any at the moment.
Using Kleisli arrows for the Maybe
monad allows printers to fail for
certain inputs.
Monads are a very general notion, can we find uses for other effects?
State
One situation where we may need to perform side-effects with lmap
is when the data we are working on is represented in some indirect way,
e.g., with explicit pointers.
A more concrete example is hash consing: sharing values which are
structurally equal. Deconstructing a hash-consed value may require a lookup in
memory. Then we can imagine a hypothetical parser working in some hash consing
monad H
.
data HIParser x a
instance Contravariant HIParser where
type First HIParser = Kleisli (MaybeT H)
lmap :: Kleisli (MaybeT H) y x -> HIParser x a -> HIParser y a
Unrolling the type definitions, the type of lmap
is equivalent to
the following, with an arrow combining state and exception.
lmap :: (y -> H (Maybe x)) -> HIParser x a -> HIParser y a
I have written a more complete example of that in the new repository
summarizing my current work as a Haskell package: profunctor-monad
.
The bodies of two equivalent parsers are copied below, the first one with a monadic definition, the second one with a (primarily) applicative definition.
-- type p :: (* -> *) -> * -> * -> *
-- A monad transformer with parsing/printing functionality (via ``IParser``).
--
-- type M :: * -> *
-- A monad for hash consing and exceptions (for parse errors).
--
-- type P (p M) I = p M I I
ppTree
:: forall p
. (Monad1 (p M), IParser (p M), First (p M) ~ Kleisli M, PMonadTrans p)
=> P (p M) I
ppTree = with @Monad @(p M) @TreeI $ uncons =: do
c0 <- firstChar =. anyChar
case c0 of
'0' -> lift leaf
'1' -> do
i <- c1 =. ppTree
j <- c2 =. ppTree
lift (node i j)
_ -> fail "Invalid character"
where
firstChar Leaf = '0'
firstChar (Node _ _) = '1'
c1 (Node i _) = i
c2 (Node _ j) = j
ppTree2
:: forall p
. ( Alternative1 (p M), Monad1 (p M), PMonadTrans p
IParser (p M), First (p M) ~ Kleisli M)
, => P (p M) I
ppTree2 =
with @Alternative @(p M) @TreeI $
uncons =:
guard . isLeaf) =: char '0' *> lift leaf
( (<|> (guard . isNode) =: char '1' *> ppNode'
)where
ppNode' = with @Monad @(p M) @TreeI $ do
i <- c1 =. ppTree2
j <- c2 =. ppTree2
lift (node i j)
c1 (Node i _) = i
c2 (Node _ j) = j
-- Maybe helpful definitions below.
-- Unique value identifier.
data I :: *
-- Shallow representation of a hash-consed tree.
data TreeI = Leaf | Node I I
-- Predicates.
isLeaf, isNode :: TreeF a -> Bool
-- Monadic smart constructors.
leaf :: H I
node :: I -> I -> H I