Session 15

Local Variables as Syntactic Abstraction

CS 3540
Programming Languages and Paradigms

Functional Programming in the Wild

As we transition out of learning functional programming to doing functional programming, it's good to keep in mind that this isn't a Racket phenomenon or a CS 3540 phenomenon.

I've been reading a bit about JavaScript, the language that drives so much of the web and our web browsers these days. npm is the package manager for JavaScript. On the npm homepage, they list the six Most Depended-Upon Packages. One of them is async, which consists of higher-order functions and common patterns for asynchronous code. You've been learning how to use higher-order functions and common FP patterns this semester. (Asynchronous code may come later...) In the past, I've also seen underscore on this list. underscore is a "utility-belt library" for JavaScript that provides the usual functional suspects (each, map, reduce, filter...). You are ready to use these tools.

Javascript is an interesting language: prototype-based OOP with plenty of freedom for FP. Web developers are now taking advantage of the advantages of FP.

Opening Exercise

What are the values of these let expressions?

    (let ((x 5)        ;; Exercise 1
          (y 6)
          (z 7))
       (- (+ x z) y))

    (let ((x 13)       ;; Exercise 2
          (y (+ 6 x))
          (z x))
       (- (+ x z) y))

The second expression does not behave as we might expect from experience with previous languages. When we initialize y and z to values that depend on the value of x, we might expect it to be the value of x in the immediately preceding binding, x = 13. That is how sequences of local variable declarations work in other languages.

What is different here? Last session, we learned that a Racket let expression does not create a sequence of declarations in that way. It is equivalent to applying a nameless lambda expression to the variable's values. When we translate the let expression into its equivalent lambda application, we see the difference.

    ( (lambda (x y z)       ;; Exercise 2, translated
        (- (+ x z) y))
      (+ 6 x)
      (z x) )

Translating the let expression into a lambda app makes clear the semantics of the let form: the region associated with the variables declared in a let is the body of the expression, not the whole expression.

This is an example of how knowing about syntactic abstractions can help you to understand why a language works the way it does. You might think that let should work some other way, but knowing that it is an abstraction of a lambda application lets you see why it cannot work any other way.

Exercise 2(b):   So, what if we do want to initialize y and z using the value of the new binding for x?

We must initialize y and z within the region of x, that is, within the body of the let!

    (let ((x 13))             ;; Exercise 2
       (let ((y (+ 6 x))      ;;    -- with a nested let
             (z x))
          (- (+ x z) y)))

This ensures that the x referred to in binding y and z is the one we intend. The value is 26 - 19 = 7.

Can we solve the similar exercise given at the end of last session's notes in the same way?

    (let ((x 13))
       (let ((y (+ y x))
             (z x))
          (- (+ x z) y)))

Now, the references to x are okay, but notice that y is given a value that depends on y. The bold y is still free!

The Context

We have begun to discuss the idea of syntactic abstractions, those features of a language that are convenient to have but that are not strictly essential to the language. From the programming language perspective, these constructs can complicate the process of interpreting 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.

Keep in mind that the existence of syntactic abstractions may be, in terms of program readability and maintainability, essential. Who would want to read, let alone write, a long program in which all local variables have been replaced with applications of lambdas? Local variables are "not essential" only in the sense that our language processors can live without them. Language designers and implementers take advantage of this fact to write program interpreters that are more efficient and easier to maintain.

Earlier in the semester, we learned that procedures that take more than one argument are really syntactic sugar. They are not essential because we can curry a multi-argument procedure into a procedure of one argument that returns a procedure that expects the rest. Last session, we considered another syntactic abstraction, local variables. This session, we will make the idea of "local variable as syntactic abstraction" concrete. Then we will see that functions can be local variables procedures, too, and leave you to read about logical connectives and selection structures.

Local Variable as Abstraction

Last time, we looked more closely at Racket's let expression, which can be defined as:

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

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

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

We learned how these expressions work by defining a translational semantics for let:

    (let ((<var_1> <exp_1>)
          (<var_2> <exp_2>)
          (<var_n> <exp_n>))
is equivalent to this lambda application:
    ((lambda (<var_1> <var_2>...<var_n>)
     <exp_1> <exp_2>... <exp_n>)

I then made this claim:

In fact, some Scheme interpreters and compilers automatically translate a let expression into an equivalent lambda application whenever they see one!

Let's see how this process works by implementing it ourselves, and then see how it really can make our lives easier.

  1. add let to our little language

  2. create syntax procedures for let expressions
        (define nested-let
          '(let (a b)
             (let (c (lambda (d) a))
               (c a))))
        (let? nested-let)
        (let->var nested-let)
        (let->val nested-let)
        (let->body nested-let)
        (let? (let->body nested-let))
        (let->var (let->body nested-let))
        (let->val (let->body nested-let))
        (let->body (let->body nested-let))
  3. translate a let expression into a lambda application
        (preprocess nested-let)
  4. use our translator to show how other code can now work for code that once contained a let expression
        (occurs-free? 'a (preprocess nested-let))
        (occurs-free? 'b (preprocess nested-let))
        (occurs-free? 'c (preprocess nested-let))
        (occurs-free? 'd (preprocess nested-let))
        (occurs-bound? 'd (preprocess nested-let))
        (occurs-bound? 'a (preprocess nested-let))

Which is easier: writing a translator or updating occurs-free? and occurs-bound?? And remember: we have also written declared-vars and unused-var? and will eventually write many more functions, such as free-vars, that operate over the language.

That's how syntactic abstraction and translational semantics can make language processors easier to write and manage.

Syntactic Abstraction and Language Processing

What have we done?

source                     machine
program   →  compiler   →  language

source       pre-          source                    machine
program   →  processor  →  program   →  compiler  → language
in full                    in core
language                   language

How can syntactic abstraction make program processors more efficient and easier to maintain? When we pre-process a syntactic abstraction into an equivalent expression that the processor already knows about, we:

...... if we extend a language this way, then all of the other existing tools that process the language will work on the preprocessed program, so we can use them to process programs in the extended language. Also

Pre-processing is an example of translating one program into an equivalent one that uses other features. It takes advantage of the idea of translational semantics, in which we define and understand a syntactic abstraction in terms of more primitive features.

Local Functions

TL;DR: Local variables can name functions, too.

We can often make a procedure simpler by decomposing it into multiple procedures. For example, both Mutual Recursion and Syntax Procedure create helper procedures that simplify a larger body of code. More importantly, we often cannot write a simple or efficient recursive program without a helper procedure. For example, Interface Procedure and Accumulator Variables use helper procedures as an integral part of making code function in an acceptable way.

The use of helpers, though, can cause some problems:

Program Derivation offers a solution to all three of these problems in many contexts. However, using this technique makes the program denser, and so it may make it more difficult to read and modify the code.

A local procedure offers a solution to the last two of these problems. It makes the procedure's name local, rather than global, and ensures that the code stays close to the code that use it.

Sometimes, we can create a local procedure as an ordinary local variable. Consider the procedure invert, which takes as an argument a list of 2-lists (lists of size 2) and returns a list in which each 2-list is reversed. For example:

    > (invert '((kramer 10) (jerry 8) (elaine 7) (george 4)))
    ((10 kramer) (8 jerry) (7 elaine) (4 george))

The simplest way to do this uses map. We might use a standard helper procedure:

    (define invert
      (lambda (list-of-2lists)
        (map swap list-of-2lists)))

    (define swap
      (lambda (2-list)
        (list (second 2-list) (first 2-list))))

We could use the Racket primitive reverse in place of swap, but it walks down the list one item at a time, because it takes lists of arbitrary length. That would be less efficient than necessary in our case; we don't need recursion because we know that each list contains exactly two items!

swap is a common name for utility procedures when working with lists of symbols. For example, you implemented a swap for s-lists on Exam 2. Introducing a new swap procedure may clash with an existing procedure of that name. For instance, we may have already defined a procedure that looks like:

    (define swap
       (lambda (first second)
          ;; ... do something ))

or this:

    (define swap
       (lambda (old new s-list)
          ;; ... do something ))

Creating a new swap procedure for use in invert would clobber those definitions and break other code. If we have not created them already, we may want to in the future, and using the name at the top-level name means we won't be able to.

How can we solve this problem? Well, we could change the name of our new procedure to something else. This approach raises at least a couple of questions:

There is another way to solve this problem. Because the binding of a procedure to its name in Racket is just like any other variable binding, we can use a let expression to create a local procedure:

    (define invert
      (lambda (list-of-2lists)
        (let ((swap (lambda (lst)
                      (list (second lst) (first lst)) )))
          (map swap list-of-2lists)) ))

Remember: procedures are first-class values in Racket. Anything we can do with any other value -- say, a number or a list -- we can do with a procedure. That includes naming one using a local variable.

Because this procedure definition is more deeply nested, it may seem a bit more complex, at least while you are still learning Racket. Even after you feel more comfortable with this construction, you may not want to write your code in this way in the first place. I often write programs by defining my procedures at the global level and then "putting them together" after I know they they work. This is similar to the principle of "never optimize before its time". Code writing time is almost always too early to optimize!

Quick Exercise: We can use program derivation to eliminate the helper procedure altogether. What is the resulting program? Which version do you prefer?

Wrap Up

Eugene Wallingford ..... ..... March 1, 2018