Session 11
Recursive Functions and Loops
A Warm-Up Exercise
As we have seen, map
is a higher-order function that's
awfully handy for solving problems with lists. However, it does
not work the way we want when used with nested lists such as the
s-lists we learned about last
time. So let's make our own special-purpose map!
Let's work with a new kind of nested list, the n-list. N-lists are just like s-lists, but with numbers:
<n-list> ::= () | (<number-exp> . <n-list>) <number-exp> ::= <number> | <n-list>
Implement (map-nlist f nlist)
, where f
is a function that takes a single number argument and
nlist
is an n-list.
map-nlist
returns a list with the same structure as
nlst
, but where each number n
has been
replaced with (f n)
. For example:
> (map-nlist even? '(1 4 9 16 25 36 49 64)) '(#f #t #f #t #f #t #f #t) > (map-nlist add1 '(1 (4 (9 (16 25)) 36 49) 64)) '(2 (5 (10 (17 26)) 37 50) 65)
map-nlist
should be mutually recursive with
map-numexp
, which operates on
number-exp
s.
For a twist: if you are seated in the front two rows, write
map-nlist
; if you are seated in the back two rows,
write map-numexp
. If you finish before I call
time, write the other function, too.
Using Mutual Recursion to Implement map-nlist
The definition on n-list is mutually inductive, so let's use
mutual recursion.
The code will look quite a bit like our mutually-recursive
subst
function
from last time.
We build the function from scratch...
The result is something like this:
(define map-nlist (lambda (f nlst) (if (null? nlst) '() (cons (map-numexp f (first nlst)) (map-nlist f (rest nlst)))))) (define map-numexp (lambda (f numexp) (if (number? numexp) (f numexp) (map-nlist f numexp))))
This is quite nice. map-nlist
says exactly what it
does: combine the result of mapping f
over the numbers
in the car
with the result of mapping f
over the numbers in the cdr
. There is no extra
detail. map-numexp
applies the function
f
, because it is the only code that ever sees a
number! If it sees an n-list, it lets map-nlist
do
the job.
With small steps and practice, this sort of thinking can become as natural to you as writing for loops and defining functions in some other style.
Recap: Writing Recursive Programs
In the last two sessions, we have studied how to write recursive programs based on inductively-defined data. Our basic technique is structural recursion, which asks us to mimic the structure of the data we are processing in our function. We then learned two techniques for writing recursive programs when our basic technique needs a little help:
- interface procedures — When our structurally-recursive function requires an argument that the function specification does not provide, we turn it into a helper function. The specified function passes its original agruments to the helper, along with an initial value for the new argument.
- mutual recursion — When our inductive data definition includes two data structures that are defined in terms of one another, we write two functions that are defined in terms of one another.
In your reading for this time, you also encountered the idea of program derivation. Mutual recursion creates two functions that call each other. Sometimes, the cost of the extra function calls is high enough that we would like to improve our code, while remaining as faithful as possible to the inductive data definition. Program derivation helps us eliminate the extra function calls without making a mess of our code.
Program derivation is a fancy name for an idea we already understand at some level. In Racket, expressions are evaluated by repeatedly substituting values. Suppose we have a simple function:
(define 2n-plus-1 (lambda (n) (add1 (* 2 n))))
Whenever we use the name 2n-plus-1
, Racket evaluates
it and gets the lambda
expression that it names. To
evaluate a call to the function, Racket does what you expect: it
evaluates the arguments and substitutes them for the formal
parameters in the function body. Thus we go from the application
of a named function, such as:
(2n-plus-1 15)
to the application of a lambda
expression:
((lambda (n) (add1 (* 2 n))) 15)
If we stopped here, we would still be making the function call. But we can apply the next step in the substitution model to convert the function call into this expression:
(add1 (* 2 15))
Our compilers can often do this for us. As noted in your reading
on program derivation, a Java compiler will generally
inline calls to simple access methods, and C++ provides
an inline
keyword that lets the programmer tell the
compiler to translate away calls to a specific function whenever
possible.
As a programming technique, program derivation enables us to refactor code in a way that results in a more efficient program without sacrificing too many of the benefits that come with writing a second function.
Work through program derivation example from the reading:
count-occurrences
.
It produces the same solution, but a different function.
A Program Derivation Exercise
map-nlist
into
a single function.
(define map-nlist (lambda (f nlst) (if (null? nlst) '() (cons (map-numexp f (first nlst)) (map-nlist f (rest nlst)))))) (define map-numexp (lambda (f numexp) (if (number? numexp) (f numexp) (map-nlist f numexp))))
map-nlist
, Refactored
First, we substitute the lambda
for the name:
(define map-nlist (lambda (f nlst) (if (null? nlst) '() (cons ((lambda (f numexp) (if (number? numexp) (f numexp) (map-nlist f numexp))) f (first nlst)) (map-nlist f (rest nlst))))))
... and then the partially-evaluated body for the
lambda
:
(define map-nlist (lambda (f nlst) (if (null? nlst) '() (cons (if (number? (first nlst)) (f (first nlst)) (map-nlist f (first nlst))) (map-nlist f (rest nlst))))))
This eliminates the back-and-forth function calls between
map-nlist
and map-numexp
. The primary
cost is an apparent loss of readability: the resulting function
is more complex than the original two. Sometimes, the trade-off
is worth it, and as you become a more confident Racket programmer
you will find the one-function solution a bit easier to grok
immediately.
We will use program derivation only when we really need it, or when the resulting code is still small and easy to understand.
Quick Exercise: Expensive Recursion
When we first learn about recursion, we often use it to implement
functions such as factorial and fibonacci. Here
is what the typical factorial
function looks like
in Racket:
(define factorial (lambda (n) (if (zero? n) 1 (* n (factorial (sub1 n))))))
Back in Session 2, though, I showed you an odd-looking implementation:
(define factorial-aps (lambda (n answer) (if (zero? n) answer (factorial-aps (sub1 n) (* n answer))))) > (factorial-aps 6 1) 720
The first recursive call to factorial-aps
is:
(factorial-aps 5 6)
Your task: Write down the rest of the recursive calls.
Tail Recursion
You may wonder why the function is written this way. It passes a partial answer along with every recursive call and returns the partial answer when it finishes.
In a very real sense, this function is iterative. It
counts down from n
to 0, accumulating partial
solutions along the way. Consider the sequence of calls made for
n
= 6:
(factorial-aps 6 1) (factorial-aps 5 6) (factorial-aps 4 30) (factorial-aps 3 120) (factorial-aps 2 360) (factorial-aps 1 720) (factorial-aps 0 720)
This function is also imperative. Its only purpose on
each recursive call is to assign new values to n
and
the accumulator variable. In functional style, though, we pass the
new values for the "variable" as arguments on a recursive call.
That sounds a lot like the for
loop we would write
in an imperative language. On each pass through the loop,
we update our running sum and decrement our counter.
At run time, factorial-aps
can be just like a
loop! Consider the state of the calling function at the moment it
makes its recursive call. The value to be returned by the
calling function is the same value that will be returned
by the called function! The caller does not need to
remember any pending operations or even the values of its formal
parameters. There is no work left to be done.
Consider our first call to the function,
(factorial-aps 6 1)
. The formal parameters are
n = 6
and answer = 1
. The value of this
call will be the value returned by its body, which is:
(if (zero? n) answer (factorial-aps (- n 1) (* n answer)))
n
is not zero, so the value of the if expression will
be the value of the else clause. The else clause is
(factorial-aps (- n 1) (* n answer))
which means that the recursive function will call be:
(factorial-aps 5 6)
Whatever (factorial-aps 5 6)
returns will be returned
as the value of the else
clause, which becomes the
value of the if
expression, which becomes the value of
(factorial-aps 6 1)
.
That's what we mean above: The value to be returned by the calling
function — which is (factorial-aps 6 1)
—
will be the same value that is returned by the called function
— which is (factorial-aps 5 6)
. When
(factorial-aps 5 6)
returns a value,
(factorial-aps 6 1)
passes it on as its own answer.
In programming languages, the last expression to evaluate in order to know the value of an expression is called the tail call. We call it that because it is the "tail" of the computation.
In the case of factorial-aps
, the tail call is a call
to factorial-aps
itself. In programming languages, we
call this function tail recursive.
When a function is tail-recursive, the interpreter or compiler can take advantage of the fact that the value returned by the calling function is the same as the value returned by the called function to generate more efficient code. How?
It can implement the recursive call "in place", reusing the same
stack frame. First, it stores the values passed in the tail call
into the same slots that hold the formal parameters of the
calling function. Second, it replaces the function call with a
goto
statement, transferring control back to the top
of the calling function.
(lambda (n ans) | (lambda (n ans) (if (zero? n) | (if (zero? n) ans | ans | { (factorial-aps (sub1 n) | n := (sub1 n) (* n ans)))) | ans := (* n ans) | goto /if/ | }
Illustrate stack frames. Use plain
factorial
and factorial-aps
.
By definition, a Racket compiler does this. The Scheme language
definition specifies that
every Scheme interpreter must optimize tail calls
into equivalent goto
s. Racket, a descendant of
Scheme, is faithful to this handling of tail calls.
Illustrate in code. Use
racket/trace
to trace both functions.
Not all languages do this. The presence of side effects and other complex forms in a language can cause exceptions to the handy return behavior we see in tail recursion. Compilers for such languages usually opt to to be conservative.
For example, Java does not handle tail calls properly, and making
it do so would complicate the virtual machine a bit. So the
handlers of Java have not made this a requirement for compilers.
Likewise for Python. Still, many programmers think it might be
worth the effort. Some Java compilers do optimize tail recursion
under certain circumstances, as does the GNU C/C++ compiler
gcc
. Tail recursion remains a hot topic in
programming languages.
With their lack of side effects, functional programming languages are a natural place to properly handle tail calls. In addition to Racket, languages such as Haskell make good use of tail call elimination. That leads to some interesting new design patterns as well.
In functional programming, we use recursion for all sorts of repetitive behavior. We often use tail recursion because, as we have seen, the run-time behavior of non-tail recursive functions can be so bad. In other cases, we use tail recursion because structuring our code in this way enables other design patterns that we desire.
The second argument to our factorial
function above is
sometimes called an accumulator variable. How do we create
one when writing a recursive function? If you'd like to learn more
about this programming pattern, read this optional
section on
accumulator variables.
Using an Interface Procedure to Implement positions-of
You had an opportunity to practice using interface procedures on Homework 4. It has an interesting property we can now see...
positions-of
required us to create a helper function that kept track of the
position number of each item in the list.
Build it if there is time, else look at solution.
Notice: positions-of-at
is naturally tail recursive!
Wrap Up
-
Reading
- Study today's lecture notes and the associated code.
-
If you'd like to see more about how to use Racket's
trace
function, check out this short video by Colleen Lewis.
-
Homework
- Homework 4 was due last night.
- Homework 5 is available and is due in one week. They give you a chance to practice with structural recursion, in particular with mutual recursion.