COS 441 - Reduction Semantics - Mar 5, 1996

A reduction semantics or rewriting semantics defines an an evaluation function eval for expressions based on a notion of reduction. We say that an expression e evaluates to an answer a if and only if e reduces to a:
    eval(e) = a   if and only if   e ->* a.
The reduction relation ->* is the reflexive and transitive closure of the simpler reduction relation ->. That is, ->* consists of zero or more -> steps.

To define ->, we need some preliminary definitions. Expressions e and values v are:

    e ::= v | x | (e e)
    v ::= c | (lambda (x) e)
Evaluation contexts E are expressions with a hole, denoted []. We can define evaluation contexts inductively:
    E ::= [] | (E e) | (v E) | (if E e e)
For Scheme where applications may have more than one argument, we have
    E ::= [] | (v* E e*) | (if E e e).

A redex is one of the following

    ((lambda (x) e) v)
    (if v e2 e3)
    (add1 n)
    ...   other primitives 
Now we can define the reduction relation ->:
    E[((lambda(x) e) v)] -> E[e[x |-> v]] (substitution avoiding capture)
    E[(if v e2 e3)]      -> E[e3] if v = #f
    E[(if v e2 e3)]      -> E[e2] if v != #t
    E[(add1 n)]          -> E[n+1]

Lemma: For all closed e, either e = v or there is a unique E and redex such that e = E[redex].

Lemma: For e closed, if e -> e' then e' is closed.

From these two lemmas, we get the following rather desirable property:

Thm: The relation -> is a function.

This is good because it means that a program will always produce the same answer.

We have yet to formally define capture avoiding substitution |->. Let's do that:

                 c[x |-> v] = c
                 x[x |-> v] = x
                 y[x |-> v] = y   (y != x)
           (e1 e2)[x |-> v] = (e1[x |-> v] e2[x |-> v])
    (lambda (x) e)[x |-> v] = (lambda (x) e)
    (lambda (y) e)[x |-> v] = (lambda (z) e[y |-> z][x |-> v])   (z not in FV(e) or FV(v))
In the last clause, renaming y to z is how we avoid capturing free occurrences of y in v when substituting inside the (lambda (y) ...).

Now let's do an example:

     ((lambda (x) (x x)) (lambda (y) y y))
  -> (x x)[x |-> (lambda (y) (y y))] = ((lambda (y) (y y)) (lambda (y) (y y)))
  -> (y y)[y |-> (lambda (y) (y y))] = ((lambda (y) (y y)) (lambda (y) (y y)))
  -> ...
At each step above, the evaluation context E is just []. By the way, since names don't matter it is clear that we are right back where we started. This is an example of an infinite loop without using letrec.

Here's another example:

     (sub1 ((lambda (x) (add1 x)) 1))              E = (sub1 [])
  -> (sub1 (add1 x)[x |-> 1]) = (sub1 (add1 1))    E = (sub1 [])
  -> (sub1 2)                                      E = []
  -> 1

Scheme programs consist of more than simply single expressions. define allows us to make definitions at the top level scope. With this in mind we can augment our reduction rules to handle this situation.

    P ::= D* e
    D ::= (define x v*)

    D* E[((lambda (x) e) v)] -> D* E[e[x |-> v]
    D* E[(add1 n)]           -> D* E[n+1]
    D1*(define x v)D2* E[x]  -> D1*(define x v)D2* E[v]
This last rule allows "looking up" the name of a definition.

We now have reduction semantics sufficient to evaluate the following expression:

(define Pi
  (lambda (l)
    (if (null? l)
	(* (car l) (Pi (cdr l))))))

(Pi '(1 2))

E = ([] '(1 2))    :> D((lambda (l) ... ) '(1 2))
E = []             :> D(if (null? '(1 2)) 1 (* (car '(1 2)) (Pi (cdr '(1 2)))))
E = (if [] 1 ... ) :> D(if #f 1 (* ...))
                   :> D(* 1 (* 2 1))
We can also do the exact same thing for the CPS version of Pi.
(define CPS-Pi
  (lambda (l k)
    (if (null? l) (k 1)
        (CPS-Pi (cdr l) (lambda (v) (* (car l) v))))))

(CPS-Pi '(1 2) (lambda (x) x))

E = [] :> D((lambda (l) ...) '(1 2) (lambda (x) x))
E = [] :> (if (null? l) 1 (CPS-Pi ...))
E = [] :> (if #f 1 (CPS-Pi ...))
E = [] :> (CPS-Pi (cdr '(1 2)) (lambda (v) ...))
Note that E = [] throughout this reduction. This means that no context is built. A call that builds no context is called a tail call. A procedure whose recursive calls are all tail calls is tail recursive. Tail recursive procedures in Scheme are iterative, like loops, because Scheme implementations are required to build no context for tail calls. In other words, tail calls never return. In contrast, C does not usually compile tail calls in this way. Try writing addition as a tail recursive procedure using successor, and you will likely get a stack overflow.

Implementing CPS

The following is an algorithm for performing CPS.

    CPS: expr X expr -> expr

    CPS(x, k)              = (k x)
    CPS(c, k)              = (k c)
    CPS((lambda (x) e), k) = (k (lambda (x k) CPS(e, k)))
    CPS((e1 e2), k)        = CPS(e1, (lamdba (x) CPS(e2, (lambda (y) (x y k)))))
    CPS((if e1 e2 e3), k)  = CPS(e1, (lambda (x) (if x CPS(e2,k) CPS(e3,k))))
Many (but not all) compilers for advanced languages like Scheme and ML use this algorithm to transform all user programs into CPS as one step of compiling. Then all calls in a program are tail calls. Hence no call ever returns and procedure calls thus become jumps with arguments.