Fundamentals of Computer Science I (CSC-151.02 2000F)


Local binding and recursion

As you've 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 problem with this technique is that we should restric 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?

Yes. 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.

However, it is not possible to bind an identifier to a recursively defined procedure in this way:

> (let ((count-down (lambda (n)
                      (if (zero? n)
                          '()
                          (cons (- n 1) (count-down (- n 1)))))))
    (count-down 10))
reference to undefined identifier: count-down

The difficulty is that when the lambda-expression is evaluated, the identifier count-down 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 count-down 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. 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. The keyword associated with this ``recursive binding'' variant of let is letrec:

> (letrec ((count-down (lambda (n)
                         (if (zero? n)
                             '()
                             (cons (- n 1) (count-down (- n 1)))))))
    (count-down 10))
(9 8 7 6 5 4 3 2 1 0)

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:

> (letrec ((up-sum
            (lambda (ls)
              (if (null? ls)
                  0
                  (+ (down-sum (cdr ls)) (car ls)))))
           (down-sum
            (lambda (ls)
              (if (null? ls)
                  0
                  (- (up-sum (cdr ls)) (car ls))))))
    (up-sum (list 1 23 6 12 7)))
-21
;; which is 1 - 23 + 6 - 12 + 7.

We can use letrec expressions to separate the husk and the kernel of a recursive procedure without having to define two procedures.

;;; Find the position of a given value in a given list.
;;; Parameters:
;;;   sought, a value.
;;;   stuff, a list.
;;; Returns:
;;;   (1) -1, if sought is not an element of stuff
;;;   (2) the position of the first appearance of sought in stuff.  That is,
;;;       a number, k, such that none of the first k elements of stuff is 
;;;       is sought but the next one after the first k elements is sought.
;;; Preconditions:
;;;   None.
;;; Postconditions:
;;;   Affect neither value nor list.
(define index
  (lambda (sought stuff)
    (index-kernel sought stuff 0)))

(define index-kernel
  (lambda (sought rest num-bypassed)
    (cond ((null? rest) -1)
          ((equal? (car rest) sought) num-bypassed)
          (else (index-kernel sought (cdr rest) (+ num-bypassed 1))))))

This 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:

(define index
  (lambda (sought ls)
    (letrec ((kernel (lambda (rest bypassed)
                       (cond ((null? rest) -1)
                             ((equal? (car rest) sought) bypassed)
                             (else (kernel (cdr rest) (+ bypassed 1)))))))
      (kernel ls 0))))

Notice, too, that since the recursive kernel procedure is now entirely 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 how to introduce precondition tests into the greatest-of-list procedure from the lab on preconditions and postconditions:

(define greatest-of-list
  (lambda (ls)
    (letrec ((all-real? (lambda (ls)
                          (or (null? ls)
                              (and (real? (car ls))
                                   (all-real? (cdr ls))))))
             (kernel (lambda (rest)
                       (if (null? (cdr rest))
                           (car rest)
                           (max (car rest) (kernel (cdr rest)))))))
      (if (or (not (list? ls))
              (null? ls)
              (not (all-real? ls)))
          (error "greatest-of-list: The argument must be a non-empty list of reals.")
          (kernel ls)))))

Embedding the kernel inside the definition of greatest-of-list rather than writing a separate greatest-of-list-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 -- if, for instance, one issued the call (index (greatest-of-list (list 5 3 7)) (list 18 6 14 7 2)) -- the correct kernel procedure would be invoked in each case, because the correct local binding would supersede all others.

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 ((var1 val1)
                    (var2 val2)
                    ...
                    (varn valn))
  body)

So, for example, one might write the index procedure as follows:

(define index
  (lambda (sought ls)
    (let kernel ((rest ls)
                 (bypassed 0))
      (cond ((null? rest) -1)
            ((equal? (car rest) sought) bypassed)
            (else (kernel (cdr rest) (+ bypassed 1)))))))

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:

(define sum
  (lambda (ls)
    (let kernel ((rest ls)
                 (running-total 0))
      (if (null? rest)
          running-total
          (kernel (cdr rest) (+ (car rest) running-total))))))

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. My colleagues like to use it. I'll admit that I don't.

History

March 2, 1997 (John Stone)

March 17, 2000 (John Stone)

24 October 2000 (Sam Rebelsky)


Disclaimer Often, these pages were created "on the fly" with little, if any, proofreading. Any or all of the information on the pages may be incorrect. Please contact me if you notice errors.

This page may be found at http://www.cs.grinnell.edu/~rebelsky/Courses/CS151/2000F/Readings/letrec.html

Source text last modified Mon Oct 23 10:49:05 2000.

This page generated on Mon Oct 23 10:51:28 2000 by Siteweaver. Validate this page's HTML.

Contact our webmaster at rebelsky@grinnell.edu