Principles of Programming Languages

Blank, Fall 2012

Assignment #4: Calculator Language with If-exp and User-defined Functions

Due: Thursday, October 9, 2014

This assignment is designed to:

  • Understand our language as it is so far
  • Explore Scheme procedures and variables
  • Add if-exp to our language
  • Add procedure-exp to our language

Solution to Assignment #3

In [1]:
(define list-of
  list-of takes a function f and returns a function.
  The returned function takes a list of items and applies 
  f to each. If (f item) is true for all items, then the
  function returns #t, otherwise #f.
  (lambda (f)
    (lambda (ls)
      (let loop ((ls ls))
         ((null? ls) #t)
         ((f (car ls)) (loop (cdr ls)))
         (else #f))))))

We add the 3 different ways to call functions, and the variable-exp to the calc-exp:

In [2]:
 calc-exp calc-exp?
  (value number?))
  (name symbol?))
  (operator symbol?)
  (arg1 calc-exp?)
  (arg2 calc-exp?))
  (function symbol?)
  (arg1 calc-exp?)
  (arg2 calc-exp?))
  (function symbol?)
  (args (list-of calc-exp?))))

We add these to the parser, hard-coding the 2-argument functions min and max, and the variable number of argument functions, avg and sum. Otherwise, if the list is of length 3, we assume that it is an application-exp:

In [3]:
(define parser
  "Takes an s-expression representing our concrete syntax; returns AST"
  (lambda (exp)
     ((symbol? exp) (var-exp exp))
     ((number? exp) (literal-exp exp))
     ((list? exp)
       ((eq? (car exp) 'min) (func-call-exp 'min 
                                            (parser (cadr exp))
                                            (parser (caddr exp))))
       ((eq? (car exp) 'max) (func-call-exp 'max 
                                            (parser (cadr exp))
                                            (parser (caddr exp))))
       ((eq? (car exp) 'avg) (func-call-var-exp 'avg 
                                            (map parser (cdr exp))))
       ((eq? (car exp) 'sum) (func-call-var-exp 'sum 
                                            (map parser (cdr exp))))
       ((= (length exp) 3) (app-exp (cadr exp)
                                    (parser (car exp))
                                    (parser (caddr exp))))
       (else (error 'parser "Invalid concrete syntax: ~a" exp))))
    (else (error 'parser "Invalid concrete syntax: ~a" exp)))))

We define some helper functions that we will need in the evaluator: avg and lookup:

In [4]:
(define avg
  (lambda (args)
    (/ (sum args) (length args))))
In [5]:
(define lookup
  (lambda (name env)
    (let ((binding (assq name env)))
      (if binding
          (cadr binding)
          (error 'lookup "No such varibale: ~a" name)))))

Finally, in the evaluator, we eval the AST:

In [6]:
(define evaluator
  (lambda (ast env) 
    (record-case ast
      (literal-exp (value) value)
      (var-exp (name) (lookup name env))
      (app-exp (operator arg1 arg2)
         ((eq? operator '+) (+ (evaluator arg1 env) 
                               (evaluator arg2 env)))
         ((eq? operator '-) (- (evaluator arg1 env) 
                               (evaluator arg2 env)))
         ((eq? operator '*) (* (evaluator arg1 env) 
                               (evaluator arg2 env)))
         ((eq? operator '/) (/ (evaluator arg1 env) 
                               (evaluator arg2 env)))))
      (func-call-exp (function arg1 arg2)
         ((eq? function 'min) (min (evaluator arg1 env)
                                   (evaluator arg2 env)))
         ((eq? function 'max) (max (evaluator arg1 env)
                                   (evaluator arg2 env)))))
      (func-call-var-exp (function args)
         ((eq? function 'avg) (avg (map (lambda (e) (evaluator e env))
         ((eq? function 'sum) (sum (map (lambda (e) (evaluator e env))

Now, we define an environment and a utility function to make testing easy:

In [7]:
(import "math")
(define env (list (list 'pi math.pi)
                  (list 'e  math.e)))
(define calc
  (lambda (exp)
      (evaluator (parser exp) env)))

(define test
  (lambda (exp)
    (printf "~a -> ~a~%" exp (calc exp))))

Now, test it out:

In [8]:
(test 1)
(test '(1 + 2))
(test '(min 56 12))
(test '(avg 4 5 6 7 8))
(test '(avg 4 5 6 7 8 9))
(test 'e)
(test 'pi)
(test '(avg pi e (1 + (8 * 7)) (sum 5 6 3) (min 6 2) (max 9 6)))
1 -> 1
(1 + 2) -> 3
(min 56 12) -> 12
(avg 4 5 6 7 8) -> 6
(avg 4 5 6 7 8 9) -> 13/2
e -> 2.71828182846
pi -> 3.14159265359
(avg pi e (1 + (8 * 7)) (sum 5 6 3) (min 6 2) (max 9 6)) -> 14.6433124137

Summary and Next steps

The beginning of our calculator language looks very good! It can do at least as much as a good calculator, maybe more: it has in-fix operators, some specific functions, and variables.

But the implementation of the different functions and operators was pretty messy. The way that we had to write the parser was a bit limiting. For example, we might have some ambiguity in the future:

In  [A]: (new-function + 34)

In  [B]: (min + 23)

In  [C]: (34 f 56)

In A, our parser may find that to be addition, when we really meant it to be an application of the new-function.

In B, we can't have a variable named min or it would through off our parser.

In C, we can't have a variable f bound to +, because our parser wouldn't know that that is a plus-exp.

Perhaps we should consider an concrete syntax like Scheme's. Then all of the functions would appear in the initial position.

Let's leave that for the moment. In fact, let's remove all of the applications of functions, and think about what it would take for the user to be able to write their own functions.

As a warm up, let's add if-exp to a stripped-down parser/evaluator.

Adding an if-exp

We know that an if-exp in Scheme is a "special form"... it short-circuits evaluation of the elements, not evaluating the branch that it doesn't need to.

Let's define what the parser and evaluator should give us:

In [9]:
;; (parser '(if 1 2 3))
;; (if-exp (lit-exp 1) (lit-exp 2) (lit-exp 3))
In [10]:
;; (calc '(if 1 2 3))
;; 2
In [11]:
;; (calc '(if 0 2 3))
;; 3

Let us pare-down the datatype to the bare minimum to focus on new forms:

In [12]:
(define-datatype calc-exp calc-exp?
  (value number?))
  (name symbol?))
  (test-exp calc-exp?)
  (then-exp calc-exp?)
  (else-exp calc-exp?)))

The parser is simply:

In [13]:
(define parser
  (lambda (exp)
     ((symbol? exp) (var-exp exp))
     ((number? exp) (lit-exp exp))
     ((eq? (car exp) 'if) (if-exp (parser (cadr exp)) 
                                  (parser (caddr exp))
                                  (parser (cadddr exp)))))))
In [14]:
(parser '(if 1 2 3))
(if-exp (lit-exp 1) (lit-exp 2) (lit-exp 3))

Test out the parser to make sure it is recursively well-defined.

To use our new if-exp, we need to have the ability to test whether an expression is true. We could introduce a boolean type. However, for the moment let's just use numbers to represent booleans. We define false to be 0; anything else will be true:

In [15]:
(define true?
  (lambda (v)
    (not (= v 0))))
In [16]:
(true? 0)
In [17]:
(true? 1)
In [18]:
(true? 42)

Our evaluator handling if-exp:

In [19]:
(define evaluator
  (lambda (ast env) 
    (record-case ast
      (lit-exp (value) value)
      (if-exp (test-exp then-exp else-exp)
        (if (true? (evaluator test-exp env))
            (evaluator then-exp env)
            (evaluator else-exp env))))))
In [20]:
(calc '(if 1 2 3))
In [21]:
(calc '(if 0 2 3))

Extensively test if-exp with recursive expressions.

Procedures and Closures

Now, let's think about procedures. Let's examine Scheme's procedures, and how they handle variables that are defined and undefined.

We can define procedures without worrying if everything is defined:

In [22]:
(lambda () x)

Oh, but we can't use that unless we have a way of later calling it. Note that in Scheme, you can define a function in one place (in one "environment") but use it in another.

In [23]:
(define f (lambda () x))
In [24]:
Traceback (most recent call last):
  File "stdin", line 1, col 1, in 'f'
  File "stdin", line 1, col 22
RunTimeError: unbound variable 'x'

But, we can later put an x in the "global environment" and it will work:

In [25]:
(define x 3)
In [26]:
(define x 5)

Let's see what happens if we evaluate f with a "local" variable x:

In [27]:
(let ((x 7))

Interesting! x was still found in the global environment... it didn't even look in the local variables, but went straight to the globals.

Now let's try the opposite: what happens if we define a local variable, then return a function that references a variable from that local environment:

In [28]:
(define f (let ((x 42))
            (lambda () x)))

What happens if we call it:

In [29]:

Interesting! Scheme remembered that x was 42. Let's see if we can change the value of x:

In [30]:
(define x 7)

Very interesting! When a lambda expression is evaluated, it "captures" the bindings of all of the defined variables. For those that aren't defined, those variables are unbound (aka "free"), and Scheme will look for their values in the "toplevel" environment dynamically at runtime.

So, a free variable:

In [31]:
(define f (lambda () q))

Is not bound to anything:

In [32]:
Traceback (most recent call last):
  File "stdin", line 1, col 1, in 'f'
  File "stdin", line 1, col 22
RunTimeError: unbound variable 'q'

And can't be found in a local environment:

In [33]:
(let ((q 4))
Traceback (most recent call last):
  File "stdin", line 1, col 1, in 'let'
  File "stdin", line 2, col 3, in 'f'
  File "stdin", line 1, col 22
RunTimeError: unbound variable 'q'

But can, in the toplevel environment:

In [34]:
(define q 5)

Let's now see if we can add such behavior to our Calc language. We start by defining our concrete syntax, and the AST that it will parse into:

In [35]:
;; (parser '(func (n) n))
;; (procedure-exp (n) (var-exp n))

So, func is lambda.

In [36]:
;; (parser '((func (n) n) 42))
;; (app-exp (procedure-exp (n) (var-exp n)))

Great! But when we evaluate a procedure-exp, we need somehow to be able to "capture" all of those defined variables. We can do that by simply sticking the environment as it exists at that time into a new form called a "closure". A closure is nothing more than a procedure-exp with the current environment stuck in there as well:

In [37]:
;; (calc '(func (n) n))
;; (closure-exp env (n) (var-exp n))

Finally, we want to evaluate a function: we call it, and pass in arguments to be bound to variables:

In [38]:
;; (calc '((func (n) n) 42))
;; 42

We need a few helper functions:

In [39]:
(define procedure-or-var?
  (lambda (exp)
    (or (var-exp? exp)
        (procedure-exp? exp))))

(define var-exp?
  (lambda (exp)
    (eq? (car exp) 'var-exp)))

(define procedure-exp?
  (lambda (exp)
    (eq? (car exp) 'procedure-exp)))

(define closure-exp?
  (lambda (exp)
    (eq? (car exp) 'closure-exp)))

We define these new special forms:

In [40]:
 calc-exp calc-exp?
  (value number?))
  (name symbol?))
  (test-exp calc-exp?)
  (then-exp calc-exp?)
  (else-exp calc-exp?))
  (parameters (list-of symbol?))
  (body calc-exp?))
  (env list?)
  (parameters (list-of symbol?))
  (body calc-exp?))
  (procedure procedure-or-var?)
  (args (list-of calc-exp?))))

The parser is pretty straightforward:

In [41]:
(define parser
  (lambda (exp)
     ((symbol? exp) (var-exp exp))
     ((number? exp) (lit-exp exp))
     ((eq? (car exp) 'func) (procedure-exp 
                                 (cadr exp) 
                                 (parser (caddr exp))))
     ((eq? (car exp) 'if) (if-exp (parser (cadr exp)) 
                                  (parser (caddr exp))
                                  (parser (cadddr exp))))
     (else (app-exp (parser (car exp))
                    (map parser (cdr exp)))))))
In [42]:
(parser '((func (n) 1) 4))
(app-exp (procedure-exp (n) (lit-exp 1)) ((lit-exp 4)))

The evaluator is also fairly easy, although we introduce a new function applier that will apply whatever function we give it to a list of evaluated args. Notice that a procedure-exp evaluates to a closure-exp, which is just a procedure-exp with the current environment injected into it:

In [43]:
(define evaluator
  (lambda (ast env) 
    (record-case ast
      (lit-exp (value) value)
      (var-exp (name) (lookup name env))
      (procedure-exp (parameters body)
         (closure-exp env parameters body))
      (if-exp (test-exp then-exp else-exp)
        (if (true? (evaluator test-exp env))
            (evaluator then-exp env)
            (evaluator else-exp env)))
      (app-exp (procedure args)
        (applier (evaluator procedure env) (map (lambda (e) (evaluator e env)) args) env)))))

When we evaluate an expression like ((func (n) n) 5) we need to be able to extend the environment with n bound to 5. To do that, all we need to do is cons (n 5) onto the environment, and use the extended environment:

In [44]:
(define extend-env
  (lambda (vars vals env)
     ((null? vars) env)
     (else (extend-env (cdr vars) (cdr vals) 
                       (cons (list (car vars) (car vals)) env))))))
In [45]:
(extend-env '(a) '(1) env)
((a 1) (pi 3.141592653589793) (e 2.718281828459045))
In [46]:
(extend-env '(a b c) '(1 2 3) env)
((c 3) (b 2) (a 1) (pi 3.141592653589793) (e 2.718281828459045))

Notice that the environment doesn't change forever... it is still the same:

In [47]:
((pi 3.141592653589793) (e 2.718281828459045))

Finally, we are ready to apply our functions. We need a couple of helper functions to extract the parts of the closure datatype. (These should be defined when we execute define-datatype. An oversight!)

In [48]:
(define closure-exp->body
  (lambda (closure)
    (cadddr closure)))

(define closure-exp->parameters
  (lambda (closure)
    (caddr closure)))

Applier will then take a closure, extend the environment with the parameters bound to the arguments, and then evaluate the body of the closure:

In [49]:
(define applier
  (lambda (f values env)
     ((closure-exp? f) 
      (evaluator (closure-exp->body f) 
                 (extend-env (closure-exp->parameters f) values env)))
     (else (apply f values)))))
In [50]:
(calc '((func (n) n) 4))

Yes, it works! Applier had actually two cases: one the closure that we have just defined, and the "else". We allow Calc functions to also be Scheme functions. Cheat! Well, yes. But that means that we can put Scheme functions in the environment, and they will work in our language:

In [51]:
;; First, we define those things that can't be defined in terms of others:

(define env (list (list 'pi math.pi)
                  (list 'e  math.e)
                  (list '+ +)
                  (list '- -)
                  (list '* *)
                  (list 'list list)
                  (list '= (lambda (a b) (if (= a b) 1 0)))
                  (list '< (lambda (a b) (if (< a b) 1 0)))
                  (list '> (lambda (a b) (if (> a b) 1 0)))

;; Next, we extend-env with those things that can use the previous things:

(set! env (extend-env '(min max) (list 
                                       (evaluator (parser '(func (a b) (if (< a b) a b))) env)
                                       (evaluator (parser '(func (a b) (if (> a b) a b))) env)
                                  ) env))
In [52]:
((max (closure-exp ((pi 3.141592653589793) (e 2.718281828459045) (+ #<procedure>) (- #<procedure>) (* #<procedure>) (list #<procedure>) (= #<procedure>) (< #<procedure>) (> #<procedure>)) (a b) (if-exp (app-exp (var-exp >) ((var-exp a) (var-exp b))) (var-exp a) (var-exp b)))) (min (closure-exp ((pi 3.141592653589793) (e 2.718281828459045) (+ #<procedure>) (- #<procedure>) (* #<procedure>) (list #<procedure>) (= #<procedure>) (< #<procedure>) (> #<procedure>)) (a b) (if-exp (app-exp (var-exp <) ((var-exp a) (var-exp b))) (var-exp a) (var-exp b)))) (pi 3.141592653589793) (e 2.718281828459045) (+ #<procedure>) (- #<procedure>) (* #<procedure>) (list #<procedure>) (= #<procedure>) (< #<procedure>) (> #<procedure>))

The above defines our env as before, first defining everything that we need as primitives. But we can define min and max in terms of less-than and greater-than, which are already defined. Note that less-than and greater-than can't be the Scheme versions directly, because they must return 0 and 1---our "booleans". They can't return #t and #f, because those don't exist in Calc language.

We set! the env to be the extended env, with min and max now defined.

Test away!

In [53]:
(calc '((func (a b) (list a b)) 1 2))
(1 2)
In [54]:
(calc '((func (n) (max 5 (max 6 (if n 100 4)))) 0))
In [55]:
(calc '((func (n) (max 5 (max 6 (if n 100 4)))) 1))
In [56]:
(calc '((func (n m) (if (< n m) n m)) 6 5))

This language should behavior very similarly to Scheme. You can write lot's of different kinds of functions.

Can you write factorial? Try!

Here is a version, except what go in the position of ???:

In [57]:
;; (calc '((func (n) (if (= n 1) 1 (* n (??? (- n 1))))) 5)

The problem is that the only way of naming things (currently) is through func. We don't have a "define".

But wait! What if we passed in factorial as another parameter:

In [58]:
;; (calc '((func (n factorial) (if (= n 1) 1 (* n (factorial (- n 1))))) 5 factorial))

But we don't have factorial... that is what we are defining. Wait! We just defined it. The problem is that we don't have a name for it. But we do have code for it... exactly what we just wrote. So, we can't pass it in by name, but we can pass it in by code... just copy it!

In [59]:
(calc '((func (n factorial) (if (= n 1) 1 (* n (factorial (- n 1) factorial)))) 
        (func (n factorial) (if (= n 1) 1 (* n (factorial (- n 1) factorial))))))

Woot! We even have recursion, even though we can't name our recursive function.

Next steps

  1. Add avg and sum back to the language, but do it by extending the environment rather than changing the language.
  2. Define a Scheme procedure named operator? (given a symbol, return #t if that symbol is an in-fix operator) and use it in the parser to allow in-fix operators (even with the caveats above). But, only the parser/concreate-syntax needs to change, not the evaluator/AST. That is, even though these operators will be in-fix, they are still just functions.
  3. Add the in-fix operators from Assignment #3: +, -, *, /, and other operators you may have added.
  4. Study how we extended the environment with (func (x y z) body).
  5. Add local variables to Calc, using syntax similar to (let ((x 1)) x). That is: (let ((VAR VALUE)...) BODY). It will use extend-env, and be somewhat similar to applier.
  6. Add an assignment-exp to Calc to allow you to change values of a variable, like (set! x (+ x 1)).
  7. We can't add (define variable value) yet because we don't have the idea of a "toplevel" environment. We'll add that in class next Tuesday. How will that affect writing recursive functions?