So far we've seen three ways in which a value can be associated with a name in Scheme:
The names of built-in procedures, such as cons and quotient,
are predefined. When DrScheme starts up, these names are
already bound to the procedures they denote.
The programmer can introduce a new binding by means of a definition. A definition may introduce a new equivalent for an old name, or it may give a name to a newly constructed value.
When a programmer-defined procedure is called, the parameters of the procedure are bound to the values of the corresponding arguments in the procedure call. Unlike the other two kinds of bindings, parameter bindings are local -- they apply only within the body of the procedure. Scheme sets aside these bindings when it leaves the procedure and returns to the point at which the procedure was called.
A let-expression in Scheme is an alternative way to create local
bindings. A let-expression contains a binding list and a
body. The body can be any expression, or sequence of
expressions, to be evaluated with the help of the local name bindings. The
binding list is a pair of structural parentheses enclosing zero or more
binding specifications; a binding specification, in turn, is a
pair of structural parentheses enclosing a name and an expression. Here's
an example of a binding list, taken from a let-expression in a real
Scheme program:
((next (car source)) (char-list '()))
This binding list contains two binding specifications -- one in which the
value of the expression (car source) is bound to the name next, and the other in which the empty list is bound to the name char-list. Notice that binding lists and binding specifications are not procedure calls; their role in a let-expression simply to give
names to certain values while the body of the expression is being
evaluated. The outer parentheses in a binding list are ``structural,''
like the outer parentheses in a cond-clause -- they are there to
group the pieces of the binding list together.
When Scheme encounters a let-expression, it begins by evaluating all
of the expressions inside its binding specifications. Then the names in
the binding specifications are bound to those values. Next, the
expressions making up the body of the let-expression are evaluated,
in order. The value of the last expression in the body becomes the value
of the entire let-expression. Finally, the local bindings of the
names are cancelled. (Names that were unbound before the let-expression become unbound again; names that had different bindings
before the let-expression resume those earlier bindings.)
Using a let-expression often simplifies an expression that contains
two or more occurrences of the same subexpression. The programmer can
compute the value of the subexpression just once, bind a name to it, and
then use that name whenever the value is needed again. Sometimes this
speeds things up by avoiding such redundancies; in other cases, there is
little difference in speed, but the code may be a little clearer. For
instance, here is a procedure remove-all which removes all
occurrences of a given item in a given list.
;;; remove-all: construct a copy of a given list, but lacking any ;;; occurrences of a given item ;;; Given: ;;; ITEM, a value. ;;; LS, a list. ;;; Result: ;;; REVISED, a list. ;;; Preconditions: ;;; None. ;;; Postcondition: ;;; The elements of REVISED are exactly the elements of LS, ;;; in the same relative order, except that ITEM is absent. (define remove-all (lambda (item ls) (if (null? ls) null (cond ((equal? (car ls) item) (remove-all item (cdr ls))) ((pair? (car ls)) (cons (remove-all item (car ls)) (remove-all item (cdr ls)))) (else (cons (car ls) (remove-all item (cdr ls))))))))
One of the least attractive features of this definition is the repetition
of the recursive call (remove-all item (cdr ls)) in three different
places. Consolidating the repeated code and giving a name to the value it
returns makes it a little easier to understand what the three cond-clauses are doing. While we're at it, we might as well do the same
thing with the other repeated expression, (car ls). Here is the
result:
;;; remove-all: construct a copy of a given list, but lacking any ;;; occurrences of a given item ;;; Given: ;;; ITEM, a value. ;;; LS, a list. ;;; Result: ;;; REVISED, a list. ;;; Preconditions: ;;; None. ;;; Postcondition: ;;; The elements of REVISED are exactly the elements of LS, ;;; in the same relative order, except that ITEM is absent. (define remove-all (lambda (item ls) (if (null? ls) null (let ((first-element (car ls)) (rest-of-result (remove-all item (cdr ls)))) (cond ((equal? first-element item) rest-of-result) ((pair? first-element) (cons (remove-all item first-element) rest-of-result)) (else (cons first-element rest-of-result)))))))
Here's a similar example, slightly more complicated: Consider the count-all-symbols procedure from the procedure from the lab on
deep recursion. We can once again use a let-expression to
consolidate repeated subexpressions in the same manner.
;;; count-all-symbols: determine how many of the ;;; ultimate constituents of a given list structure are ;;; symbols ;;; Given: ;;; LS, a list. ;;; Result: ;;; COUNT, a nonnegative integer. ;;; Precondition: ;;; LS is a list. ;;; Postcondition: ;;; COUNT is the number of symbols at all levels of LS. (define count-all-symbols (lambda (ls) (if (null? ls) 0 (let ((symbols-in-cdr (count-all-symbols (cdr ls)))) (cond ((list? (car ls)) (let ((symbols-in-car (count-all-symbols (car ls)))) (+ symbols-in-car symbols-in-cdr)) ((symbol? (car ls)) (+ 1 symbols-in-cdr)) (else symbols-in-cdr)))))))
It is possible to nest one let-expression inside another,
thus:
(let ((sample-list '(a b c d e)))
(let ((sample-cdr (cdr sample-list)))
(length sample-cdr)))
One might be tempted to try to combine the binding lists for the nested
let-expressions, thus:
;; Combining the binding lists doesn't work!
;;
(let ((sample-list '(a b c d e))
(sample-cdr (cdr sample-list)))
(length sample-cdr)))
This wouldn't work (try it and see!), and it's important to understand why
not. The problem is that, within one binding list, all of the
expressions are evaluated before any of the names are bound.
Specifically, Scheme will try to evaluate both '(a b c d e) and
(cdr sample-list) before binding either of the names sample-list and sample-cdr; since (cdr sample-list) can't be
computed until sample-list has a value, an error occurs. You have
to think of the local bindings coming into existence simultaneously rather
than one at a time.
Because one often needs sequential rather than simultaneous binding, Scheme
provides a variant of the let-expression that rearranges the order
of events: If one writes let* rather than let, each binding
specification in the binding list is completely processed before the next
one is taken up:
;; Using LET* instead of LET works!
;;
(let* ((sample-list '(a b c d e))
(sample-cdr (cdr sample-list)))
(length sample-cdr)))
The star in the keyword let* has nothing to do with
multiplication. Just think of it as an oddly shaped letter.
One can use a let- or let*-expression to create a
local name for a procedure:
;;; hypotenuse-of-right-triangle: compute the length of ;;; the hypotenuse of a right triangle, given the lengths ;;; of its legs ;;; Given: ;;; FIRST-LEG and SECOND-LEG, both real numbers. ;;; Result: ;;; HYPOTENUSE, a real number ;;; Preconditions: ;;; FIRST-LEG and SECOND-LEG are strictly positive. ;;; Postcondition: ;;; HYPOTENUSE is the length of the hypotenuse of a right ;;; triangle with FIRST-LEG and SECOND-LEG as its legs. (define hypotenuse-of-right-triangle (let ((square (lambda (n) (* n n)))) (lambda (first-leg second-leg) (sqrt (+ (square first-leg) (square second-leg))))))
Regardless of whether square is defined outside this definition, the
local binding gives it the appropriate meaning within the lambda-expression that describes what hypotenuse-of-right-triangle
does.
I am indebted to Professor Ben Gum for his contributions to the development of this reading.