Programming Languages · Functional Programming

Recursion Schemes: Catamorphisms, Anamorphisms, and Hylomorphisms

The zoo of structured recursion operators, what each adds over the plain fold, and how the composition of a fold with an unfold collapses into a single pass.

The fixed-point encoding supplied the two operators and their universal properties. This article is about the space between them: the schemes that pass more information to the algebra, the ones that pass a history, and the ones that let an unfold stop early. Each has a universal property, each corresponds to a specific class of recursion, and choosing among them is a matter of matching the information the recursive step needs, not of style.

The two directions, and the asymmetry that matters

cata consumes and ana produces. Their types are duals:

cata :: (f b -> b) -> Fix f -> b
ana  :: (s -> f s) -> s -> Fix f

The duality is exact in the types and inexact in the semantics, and the asymmetry comes from which fixed point Fix f denotes. As the greatest fixed point it contains the infinite structures, so a coalgebra that never returns a stopping layer has a value, and cata over that value diverges unless the algebra is lazy in the recursive position. As the least fixed point it contains only finite depth, so the same coalgebra has no value of that type to produce. In a lazy language the greatest reading is the useful one, and it is how repeat and iterate are defined. In a strict language the same coalgebra loops, and the programmer needs an external bound.

The second asymmetry is in the seed. A fold has no seed; the structure is the input. An unfold has only a seed, and the seed type is the generator’s state. Two unfolds with the same output type and different seed types are different functions, and the seed is where the state of a generator, the remaining input of a parser, or the current value of a recurrence lives.

Paramorphisms: the subtree alongside its folded result

cata hands the algebra the results for the children and nothing else. A para hands it the children themselves alongside their results:

para :: Functor f => (f (Fix f, b) -> b) -> Fix f -> b

The motivating example is the function that computes both a value and a check on that value. A maximum over a tree needs the child’s maximum to combine; a check that the tree is a valid search tree needs the child’s root value as well. cata can compute the first, and a para is required for the second unless the algebra is restructured to carry the extra information in the result type, which is what a zygomorphism does.

The cost of para is retention. Because the algebra receives the subtree, every subtree stays alive until its parent’s algebra application completes, so the peak residency is the whole structure rather than the path from the root. A cata that computes a summary can release each subtree as soon as its result is computed; a para cannot. The difference is invisible in the types and visible in a heap profile.

The general rule that connects the two: a cata whose carrier is a pair of the summary and the subtree is a para written by hand. The scheme exists to avoid that hand-coding, not to enable anything new.

Zygomorphisms: a fold with a helper

A zygomorphism runs two folds at once, one of which may use the other’s result. The classic application is a function that computes a summary and a property of that summary:

zygo :: Functor f => (f b -> b) -> (f (b, c) -> c) -> Fix f -> c

The first algebra is the helper; the second receives, for each child, both the helper’s result and the main result. A use case is a fold that computes a size and simultaneously checks that every subtree’s size is within a bound, where the check needs the child sizes. The helper is a genuine separate traversal in the naive formulation, and zygo fuses it.

zygo is expressible as a cata with a pair carrier, and the scheme’s value is again notational plus the guarantee that both folds share the traversal. The pair carrier version is what a compiler would produce after inlining; the named scheme is what makes the sharing explicit to a reader.

Histomorphisms: the whole history

A histomorphism hands the algebra a summary of all previous results along the path, not just the immediate children’s. The canonical use is dynamic programming on a recursive structure, where the answer for a node depends on answers for a set of ancestors or siblings. The motivating example from the literature is the maximum segment sum and the Fibonacci-like recurrences that need the two previous values: a cata can carry the pair in its carrier, and a histomorphism carries it in the scheme.

The implementation is the interesting part. A histomorphism is a catamorphism whose carrier is a coalgebra of the cofree comonad on the functor, which is a tree of all the results computed so far. In practice this is a lazily-built structure that the algebra indexes into. The practical cost is that the history is retained, so a histomorphism over a deep structure holds every result along the path, where a cata holds only the children’s results.

The Uustalu–Vene result that gives the family its unity is that each recursion scheme is a catamorphism into a particular comonad: cata into the identity comonad, para into the product comonad whose environment is the subtree, zygo into the product comonad whose environment is the helper’s result, histo into the cofree comonad. Once that is established, the schemes stop being a list of tricks and become one construction applied to different comonads. The practical benefit is that a new scheme is designed by choosing a comonad, and the laws come for free.

Apomorphisms: an unfold that can stop early

The dual of para is the apomorphism, an unfold whose coalgebra may return either a layer to continue from or an already-built structure to splice in:

apo :: Functor f => (s -> f (Either (Fix f) s)) -> s -> Fix f
apo ψ = Fix . fmap (either id (apo ψ)) . ψ

The coalgebra returns Left a finished subtree or Right a new seed. The use case is an unfold whose tail is already available. A function that generates the first k elements of a sequence and then appends a pre-existing list is an apomorphism; a cata-based version would have to build the prefix and then traverse it again to append. The scheme lets the prefix be built and the suffix be shared, which matters when the suffix is large.

The same retention caveat applies in reverse: an apomorphism that splices in a large existing structure shares it rather than copying it, so the result may hold the original alive. That is usually the point, and it is a surprise when the original was expected to be garbage.

Hylomorphisms: the composition, and when it fuses

hylo φ ψ = cata φ . ana ψ is the composition, and the fused definition avoids the intermediate structure entirely:

hylo :: Functor f => (f b -> b) -> (s -> f s) -> s -> b
hylo φ ψ = φ . fmap (hylo φ ψ) . ψ

The fused form is not merely an optimization. In a lazy language, the composed form builds layers that are immediately consumed, and the garbage collector reclaims them; the fused form never builds them. The difference is the difference between a chain of stages and a loop, and it is the same difference that fold fusion exploits for lists.

The fused form also changes the failure modes. A coalgebra that produces an infinite structure is fine when consumed lazily by a fold that stops early, and the composed version behaves the same way. A coalgebra that produces a structure larger than memory is fine in the composed version only if the fold consumes each layer before the next is produced, which is exactly what the fused version guarantees. The programmer who writes cata φ . ana ψ and expects the same behaviour is relying on the compiler to fuse, and the compiler does not always.

Choosing a scheme

The choice is determined by the information the recursive step needs, and there is a decision order that is worth stating explicitly.

If the step needs only the children’s results, cata is enough and is the cheapest in retention. If it needs the children themselves, para is the direct expression and the retention is the price. If it needs a second summary computed in the same traversal, zygo expresses it without a second pass. If it needs results from further up the path, histo is the scheme, and the retained history is unavoidable. If it produces rather than consumes, ana is the base case; if it produces and may splice an existing tail, apo is the direct form. If it does both, hylo is the composition, and the fused definition is the version to write.

The schemes compose, and the compositions are where the useful programs are. A compiler pass that parses a term into one functor, transforms it, and emits another is a hylomorphism between two functors; a traversal that renames bound variables while checking scoping is a para whose algebra carries a scope. The algebra of these compositions is where the “origami” style gets its name, and Gibbons’s survey is the readable account of how the folds combine.

Where the abstraction stops paying

Every scheme in the family is a structurally recursive traversal of a single functor. A recursion that is not structural, such as an interpreter that recurses on a term and an environment simultaneously, or a unification that recurses on two terms, has no scheme. A recursion whose structure changes between cases, such as an evaluator that sometimes returns a term and sometimes a value, needs a sum of functors and a case analysis that the schemes do not provide.

The second limit is performance. cata as defined cannot be tail recursive in general, because the algebra is applied after the recursive calls return, and the schemes that carry extra information carry it through an extra type constructor that a native recursion does not need. A hand-written fold over a native datatype, with a strict accumulator and a worker-wrapper split, can be faster than the Fix-encoded version, because the compiler sees the constructors directly. The schemes are a specification and a library, not a code-generation strategy.

The third limit is ergonomics. A datatype encoded as Fix f needs its functor written out, its Functor instance derived, and its pattern matches written against the layer rather than the type. Languages with better support, through a deriving mechanism or a compiler plugin, reduce the cost; languages without one make the encoding a deliberate choice for the few types where the laws are worth it.

The scope of this article is the operators above the two base schemes; the encoding they are defined over, including the sense in which a fixed point is initial or final, belongs to the fixed-point encoding itself, which is the prerequisite. What is added here is the middle of the family: the schemes that pass a subtree or a history to the algebra, the unfold that can stop early, and the fused composition.

The reason the family is worth knowing anyway is that it names the recursions. A reviewer who sees para knows the subtrees are retained; a reviewer who sees hylo knows there is no intermediate structure; a reviewer who sees cata knows the traversal is structural and can be fused. That is information a plain recursive definition does not carry, and it is the part of the abstraction that survives the performance objections. The fixed-point encoding itself is the prerequisite, and the fold is the special case that most programmers already know.

References

  1. Functional Programming with Bananas, Lenses, Envelopes and Barbed Wire — Erik Meijer, Maarten Fokkinga, Ross Paterson, FPCA 1991
  2. Recursion Schemes from Comonads — Tarmo Uustalu and Varmo Vene, Nordic Journal of Computing, 2001
  3. Origami Programming — Jeremy Gibbons, in The Fun of Programming, Palgrave, 2003
  4. Data.Functor.Foldable — recursion-schemes package — Hackage