Local Procedure Bindings and Recursion
Summary:
We consider letrec, a variant of
let that permits us to write recursive local procedures.
Introduction
As you have probably noted, we often find it useful to write helper
procedures that accompany our main procedures. For example, we might
use a helper to recurse on part of a larger structure or to act as the
kernel of a husk-and-kernel procedure.
One issue with this technique is that it often makes little sense for
other procedures to use the helper, so we should restrict access to the
helper procedure. In particular, only the procedure that uses the helper
(unless it's a very generic helper) should be able to access the helper.
We know how to restrict access to variables (using let
and let*). Can we do the same for procedures?
Local Procedure Bindings
Yes. As the reading on local bindings
suggested, it is possible for a let expression to bind an
identifier to a non-recursive procedure:
> (let ((square (lambda (n) (* n n))))
(square 12))
144
Like any other binding that is introduced in a let expression,
this binding is local. Within the body of the let expression,
it supersedes any previous binding of the same identifier, but as soon as the
value of the let expression has been computed, the local
binding evaporates.
A Problem: Recursive Procedure Bindings
However, it is not possible to bind an identifier to a recursively defined
procedure in this way. For example, consider the following expression which
is intended to find the largest value in a list
> (let ((largest (lambda (lst)
(if (null? (cdr lst))
(car lst)
(max (car lst) (largest (cdr lst)))))))
(largest (list 2 1 4 -5 6 13 3)))
reference to undefined identifier: largest
The difficulty is that when the lambda expression is
evaluated, the identifier largest has not yet been bound,
so the value of the lambda expression is a procedure that
includes an unbound identifier. Binding this procedure value to the
identifier largest creates a new environment, but does not
affect the behavior of procedures that were constructed in the old
environment. So, when the body of the let expression invokes
this procedure, we get the unbound-identifier error.
Changing let to let*
wouldn't help in this case, since even under let* the
lambda expression would be completely evaluated before the binding
is established.
A Solution: letrec
What we need is some variant of let that binds the
identifier to some kind of a place-holder and adds the binding
to the environment first, then computes the value of the
lambda expression in the new environment, and then finally
substitutes that value for the place-holder. This will work in Scheme,
so long as the procedure is not actually invoked until we get into the
body of the expression.
Fortunately, the designers of Scheme decided to let us write local
procedures using that technique. The keyword associated with this
recursive binding
variant of let
is named letrec.
> (letrec ((largest (lambda (lst)
(if (null? (cdr lst))
(car lst)
(max (car lst) (largest (cdr lst)))))))
(largest (list 2 1 4 -5 6 13 3)))
13
A letrec expression constructs all of its place-holder
bindings simultaneously (in effect), then evaluates all of the
lambda expressions simultaneously, and finally replaces all
of the place-holders simultaneously. This makes it possible to include
binding specifications for mutually recursive procedures (which invoke
each other) in the same binding list. Here's a particularly silly
example, which takes a list of numbers and alternately adds and
subtracts them.
> (letrec ((up-sum
(lambda (ls)
(if (null? ls)
0
(+ (car ls) (down-sum (cdr ls))))))
(down-sum
(lambda (ls)
(if (null? ls)
0
(+ (- (car ls)) (up-sum (cdr ls)))))))
(up-sum (list 1 23 6 12 7)))
-21
; which is 1 - 23 + 6 - 12 + 7.
Husk-and-Kernel with Local Kernels
We can use letrec expressions to separate the husk and the kernel of
a recursive procedure without having to define two procedures.
This technique works, but it's more stylish to construct the kernel
procedure inside a letrec expression, so that the extra identifier
can be bound to it locally:
Of course, now that the recursive kernel procedure is now inside the
body of the index procedure, it is not necessary
to pass the value of sought to the kernel as a
parameter. Instead, the kernel can treat sought
as if it were a constant, since its value doesn't change during any
of the recursive calls.
The same approach can be used to perform precondition tests efficiently,
by placing them with the husk in the body of a letrec expression and
omitting them from the kernel. For instance, here's one way to introduce
precondition tests into the drawings-leftmost
procedure from the
reading on verifying preconditions:
Embedding the kernel inside the definition of
drawings-leftmost rather than writing a separate
drawings-leftmost-kernel procedure has another
advantage: It is impossible for an incautious user to invoke
the kernel procedure directly, bypassing the
precondition tests. The only way to get at the
recursive procedure to which kernel is bound is
to invoke the procedure within which the binding is established.
We've recycled the name kernel in this example to drive
home the point that local bindings in separate procedures don't
interfere with one another. Even if both procedures were active at the
same time, the correct kernel procedure would be used in
each case because the correct local binding would supersede all others.
Hence, even though both list-index
and drawings-leftmost have a helper named
kernel, we can safely write
(list-index figure (drawings-leftmost figure))
to find the index of the leftmost drawing in figure.
Multiple Local Procedures
Note that drawings-leftmost effectively has
three local procedures that it relies upon.
Not only does it use kernel (formerly called
drawings-leftmost-kernel), but also
all-drawings? and even
drawing-leftmost.
While we have used letrec to define a local kernel, we can
also use it to define other local recursive procedures, such
as the all-drawings? predicate. As we've
seen in the past, we can also use let
or let* to define non-recursive
procedures. Hence, if we want to define all three helpers within
drawings-leftmost, we might write something like
the following.
An Alternative: The Named let
Many programmers use letrec expressions in writing most of these
husk-and-kernel procedures. When there is only one recursive procedure
to bind, however, a contemporary Scheme programmer might well use yet
another variation of the let expression -- the named let
.
The named let has the same syntax as a regular
let expression, except that there is an identifier between the
keyword let and the binding list. The named let
binds this extra identifier to a kernel procedure whose parameters are the
same as the variables in the binding list and whose body is the same as the
body of the let expression. Here's the basic form
(let name ((param1 val1)
(param2 val2)
...
(paramn valn))
body)
What does this do? In essence, it defines a procedure named
name with parameters
param1 through
paramn. Then,
it calls
that procedure on arguments
val1 through
valn.
You can think of this as a more elegant (and, eventually, more readable)
shorthand for
(letrec ((name (lambda (param1 ... paramn)
body)))
(name val1 ... valn))
Alternately, you can think of this as first binding each of
param1 through
paramn to
the corresponding value. It also creates a procedure with the same
parameters. It then
executes the body. When the body calls the procedure
name, those bindings get updated, and the body
starts again.
So, for example, one might write the list-index
procedure as
When we enter the named let, the identifier rest
is bound to the value of ls and the identifier
bypassed is bound to 0, just as if we were
entering an ordinary let expression. In addition, however, the
identifier kernel is bound to a procedure that
has rest and bypassed
as parameters and the body of the named let as its body. As we
evaluate the cond expression, we may encounter a recursive call
to the kernel procedure -- in effect, we
re-enter the body of the named let, with rest
now re-bound to the former value of (cdr rest) and
bypassed to the former value of (+
bypassed 1).
As another example, here's a version of sum
that uses a named let:
Scheme programmers seem to be mixed in their reaction to the named
let. Some find it clear and elegant, others find it murky and too
special-purpose. Our colleagues prefer it. We'll admit that we first
found it murky, but eventually came to prefer it. We hope that you
will, too.