Lecture 6: Lambda calculus I

Goals for today:

  • Implementing substitution efficiently with environments for let
  • Develop lam, a language with first class values
    • Understand substitution in lam

Logistics

  • Steven’s office hours shifted this week by 30 minutes (11:30 – 12:30)
  • New homework released soon
  • Remember: quiz next Thurs/Fri, see previous week’s notes

Implementing let with environments

  • Q: What is the runtime of our substitution algorithm?
    • A: Linear in the size of the program!
  • This runtime seems quite undesirable; every time we introduce a new variable, we will have to scan over the whole program text!
  • No real program implementations use substitution this way: most implementations make use of an environment for keeping track of which variables are in-scope
  • An environment is a map from identifiers to values. We write these as [x |-> v1, y |-> v2, …]
  • We can run an interpreter by hand that manipulates environments. Now our arrow rules produce a sequence of (environment, program) pairs:
[], (let (x 10) (let (y 20) (+ x y)))
--> [x |-> 10], (let (y 20) (+ x y))
--> [x |-> 10, y |-> 20], (+ x y)
--> [x |-> 10, y |-> 20], (+ 10 y)
--> [x |-> 10, y |-> 20], (+ 10 20)
--> [x |-> 10, y |-> 20], 30

Implementing an interpreter

  • To model the environment, we use a hashset datatype:
(define-type-alias Env (Hashof Symbol Value))
(define mt-env (hash empty)) ;; "empty environment"
  • There are two key functions for datatypes of type Hashof Keytype Valuetype:
    • extend hashset newkey newvalue, which creates a new hashset out of hashset with an entry that maps newkey to newvalue
    • hash-ref hashset key, which returns an Optionof Value, which is none if key is not in the hashtable and some v if v is in the hashtable
  • We will implement some helper functions to deal with environments:
(define (lookup (s : Symbol) (n : Env))
  (type-case (Optionof Number) (hash-ref n s)
    [(none) (error s "not bound")]
    [(some v) v]))

(extend : (Env Symbol Number -> Env))
(define (extend old-env new-name value)
  (hash-set old-env new-name value))
  • Now, we are ready to implement our evaluator. What should it do?
(define (interp e nv)
  (type-case Exp e
    [(numE n) n]
    [(varE s) (lookup s nv)]
    [(plusE l r) (+ (interp l nv) (interp r nv))]
    [(let1E var val body)
     (let ([new-env (extend nv
                            var
                            (interp val nv))])
       (interp body new-env))]))
  • And we can run this on some examples:
(test (interp (let1E 'x (numE 10)
               (let1E 'x (numE 20)
                      (varE 'x))) mt-env) 20)

(test (interp (let1E 'x (numE 10) (plusE (varE 'x) (varE 'x))) mt-env) 20)

(test (interp (let1E 'x (numE 10)
                   (plusE (varE 'x)
                          (let1E 'x (numE 20) (varE 'x)))) mt-env) 30)

(test (interp (plusE (let1E 'x (numE 10) (varE 'x)) (let1E 'x (numE 15) (varE 'x))) mt-env) 25)
  • Ponder: How does our interpreter deal with variables going out of scope? Why did that last example work?

First-class functions: the lambda calculus

  • Now we begin our journey into a rich and beautiful language: the lambda-calculus
  • In the lambda calculus, functions are values (in other words, we say functions are first-class in our language).
  • Plait has first-class functions:
> (lambda (x) (+ x 1 ))
- (Number -> Number)
#<procedure>
  • Syntactically, a lambda (lambda (x) e) consists of two parts: an argument x, which must be a symbol, and a body e which can be any Plait expression
  • Lambdas are sometimes called anonymous functions because they do not have to be given a name when they are declared.
  • We can call lambdas just like we would any other function:
> ((lambda (x) (+ x 1)) 10)
- Number
11
  • Calling a lambda involves substitution. Let’s add some notation for substitution: for an expression e1, we denote substituting the name x for the expression e2 as e1[x |-> e2]
  • Then, we can run this program by hand:
((lambda (x) (+ x 1)) 10)
--> (+ x 1)[x |-> 10]
--> (+ 10 1)
--> 11
  • Just because lambdas can be anonymous doesn’t mean they have to be: we can bind lambda to names just like any other value by using let. This lets us conveniently create local functions:
> (let [(addone (lambda (x) (+ 1 x)))]
    (addone 10))
- Number
11
  • Let’s run this by hand:
(let [(addone (lambda (x) (+ 1 x)))]
    (addone 10))
--> (addone 10)[addone -> (lambda (x) (+ 1 x))]
--> ((lambda (x) (+ 1 x)) 10)
--> (+1 x)[x |-> 10]
--> (+ 1 10)
--> 11
  • Lambdas have delicate scoping rules which we should investigate carefully
  • Now for another interesting case: What should this program do?
> (let ([x 10])
    (let [(addxy (lambda (y) (+ x y)))]
      (addxy 5)))
  • What is the scope of x in the body of the lambda? Intuitively, it refers to the x that is in-scope when the lambda is defined: lambdas hold onto values that are in-scope when they are created.
  • Let’s run this by hand:
(let ([x 10])
    (let [(addxy (lambda (y) (+ x y)))]
      (addxy 5)))
--> (let [(addxy (lambda (y) (+ x y)))]
      (addxy 5))[x |-> 10]
--> (let [(addxy (lambda (y) (+ 10 y)))]
      (addxy 5))
...
--> 15
  • This seems simple enough, but can lead to some surprising subtleties! Look at this program:
> (let [(x 10)]
    (let [(addx (lambda (y) (+ x y)))]
      (let [(x 20)]
        (addx 5))))
  • This program should output 15! Why? Remember dynamic scope: we want the scope of a name to be independent of the runtime behavior of the program. So, the scope of x inside the body of the lambda should not depend on subsequently defined local variables.

  • Now for another interesting case: What should this program do?

> (let ([x 10])
    (let [(addxy (lambda (y) (+ x y)))]
      (addxy 5)))
  • What is the scope of x in the body of the lambda? Intuitively, it refers to the x that is in-scope when the lambda is defined: lambdas hold onto values that are in-scope when they are created.
  • Let’s run this by hand:
(let ([x 10])
    (let [(addxy (lambda (y) (+ x y)))]
      (addxy 5)))
--> (let [(addxy (lambda (y) (+ x y)))]
      (addxy 5))[x |-> 10]
--> (let [(addxy (lambda (y) (+ 10 y)))]
      (addxy 5))
...
--> 15
  • This seems simple enough, but can lead to some surprising subtleties! Look at this program:
> (let [(x 10)]
    (let [(addx (lambda (y) (+ x y)))]
      (let [(x 20)]
        (addx 5))))
  • This program should output 15! Why? Remember dynamic scope: we want the scope of a name to be independent of the runtime behavior of the program. So, the scope of x inside the body of the lambda should not depend on subsequently defined local variables.

Basic semantics of lam

  • What do we expect the following program to do?
(let [(x 10)]
    (let [(addone (lambda (x) (+ 1 x)))]
      (addone x)))
  • We expect it to return 11. Why? We see that the x in the lambda shadows the outer x. This is intuitive, we expect x to refer to its definition in the inner-most scope.
  • let’s run this by hand:
(let [(x 10)]
    (let [(addone (lambda (x) (+ 1 x)))]
      (addone x)))
--> (let [(addone (lambda (x) (+ 1 x)))]
      (addone x))[x |-> 10]
--> (let [(addone (lambda (x) (+ 1 x)))]
      (addone 10))
--> (addone 10)[addone |-> (lambda (x) (+ 1 x))]
--> (addone 10)[addone |-> (lambda (x) (+ 1 x))]
--> ((lambda (x) (+ 1 x)) 10)
--> (+ 1 x)[x |-> 10]
--> (+ 1 10)
--> 11
  • So, lambdas have scoping rules that are quite similar to let

  • To begin, let’s consider a core language called lam that consists of three syntactic constructs:

(define-type LExp
  [varE (n : Symbol)]                 ; variable reference
  [lamE (x : Symbol) (body : LExp)]   ; lambda introduction
  [appE (e : LExp) (arg : LExp)])     ; lambda application: run function e1 with argument
  • Now let’s consider the semantics. What is the right notion of a value here?
  • We want our language to support “first-class functions”, meaning functions can be data, so let’s make our value type support lambdas:
(define-type Value
  [funV (arg : Symbol) (body : Lam)])
  • Then, we can define our evaluator in terms of substitution (but note, this evaluator is not in its final form and has some subtle issues):
(define-type LExp
  [varE (s : Symbol)]
  [lamE (x : Symbol) (body : LExp)]
  [appE (e : LExp) (arg : LExp)])

(define-type Value
  [funV (arg : Symbol) (body : LExp)])

(define (get-arg v)
  (type-case Value v
    [(funV arg body) arg]))

(define (get-body v)
  (type-case Value v
    [(funV arg body) body]))

; perform e1[x |-> e2]
(subst : (LExp Symbol LExp -> LExp))
(define (subst e1 x e2)
  (type-case LExp e1
    [(varE s) (if (symbol=? s x) e2 (varE s))]
    [(lamE id body) (if (symbol=? x id) (lamE id body) (lamE id (subst body x e2)))]
    [(appE e1App e2App) (appE (subst e1App x e2) (subst e2App x e2))]))

(define (interp e)
  (type-case LExp e
    [(varE s) (error 'runtime "unbound symbol")]
    [(lamE id body) (funV id body)]
    [(appE e1 e2)
     ; run e1 to get (lambda (id) body)
     ; run e2 to get a value argV
     ; run body[id |-> v]
     (letrec [(e1V (interp e1))
              (body (get-body e1V))
              (id (get-arg e1V))
              (argV (interp e2))]
       (interp (subst body id (lamE (get-arg argV) (get-body argV)))))]))
  • We can try this on a few inputs:
(interp (appE (lamE 'y (varE 'y)) (lamE 'x (varE 'x))))
- Value
(funV 'x (varE 'x))
  • Here is a very interesting example called Omega, which runs forever!
(define om (lamE 'x (appE (varE 'x) (varE 'x))))
(interp (appE om om))
  • Note: we can write let in terms of lambda!
(let [(x 10)]
  (+ 1 x))
=
((lambda (x) (+ 1 x)) 10)
  • In general, we can translate (let1 ('x e1) e2) into a lambda term (lambda (x) e2) e1