(Un)fold as (Co)algebra

One cool thing about lists is that they have canonical ways of consuming and producing them: folds, and unfolds. It turns out that these are canonical, in that folding and unfolding functions are themselves isomorphic to lists. In this post, we’ll explore why this is true.

This post will build on a previous post I made a while back, so I’d recommend checking that out as well if this topic interests you.

Background Information

Our starting point is the standard definition of a list:

data [a] = a : [a] | []

This recursive definition says that a list is either empty, or an element prepended to a list. We also have the two fundamental fold and unfold operations:

foldr :: (a -> r -> r) -> r -> [a] -> r
unfoldr :: (r -> Maybe (a, r)) -> r -> [a]

There’s already some nice symmetry between the two, but there’s actually much more symmetry lying underneath this surface.

List as Fix Point

As usual in Haskell, you can decompose a recursive definition into a functor, which isn’t recursive, and the “prototypical recursive type”: Fix

data Fix f = Fix (f (Fix f))

For the case of list, we have:

data L a r = Nil | Cons a r

type [a] = Fix (L a)

The r type in L a r represents the “recursive part”. By taking Fix of L a, we end up with something that is not only isomorphic to [a], but structurally the same thing. We have the exact same pattern of construction as the normal list.

Fix as Initial Algebra

Roughly speaking, for an endofunctor $F$, an “$F$-Algebra” is going to be a tuple:

$$ (A, \epsilon_A : F A \to A) $$ consisting of an object, along with an evaluation function, from $F A$ to $A$. These algebras form a category, with morphisms being commutative diagrams like this:

1.png

These are morphisms $\varphi : A \to B$ which respect the structure of the algebra.

What we learned last time is that $\text{Fix} F$ is nothing more than a concrete representation of the initial object in this category. That is to say, there is a morphism $\text{Fix} F \to A$ for any other $F$-Algebra $A$.

Fix is Terminal CoAlgebra

Similarly, for an endofunctor $F$, an “$F$-CoAlgebra” is going to be a tuple:

$$ (A, \alpha_A : A \to F A) $$

where $A$ is an object, and $\alpha_A$ is a morphism from $A$ to $F A$ in the category over which $F$ is an endofunctor. This form a category, once again, with structure preserving morphisms. The commutative diagram becomes:

2.png

Now, my claim is that by duality, $\text{Fix} F$ is going to be a concrete representation of the terminal object in this category. That is to say, there is a morphism $A \to \text{Fix} F$ for any $F$-CoAlgebra $A$.

I leave this without proof, because it seems somewhat natural that this would be true, it holds in practice in Haskell-land, and this post isn’t really about the Math anyways.

In Abstract Haskell

In Haskell, we can represent an $F$-Algebra a as:

(f a -> a)

A function f a -> a suffices to specify this specific algebra. To then say that Fix f is initial, is to say there exists a function:

Fix f -> a

provided that a is an $F$-Algebra. Passing this fact explicitly, we have a function:

(f a -> a) -> Fix f -> a
Fix f -> (f a -> a) -> a

In fact:

Fix f = forall a. (f a -> a) -> a

This initiality property completely characterizes Fix f

For CoAlgebras, we represent them as functions:

(a -> f a)

The terminality of Fix f now means that there will be a function

a -> Fix f

provided that a is a CoAlgebra. That means that there is a function:

(a -> f a) -> a -> Fix f

In, fact, I claim that we have:

exists a. (a -> f a, a) = Fix f

For Lists

The two functions I want you to keep in mind are:

-- initiality
(f a -> a) -> Fix f -> a

-- terminality
(a -> f a) -> a -> Fix f

These must exist, and in fact completely characterize Fix f.

Now, for the case of [a], we have:

(L a r -> r) -> [a] -> r

(r -> L a r) -> r -> [a]

as our characteristic functions.

Now, if you look at the definition of L a r:

data L a r = Nil | Cons a r

You see that it is in fact isomorphic to Maybe (a, r). This means that our terminality function is nothing more than:

(r -> Maybe (a, r)) -> r -> [a]

But this is nothing more than unfoldr!

Similarly, a consumer L a r -> r needs to provide a way to handle both the Nil and the Cons cases. This means that L a r -> r is isomorphic to (r, a -> r -> r). But then we have:

(r, a -> r -> r) -> [a] -> r

(a -> r -> r) -> r -> [a] -> r

which is nothing more than foldr!

Concrete Isomorphisms

Concretely, we can create two data types to represent folds and unfolds:

data Fold a = Fold (forall r. (L a r -> r) -> r)

data Unfold a = forall r. Unfold r (r -> L a r)

The first data type, Fold encodes the characterization of [a] as the initial object in the category of L-Algebras.

Unfold encodes the characterization of [a] as the terminal object in the category of L-CoAlgebras.

Concretely, we’d like to prove the various isomorphisms between these objects and [a]:

list2Fold :: [a] -> Fold a
fold2List :: Fold a -> [a]

list2Unfold :: [a] -> Unfold a
unfold2List :: Unfold a -> [a]

two of these are actually very easy, and quite elegant:

fold2List :: Fold a -> [a]
fold2List (Fold f) = f <| \case
  Nil -> []
  Cons a as -> a : as
  
list2Unfold :: [a] -> Unfold a
list2Unfold xs = Unfold xs <| \case
  [] -> Nil
  (a : as) -> Cons a as

I just find it amazing how strong the symmetry is between these two functions! It feels like you’re uncovering some kind of underlying beauty in the universe!

The other two are less elegant, but relatively straightforward:

list2Fold :: [a] -> Fold a
list2Fold [] = Fold (\eps -> eps Nil)
list2Fold (a : as) =
  let Fold f = list2Fold as
  in Fold (\eps -> eps (Cons a (f eps)))
  
unfold2List :: Unfold a -> [a]
unfold2List (Unfold r f) =
  let go r' = case f r' of
        Nil -> []
        Cons a r'' -> a : go r''
  in go r

Conclusion

These were just some rough notes about some insights I’ve had into the duality of foldr and unfoldr, as well as their connection with Algebras and CoAlgebras. Another point of exploration would be a certain duality between foldl and foldr, but I’ll save that for another day.