Skip to main content

Someone wanted to learn this too, so Grasp built them a personal learning path.

You can keep exploring this one or create your own

Mastering Fold: The Universal List Combinator

Hello! Welcome to the fourth lesson in our module on "Higher-Order Functional Patterns."

In our last lesson, we explored partial application, seeing how Haskell's implicit currying and Python's explicit functools.partial allow us to create specialized functions from general ones. This ability to treat functions as data is a cornerstone of functional programming.

Today, we'll explore what is arguably the most powerful list processing function of all: fold, also known as reduce. Our goal is to generalize list processing by implementing fold (reduce), analyzing its power as a universal combinator inherited from Lisp. We'll see how this single higher-order function can be used to implement a vast range of other operations, effectively abstracting the very pattern of list iteration.

1. The Essence of Folding

At its core, fold (or reduce) is a higher-order function that "collapses" a data structure into a single value. Imagine you have a list of numbers and you want to find their sum. You start with an initial value (0), take the first element from the list, combine it with your current value, and repeat until the list is empty.

This pattern of iterating over a list while carrying along an "accumulator" is precisely what fold captures. It requires three things:

  1. A binary function to combine the accumulator with an element (e.g., +).
  2. An initial value for the accumulator (e.g., 0).
  3. The list to process.

Fold Reduce Operations

To start, let's watch a brief introduction to the concept of fold/reduce operations. This will clarify the basic idea before we dive into the details.

Please watch the first minute of the video (00:03 - 01:15). Focus on how it contrasts map (which transforms each element) with fold (which combines all elements into one result).

2. Direction Matters: foldl vs. foldr

The simple idea of "combining elements" has a subtle but critical detail: in what order do you combine them? This leads to two variations of fold:

  • foldl (fold left): Starts from the left of the list. It's left-associative.
    foldl (+) 0 [1, 2, 3] becomes (((0 + 1) + 2) + 3).
  • foldr (fold right): Starts from the right of the list. It's right-associative.
    foldr (+) 0 [1, 2, 3] becomes (1 + (2 + (3 + 0))).

For an associative operation like addition, the result is the same. However, for non-associative operations like subtraction, the order is crucial.

Fold Reduce Operations

This next segment of the video clearly demonstrates the difference between left and right folds using subtraction, a non-associative operation.

Watch from 01:15 to 03:02. Pay close attention to how the parentheses are grouped for fold left versus fold right and how this leads to different results for subtraction.

This difference in associativity is not just a theoretical curiosity; it has practical implications for performance and even what kinds of data structures (like infinite lists) can be processed, which we'll touch on later.

3. A Look at the Implementation

Understanding the recursive structure of foldl and foldr is key to grasping their behavior. Given your study of recursion, these definitions should look familiar.

Wikipedia Fold (higher-order function) - Wikipedia

The Wikipedia article on fold provides the canonical Haskell implementations, which serve as a clear, formal definition of their recursive behavior.

Please read the 'Implementation' section, focusing on the two Haskell code blocks for foldl and foldr. Notice how the recursive call is structured differently in each case.

Let's break down what you just saw:

foldl (left fold):

foldl f z []     = z
foldl f z (x:xs) = foldl f (f z x) xs

The combining function f is applied to the accumulator z and the head of the list x before the recursive call. The result becomes the new accumulator for the rest of the list xs. This is a tail-recursive process.

foldr (right fold):

foldr f z []     = z
foldr f z (x:xs) = f x (foldr f z xs)

The function f is applied to the head x and the result of the entire recursive call on the tail xs. The computation is suspended until the recursion reaches the end of the list, and then the results are combined from right to left.

4. Fold in Python and Haskell

Different languages expose this pattern in slightly different ways.

Python: functools.reduce

In Python, the primary tool for folding is functools.reduce. It's important to know that reduce is a foldl.

Fold Left and Right in Python - Burgaud.com

This article provides a great overview of how fold is implemented in Python, including the built-in reduce function and how to construct a foldr.

Please read the section 'Fold in Python'. Note that functools.reduce is a foldl. Pay attention to the clever implementation of foldr using the 'third duality theorem': foldr on a list is equivalent to foldl on the reversed list with a flipped function.

As the article shows, you can implement foldr in Python by reversing the list and flipping the arguments of the combining function. This is a beautiful example of reasoning from first principles.

Haskell: foldl and foldr

Haskell, being a functional-first language, provides both foldl and foldr in its standard library (Prelude).

Haskell for Imperative Programmers #9 - Folding (foldr, foldl)

This video provides a Haskell-centric view of folding, explaining the type signatures and showing practical examples.

Please watch the following two segments: The introduction (00:00 - 01:46) to understand the type signature of foldr. The comparison with foldl (07:13 - 08:48) to reinforce the difference in evaluation order.

5. The Universal Power of Fold

Now we arrive at the central idea: fold is so general that it can be used to implement most other list-processing functions. This is why it's sometimes called a universal combinator, a concept that has its roots in Lisp, one of the earliest functional languages.

By choosing the right combining function and initial value, you can express map, filter, length, reverse, and more.

Reimplementing Functions with Fold

Let's see this in action. The following resources demonstrate how to build common list functions from scratch using only fold.

Fold Left and Right in Python - Burgaud.com

First, let's look at the Python implementation. This article provides concise lambda-based implementations of many functions using functools.reduce (foldl).

Read the section 'Reimplementing existing functions with reduce (foldl)'. Focus on the implementations of lmap and lfilter. Try to trace how the accumulator (which starts as an empty list []) is built up with each step.

Haskell for Imperative Programmers #9 - Folding (foldr, foldl)

Now, let's see the Haskell perspective, which often uses foldr for these implementations.

Watch from 01:46 to 07:13. This section shows how to implement sum, count, length, and, most importantly, map using foldr. Compare the implementation of map here with the foldl-based one you saw for Python.

Here is a summary of how map and filter can be defined with foldr:

Function Implementation with foldr (Haskell-style) Explanation
map f xs foldr (\x acc -> f x : acc) [] xs The combining function applies f to the current element x and prepends (:) it to the accumulator acc (the result of mapping f over the rest of the list). The initial value is an empty list [].
filter p xs foldr (\x acc -> if p x then x : acc else acc) [] xs The combining function checks if the predicate p holds for the current element x. If it does, x is prepended to the accumulator. If not, the accumulator is returned unchanged.

The fact that so many standard operations can be reduced to fold is profound. It demonstrates a deep level of abstraction. Formally, any function that is defined by recursion on the structure of a list can be expressed as a fold.

Wikipedia Fold (higher-order function) - Wikipedia

The Wikipedia article formalizes this idea in its 'Universality' section.

Please read the short 'Universality' section. The key insight is that any function g with the recursive structure g (x:xs) = f x (g xs) can be written as foldr f v.

6. A Note on Associativity and Parallelism

Why does the distinction between foldl and foldr matter so much if the operation is associative (like addition or multiplication)? The answer lies in performance and parallelism.

A left or right fold imposes a strictly sequential order. However, if an operation is associative, we are free to regroup the computations in any way we like. This allows for a "tree-like" reduction, which is ideal for parallel processing.

Fold Reduce Operations

This final video segment explains why associativity is a critical property for parallelizing reduction operations.

Watch from 04:24 to 06:29. The video defines associativity and explains that it gives us the freedom to reorder computations, which is the key to parallel execution. The visualization of expression trees is particularly helpful.

This is a powerful connection between a pure mathematical property (associativity) and a very practical systems-level concern (parallel performance). It's a great example of how understanding programming theory can inform architecture and implementation choices.

Conclusion

In this lesson, we've explored fold, one of the most fundamental patterns in functional programming.

Key Takeaways:

  • fold (or reduce) is a higher-order function that abstracts the pattern of reducing a list to a single value by repeatedly applying a combining function.
  • The order of operations distinguishes foldl (left-associative, tail-recursive) from foldr (right-associative, builds up suspended computations).
  • fold is a universal combinator for lists. Many other list functions, including map, filter, and length, can be implemented in terms of fold.
  • This universality stems from fold's ability to represent any function defined by primitive recursion over a list's structure.
  • For associative operations, the order of folding can be rearranged, enabling efficient parallel tree-like reductions.

Preview of the Next Lesson:

Now that we have explored function composition, partial application, and fold, we have the main tools used in a style of programming called "point-free" or "tacit" style. In our next lesson, we will evaluate point-free style and its trade-offs for code clarity, with examples from Haskell and its historical context in combinator calculus. We'll see how these higher-order functions can be chained together to create complex logic without ever explicitly mentioning the data being processed.

Can't find a good explanation? Sign up and we'll make it for you

Sign up