Session 10
Recursive Programming Patterns
A Warm-Up Exercise
In order to install this Windows Update,
you must first install an update
for Windows Update.
— recursive poetry from Microsoft
I occasionally ask you to implement a function that already exists in Racket. Homework 4 includes problems that resemble existing Racket functions. Other times, a student does not read documentation or instructions. (Present company excluded, of course!) As a result, they write functions that already exist, ometimes under a different name. That's not even always a bad thing... Sometimes it's better to use your time practicing than poking around documentation.
Write a structurally recursive Racket function named
(get los n)
, where los
is a list of
symbols and n
is a non-negative integer.
get
returns the symbol in position n
of los
.
For example:
> (get '(a b c) 2)
'c
> (get '(d c b a z) 1)
'c
> (get '(d c b a z) 10)
get: no such position
Make the position 0-based. If n
is too big for the
list, return an error message as a string.
Start with the inductive definition for a list of symbols:
<list-of-symbols> ::= () | (<symbol> . <list-of-symbols>)
A Solution for get
Check out a candidate solution in this source file. This is a fine example of using structural recursion to process an inductively-defined datatype.
We can use get
on other kinds of lists, too, because
it never applies a function to the values in the list. As we
have seen, a Racket list is a pair whose cdr
is a
list. We can define generic lists inductively as:
<list> ::= () | (<any> . <list>)
Racket even has a built-in predicate for the "any" datatype,
named
any/c
.
We don't need it here, because we don't operate on the items in
the list; we only return one.
Notice a couple things. First, even though there is a nested
if
expression in the code, we consider only
two kinds of list argument: it is either the empty list or
a pair. The second if
helps us solve the pair
case: Is the item we are looking for in the first part of the
pair? Try to think clearly about the different parts of the
problem. That will help you make sense of when you need another
choice, and why.
Second, if the second argument were a symbol and we wanted to return the position of its first occurrence in the list, like Problem 5 on Homework 4, then we would need something new. Learning that new technique is our first job today.
get
is essentially an implementation of Racket's
primitive
list-ref
function, which many of you discovered when not using
second
or sixth
on a previous assignment.
You can find
many other functions for working with lists
in the Racket docs. Most of them are also great exercises for
practicing your recursive programming skills!
Finally, my solution uses Racket's primitive
error
function. Feel free to use it whenever a function has an error
case. Note also that we can test our error cases, using another
test feature of Rackunit.
Recap: Writing Recursive Programs
Writing recursive functions well and confidently requires you to know several techniques, just as writing loops well and confidently does. Last session, we explored the basic technique for writing recursive programs, the technique on which we base all of our recursive functions: structural recursion. With this technique, we mimic the structure of an inductive data specification in the code that processes the data.
This idea is simple but powerful. What if we are asked to write a function to process...
- a non-negative integer
- JSON data
- a
foo ::= bar | baz | (bif . foo)
? We can write a template for the function directly from the data type!
This session continues our discussion of recursive programming by introducing two new techniques:
- interface procedure and
- mutual recursion.
These techniques help us to do structural recursion in the face of specific circumstances that we commonly encounter.
Interface Procedures
Structural recursion is the basis for nearly every function we write. Occasionally, we will encounter bumps along the way to a solution. Rather than pitching structural recursion and flailing at our code without guidance, we will use specific techniques to get over, or around, the bump. Over the next few sessions, we will learn several techniques that we can use when we encounter difficulties using structural recursion. The first of these is the interface procedure.
Unless you are omniscient, writing a recursive function will occasionally require "fixing" the function along the way instead of writing it straight through from beginning to end.
Consider the function annotate
, which takes as its
only argument a <list-of-symbols>
. For example,
if we pass to annotate
this list:
(jerry george elaine kramer)
it returns a list with each symbol annotated by its position in the list:
((jerry 1) (george 2) (elaine 3) (kramer 4))
We can use structural recursion to build the framework of our answer:
(define annotate (lambda (los) (if (null? los) ; then: handle an empty list ; else: handle a pair )))
The base case of the data spec is the empty list. In this case, an empty list can be returned, since there are no items to annotate:
(define annotate (lambda (los) (if (null? los) '() ; else handle a pair )))
The inductive case is a symbol followed by a list of symbols. We
can combine the annotated symbol with the rest of the list
annotated using cons
. The result is:
(define annotate (lambda (los) (if (null? los) '() (cons /something computed from (first los)/ (annotate (rest los))))))
When we write a function that computes a list of answers, one for each item in the original list, we will often use a piece of code that looks just like this. It will constitute a common mechanism for "putting our answer back together".
How can we annotate a symbol? By creating a list consisting of the symbol and its position of the symbol in the list:
(define annotate (lambda (los) (if (null? los) '() (cons (list (first los) position) (annotate (rest los))))))
Oops! We've run into a slight problem. We need the position of the symbol in the list, but we haven't supplied it anywhere. We could pass the current position down to each recursive call:
(define annotate (lambda (los position) (if (null? los) '() (cons (list (first los) position) (annotate (rest los) (add1 position))))))
This does the work we need, but we have two related problems:
-
What is the initial value of
position
, the one used on the first call toannotate
? The original caller will have to tellannotate
to start at position 1! -
annotate
is defined to take one argument, but we have produced a function that requires two. We don't usually want to change the spec of a program, even when we have the power to do so, because other parts of our code may rely on the specified interface.
In the case ofannotate
, changing the interface requires that all calls pass two arguments. Yet we always start annotating with position 1, and now we will have to repeat the 1 in every "first call" call toannotate
. -
Finally, we will no longer be able to
map
this function over a list of lists, because it takes two arguments.map
requires a one-argument function. By takingmap
and similar higher-order functions out of our toolbox, we give up much of the power and productivity in the functional style.
These reasons should persuade us to look for a different solution. Programmers face this problem all of the time and have developed a common "patch".
First, rename this version of the solution as a helper function:
(define annotate-with-position (lambda (los position) (if (null? los) '() (cons (list (first los) position) (annotate-with-position (rest los) (add1 position))))))
Second, implement annotate
as a function that calls
the renamed function:
(define annotate ;; now write annotate ... (lambda (los) ;; ... to jump-start the helper (annotate-with-position los 1)))
We call the new annotate
an
interface procedure. It serves as an interface to the
function that does the real work.
Creating an interface procedure is a common practice in many kinds of programming, including functional programming. It allows us to write our code naturally — in the way that follows our understanding of the problem — even when the task becomes complicated, without disturbing the tranquility of the world in which the function resides.
The interface function pattern illustrates a valuable wisdom:
When you encounter a difficulty implementing structural
recursion, don't give up on the technique. We are
following the structure of our data for many good reasons.
Instead of giving up, solve the new difficulty. The problem
we encountered while implementing annotate
is so
common that other programmers have developed a standard solution.
This wisdom generalizes beyond structural recursion to any
well-justified technique, including most every design pattern
we use.
A New Wrinkle: subst
Last session, we defined two functions,
remove-upto
and
remove-first
,
over lists of symbols. List of symbols is the data type
on which they operate, and we had an inductive definition for the
type that guided our work.
Today, let's consider a more complex data structure, one that will be of great use to us when we write programs to process languages: the s-list. The difference between a list of symbols and an s-list is that the elements of the list can themselves be s-lists.
Here is the BNF notation for an s-list:
<s-list> ::= () | (<symbol-expression> . <s-list>) <symbol-expression> ::= <symbol> | <s-list>
And here are some examples:
() (()) (a) ((a) b) (a b c) (a (b) c) (a b c d) (if (zero? n) zero (/ total n)) (a b c d e f g h) (cons (foo (car x)) (foo-cdr (cdr x)))
The items on the left are lists of symbols, but they are also
s-lists. A symbol-expression
can be a symbol, or an
s-list.
Let's define a function, subst
, that substitutes one
symbol for another anywhere in an s-list. You can think of this
as like a global "search and replace" operation. For example,
when applied to a program, subst
can serve as the
foundation of an operation for renaming variables — a common
refactoring that all programmers do.
This function takes three arguments: the new symbol, the old symbol, and an s-list to operate on:
> (subst 'd 'b '(a b c a b c d)) (a d c a d c d) > (subst 'a 'b '((a b) (((b g r) (f r)) c (d e)) b)) ((a a) (((a g r) (f r)) c (d e)) a)
Following the principle of structural recursion, the structure
of subst
should follow the structure of the BNF
specification for an s-list:
(define subst (lambda (new old slist) (if (null? slist) ;; handle the empty list ;; handle a pair that contains a symbol-expression )))
If slist
is empty, there are no occurrences of
old
to replace. The answer is the empty list.
(define subst (lambda (new old slist) (if (null? slist) '() ;; handle a pair containing a symbol-expression )))
The second arm of our BNF definition defines a case where
slist
is a pair with the form
(<symbol-expression> . <s-list>)
. The
result of subst
will be the result of substituting
new
for old
in both parts, the
<symbol-expression>
and the
<s-list>
.
The first element of the pair is a symbol-expression
.
Note, however, that symbol-expression
is also defined
in terms of a choice. Our natural inclination might be to
implement this choice with a conditional expression. There are
two alternatives: the first element is a symbol, or it is an
s-list.
(define subst (lambda (new old slist) (if (null? slist) '() (if (symbol? (first slist)) ;; handle a symbol in the first, handle slist in the rest ;; handle an slist in the first, handle slist in the rest ))))
We have to return a list with the same structure as our input, so
in both cases we cons
the result from the
first
into the result from the rest
.
(define subst (lambda (new old slist) (if (null? slist) '() (if (symbol? (first slist)) (cons ;; handle symbol in first ;; handle slist in rest) (cons ;; handle slist in first ;; handle slist in rest) ))))
If it is a symbol, we must determine whether or not to replace it
with new
. We replace the symbol if it is equal to
old
, and otherwise we leave it alone:
(define subst (lambda (new old slist) (if (null? slist) '() (if (symbol? (first slist)) (if (eq? (first slist) old) (cons new ;; handle the slist in the rest) (cons (first slist) ;; handle the slist in the rest) (cons ;; handle slist in first ;; handle slist in rest) )))))
In both of these cases, we need to substitute new
for
old
in the rest
of the list. The
rest
is an s-list, so we can use subst
to compute that part of our result.
(define subst (lambda (new old slist) (if (null? slist) '() (if (symbol? (first slist)) (if (eq? (first slist) old) (cons new (subst new old (rest slist))) (cons (first slist) (subst new old (rest slist)))) (cons ;; handle slist in first ;; handle slist in rest) ))))
The only thing left to do is to decide what to do when the first
member of slist
is not a symbol. In that case, it
is an s-list. We are in luck. We already have a function for
substituting symbols in s-lists. It is the function that we are
writing, subst
! So, we can:
-
substitute
new
forold
in thefirst
of the list, -
substitute
new
forold
in therest
of the list, and - construct a new pair consisting of these two results.
The code looks like this:
(define subst (lambda (new old slist) (if (null? slist) '() (if (symbol? (first slist)) (if (eq? (first slist) old) (cons new (subst new old (rest slist))) (cons (first slist) (subst new old (rest slist))) ) (cons (subst new old (first slist)) (subst new old (rest slist)))))))
And we are done, or at least we have a working solution. Our basic structural recursion technique guided us along the way. But is this a good solution?
Programming Aside: Notice how the indentation of this code makes the control structures we are using as clear as possible. Whenever you write a program — especially in a language like Racket (with a uniform syntax (and so (many) function calls!)) — you should strive to write code that tells us how to read itself.
The Wrinkles
Our function works but, if we are honest with ourselves, we must admit that it has a couple of weaknesses.
First, we have repeated the expression
(subst new old (rest slist))
three times, including
twice in the if
expression that handles
symbols. We know that repeated code can cause all sorts of
problems in maintenance. But having to write these expressions
separately also makes it hard for us to write the function in the
first place. A mistake, even a typo, in any of the expressions
will break our function. Besides, all the repetition makes the
code harder to read.
Second, it is not really faithful to the structure suggested by the BNF. Look at the definition of an s-list again:
<s-list> ::= () | (<symbol-expression> . <s-list>) <symbol-expression> ::= <symbol> | <s-list>
Structural recursion tells us that the structure of our code should reflect the structure of the data. Our code does not. There are two BNF expressions in the data definition, but we have written only one function!
The second weakness causes the first. By not following the data structure, we have created extra cases to solve and made the problem harder to solve. That is what requires us to duplicate code.
If you look back at the step-by-step evolution of our function, you will see a clue hinting at this second weakness. We had to leave ourselves detailed notes using comments so that we did not lose our place as we solved small parts of the problem. Those comments are a sign that we are managing a lot of complexity in our heads. But the data type we are processing is not that complex!
My running commentary does more than give us a clue about when we went off track. It tells us exactly where we went off track:
The first element of the pair is asymbol-expression
. Note, however, thatsymbol-expression
is also defined in terms of a choice. Our natural inclination might be to implement this choice with a conditional expression. ...
A better way to reflect the choice between kinds of symbol expression would be to follow the data definition. An s-list is defined in terms of symbol expressions, and a symbol expression is defined in terms of s-lists. We say that such data types are mutually inductive. We'd like for our code to show this relationship, too.
Patterns that show up in data should probably show up in the code that processes the data. (And perhaps in the languages we use to write the code...)
Mutual Recursion: A Better subst
For our program structure to follow the pattern of the BNF, we must
define a function for substituting symbols in s-lists, called
subst
, and a function for substituting symbols
in symbol expressions, called, say, subst-symbol-expr
.
Because each data type is defined in terms of the other, these
functions will call one another. This technique is called
mutual recursion, because the recursion involves two
functions that call one another, working together to create a
solution.
Let's begin again and write subst
from scratch. For
now, let's suppose that subst-symbol-expr
exists.
(define subst (lambda (new old slist) (if (null? slist) '() ;; handle a pair that contains a symbol-expression )))
The "else" clause becomes quite easy to write:
-
substitute
new
forold
in thefirst
of the s-list usingsubst-symbol-expr
, -
substitute
new
forold
in therest
of the s-list usingsubst
, and - make a new pair from the results using
cons
.
The definition of subst
becomes:
(define subst (lambda (new old slist) (if (null? slist) '() (cons (subst-symbol-expr new old (first slist)) (subst new old (rest slist))) )))
Isn't that much clearer?
Now we have to write subst-symbol-expr
. Using
structural recursion, the definition of this function follows the
BNF definition of the data type it processes, a symbol expression.
The BNF lists two alternatives for a symbol expression: it is
either a symbol, or it is an s-list. So:
(define subst-symbol-expr (lambda (new old symexp) (if (symbol? symexp) ;; handle a symbol ;; handle an slist )))
If the symbol expression is a symbol, then we decide whether to replace it with the new symbol:
(define subst-symbol-expr (lambda (new old symexp) (if (symbol? symexp) (if (eq? symexp old) new symexp) ;; handle an slist )))
If not, then it is an s-list. But we have already written a
function that can make substitutions in an s-list:
subst
! Call it:
(define subst-symbol-expr (lambda (new old symexp) (if (symbol? symexp) (if (eq? symexp old) new symexp) (subst new old symexp))))
That's pretty clear, too.
Our solution now consists of two relatively small, relatively simple functions that work together to solve the problem.
What are the advantages of our new program?
- It now follows more closely the BNF definition of the s-list data type, which consists of two types of expression. This makes the code easier to read and modify, because readers can easily find the parts of the program they care about from the parts of the data definition.
-
We have simplified the definition considerably. Nested
if
s can be hard to understand and trace, even for experienced programmers using good programming style. We now have only one nestedif
, and it is simpler than the same nesting we did in our first function. -
We don't repeat code, in particular the three uses of
subst
on therest
of the s-list, or multiple calls tofirst
andrest
. - Defining separate functions for each non-terminal in the BNF breaks the programming process into manageable parts and allows us to concentrate our efforts on one thing at a time.
Mutual recursion will be our technique of choice whenever we have a multiple-part data definition.
An Exercise: count-occurrences
Use mutual recursion to implement
(count-occurrences s slist)
, which counts how many
times the symbol s
occurs in slist
.
For example:
> (count-occurrences 'a '(a b c)) 1 > (count-occurrences 'a '(((a be) a ((si be a) be (a be))) (be g (a si be)))) 5
The first step is to examine the BNF:
<s-list> ::= () | (<symbol-expression> . <s-list>) <symbol-expression> ::= <symbol> | <s-list>
From the BNF, we expect to write two functions, one that counts the symbol in an s-list and one that counts the symbol in a symbol expression.
We start with the pattern suggested by the BNF for s-list:
(define count-occurrences (lambda (s slist) (if (null? slist) ;; slist is empty ;; slist is a pair )))
We can conclude without much effort that a symbol occurs in an
empty list 0 times. In a non-empty list, the number of times it
occurs is equal to the number of times it occurs in the
first
part of the list plus the number of times
it occurs in the rest
of the list.
Because this is a mutually recursive specification and function,
we will assume that a function named
count-occurrences-sym-expr
exists, so that we can use
it to count the number of occurrences in the car
of
the pair:
(define count-occurrences (lambda (s slist) (if (null? slist) 0 (+ (count-occurrences-sym-expr s (first slist)) (count-occurrences s (rest slist))) )))
Now, we define count-occurrences-sym-expr
. The BNF
description for symbol expressions suggests the following
pattern:
(define count-occurrences-sym-expr (lambda (s sym-expr) (if (symbol? sym-expr) ;; sym-expr is a symbol ;; sym-expr is an slist )))
If the symbol expression is a symbol, then we need to determine
whether it is the symbol we're counting or not and return the
appropriate value, 0 or 1. If it is an s-list, then we have a
function for counting occurrences —
count-occurrences
:
(define count-occurrences-sym-expr (lambda (s sym-expr) (if (symbol? sym-expr) (if (eq? s sym-expr) 1 0) (count-occurrences s sym-expr) )))
And we are done! Following the structure of the two data types gave us four small, focused problems to solve. The functions we were writing solved two of those cases.
Let the data structure be your guide.
Wrap Up
-
Reading
- Review the lecture notes. Try to write the code again from scratch. Review the code.
- Then read a short section on increasing efficiency through program derivation, which we will review next time.
-
Homework
- Homework 4 is available and is due next session. All the problems require "vanilla" structural recursion — no mutual recursion needed. You will use an interface procedure for one of the problems.