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.
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
-
Reading
- Study these notes, paying attention to any ideas we did not discuss in class.
-
Read
this short section
before our next session. It looks at
let
expressions a bit more and connects them to the idea of syntactic abstraction. - Optional — If you would like to see more examples involving local variables, read Sections 2.4 Variables and Let Expressions and 2.5 Lambda Expressions in Dybvig's online The Scheme Programming Language. You can stop when you reach Section 2.6.
-
Homework
- Homework 7 will be available after next session (after spring break).
-
Quiz
- Quiz 2, over recursive programming techniques, is today at the end of class.