Skip to main content

Naming values with local bindings

Due
Friday, 22 February 2019
Summary
Algorithm designers regularly find it useful to name the values their algorithms process. We consider why and how to name new values that are only available within a procedure.

Introduction

When writing programs and algorithms, it is useful to name values we compute along the way. For example, in an algorithm that, given a list of numbers, sorts that list of numbers, it may be useful to name the sorted list along the way. When we associate a name with a value, we say that we bind that name to the value.

So far we’ve seen three ways in which names are bound to values in Scheme.

  • The names of built-in procedures, such as + and quotient, are predefined. There are also some predefined values, such as pi. When the Scheme interpreter 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 computed 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 discards these bindings when it leaves the procedure and returns to the point at which the procedure was called.

As you develop more and longer procedures, you will find that there are many times you want to create local names for values that are not parameters. We will consider such names in this reading.

Redundant Work

You may have already noted that there are times when it seems that you repeat work that should only have to be done once. For example, consider the problem of finding the East-West center of the Midwest. We will once again use our data set of zip code entries. Here’s a sample entry.

'("52211" 41.759702 -92.436452 "Brooklyn" "IA" "Poweshiek")

Our first step is to identify whether or not an entry is in the Midwest. Here’s our first attempt. (And yes, this code is close to the first thing that the authors of this piece wrote.)

;;; Procedure:
;;;   midwest-state?
;;; Parameters:
;;;   entry, an entry from a zip code database
;;; Purpose:
;;;   Determine if entry appears to be in the US midwest.
;;; Produces:
;;;   in-midwest?, a Boolean value
;;; Preconditions:
;;;   * Each entry in cities is a list of the form
;;;     '(zip:string latitude:real longitude:real city:string state:string county:string)
;;;   * The state is represented by its two letter abbreviation.
;;; Postcondition:
;;;   * If `entry` is one of the midwest states, `in-midwest` is #t.
;;;   * Otherwise, `in-midwest` is #f.
;;; Props:
;;;   The list of midwest states is taken from
;;;   <https://simple.wikipedia.org/wiki/Midwestern_United_States>.
(define midwest-state?
  (lambda (entry)
    (or (equal? (list-ref entry 3) "IA")
        (equal? (list-ref entry 3) "IL")
        (equal? (list-ref entry 3) "IN")
        (equal? (list-ref entry 3) "MI")
        (equal? (list-ref entry 3) "MN")
        (equal? (list-ref entry 3) "MO")
        (equal? (list-ref entry 3) "OH")
        (equal? (list-ref entry 3) "WI"))))

What do you notice about this code? You might notice that it is awfully repetitious. We are extracting the state with (list-ref entry 3) eight times, more or less. That seems a bit excessive. It seems like we would be better off somehow using state to name (list-ref entry 3) and then comparing state to each of the eight state abbreviations.

More importantly, (list-ref entry 3) does not extract the state. It extracts the city. We now have eight lines of code to fix.

(define midwest-state?
  (lambda (entry)
    (or (equal? (list-ref entry 4) "IA")
        (equal? (list-ref entry 4) "IL")
        (equal? (list-ref entry 4) "IN")
        (equal? (list-ref entry 4) "MI")
        (equal? (list-ref entry 4) "MN")
        (equal? (list-ref entry 4) "MO")
        (equal? (list-ref entry 4) "OH")
        (equal? (list-ref entry 4) "WI"))))

What if somewhere else in our code, we were actually looking for the city, rather than the state? We’d have to think carefully about each all to make sure that we had achieved what we wanted.

Nonetheless, we now seem to have a correct working version of midwest-state?, or at least we hope we do. Let us now move on to finding the central longitude. Here’s one approach: We find the longitude of the westernmost city in the Midwest. We find the longitude of the easternmost city in the Midwest. We average the two.

;;; Procedure:
;;;   midwest-central-longitude
;;; Parameters:
;;;   cities, list of entries from a zip code database
;;; Purpose:
;;;   Find the approximate center longitude of the midwest
;;; Produces:
;;;   central-longitude, a real number
;;; Preconditions:
;;;   * Each entry in cities is a list of the form
;;;     '(zip:string latitude:real longitude:real city:string state:string county:string)
;;;   * The state is represented by its two letter abbreviation.
;;;   * All longitudes are real
;;;   * cities contains at least one Midwestern city.
;;; Postconditions:
;;;   A reasonable person would accept that central-longitude is
;;;   the East-West center of the midwest.
(define midwest-central-longitude
  (lambda (cities)
    (* 1/2
       (+ (list-ref (list-ref (sort (filter midwest-state? cities)
                                    (lambda (entry1 entry2)
                                      (< (list-ref entry1 2) (list-ref entry2 2))))
                              0)
                    2)
          (list-ref (list-ref (reverse (sort (filter midwest-state? cities)
                                             (lambda (entry1 entry2)
                                               (< (list-ref entry1 2) (list-ref entry2 2)))))
                              0)
                    2)))))

What do you notice about this code? First, it’s probably a bit had to read. What’s with this “(list-ref (list-ref” thing? Why are we reversing a list? Are the two lists the same? The look like it, but there’s a lot to read through.

Second, it’s a bit repetitious. We’re sorting the list in the same way two different times. As we’ve just seen, if we have a mistake, we’ll have to figure out which places to fix.

Third, it may take some time to sort a list. We’ve effectively doubled the time we spend sorting the list in the same way.

Think of how much worse this would all be if we hadn’t decided to write a separate midwest-state? predicate! In fact, writing separate procedures is often a way we clarify our code. Let’s do that for this example. Since we’re sorting cities by longitude twice, we might as well come up with a procedure that does so for us. Because it’s intended primarily as a helper for midwest-central-longitude, we won’t bother with the preconditions or postconditions.

;;; Procedure:
;;;   sort-cities-by-longitude
;;; Parameters:
;;;   cities, a list of cities in the standard zip format.
;;; Purpose:
;;;   Sort the cities.
;;; Produces:
;;;   sorted, the same list of cities, now sorted by longitude
(define sort-cities-by-longitude
  (lambda (cities)
     (sort cities
           (lambda (entry1 entry2)
             (< (list-ref entry1 2) (list-ref entry2 2))))))

We can now use that procedure to simplify and clarify slightly.

(define midwest-central-longitude
  (lambda (cities)
    (* 1/2
       (+ (list-ref (list-ref (sort-cities-by-longitude
                               (filter midwest-state? cities))
                              0)
                    2)
          (list-ref (list-ref (reverse (sort-cities-by-longitude
                                        (filter midwest-state? cities)))
                              0)
                    2)))))

That’s better. But it still feels repetitious. We can, perhaps, combine the sorting and filtering into one procedure.

;;; Procedure:
;;;   midwest-by-longitude
;;; Parameters:
;;;   cities, a list of cities in the standard zip format
;;; Purpose:
;;;   Extract the Midwestern cities and then sort them by
;;;   longitude.
;;; Produces:
;;;   midwestern, a list of cities in the standard zip format
(define midwest-by-longitude
  (lambda (cities)
    (sort-cities-by-longitude (filter midwest-state? cities))))

That makes it even easier to read, if a bit repetitious.

(define midwest-central-longitude
  (lambda (cities)
    (* 1/2
       (+ (list-ref (list-ref (midwest-by-longitude cities)
                              0)
                    2)
          (list-ref (list-ref (reverse (midwest-by-longitude cities))
                              0)
                    2)))))

Can we simplify it even more? While you’re thinking about that, let’s return to our first helper procedure, midwest-state?.

(define midwest-state?
  (lambda (entry)
    (or (equal? (list-ref entry 4) "IA")
        (equal? (list-ref entry 4) "IL")
        (equal? (list-ref entry 4) "IN")
        (equal? (list-ref entry 4) "MI")
        (equal? (list-ref entry 4) "MN")
        (equal? (list-ref entry 4) "MO")
        (equal? (list-ref entry 4) "OH")
        (equal? (list-ref entry 4) "WI"))))

Is there a way to have that procedure do less repetition? Here’s a trick that you may have seen before. We can avoid some repeated computations by doing the computation once and then passing the result to another procedure. Since a procedure call associates a name with a value, we can then use the computed value again and again without recomputing it.

;;; Procedure:
;;;   midwest-state-abbreviation?
;;; Parameters:
;;;   abbrev, a string
;;; Purpose:
;;;   Determines if abbrev is an abbreviation of a Midwestern state.
;;; Produces:
;;;   in-midwest?, a Boolean
;;; Preconditions:
;;;   [No additional]
;;; Postconditions:
;;;   * If `abbrev` is the abbreviation for one of the midwest states, 
;;;     `in-midwest` is #t.
;;;   * Otherwise, `in-midwest` is #f.
;;; Props:
;;;   The list of midwest states is taken from
;;;   <https://simple.wikipedia.org/wiki/Midwestern_United_States>.
(define midwest-state-abbreviation?
  (lambda (abbrev)
    (or (equal? abbrev "IA")
        (equal? abbrev "IL")
        (equal? abbrev "IN")
        (equal? abbrev "MI")
        (equal? abbrev "MN")
        (equal? abbrev "MO")
        (equal? abbrev "OH")
        (equal? abbrev "WI"))))
(define midwest-state?
  (lambda (entry)
    (midwest-state-abbreviation? (list-ref entry 4))))

We can use the same trick to simplify midwest-central-longitude.

;;; Procedure:
;;;   average-first-and-last-longitude
;;; Parameters:
;;;   cities, a list of cities in the standard zip format.
;;; Purpose:
;;;   Average the longitude of the first and last entry in the list.
;;; Produces:
;;;   ave, a real number
(define average-first-and-last-longitude
  (lambda (cities)
    (* 1/2
       (+ (list-ref (list-ref cities 0) 2)
          (list-ref (list-ref (reverse cities) 0) 2)))))
(define midwest-central-longitude
  (lambda (cities)
    (average-first-and-last-longitude (midwest-by-longitude cities))))

We’ve made our code more readable. We’ve made our code more efficient. We’ve created a lot of helper procedures. Some, like midwest-state-abbreviation?, are likely to be useful in other situations. Others, like average-first-and-last-longitude, are clearly intended only for this one purpose. It seems we’ve spent a lot of work, some of which was unnecessary. There has to be a better way.

Scheme’s let Expressions

There is. Scheme provides let expressions as an alternative way to create local bindings. A let-expression contains a binding list and a body. The body can be any expression, or any sequence of expressions, to be evaluated with the help of the local name bindings. The binding list takes the form of a parentheses enclosing zero or more binding expressions of the form (name value).

That precise definition may have been a bit confusing, so here’s the general form of a let expression

(let
  ([name1 exp1]
   [name2 exp2]
   ...
   [namen expn])
  body1
  body2
  ...
  bodym)

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

Here’s one way to write midwest-central-longitude with let and without helpers.

(define midwest-central-longitude
  (lambda (cities)
    (let ([sorted-midwest-cities
           (sort (filter midwest-state? cities)
                 (lambda (entry1 entry2)
                   (< (list-ref entry1 2) (list-ref entry2 2))))])
      (* 1/2
         (+ (list-ref (list-ref sorted-midwest-cities 0) 2)
            (list-ref (list-ref (reverse sorted-midwest-cities) 0) 2))))))

This change would also make it somewhat easier for us to change our “central longitude” policy slightly. For example, we might want to compute an average using the two westernmost and the two easternmost cities.

(define midwest-central-longitude
  (lambda (cities)
    (let ([sorted-midwest-cities
           (sort (filter midwest-state? cities)
                 (lambda (entry1 entry2)
                   (< (list-ref entry1 2) (list-ref entry2 2))))])
      (* 1/4
         (+ (list-ref (list-ref sorted-midwest-cities 0) 2)
            (list-ref (list-ref sorted-midwest-cities 1) 2)
            (list-ref (list-ref (reverse sorted-midwest-cities) 0) 2)
            (list-ref (list-ref (reverse sorted-midwest-cities) 1) 2))))))

This change might then lead us to realize that the only data we need after filtering is the longitude.

(define midwest-central-longitude
  (lambda (cities)
    (let ([sorted-midwest-longitudes
           (sort (map (section list-ref <> 2)
                      (filter midwest-state? cities))
                 <)])
      (* 1/4
         (+ (list-ref sorted-midwest-longitudes 0)
            (list-ref sorted-midwest-longitudes 1)
            (list-ref (reverse sorted-midwest-longitudes) 0)
            (list-ref (reverse sorted-midwest-longitudes) 1))))))

We probably wouldn’t have dared to make that change if we’d had to change four separate filter-then-sort calculations.

*Important! Note that even though binding lists and binding specifications start with parentheses, they 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 – they are there to group the pieces of the binding list together.

As we’ve seen, 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 as the recomputation of values. In other cases, there is little difference in speed, but the code may be a little clearer.

Comparing let and define

You may have missed it, but there are a few subtle and important issues with the use of let rather than define to name values and procedures. One difference has to do with the availability (or scope) of the name. Values named by define are available essentially everywhere in your program. In contrast, values named by let are available only within the let expression. (In case you were wondering, the term scope has nothing to do with the mouthwash.)

In addition, local variables (given by let) and global variables (given by our standard use of define) affect previous uses of the name differently (or at least appear to). When we do a new top-level define, we permanently replace the old value associated with the name. That value is no longer accessible. In contrast, when we use let to override the value associated with a name, as soon as the let binding is finished, the previous association is restored.

Finally, there’s a benefit to using let instead of define according to the principle of information hiding. Evidence suggests that programs work better if the various pieces do not access values relevant primarily to the internal workings of other pieces. If you use define for your names, they are accessible (and therefore modifiable) everywhere. Hence, you enforce this separation by policy or obscurity. In contrast, if you use let to define your local names, these names are completely inaccessible to other pieces of code. We return to this issue in our discussion of the ordering of let and lambda below.

Behind the scenes: How Scheme might implement let.

The let construct seems quite new and different. We’re telling Scheme to evaluate a bunch of expressions simultaneously, then to assign names to them, and then to do something using those new names. Consider the following expression.

(let ([a (list-ref values 0)] 
      [b (list-ref values 1)]
      [c (list-ref (reverse values) 0)]
      [d (list-ref (reverse values) 1)])
  (* 1/4 (+ a b c d)))

What happens when DrRacket evaluates this expression?

  • It evaluates the four expressions: (list-ref values 0), (list-ref values 1), (list-ref (reverse values) 0), and (list-ref (reverse values) 1).
  • It binds the names a, b, c, and d to the corresponding values.
    For example, b gets the second element of values.
  • It computes the expression (* 1/4 (+ a b c d)).
  • It forgets about the bindings at the end of the let expression.

But we could also write that as a procedure call. Let’s use the name proc for something that takes a, b, c, and d, as parameters and then averages the four values.

(define proc
  (lambda (a b c d)
    (* 1/4 (+ a b c d))))

What happens if we call (proc (list-ref values 0) (list-ref values 1) (list-ref (reverse values) 0) (list-ref (reverse values) 1))?

  • DrRacket evaluates the four arguments: (list-ref values 0), (list-ref values 1), (list-ref (reverse values) 0), and (list-ref (reverse values) 1).
  • The procedure call binds the parameters a, b, c, and d to the corresponding arguments. For example, b gets the value of (list-ref values 1).
  • It computes the expression (* 1/4 (+ a b c d)).
  • It forgets about the bindings at the end of the procedure call.

It’s basically the same thing!

In fact, because Scheme simply replaces any procedure name with the body of the procedure, we can do the same thing.

;(let ([a (list-ref values 0)] 
;      [b (list-ref values 1)]
;      [c (list-ref (reverse values) 0)]
;      [d (list-ref (reverse values) 1)])
((lambda (a b c d) 
   (* 1/4 (+ a b c d)))
 (list-ref values 0) 
 (list-ref values 1) 
 (list-ref (reverse values) 0) 
 (list-ref (reverse values) 1))

It’s a bit harder to read. (Okay, it’s much harder to read.) But the computation is the same.

Because of the correspondence between let and procedure calls, many Scheme implementations simply turn let expressions into calls to anonymous procedures. That is, behind the scenes, a let expression of the form

(let
  ([name1 exp1]
   [name2 exp2]
   ...
   [namen expn])
  body1
  body2
  ...
  bodym)

becomes a procedure call of the form

((lambda (name1 name2 ... namen)
   body1
   body2
   ...
   bodym)
 exp1 exp2 ... expn)

If we can simulate let with a procedure call, and the Scheme interpreters often do so, why would the designers of Scheme ever decide to include let? Because most people find the let expression much easier to read than the procedure call. In the let expression, the association between name and value is clear because they are together. In the strange procedure call, they are far apart.

Some computer scientists call the creation of a new construct that adds no new functionality “syntactic sugar”. That is, it is a kind of syntax (a way of expressing ideas) that makes life sweeter.

Sequencing bindings with let*

Sometimes we may want to name a number of interrelated things. For example, suppose we wanted to square the average of a list of numbers. (It may not sound all that interesting, but it’s something that people do sometimes). Since computing the average involves summing values, we may want to name three different things: the total (the sum of the values), the count (the number of values), the mean (the average of the values). We can nest one let-expression inside another to name both things.

> (let ([total (reduce + values)]
        [count (length values)])
    (let ([mean (/ total count)])
      (* mean mean)))

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 ([total (reduce + values)]
        [count (length values)]
        [mean (/ total count)])
    (* mean mean))

This approach won’t work (try it and see!). It’s important to understand why not. The problem is as follows. Within one binding list, all of the expressions are evaluated before any of the names are bound. Specifically, Scheme will try to evaluate both (reduce + values) and (/ total count) before binding either of the names total and mean; since (/ total count) can’t be computed until total and count have value, an error occurs.

The takeaway message is thus: 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* ([total (reduce + values)]
         [count (length values)]
         [mean (/ total count)])
    (* mean mean))

The star in the keyword let* has nothing to do with multiplication. Just think of it as an oddly shaped letter. It means do things in sequence, rather than all at once. While someone probably knows the reason to use * for that meaning, the authors of this text do not.

Implementing let*

You’ve seen that let is just syntactic sugar for an anonymous procedure call. What about let*? Since you’ve just seen that we could convert a nested let into a let*, you might conclude that we could also do the reverse and convert a let* into a series of nested let expressions. And you’d be right.

That is,

(let*
  ([name1 exp1]
   [name2 exp2]
   ...
   [namen expn])
  body1
  body2
  ...
  bodym)

is the same as

(let ([name1 exp1])
  (let ([name2 exp2])
    ...
     (let ([namen expn])
       body1
       body2
       ...
       bodym)))

That let expression, in turn, is shorthand for an incredibly complex set of nested procedure calls that we could not hope to express correctly.

Positioning let relative to lambda

In the examples above, we’ve tended to do the naming within the body of the procedure. That is, we write

(define proc
  (lambda (params)
    (let (...)
      exp)))

However, Scheme also lets us choose an alternate ordering. We can instead put the let before (outside of) the lambda.

(define proc
  (let (...)
    (lambda (params)
      exp)))

Why would we ever choose to do so? Let us consider an example. Suppose that we regularly need to convert years to seconds. (About a decade ago, SamR said, “When you have sons between the ages of 5 and 12, you’ll understand.”) You might begin with

(define years-to-seconds
  (lambda (years)
    (* years 365.24 24 60 60)))

This produce does correctly compute the desired result. However, it is a bit hard to read. For clarity, you might want to name some of the values.

(define years-to-seconds
  (lambda (years)
    (let* ([days-per-year 365.24]
           [hours-per-day 24]
           [minutes-per-hour 60]
           [seconds-per-minute 60]
           [seconds-per-year (* days-per-year hours-per-day
                                minutes-per-hour seconds-per-minute)])
      (* years seconds-per-year))))
> (years-to-seconds 10)
315567360.0

We have clarified the code, although we have also lengthened it a bit. However, as we noted before, a second goal of naming is to avoid recomputation of values. Unfortunately, even though the number of seconds per year never changes, we compute it every time that someone calls years-to-seconds. How can we avoid this recomputation? One strategy is to move the bindings to define statements.

(define days-per-year 365.24)
(define hours-per-day 24)
(define minutes-per-hour 60)
(define seconds-per-minute 60)
(define seconds-per-year 
  (* days-per-year hours-per-day minutes-per-hour seconds-per-minute))
(define years-to-seconds
  (lambda (years)
    (* years seconds-per-year)))

However, such a strategy is a bit dangerous. After all, there is nothing to prevent someone else from changing the values here.

(define days-per-year 360) ; Some strange calendar, perhaps in Indiana.
...
> (years-to-seconds 10)
311040000

What we’d like to do is to declare the values once, but keep them local to years-to-seconds. The strategy is to move the let outside the lambda.

(define years-to-seconds
  (let* ([days-per-year 365.24]
         [hours-per-day 24]
         [minutes-per-hour 60]
         [seconds-per-minute 60]
         [seconds-per-year (* days-per-year hours-per-day
                              minutes-per-hour seconds-per-minute)])
    (lambda (years)
      (* years seconds-per-year))))
> (years-to-seconds 10)
315567360.0

As you will see in the lab, it is possible to empirically verify that the bindings occur only once in this case, and each time the procedure is called in the prior case.

One moral of this story is whenever possible, move your bindings outside the lambda. Let’s return to the midwest-state-abbreviation? procedure we wrote above.

(define midwest-state-abbreviation?
  (lambda (abbrev)
    (or (equal? abbrev "IA")
        (equal? abbrev "IL")
        (equal? abbrev "IN")
        (equal? abbrev "MN")
        (equal? abbrev "MO")
        (equal? abbrev "OH")
        (equal? abbrev "WI"))))

That code is still pretty repetitious. In thinking about how to solve this better, we might say to ourselves “tally is pretty good at searching lists. How about if we just make a list of the state abbreviations and search that.” Let’s try.

> (tally-value (list "IA" "IL" "IN" "MN" "MO" "OH" "WI") "IL")
1
> (tally-value (list "IA" "IL" "IN" "MN" "MO" "OH" "WI") "MA")
0

That analysis lead us to a new, more concise, definition of midwest-state-abbreviation?.

(define midwest-state-abbreviation?
  (lambda (abbrev)
    (not (zero? (tally-value (list "IA" "IL" "IN" "MN" "MO" "OH" "WI") "IL")))))

But that definition requires DrRacket to build the list every time we call the midwest-state-abbreviation? procedure. It may not matter if we do that once, or twice, or even a hundred times. But when we’re tallying a list of 42,000 elements, that’s a lot of extra work. Hence, we might more sensibly write the following.

(define midwest-state-abbreviation?
  (let ([midwest-abbreviations
         (list "IA" "IL" "IN" "MN" "MO" "OH" "WI")])
    (not (zero? (tally-value (list "IA" "IL" "IN" "MN" "MO" "OH" "WI") "IL")))))

Unfortunately, it is not always possible to move the bindings outside of the lambda. In particular, if your let-bindings use parameters, then you need to keep them within the body of the lambda. Consider the other half of the initial example. In that case, we needed to filter and sort the list of cities. We could not write the following.

(define midwest-central-longitude
  (let ([sorted-midwest-cities
         (sort (filter midwest-state? cities)
               (lambda (entry1 entry2)
                 (< (list-ref entry1 2) (list-ref entry2 2))))])
    (lambda (cities)
        (* 1/2
           (+ (list-ref (list-ref sorted-midwest-cities 0) 2)
              (list-ref (list-ref (reverse sorted-midwest-cities) 0) 2))))))

Why not? Because the computation of sorted-midwest-cities involves the list of cities.

Local procedures

As you may have noted, let behaves somewhat like define in that programmers can use it to name values. But we’ve used define to name more than values; we’ve also used it to name procedures. Can we also use let for procedures?

Yes, one can use a let- or let*-expression to create a local name for a procedure. And we name procedures locally for the same reason that we name values, because it speeds and clarifies the code.

(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 also defined outside this definition, the local binding gives it the appropriate meaning within the lambda-expression that describes what hypotenuse-of-right-triangle does.

Note, once again, that there are two places one might define square locally. We can define it before the lambda (as above) or after the lambda (as below). In the first case, the definition is done only once. In the second case, it is done every time the procedure is executed.

(define hypotenuse-of-right-triangle
  (lambda (first-leg second-leg)
    (let ([square (lambda (n) (* n n))])
      (sqrt (+ (square first-leg) (square second-leg))))))

So, which we should you do it? If the helper procedure you’re defining uses any of the parameters of the main procedure, it needs to come after the lambda. Otherwise, it is generally a better idea to do it before the lambda. As you practice more with let, you’ll find times that each choice is appropriate. It may be difficult at first, but it will become clearer as time goes on.

Nested define Statements

Although most versions of Scheme frown upon using define for local bindings, DrRacket also lets you create local bindings by nesting define statements within each other. We recommend that you limit your use of define to top-level definitions, using just let and let* for internal definitions. While you can use one define within another, the semantics are a bit complicated, and such use can lead to unexpected results and therefore confusion. (We also find internal define statements inelegant; others may feel differently.)

Here’s a simple example of why we suggest that you use let rather than define. Suppose that you’ve decided to increment one of the parameters and would like to use the same name to refer to that parameter. You know that Scheme does not let you easily change the value bound to a name. However, it does allow you to rebind the name. You might try the following. (We’ve used x in the body to stand in for a

(define sample-w/let
  (lambda (x)
    (let ([x (+ x 1)])
      (list x x x))))

(define sample-w/define
  (lambda (x)
    (define x (+ x 1))
    (list x x x)))

What happens if we use these two definitions? Let’s start with let.

> (sample-w/let 41)
'(42 42 42)

That works fine. What about the one that uses define?

> (sample-w/define 41)
. . x: undefined;
 cannot use before initialization

My, that’s strange. Isn’t x defined through the lambda? It turns out that while x is defined by the lambda, the evaluate of define is such that it binds the name before it evaluates the expression. That is, in evaluating (define name exp), the Scheme interpreter first puts name into the binding table with a value of undefined. Then it evaluates the expression. Then it updates the binding table. That may be a strange order, particularly in the middle of the procedure, but it tends to be useful at the top level.

Self checks

Check 1: Exploring let

What are the values of the following let-expressions? You may use DrRacket to help you answer these questions, but be sure you can explain how it arrived at its answers.

a.

(let ([tone "fa"]
      [call-me "al"])
  (string-append call-me tone "l" tone))

b.

(let ([total (+ 8 3 4 2 7)])
  (let ([mean (/ total 5)])
    (* mean mean)))

c.

(let* ([total (+ 8 3 4 2 7)]
       [mean (/ total 5)])
   (* mean mean))

d.

(let ([total (+ 8 3 4 2 7)]
      [mean (/ total 5)])
   (* mean mean))

e.

(let ([inches-per-foot 12.0]
      [feet-per-mile 5280])
  (let ([inches-per-mile (* inches-per-foot feet-per-mile)])
    (* inches-per-mile inches-per-mile)))

Check 2: Comparing let and let*

a. You may have discovered that Check 1b and Check 1c are equivalent. Which do you prefer? Why?

b. Rewrite Check 1e to use let*.