Programming with bananas and barbed wire. Part 1
This post is the first in a two-part series on recursion schemes. Here, I want to build intuiton and understanding of what recursion schemes are and how to write them yourself. In the second part we will explore Ed Kmett’s recursion-schemes library, to show you, how to use recursion schemes in real-life Haskell code. Examples in the following text are intentionally kept as simple as possible, to aid in understanding of underlying ideas more than the examples themselves. Let us jump right in.
Expressing recursion
We usually write recursive data types like
data Expr = Const Int
| Add Expr Expr
| Mul Expr Expr
This is a common way of writing a syntax tree, but it is not without downsides. Consider the following function that pretty-prints a tree made from this datatype.
print :: Expr -> String
print (Const i) = show i
print (Add a b) = print a ++ " + " ++ print b
print (Mul a b) = print a ++ " * " ++ print b
This is the usual way of defining recursion we are used to everywhere — we define it explicitly. What if instead of pretty printing the syntax tree we wanted to calculate a numeric value of an operation it describes? We would have to write a function just like print
, manually recursing into individual elements. While there is a little chance of making an error in this simple example, mistakes while writing recursive functions are not unheard of. There is also a possible performance penalty, as GHC is way better in optimizing non-recursive then recursive code.
Can we do better? Possibly. Consider the following data type, where we are using a type parameter for encoding recursion.
data ExprF a = Const Int
| Add a a
| Mul a a
deriving (Show, Eq)
The idea here is that we will use something called a ‘fixed point’ of a functor to encode recursive behavior inside a
somehow. Before we get to that, let us prove our type is, indeed, a functor. To do that, we have to implement fmap
that abides by the functor laws. I propose the following fmap
fmap f (Const i) = Const i
fmap f (Add a a) = Add (f a) (f a)
fmap f (Mul a a) = Mul (f a) (f a)
So by applying f
with fmap
we unpack a level out of a functor, apart from the Const
case, where we return the thing itself. The reason for passing i
through is that fmap
has type a -> b
, but the type of i
is always Int
, so we cannot match the types in general.
Our good old functor laws are
fmap id == id
fmap (f . g) == fmap f . fmap g
To check that the first law is fulfilled, insert id
for the function f
and the proof will flow from definition above. The second case for
Add
will look as follows. It is the same for Mul
, since they have the same structure.
Add (f . g $ a) (f . g $ b) = fmap f (Add (g a) (g b)) = fmap f .
fmap g $ Add a b
so it is fulfilled as well. Of course, we can skip this bit in GHC thanks to DeriveFunctor
extension that allows us to derive fmap
automatically. We will reference this implementation later though, so it’s best if it is written down explicitly.
Representing our type as a fixed point
We have a type ExprF a
, which we know is a functor. This data type looks a bit useless, given that the type itself depends on the amount of levels of nesting
> :t Const 2
Const 2 :: ExprF a
> :t Add (Const 1) (Const 2)
Add (Const 1) (Const 2) :: ExprF (ExprF a)
And so on. Even worse, the type demands that all branches have the same depth, so it looks effectively unusable. But I’ve mentioned fixed point as something that can help encoding arbitrary recursive behaviour in a
, what is it?
data Fix f = Fix (f (Fix f)) --or
data Fix f = Fix {outF :: f (Fix f)}
In mathematics, a fixed point is an argument of a function that is mapped to itself by that function. If we expand one layer from Fix f
, the inner application, we get
Fix (f (Fix f)) = Fix (f (Fix (f (Fix f))))
and we can do it forever. So Fix f
is an infinite chain of Fix
applications, and infinite chains do not care about one more application. If we apply Fix f
to Fix f
, we get Fix f
again and that is what is meant by a fixed point in this context.
Let us define a simple syntax tree in terms of the initial Expr
Mul (Add ((Const 2) (Const 2))) (Const 2)
We should be able to represent the same thing with our ExprF a
. Let us start with the innermost Const
Fix $ Const 2
Checking the types checks out
> :t Fix $ Const 2
Fix $ Const 2 :: Fix ExprF
We do not have to write an infinite chain because Const
does not have any a
s in its type definition. To get the whole thing, we have to wrap inside Fix
on each level
let fixedExpr =
Fix (Mul (Fix (Add (Fix $ Const 2) (Fix $ Const 2))) (Fix $ Const 2))
The types check out again. I bet this was easier than it looked like at the beginning of this story.
Algebras
An algebra is an ‘unwrapping’ function.
type Algebra f a = f a -> a
So for our ExprF
type, applying it to String
will give us an algebra ExprF String -> String
printAlg :: ExprF String -> String
printAlg (Const i) = show i
printAlg (Add a b) = "(" ++ a ++ " + " ++ b ++ ")"
printAlg (Mul a b) = a ++ " * " ++ b
Wait, but it cannot be of any use, I can hear you say. We do not have any way of applying that function unwrapping strings into strings to our fixedExpr
. Fear not!
Catamorphisms
cata :: Functor f => (f b -> b) -> Fix f -> b
cata f = f . fmap (cata f) . outF
Calling cata printAlg fixedExpr
gives "(2 + 2) * 2"
. WOW! The first time I got this I got so excited, this is amazing. Awesome. How does it work? outF
unwraps one level from fixedExpr
then fmap
which we wrote some time ago recurses inside. When it gets to leafs which are Const i
, it converts each to a string. Then f
s level up are called, but they already have their arguments as strings. We have eliminated recursion from our functions altogether! The only thing we had to define was what happens to a single element of ExprF String
. This is an essence of functional programming, to compose a program from multiple parts, each focused on doing it’s own thing well.
We have achieved so much so easily, can we get an integer value for the operation defined by same tree? We just need a simple new algebra:
getValue :: ExprF Int -> Int
getValue (Const i) = i
getValue (Add a b) = a + b
getValue (Mul a b) = a * b
cata getValue fixedExpr => 8
Notice how, again, we just have to describe what happens to a single element, and all the internal types are Int
s. This keeps our code concise and fast. There is only one place where recursion happens, in the definition of cata
. Once we’re sure it is without errors, the possibility of making a mistake writing recursion disappears.
Anamorphism
We can abstract away folds, but can we unfold from a single value using a similar scheme? Sure we can. For that we need an opposite of algebra, a coalgebra
type Coalgebra f a = a -> f a
unwrap :: Coalgebra ExprF Int
unwrap i
| i < 4 = Add (i + 1) (i + 2)
| otherwise = Const i
Anamorphism is a kind of opposite of a catamorphism, so let us see what we get if we just flip functions around in the catamorphism. We have to remember to wrap where cata unwraps:
ana f = Fix . fmap (ana f) . f
ana unwrap 1 => No instance for (Show (Fix ExprF)) arising from a use of ‘print’
Oh, there is no automatic way of printing Fix
, which may be infinite. But we know how to print it, just use our catamorphism:
cata printAlg $ ana unwrap 1 => "(((4 + 5) + 4) + (4 + 5))"
Nice.
Paramorphisms
Notice that neither of these functions has access to the original structure. Stages higher up in a catamorphism (fold) only see the pretty-printed versions of elements down the tree, if we are creating a string-based representation. In many real-world tasks, this is not enough, and we need access to the original values along with their output representations. It would be a bit silly to parse output strings when folding, and it would negate any possible advantages of recursion schemes.
Algebras that carry that information are called R-Algebras
type RAlgebra f a = f (Fix f, a) -> a
So instead of just accepting an element inside a functor, we get a tuple with the original entry and the pretty-printed element. A recursion scheme accepting this type is called a paramorphism
para :: forall f a . (Functor f) => RAlgebra f a -> Fix f -> a
para rAlg = rAlg . fmap fanout . outF
where fanout :: Fix f -> (Fix f, a)
fanout t = (t, para rAlg t)
Notice how types perfectly match up — so much so that we can almost write these schemes by intuition. If you’re typing this in ghci, enable ScopedTypeVariables
. It is needed for types f
and a
in fanout
to be interpreted by ghc as the same as f
and a
as in the definition of para
. Explicit forall
just signals that both f
and a
should be available as the same types in fanout
.
A cool example of a paramorphism would be to sum the additions together, if it turns we want to shorten the output for some important reason
concatSums :: RAlgebra ExprF String
concatSums (Const i) = show i
concatSums (Add (aExpr, _) (bExpr, _)) =
show $ cata getValue aExpr + cata getValue bExpr
concatSums (Mul (_, a) (_, b)) = a ++ " * " ++ b
Here, we have used both — catamorphism for wrapping up the sums and paramorphism for neatly doing everything in one step.
Notice how Const i
behaves differently then Add
and Mul
. We can see why it is so from the definition of fmap
above. fmap something (Const i)
equals Const i
by definition, so it allows us to populate leaves in the tree without calling fanout
and to have a neat starting point for providing two arguments to Add
and Mul
Coolness.
Summing up
We have learnt how to abstract recursion away to transform recursive code into one that exploits recursion inherently present in the datatype itself. We now know what a fixed point of a functor is and can write five different algebras before morning coffee. If you have a minute, please let me know how this tutorial worked for you. I would love to use your feedback when writing part 2.
Next part
In part 2, we will explore the recursion-schemes library to move our understanding into the real world Haskell. The ideas will be broadly the same, with some magic sauce to make these functions even more generic. You can still use what you have learned here in your code as is, though, so feel free to play with it.
Further reading
In researching this post I have mainly used the following resources: