Session 16
Syntactic Abstraction: Local Variables

For Want of a Local Variable...

Racket has a primitive function named assoc. It returns the first pair in a list that starts with a given symbol:

> (assoc 'c '((a . 11) (b . 24) (c . 3)))
'(c . 3)

If it doesn't find such a pair, it returns false:

> (assoc 'd '((a . 11) (b . 24) (c . 3)))
#f

This two-pronged feature makes assoc handy for implementing the lookup function that I needed for the second version of my cipher language evaluator:

(define lookup
  (lambda (var env)
    (if (assoc var env)
        (cdr (assoc var env))
        (error 'lookup "invalid variable reference ~a" var))))

... which gives us the behavior we need:

> (lookup 'c '((a . 11) (b . 24) (c . 3)))
3

> (assoc 'd '((a . 11) (b . 24) (c . 3)))
lookup: invalid variable reference d

This code is slick, but we have to call assoc twice whenever we find the var we are looking for. Can't we do better? Sure: we can use a local variable.

"Um, Professor Wallingford. You have not showed us how to use local variables in Racket yet.

This has caused some of you extra friction as you've learned to write Racket functions. That was intentional. We were learning a new style of programming, and local variables lure us off the path of learning.

You see, one of the main reasons we use local variables in other languages is to sequence a computation: first do this, then do this, ..., with local variables holding partial results along the way. But that's not the primary way we think in functional programming, which uses functions to do as much work as possible.

So we forewent local variables for a while so that you would have a chance to practice the new style without so much temptation to fall back into procedural thinking. (You may have been tempted, but we didn't have the tool...) I was careful to select as many problems as possible whose solutions did not need local variable, so that we could practice with as few distractions as possible.

Writing lookup reminds us that we use local variables for other reasons, too. We use them not only to sequence a computation but we also use them to to give a name to a value for readability.

In lookup, I might want to call assoc once and give its value name.

If I create a local variable named match:

match = (assoc var env)

then I can say:

(if match
    (cdr match)
    (error 'lookup "invalid variable reference ~a" var))

The functional programmer in me know that I can already do this, using a function:

(define get-value
  (lambda (match var)    ; have to pass var for error case
    (if match
        (cdr match)
        (error 'lookup "invalid variable reference ~a" var))))

That creates a name and uses the named object twice. If I call it:

(get-value (assoc var env) var)

I assign a value to match and get a result. Voilá! That's the body of lookup:

(define lookup
  (lambda (var env)
    (get-value (assoc var env) var)))

I can even do without creating a stand-alone function, if my lambda-fu is strong:

(define lookup
  (lambda (var env)
    ((lambda (match)      ; var is available here!
        (if match
            (cdr match)
            (error 'lookup "invalid variable reference ~a" var)))
      (assoc var env))))

That's great and all, but why do I have to think about lambda here? Wouldn't it be nice if I could simply make a local variable?

Yes, yes, it would. The designers of Racket give us the ability to do so. But we just saw something very important: Racket doesn't need much new machinery under the hood to make this happen. The language already supports everything we need to get the job done.

a photo of a glass bowl containing sugar cubes, sitting on a wooden table
Gimme some sugar.

Syntactic Abstraction

We are all familiar with programming language features that are not strictly necessary to make the language complete. A good example is the for statement in Java. for is not strictly necessary, because we can always replace a for loop with a while loop that does the same thing. for is nice, though, because it brings all of the control elements of the loop together into one place.

Or consider the simple assignment statement:

x = y + z

Programmers often find themselves using this statement to update the value of a variable:

x = x + 5

... which gives rise to the convenient shorthand we see in Python:

x += 5

In languages like C++ and Java, programmers write lots of for-loops and find themselves incrementing lots of counters:

x = x + 1

... which gives rise to the even shorter shorthand:

x++

We don't need these extra constructs, but they sure are handy.

The formal name for such language features is syntactic abstraction, though many people call them syntactic sugar. They make programming easier by abstracting away the details of a common construction into a simpler or more direct statement. They are convenient but not necessary. They make the language sweeter for humans.

As programmers, we often feel as if syntactic abstractions are essential to our task of writing code easily. Indeed, much of my research over many years in artificial intelligence and object-oriented programming was built on the foundation of creating and using very high-level programming languages for developing intelligent systems. These languages aren't necessary. People could always have written their programs in Python, Ada, Java, Racket, Ruby, Smalltalk, Lisp, or C++. But the new languages made it possible to write programs in terms of domain knowledge and problem-solving strategies, rather programs in terms of for statements, or in terms of car and cdr expressions.

But from the programming language perspective, syntactic sugar is not essential and complicate the process of interpreting programs written in the a language. Part of our study of the design of programming languages is to identify which features are essential so that we can understand how interpreters work, and how an interpreter can pre-process the sugar away.

We have already learned that one feature we probably thought was essential — functions that take more than one argument — is really syntactic sugar. The idea underlying this abstraction was called currying. We have also used Racket's multi-way conditional expression, cond, and learned that it is a syntactic abstraction of the more basic if expression. (Or vice versa!)

Over the next few sessions, we will consider a number of other common programming language features and investigate whether they are essential or are "just sugar".

Local Bindings and lambda

Up to this point in the course, we have used only three kinds of identifiers in our programs: the names of primitive functions, the names of functions and other data values that we defined at the top level, and the names of formal parameters on functions. Of these, only the formal parameters behave like the "local variables" that we are accustomed to using in other programming languages.

These names are local to the function in which they are declared. They have not yet been variable, because we have not had a way to assign new values to a name yet. But we haven't needed to. Any time we have needed to "change the value" of an variable, we have passed the new value as an argument in a recursive call.

This idea also explains how we have managed to write programs for eight weeks without creating any local variables. Any time we needed a local variable, like position in the function positions-of, we created a new function with a new parameter:

(define positions-of-helper
  (lambda (s los position)
    ...))

and passed the value of the variable when we called the function:

(define positions-of
  (lambda (s los)
    (positions-of-helper s los 0)))

A function satisfied our needs.

The lambda special form provides a binding mechanism by which names are created and values are associated with names. Last week, we considered to determine statically whether a variable reference is bound to the value of a formal parameter in a program. Let's now move on to consider identifiers in more detail and how they get their values.

Local Bindings and let

Unsurprisingly, Racket has a way of creating expressions that use local variables: the special form let. Here is a let expression that creates a local variable named x, assigns it the value 3, and uses it to compute another value:

(let ((x 3))
  (+ x (* x 10)))

We can also create and use two local variables in the same expression:

(let ((x 3)
      (y 5))
  (+ x (* y 10)))

Now we now have the tool we need to implement the lookup function in the way we'd like:

(define lookup
  (lambda (var env)
    (let ((match (assoc var env)))
      (if match
          (cdr match)
          (error 'lookup "invalid variable reference ~a" var)))))

Of course, let uses prefix notation, like the rest of Racket, and the placement of the parentheses is — as always! — important. So let's have a closer look.

The general form of a Racket let expression is:

<let-expression> ::= (let <binding-list> <body>)

  <binding-list> ::= ()
                   | ( <binding> . <binding-list> )

       <binding> ::= (<var> <exp>)

          <body> ::= <exp>

The let special form takes two arguments. The first is a list of variable bindings. Though Racket permits an empty list, in practice we almost never use one. (In all my years of programming in Lisp, Scheme, and Racket, I have never written a no-variable let expression.) The second is an expression that uses these bindings.

We will examine let expressions more closely in our next session. We will also begin to use them in our own code whenever they are helpful.

Keep in mind something we saw earlier in the session: We were able to accomplish the same behavior without a let expression! Racket can take advantage of this fact!

Wrap Up