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 bIn 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 bApplicative 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
<|> parseAppA 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 tBidirectional 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 TermThe final parser would look like this:
term :: IParser Term Term
term
= isVar =? var
<|> isLambda =? lambda
<|> isApp =? appwith 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 aBut this lacks the elegance of previous “high-level” abstractions.
Instead, consider that Profunctor gives us lmap.
lmap :: (y -> x) -> p x a -> p y aThe combination of lmap and (=?) (filter) is in fact equivalent to:
filterMap :: (y -> Maybe x) -> p x a -> p y aThe 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 = idSo 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 aMaybe
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 . KleisliPure 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 . fHowever, 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 . arrThere 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 aUnrolling 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 aI 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