Hello! Welcome to the first lesson in our module on "Recursion and Structural Thinking."
In our previous module, we explored the foundations of functional programming, such as pure functions, immutability, and referential transparency. Now, we'll build on that by diving into recursion, the primary mechanism for iteration and control flow in the functional paradigm.
This lesson focuses on writing recursive functions for list processing. We will:
- Trace the concept back to its origins in Lisp, understanding how the earliest functional language handled lists.
- Examine how modern functional languages like Haskell have evolved these ideas, introducing more declarative syntax.
- Compare these two approaches to see the underlying principles that have remained constant.
Given your background in mathematics and computer science, you'll likely recognize the connection between these recursive definitions and proof by induction, which you've recently been studying.
1. The Genesis: List Processing in Lisp
Functional programming's approach to data processing was born with Lisp (List Processing) in the late 1950s. The foundational paper by John McCarthy, "Recursive Functions of Symbolic Expressions and Their Computation by Machine, Part I" (1960), laid out a complete mathematical system for this. We'll start by exploring the core ideas from this seminal work.
The fundamental data structure in Lisp is the S-expression (Symbolic expression), which is either an "atom" (an indivisible value like a number or a symbol) or a pair of other S-expressions. This pair structure is called a "cons cell."
To understand how lists are built and manipulated, let's look at the five elementary functions McCarthy defined.
Recursive Functions of Symbolic Expressions and Their ...
This is the original 1960 paper by John McCarthy that introduced Lisp. We'll focus on the sections that define S-expressions and the core functions for manipulating them.
Please read Section 3, parts 'a. A Class of Symbolic Expressions' and 'c. The Elementary S-functions and Predicates'. Focus on understanding how lists are just a special case of S-expressions (a chain of pairs ending in NIL) and the definitions of atom, eq, car, cdr, and cons.
As you've just read, all list operations in Lisp boil down to three essential functions operating on these pairs:
cons[x, y]: Constructs a new pair(x . y).car[p]: Returns the first element of the pairp. (Contents of the Address Register)cdr[p]: Returns the second element of the pairp. (Contents of the Decrement Register)
A list is simply a chain of these pairs, where the cdr of each pair points to the next, and the final pair's cdr points to a special atom, NIL (or ()), signifying the end of the list.
For example, the list (A B C) is represented internally as (A . (B . (C . NIL))).
Functional Programming with Scheme
<original_guidance> </original_guidance>
With these building blocks, we can define any list-processing function recursively. The key is to use a conditional expression to distinguish between two cases:
- The base case: Usually an empty list (
NIL). - The recursive step: Operating on a non-empty list by using
carto get the first element andcdrto get the rest of the list, then calling the function again on the rest of the list.
Let's see how McCarthy defined functions like append.
Recursive Functions of Symbolic Expressions and Their ...
Let's return to McCarthy's paper to see how these elementary functions are used to build more complex recursive functions.
Please read Section 3, part 'd. Recursive S-functions' — begin with the section introduction. Pay close attention to the definition of append (introduced just below the note on list functions). Notice how it uses null (to check for the base case), car, cdr, and cons to recursively build the new list.
The definition of append[x; y] is a classic example of this pattern:append[x; y] = [null[x] → y; T → cons[car[x]; append[cdr[x]; y]]]
In a more modern Lisp dialect like Scheme, this would look like:
(define (append x y)
(if (null? x)
y
(cons (car x) (append (cdr x) y))))
Let's trace (append '(A B) '(C D)):
xis'(A B), not null. The result is(cons 'A (append '(B) '(C D))).- Inside the recursion,
xis'(B), not null. The result is(cons 'B (append '() '(C D))). - Inside again,
xis'(), which is null. The base case is met, and it returnsy, which is'(C D). - This result is passed back up. Step 2 becomes
(cons 'B '(C D)), which evaluates to'(B C D). - This is passed to step 1, which becomes
(cons 'A '(B C D)), evaluating to the final result:'(A B C D).
The following resource provides more examples of recursive list functions written in Scheme, which uses the same core car/cdr/cons paradigm.
Functional Programming with Scheme
This document provides a clear, modern presentation of recursive list processing in Scheme. It's a great way to see McCarthy's ideas in action.
Please read the section 'Recursive Functions on Lists' (starts on page 26). Look at the implementations of count1, length, concat (which is append), and reverse. Notice how they all follow the same pattern: a base case for the empty list and a recursive step using car and cdr.
2. The Modern Approach: Pattern Matching in Haskell
Modern functional languages like Haskell build on these foundational ideas but provide more expressive syntax for deconstructing data. Instead of using functions like car and cdr to pull a list apart, Haskell uses pattern matching.
This video introduces recursion in Haskell and the two main ways of handling conditional logic: guards and pattern matching.
Haskell for Imperative Programmers #3 - Recursion, Guards, Patterns
This video explains how recursion works in Haskell. It will introduce guards and pattern matching, which are the modern alternatives to Lisp's cond and car/cdr.
Watch the recursion introduction. Pay close attention to how pattern matching is used to define a function for different inputs (e.g., the base case and the recursive case).
In Haskell, a list has two possible forms, or patterns:
[]: The empty list (the base case).(x:xs): A non-empty list, wherexis the head (likecar) andxsis the tail (likecdr).
We can define a function by providing separate equations for each pattern. Let's write a function to calculate the sum of a list of integers:
sumList :: [Int] -> Int
sumList [] = 0
sumList (x:xs) = x + sumList xs
Let's break this down:
sumList :: [Int] -> Intis the type signature. It states thatsumListis a function that takes a list of integers and returns an integer.sumList [] = 0: This is the first equation. If the input list matches the pattern[](is empty), the function returns0. This is our base case.sumList (x:xs) = x + sumList xs: This is the second equation. If the input is not empty, it will match the pattern(x:xs). Haskell automatically binds the head of the list to the variablexand the tail toxs. The function then returns the headxplus the result of callingsumListon the tailxs. This is our recursive step.
3. Comparing the Approaches
Let's compare the implementation of a length function in both styles.
Lisp/Scheme Style (Explicit Deconstruction)
(define (length L)
(if (null? L)
0
(+ 1 (length (cdr L)))))
- Base Case Check:
(null? L)explicitly checks if the list is empty. - Deconstruction: We must call
cdrto get the rest of the list for the recursive call.caris not needed here, but would be for a function likesum. - Construction: Not used in this example, but would require
cons.
Haskell Style (Declarative Pattern Matching)
length :: [a] -> Int
length [] = 0
length (_:xs) = 1 + length xs
- Base Case Check: Handled implicitly by matching the
[]pattern. - Deconstruction: Handled implicitly by matching the
(_:xs)pattern. The head is ignored (using_) and the tail is bound toxs. - Construction: Not used here, but the same
(:)operator is used to construct lists.
| Feature | Lisp/Scheme (Original Approach) | Haskell (Modern Approach) |
|---|---|---|
| Data Structure | S-expression pairs (car . cdr) | Built-in list type [a] |
| Base Case | Check with a function, e.g., (null? list) | Match a pattern, e.g., [] |
| Deconstruction | Call functions car and cdr | Match a pattern, e.g., (x:xs) |
| Syntax | Verbose, nested function calls | Concise, declarative equations |
| Conditionals | if or cond expressions | Pattern matching, guards |
The fundamental recursive logic is identical: solve a problem by breaking it into a smaller version of itself until you reach a simple base case. The evolution is in the syntax, which has become more declarative, allowing the programmer to state what the data looks like ([] or x:xs) rather than how to pull it apart (car, cdr).
Practice Problem
To solidify your understanding, think about how you would write a function map. This function takes another function f and a list, and returns a new list where f has been applied to every element. For example, map(square, [1, 2, 3]) would return [1, 4, 9].
Sketch out the implementation in both styles:
-
Lisp/Scheme Style:
- What is the base case? What should
(map f '())return? - In the recursive step, what do you do with
(car lst)? - How do you combine that result with the recursive call on
(cdr lst)?
(define (my-map f lst) (if (null? lst) '() ;; Your recursive step here, using cons, f, car, and cdr )) - What is the base case? What should
-
Haskell Style:
- What is the equation for the empty list pattern,
myMap f []? - What is the equation for the non-empty list pattern,
myMap f (x:xs)? How do you applyftoxand combine it with the recursive call onxs?
myMap :: (a -> b) -> [a] -> [b] myMap f [] = -- Your base case here myMap f (x:xs) = -- Your recursive step here, using f, x, xs, and the : operator - What is the equation for the empty list pattern,
Take a moment to think through the solutions. The recursive structure is the same in both.
Click to see the solutions
Lisp/Scheme Solution:
(define (my-map f lst)
(if (null? lst)
'()
(cons (f (car lst)) (my-map f (cdr lst)))))
In the recursive step, we apply f to the head of the list (f (car lst)) and cons it onto the result of mapping f over the rest of the list (my-map f (cdr lst)).
Haskell Solution:
myMap :: (a -> b) -> [a] -> [b]
myMap f [] = []
myMap f (x:xs) = (f x) : (myMap f xs)
The logic is identical. We apply f to the head x, resulting in (f x), and use the (:) operator (which is Haskell's cons) to prepend it to the result of mapping f over the tail xs.
Conclusion
In this lesson, we explored the fundamental pattern of recursive list processing.
Key Takeaways:
- Recursion is the primary control structure for iteration in functional programming.
- The original Lisp approach relies on explicitly deconstructing lists using
car(head) andcdr(tail), and building them withcons. The base case is checked with a conditional likeiforcond. - Modern functional languages like Haskell use pattern matching to declaratively deconstruct data. A function is defined with separate equations for different patterns (e.g.,
[]for empty,(x:xs)for non-empty), which makes the code often cleaner and more readable. - Despite syntactic differences, the underlying recursive thinking—identifying a base case and a recursive step that moves towards it—is universal.
Next Lesson Preview:
We'll formalize the pattern we've learned today into the concept of structural recursion, which is the natural way to process recursively-defined data types. We'll see that this pattern isn't limited to lists and can be applied to other structures like trees, a concept pioneered by the ML family of languages.
Can't find a good explanation? Sign up and we'll make it for you
Sign up