7.7.0.3

4 — Compilers; Fun

Friday, 17 January 2020

Presenters Chase Boni, Noah Graff

Compilation via Static Distance

The static distance between a variable and its declaration is often called de Bruin index, after its inventor in the theoretical realm.

Static distance denotes the number of variable declarations between a variable reference and its declaration.

Lectures/4/static.rkt

  #lang racket
   
  (require "../3/data-rep.rkt")
   
  ;; here is the AST of the complex example:
  (define ex1
    (decl "x" (decl "y" 5 {node + "y" "y"})
          (decl "y" 42
                (decl "x" "x"
                      (node + "x" "y")))))
   
  (define ex2
    (decl "x" 1
          (node +
                (decl "y" 2
                      (decl "z" (node + "y" "y")
                            (node * "x" "z")))
                (decl "x" 3
                      (decl "z" 4
                            (node * "x" "z"))))))
          
   
  ;; - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - 
  ;; a static distance representation 
   
  (struct sd [count] #:transparent)
  #; {type SD  = Int ||
                 (node O SD SD) ||
                 (decl Var SD SD) || 
                 (sd N)}
  #; {type O = + || *}
   
  ;; here is the AST with sd nodes from above:
  (define sd-ex1
    (decl "x" (decl "y" 5 {node + (sd 0) (sd 0)})
          (decl "y" 42
                (decl "x" (sd 1)
                      (node + (sd 0) (sd 1))))))
   
  (define sd-ex2
    (decl "x" 1
          (node +
                (decl "y" 2
                      (decl "z" (node + [sd 0] [sd 0])
                            (node * [sd 2] [sd 0])))
                (decl "x" 3
                      (decl "z" 4
                            (node * [sd 1] [sd 0]))))))
   
   
   
   
  ;; - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - 
  (provide sd-ex1 ex1 sd-ex2 ex2 (struct-out sd))
   

Figure 11: Static Distance

Let’s write a "Compiler" that translates a given AE into one where variables are replaced by static distances. The "compiler" can also raise an error if it finds an undeclared variable.

Lectures/4/static-compiler.rkt

  #lang racket
   
  (require "../3/data-rep.rkt")
  (require "../3/environment.rkt")
  (require "static.rkt")
   
  ;; - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
  ;; VE -> SD
  ;; replace every variable refernce with a static distance 
  (define (static-distance ae0)
   
    #; {VE [Listof String] -> SD}
    ;; ACCUMULATOR env ~ declared variables from ae to ae0
    (define (sd/acc ae env)
      (match ae
        [(? integer?)   ae]
        [(node o a1 a2) (node o (sd/acc a1 env) (sd/acc a2 env))]
        [(decl x a1 a2) (decl x (sd/acc a1 env)
                              (sd/acc a2 (add x 'dummy env)))]
        [(? string? x)  (if (defined? x env)
                            (sd (position-of ae env))
                            (error 'sd "undeclared variable: ~e" x))]))
   
    (sd/acc ae0 '[]))
   
  ;; - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
  (module+ test
    (require rackunit)
    (check-equal? (static-distance ex1) sd-ex1)
    (check-equal? (static-distance ex2) sd-ex2))
   
  (provide static-distance)
   

Figure 12: A Static Distance Compiler

Then interpret the resulting target code with an imperative, fixed-size stack; see figure 13. If this were a compiler course concerned with correctness, I would explain the code in this figure via a systematic transformation that explains how the functional accumulator can be replaced in a provably correct manner with an imperative stack. But since this is a PL course, I merely show what compilers achieve.

Lectures/4/static-interpreter.rkt

  #lang racket
   
  (require "../3/data-rep.rkt")
  (require "static.rkt")
  (require "static-compiler.rkt")
  (require "environment2.rkt")
   
  #; {SD -> Number}
  ;; determine the value of ae via a substitutione semantics 
  (define (value-of-stack ae0)
   
    ;; the accumulator as an imperative, low-level stack 
    (define stack (create-empty (max-depth ae0)))
   
    #; {SD Env -> Number}
    (define (value-of ae)
      (match ae
        [(? integer?)   ae]
        [(node o a1 a2) (o (value-of a1) (value-of a2))]
        [(decl x a1 a2) (push! (value-of a1) stack)
                        (define result (value-of a2))
                        (pop! stack)
                        result]
        [(sd i)         (lookup i stack)]))
    
    (value-of (static-distance ae0)))
   
  ;; SD -> Natural
  ;; how deep are the variable declarations maximally nested? 
  (define (max-depth ae)
    (match ae
      [(? integer?)   1]
      [(node o a1 a2) (max (max-depth a1) (max-depth a2))]
      [(decl x a1 a2) (max (max-depth a1) (+ (max-depth a2) 1))]
      [(? string?)    1]))
   
  ;; - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - 
  ;; how to turn "working thru an example" into a complete test suite 
   
  (module+ test
    (require rackunit)
    (require "../3/examples.rkt")
    
    (check-equal? (value-of-stack ve-ex1) ve-val1)
    (check-equal? (value-of-stack ve-ex2) ve-val1)
    (check-equal? (value-of-stack ve-ex3) ve-val1)
    (check-equal? (value-of-stack ve-ex4) ve-val1)
    (check-equal? (value-of-stack ve-ex5) ve-val1)
    (check-equal? (value-of-stack ve-ex6) ve-val1)) 
   

Figure 13: A Static Distance Interpreter

In practice, Backus, the head of the first (FORTRAN) compiler team, came up with the idea of pre-computing the amount of memory needed, statically allocating it, and turning variable references into constant-time access. He won the Turing Award for this work—and used his Turing Award Lecture to disavow imperative programming in favor of functional programming. He dedicated the rest of his career to this research.

It uses a fixed-size vector-based environment, called a stack. The size is a function of the given program, namely the maximal depth of declarations. The value-of-stack function assigns to this vector at the current depth when it extends the environment, and it retrieves values via a vector dereference—all at constant time and space.

The following figure displays the environment.

Lectures/4/environment2.rkt

  #lang racket
   
  (provide
   
   #; {type Env :
            empty  :: AE -> Stack,
            add    :: N Number Stack -> Void,
            lookup :: N Stack -> Number}
   
   create-empty push! pop! lookup)
   
  ;; - - - - - - - - - - - - - - - - - - - - - - - - - - - - - 
  ;; here is one way to implement an Stackironment 
   
  #; (type Stack = [Vectorof Integer])
  ;; INTERPRETATION the last slot is how far from the left
  ;; the Integers are meaningful, i.e., it points to the top 
   
  (define (create-empty max-depth)
    (define stack (make-vector (+ max-depth 1) 'undefined))
    (vector-set! stack max-depth 0)
    stack)
   
  (define (push! val stack)
    (define top (vector-ref stack (- (vector-length stack) 1)))
    (vector-set! stack top val)
    (vector-set! stack (- (vector-length stack) 1) (+ top 1)))
   
  (define (pop! stack)
    (define top (vector-ref stack (- (vector-length stack) 1)))
    (vector-set! stack (- (vector-length stack) 1) (- top 1)))
    
  (define (lookup i stack)
    (define top (vector-ref stack (- (vector-length stack) 1)))
    (vector-ref stack (- top i 1)))
   

Figure 14: An Imperative Environment

Note These algorithms save both space and time, and that is the essence of compiler development. That is, instead of looking at language design and analysis, compiler research develops algorithms that reduce speed, time, energy consumption and other non-functional attributes of a program’s execution (in theory. In practice, compilers often change the behavior of some programs in subtle ways).

Compiler Correctness

Again, we can think of a programming language in terms of mathematics, and we can thus state and prove that the two interpretations are equal functions.

Theorem value-of == value-of-stack o static-distance

Now the difference between the extensional view of this equation and the intensional construction of the functions is even clearer. While the plain value-of function consumes O(n) time and space where n measure the size of the program, the compiler-interpreter combination takes O(1) time—a vast improvement in theory.

Globally Defined Functions

The idea of "programming" this way is due to Goedel (mid 1920s) in his proof of the incompleteness of first-order logic.

Let’s use a syntax like BSL, where all functions are defined up front and have global scope:

Lectures/4/bsl-as-data.rkt

  #lang racket
   
  ;; internal representation of BSL (functional expressions) 
   
  (struct node [op left right] #:transparent)
  (struct decl [variable value scope] #:transparent)
  (struct fun  [fname parameter body] #:transparent)
  (struct call [fname argument] #:transparent)
  (struct prog [fdefs body] #:transparent)
   
  #; {Program = [prog [Listof FDef] FExpr]}
  #; {FDef = (fun Var Var FExpr)}
  #; {FExpr  = Int || (node + FExpr FExpr) || (node * FExpr FExpr) ||
             (decl Var FExpr FExpr) ||
             Var
             (call Var FExpr)}
  #; {Var = String}
   
  ;; - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
  (provide
   (struct-out node)
   (struct-out decl)
   (struct-out fun)
   (struct-out call)
   (struct-out prog))
   

Figure 15: Globally Defined Functions

The examples looks more complicated but you should be able to figure out how they correspond to the first programs your wrote in Fundamentals I.

Lectures/4/bsl-example.rkt

  #lang racket
   
  (require "bsl-as-data.rkt")
   
  (define prog1
    (prog
      (list 
        (fun "f" "x" "x")
        (fun "g" "x" (call "f" (node * 10 "x")))
        (fun "h" "x" (node + (call "f" "x") (call "g" "x"))))
      (call "h" 10)))
   
  (provide prog1)
   

Figure 16: Examples of Globaly Defined Functions

As soon as we have a grammar, we need to explain the static scope of variable-introducing constructs. So,

[prog [list FDef ...]  FExpr]

introduces function definitions. Each definition binds its function name in all expressions to its right. Next, a function definition by itself

(fun fname parameter body)

binds fname and parameter in body. As for function calls,

(call fname arg)

it introduces a construct with a name but it does NOT create a scope. Finally, all other expressions are scoped as before.

The very shape of these programs suggests a way to interpret them: the function definitions comprise a global table for a locally-defined interpreter of expressions:

Lectures/4/bsl-interpreter.rkt

  #lang racket
   
  ;; static constraint: programs may not interpret undeclared variables 
   
  (require "../3/environment.rkt")
  (require "bsl-example.rkt")
  (require "bsl-as-data.rkt")
   
  #; {Program -> Number}
  (define (program-value-of p)
    (match p
      [(prog definitions body) (value-of body definitions)]))
   
  #; {FExpr [Listof FDef] -> Number}
  ;; determine the value of ae via a substitutione semantics 
  (define (value-of ae0 definitions)
   
    #; {FExpr Env -> Number}
    ;; ACCUMULATOR env tracks all declarations between ae and ae0
    (define (value-of ae env)
      (match ae
        [(? integer?)   ae]
        [(node o a1 a2) (o (value-of a1 env) (value-of a2 env))]
        [(decl x a1 a2) (value-of a2 (add x (value-of a1 env) env))]
        [(? string?)
         (if (defined? ae env)
      (lookup ae env)
      (error 'value-of "undeclared variable ~e" ae))]
        [(call fn ae)
         (cond
           [(defined-function? fn definitions)
            (define function [lookup-fun-definition fn definitions])
            (define argument (value-of ae env))
            (fun-apply function argument)]
           [else (error 'value-of "undeclared function")])]))
   
    #; {FDef Number -> Number}
    (define (fun-apply function-representation argument-value)
      (match function-representation
        [(fun _ fpara fbody)
         (value-of fbody (add fpara argument-value empty))]))
   
    (value-of ae0 empty))
   
  ;; - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
  ;; function definitions as environments 
   
  #; {Var [Listof FDef] -> [Maybe FDef]}
  (define (defined-function? name definitions)
    (memf (match-lambda [(fun fname _1 _2) (equal? fname name)])
          definitions))
   
  #; {Var [Listof FDef] -> FDef}
  (define (lookup-fun-definition name definitions)
    (first (defined-function? name definitions)))
   
  ;; - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
  (module+ test
    (require rackunit)
   
    (check-equal? (program-value-of prog1) 110))
   

Figure 17: An Interpreter for Global Functions

Note how this follows the design recipe for hierarchical and mutually recursion data definitions.

Observations:
  1. recursion is useless because there is no conditional

  2. the syntactic separation of function defs and decls is awkward

  3. the interpreter brings that out

  4. the idea of naming a function while defining it is awkward

Locally Defined, Nameless Functions

The idea of nameless functions is due to Church (late 1920s, published in 1942). It became known as the λ calculus, a model of computation that is equivalent and pre-dates Turing machines.

This kind of analysis of an interpreter (or mathematical specification) thus suggests a different language design. Specifically, it suggests two independent ideas:
  • separate the naming of a function from its "specification", and

  • introduce a explicit definition expression.

But since we already have declarations, the first idea suffices to get functions working in our language.

Roughly speaking, this is ISL+ but without recursive scope.

We use the fun structure to represent the nameless “lambda” functions from Fundamentals I and from languages that you have gotten to know since, such as Java, Python, JavaScript, etc.

Lectures/4/isl-as-data.rkt

  #lang racket
   
  ;; internal representation of BSL (functional expressions) 
   
  (struct node [op left right] #:transparent)
  (struct decl [variable value scope] #:transparent)
  (struct fun  [parameter body] #:transparent)
  (struct call [fname argument] #:transparent)
   
  #; {ISLExpr  = Int || (node O ISLExpr ISLExpr) ||
               (decl Var ISLExpr ISLExpr) ||
               Var
               ;; - - - - - - - - - - - - - 
               (fun Var ISLExpr)
               (call ISLexpr ISLExpr)}
  #; {O = + || *}
  #; {Var = String}
   
  ;; - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
  (provide
   (struct-out node)
   (struct-out decl)
   (struct-out fun)
   (struct-out call))
   

Figure 18: Locally Defined Functions

As figure 18 shows, the introduction of nameless functions eliminates a struct definition and two clauses from the grammar; it also generalizes the call clause.

Lectures/4/isl-example.rkt

  #lang racket
   
  (require "isl-as-data.rkt")
   
  (define prog1
    (decl "bacon" (decl "y" 42 (fun "x" (node + "x" "y")))
      (decl "matthias" (decl "y" 68 (fun "z" (call "z" "y")))
        (call "matthias" "bacon"))))
   
  (provide prog1)
   

Figure 19: Examples of Locally Defined Functions

Figure 19 introduces two "modules": bacon and matthias, each defining a private variable called x and "exporting" a function. The "linking" part calls the function exported from "matthias" on the function exported from "bacon" from the eponymous modules. How the two "modules" don’t get confused about which "x" is which, is key to the next interpreter.

The introduction of "lambda" tremendously simplifies the scope explanation. A function expression

(fun fname para body)

binds its function parameter in body. All other expressions are scoped as before the introduction of functions.

A program is an ISLExpr where all variables are declared.

The adaptation of value-of to this generalized grammar demands a clarification, however. Thus every kind of expression evaluated to a number. Now that our grammar includes "lambda" expressions, an expression may evaluate to something else, and note how the generalized (function) call representation takes advantage of this. Here is how we represent the value of a fun expression. In the literature, function-value is called closure.

Lectures/4/possible-values.rkt

  #lang racket
   
  ;; - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
  ;; internal representation of function values 
   
  (struct function-value [parameter body env] #:transparent)
   
  #; {Values = Integer || (function-value Var Expr Env)}
  ;; where Var is from -as-data and Env is from environment 
   
  (define (value? x)
    (or (integer? x) (function-value? x)))
   
  (provide
   #; {Any -> Boolean : Value}
   value?
   
   (struct-out function-value))
   
   

Figure 20: Values are more Numbers

This new struct simply records everything that is current available so that it can be used as necessary.

In our context "necessary" means the fun-apply function of course. Once the first sub-expression of call is evaluated, it better be such a function-value and fun-apply can use it to evaluate it almost like before.

Lectures/4/isl-interpreter.rkt

  #lang racket
   
  ;; static constraint: programs may not interpret undeclared variables 
   
  (require "../3/environment.rkt")
  (require "isl-as-data.rkt")
  (require "isl-example.rkt")
  (require "possible-values.rkt")
   
  #; {FExpr -> Value}
  ;; determine the value of ae via a substitutione semantics 
  (define (value-of ae0)
   
    #; {FExpr Env -> Value}
    ;; ACCUMULATOR env tracks all declarations between ae and ae0
    (define (value-of ae env)
      (match ae
        [(? integer?)   ae]
        [(node o a1 a2) (o (value-of a1 env) (value-of a2 env))]
        [(decl x a1 a2) (value-of a2 (add x (value-of a1 env) env))]
        [(? string?)
         (if (defined? ae env)
      (lookup ae env)
      (error 'value-of "undeclared variable ~e" ae))]
        [(call ae1 ae2)
         (fun-apply (value-of ae1 env) (value-of ae2 env))]
        ;; - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
        [(fun para body) (function-value para body env)]))
   
    #; {Value Value -> Value}
    (define (fun-apply function-representation argument-value)
      (match function-representation
        [(function-value fpara fbody env)
         (value-of fbody (add fpara argument-value env))]))
   
    (value-of ae0 empty))
   
  ;; - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
  (module+ test
    (require rackunit)
   
    (check-equal? (value-of prog1) 110))
   
  (provide value-of)
   

Figure 21: An Interpreter for Locally Functions

The key difference is that the evaluation of the function’s body no longer uses the empty environment but the one that is stored in the given function-value. Why?

Observations:

The first two questions deserve a through exploration. The third one has a curious answer, interesting for theoretical programming languages.

Y ~~~ A Phenomenon

The original Y combinator is due to Curry; this version originated with Plotkin.

It turns out that lambda can create recursive functions, and hence nothing is lost from the perspective of computability. Here is how to get a recursive factorial function without ever defining a recursive function:

Lectures/4/Y.rkt

  #lang racket
   
  (define Y
    (lambda (f)
      [(lambda (alpha) (alpha alpha))
       (lambda (x) (f (lambda (v) [(x x) v])))]))
   
  (define ! (lambda (n) (if (zero? n) 1 (* n (! (sub1 n))))))
   
  (define make-factorial
    (lambda (!)
      (lambda (n) (if (zero? n) 1 (* n (! (sub1 n)))))))
   
  (define factorial (Y make-factorial))
   
  (define r (random 100))
   
  (= (! r) (factorial r))
   

Figure 22: The Y Combinator

Stop! Rewrite the Y combinator as an ISLExpr. Then add a representation of if expression to the language. Mimic subtraction with negative number. Finally, run this factorial program via value-of.

If you are curious how one can justify the Y combinator, see On the Why of Y.

No widely used programming language relies on this trick to create recursive functions. Only pure theoreticians care.

Another theoretical trick is to represent numbers, booleans, if, and everything else via lambda. In the late 1970s and early 1980s, researchers briefly considered using this idea to create compilers and hardware. Nothing came of it.