Tutorial: fold across data structures
This lecture is the worked-exercise capstone of Module 6. Across the
previous five lectures we built up a small but powerful toolkit:
higher-order functions,
map, filter,
fold_left and fold_right, the
pipeline operator |>, and function composition.
The thesis of this module has been that this small toolkit is enough
to express a surprising amount of list processing without writing a
single hand-coded recursion. In this tutorial we try to make good on
that claim: pick a list function from the standard library, then
build it from the toolkit.
The point of the exercise is not that you should always re-derive
standard library functions in real code; you should not. Use
List.length rather than List.fold_left (fun n _ -> n + 1) 0,
because the standard library version expresses intent more clearly
and is usually faster. The point is to see how versatile the
toolkit is: how few primitives you need before the rest follows. By
the end of this lecture, if I asked you to write a new list-flavoured
function on the spot, you should reach for map, filter, or
fold first.
We will work through seven problems: four rebuild small List
functions on top of fold, and three lift fold to other
data structures (binary tree pre/in/post/level order, and rose
trees).
Problem 1: sum and product
sum is the canonical fold: pass the operator (+) and the
identity element for the operator (0). For product, the operator
is ( * ) and the identity is 1. The general pattern: any
associative binary operator with an identity element gives you a
one-line fold-based aggregation.
(Why does the identity element matter? Because of the empty-list
case. fold_left (+) 0 [] returns 0; fold_left ( * ) 1 []
returns 1. Picking the identity makes those answers consistent
with the mathematical convention that an empty sum is zero and an
empty product is one.)
Problem 2: concat
Flatten a list of lists into a single list. (Also called flatten
in some libraries.)
The operator @ is list-append. Each inner list is appended to the
accumulator; the rightmost inner list ends up at the back, the
leftmost at the front. Note the use of fold_right rather than
fold_left: with fold_left, the accumulator would build up
backwards because we would append the first inner list last.
(Try it on paper if it is not obvious why.) fold_right walks
right-to-left, so the leftmost inner list is the last one prepended,
and ends up at the front of the result.
The standard library's List.concat is similar but optimised for
the common case.
Problem 3: for_all and exists
Two list predicates.
for_all p xs is true if every element satisfies p; exists p xs is true if at least one does. The two fold-based
implementations use && and || respectively, with the appropriate
identity (true for AND, false for OR).
A subtlety: these implementations do not short-circuit. The fold
visits every element of the list, even if the answer is already
determined. The standard library's List.for_all and List.exists
are written directly and do short-circuit (returning false as
soon as an element fails for_all, returning true as soon as one
succeeds in exists). For long lists where failure or success
arrives early, the standard library is faster. Another reason to
prefer the library version over the home-rolled fold one in real
code.
Problem 4: count
Count how many elements satisfy a predicate.
The result is 3 (the three strictly positive elements: 5, 8,
2). We could have written this as List.length (List.filter p xs): filter to keep the passing elements, then count. The
two-step version is arguably clearer; the fold version makes one
pass and never allocates the intermediate list. For short lists this
does not matter; for long ones the fold version saves both time and
garbage.
Problem 5: tree folds in three orderings
Everything so far has been a fold on a list. The fold pattern
generalises directly to any recursive data type. A binary tree:
Three natural traversal orderings, all the same recursion with
f acc v permuted:
The only thing that changes is where the visit f acc v sits
relative to the two recursive calls. In-order: between the
children. Pre-order: before. Post-order: after.
To see the difference, try them on a small binary search tree:
In-order on a BST returns elements in sorted order: that is the defining property of a BST. Pre-order lists every node before its children, which mirrors how the tree is built. Post-order lists every node after its children, which is what you want when each node's value depends on its subtrees being processed first (think "evaluate the leaves before the internal nodes").
Problem 6: level-order with a queue
Pre/in/post are easy because they follow the tree's own recursive shape. Level-order (or breadth-first) traversal is different: it visits all nodes at depth 0, then all at depth 1, then 2, and so on. The recursive shape of the tree does not give you that order for free, because each recursive call dives straight to a child before visiting the sibling.
The standard trick is an explicit queue of pending nodes:
The inner function go takes a list-as-queue. At each step, take
the head; if it's a Leaf, drop it and continue; if it's a Node,
visit v and append the two children to the back of the queue
with @ [l; r]. Appending to the back is what makes the traversal
breadth-first: a node's children are queued behind all the nodes
already pending at the current depth, so the current level finishes
before the next one starts.
Problem 7: fold over rose trees
A rose tree is an n-ary tree: each node carries a value and a list of children, instead of exactly two:
A fold over a rose tree combines tree recursion with list recursion:
visit this node's value, then List.fold_left over the children,
recursively folding each one:
The body has two folds nested in each other: an outer "fold this
node" (pre-order on the rose tree) and an inner List.fold_left
across the children's results. This is the pattern that scales fold
to almost any algebraic data type: one fold per recursive position.
A small example: a threaded discussion. Each comment carries text (or here, a label) and a list of direct replies; each reply is itself a comment that may have its own replies.
That is the pre-order walk of the discussion: each comment listed before its replies, replies listed before their sub-replies. This is the same order most threaded forums use to render comments top-to-bottom on a page.
A wider example: word frequencies
Let us combine pieces from across the module into a slightly larger example. We count how often each word appears in a piece of text. We will return the answer as an association list (a list of pairs); in Module 7 we will see proper hash tables and balanced maps.
The result is [("fox", 2); ("the", 3); ("dog", 1); ("lazy", 1); ("over", 1); ("jumps", 1); ("brown", 1); ("quick", 1)]: each fold
step prepends the freshly-bumped pair, so the words appear in
most-recently-touched-first order.
The pipeline reads top-to-bottom: lowercase, split into words, drop empty pieces, then fold to build up a frequency table. The fold's accumulator is an association list of word/count pairs; for each word, we look up its current count (or 0 if absent), remove the old entry, and prepend a new one with the bumped count.
This is the kind of code that, in Python or Java, would take a
small loop with a hash table. In OCaml with the higher-order
toolkit, it is a single pipeline. The trade-off is that this
implementation is O(n^2) in the number of distinct words (each
List.assoc and List.remove_assoc is linear); the proper solution
uses Map or Hashtbl, which we will meet in
Module 7. For now, the point is that the
shape of the computation is captured cleanly.
A quick check
Which of the following is not a fold doing constant work per element as it walks the list once?
List.lengthList.filter pList.map fList.sort compare
Why: length, filter, and map are all linear walks of the
list, combining each element into the accumulator with a
constant-time step. They are textbook folds. Sorting is the odd one
out, with a qualifier worth knowing: you can write insertion sort
as a fold (fold_right insert xs []), but the combining step
(insert) is itself a linear walk of the accumulator, so the whole
thing is O(n^2). What no fold gives you is List.sort's
O(n log n) merge sort: that algorithm repeatedly compares elements
far apart, which does not fit the one-pass,
combine-as-you-go shape.
List.fold_left (fun acc x -> x :: acc) [] [1; 2; 3] is...
[1; 2; 3][3; 2; 1][]- An error.
Why: fold_left walks left to right. Initial acc = []. After
1: [1]. After 2: [2; 1]. After 3: [3; 2; 1]. So this is
the classic one-line List.rev. To get back the original order, use
fold_right (which walks right-to-left).
A code challenge:
Write longest : string list -> string option that returns the
longest string in a list, or None for an empty list. If several
strings share the greatest length, return the first of them. Use
List.fold_left with String.length. (Hint: the accumulator is a
string option.)
Show reference solution
Reference solution:
let longest xs =
List.fold_left
(fun acc s ->
match acc with
| None -> Some s
| Some t ->
if String.length s > String.length t then Some s else acc)
None xs
The accumulator is a string option, starting at None. For each
string: if the accumulator is None, take this string as the
current best. Otherwise keep whichever is longer; the strict >
means a tie keeps the earlier string. The result is None exactly
when the list was empty.
Activity
Show reference solution
What you should be able to do now
By the end of Module 6 you should be able to:
- Recognise a higher-order function (one that takes or returns a function) and read its type fluently.
- Reach for
List.mapwhenever you have "transform each element of a list." - Reach for
List.filterwhenever you have "drop elements that fail a test," and forList.filter_mapwhen you also want to transform. - Reach for
List.fold_left/List.fold_rightwhen the answer is not a list, or when you need both summary and transformation in one pass. - Chain operations with
|>pipelines, top-to-bottom. - Recognise when the standard library already has a function for the job (it almost always does).
What's next
Module 7 is a turn back toward the
imperative side of OCaml: side effects (mutable references,
exceptions, Printf), and modules (the OCaml language feature for
organising code into named, type-bearing units). Higher-order
functions remain in play throughout; we will see them again in
Module 7 in the form of references that hold functions and in
Module 8 in the form of monads, where
the whole programming pattern is built on higher-order composition.
Reading
- Cornell CS3110, Fold (re-implementing the List module): https://cs3110.github.io/textbook/chapters/hop/fold.html
- John Hughes, Why Functional Programming Matters: the classic case for the higher-order style and how it scales: https://www.cs.kent.ac.uk/people/staff/dat/miranda/whyfp90.pdf
Sources
This lecture's prose, worked examples, and quizzes are original to
this course. Materials referenced during preparation are listed in
the Reading section above; Cornell CS3110 and Real World OCaml
are CC BY-NC-ND-licensed and have not been derivatively reused.
See LICENSES.md
at the repository root for the full source posture.