Theory and Implementation of a Functional Programming Language

Ari Lamstein^{∗}

October 2000

Abstract

The goal of this research is to design and implement a small functional programming language that incorporates some of the features that arise from the theoretical study of programming language semantics. We begin with the study of the λ-calculus, an idealized mathematical language. We present the language PPL, a strongly typed, call-by-name language which supports recursion and polymorphism. We describe a compiler of PPL into a low-level assembly language. The compilation is based on an abstract machine interpretation and includes a type inference algorithm.

1 Introduction

The foundations of functional programming languages lie in the untyped λ-calculus, which was developed by Church in 1932. It was developed with two mathematical goals in mind [1]:

1. To develop a general theory of functions.

2. To use that theory as a foundation for mathematics.

While the attempts at (2) were not successful, the λ-calculus developed as a rich theory of computation [1]. It is well-known that Turing’s notion of computability is equivalent to λ-definability; that is, the class of computable functions can be encoded in the λ-calculus [4]. As we will see, it is this fact coupled with its simple semantics that leads us to use the λ-calculus as a model for functional programming languages.

The principal difference between functional programming languages and their imperative counterparts (such as C or Java) is how programs written in them are viewed. Programs in imperative languages are viewed as a sequence of statements that operate by altering the content of the memory. Programs in functional languages, however, are viewed as expressions; their evaluation corresponds to the program’s execution.

∗This research was conducted under the supervision of Peter Selinger at the University of Michigan. The author can be contacted via email at ari@lamstein.com.

An effect of this is the strength of variable bindings. In functional languages such bindings are permanent, as they are in common mathematical notation. To say “let x = 5;” in a functional language such as ML means that one is forbidden to later assign another value to that variable. In an imperative language, however, variable bindings are considered “mutable,” as updating memory is a statement, and not an expression.

In a purely functional language an expression yields the same result each time it is evaluated. Statements in an imperative language, however, can behave differently at different times they are executed. This is because the statements can have “side effects”, such as reading and writing to memory.

Thus, constructs such as for and while loops are seldom used in functional languages, as the expressions in the loop body have the same result each time they are executed. Such loops are only useful in the presence of side effects, where one can alter a physical property of the computer, such as the screen or an address of memory.

By minimizing side effects, one uses a higher-level approach when writing algorithms. Rather than concerning oneself with how the computation is carried out, we instead consider a description of the result being computed. For example, consider writing the factorial function in ML:

let fac x = if (x=0) then 1 else (x * (fac (x-1)));

Here we just state the definition of the function. Now consider implementing the same function in an imperative style in C:

int fac (int x) {
  int result=1;
  while (x>0) {
     result = result*x;
     x = x - 1;
  }
return result;
}

Here we concern ourselves with how the actual computation is carried out: we allocate memory for a new variable, repeatedly change its value, and then return that final value. In ML we leave the process of allocating and updating memory to the compiler.

The primary benefit of imperative languages is speed. By directly manipulating memory and relying less on recursion, programs in imperative languages are often able to perform the same task in less time. But as computers become faster, this advantage is meaningful for a smaller number of programs. Also, compiler optimizations for functional languages sometimes produce code which is comparable to that obtained from imperative languages.

The primary benefits of functional languages are the clarity and small size of code needed to write an algorithm. Clarity is achieved by freeing the programmer from the low-level implementation of computation. One is rarely faced with the arduous task that is common when reading and writing code which is written in imperative languages: namely, keeping track of both the value and meaning of several variables.

Functional languages place no more restrictions on functions than on variables. One can pass functions to other functions, store them in data structures, or return them as the result of a function. One can even pass functions with an arbitrary number of their parameters instantiated.

The notion of passing a function with an arbitrary number of its parameters instantiated is related to curried notation. As an example, consider the function plus, which one normally thinks of as taking a pair and returning a value; i.e. a function with the signature

                   plus:  (int * int) → int .

However, one can also think of addition as the application of two functions, each of which takes one argument. This yields the signature

                   plus:  int → (int → int) .

Here, for example, the result of providing 3 to plus is a function which takes an integer and adds 3 to it. Thus, (plus 3)5 = 8 and (plus 10)5 = 15. This transformation of a function which takes n arguments to the application of n functions, each of which takes one argument, is called currying. It is even possible to apply plus to just one argument, and pass the result (which is a function) as an argument to another function. This property allows one to create very robust libraries. For example, consider the library function map in ML. This function has the type signature

map : (α → β) → (α list ) → (β list ).

It takes two arguments, a function f from α to β, and a list of elements of type α. It returns a list of elements of type β, which is obtained by applying f to each element of the list. Here we use α and β to represent the fact that the map function is polymorphic, allowing one to substitute arbitrary types for α and β.

The above properties make many tasks, such as those involving list manipulation, relatively easy. As an example, suppose one wanted to increase the value of each element in a list of integers intlist by 3. One could do this by simply writing

           let list' = List.map (plus 3) intlist  ,

where plus is the curried addition function from above. This example is representative of how algorithms in functional languages are generally written: by the application of one or more functions to a data structure. This is a level of modularization which is not possible without higher order functions.

2 The Untyped λ-calculus

2.1 The Language

We now introduce the fundamental definitions of the λ-calculus. We refer the reader to [1, 8, 3] for a more comprehensive treatment.

Let V be a countably infinite set of variables (written x, y, z, …). The class of λ-terms consists of words constructed from the following alphabet:

• variables: x, y, z …

• lambda abstractor: λ

• parentheses: (, )

• period: .

Definition. The class Λ of λ-terms is the least class satisfying the following:

1. if x is a variable, then x ∈ Λ,

2. if M, N ∈ Λ, then (MN) ∈ Λ,

3. if M ∈ Λ, then (λx.M) ∈ Λ.

Terms of the form (MN) represent the application of the function M to the argument N. Terms of the form (λx.M) are called λ-abstractions, and denote the function that maps x to M. From the above definition, we can see that the following are λ-terms.

x
(xx)
(λy.(xx))
((λy.(xx))(λx.(xx)))

We will drop unnecessary parentheses from λ-terms by using the following conventions. Application associates to the left, and so MNP stands for ((MN)P). We write λx_{1}x_{2} … x_{n}.M for λx_{1}.(λx_{2} … (λx_{n}.M) …)

Application binds stronger than λ-abstraction, and so λx.MN means λx.(MN) and not (λx.M)N. Thus, the right-hand side of a λ-abstraction extends as far to the right as possible.

The operations which we will perform on λ-terms depend on the distinction between free and bound variables. Informally, in the term λx.M the variable x is said to be bound, and the subterm M is called the scope of the binding. A variable is called free if it is not bound.

Definition. The set of free variables of a λ-term is defined as follows:

\begin{aligned}F V (x) & = & x \\ F V (λx.M) & = & F V (M) \ \{x\} \\ F V (MN) & = & F V (M) ∪ F V (N) \end{aligned}

Note that a variable may appear both free and bound in a term, such as x in (λx.x)x. One can think of bound variables as local variables. The name of a bound variable

is not significant, as both λx.x and λy.y denote the same function. We will henceforth identify λ-terms which differ only by a renaming of bound variables. Such terms are called α-equivalent.

Definition. The terms M, N ∈ Λ are said to be α-equivalent, written M=_{α}N, if M and N are equal except possibly for the renaming of bound variables.

Thus, λx.xyz=_{α}λw.wyz but λx.xyz ≠_{α} λx.xwz as y is not bound in λx.xyz. Another example is λx.xyz ≠_{α} λy.yyz. Here the second term has a bound variable (the last y) which occurs free in the first term!

2.2 Substitution

Our primary interest with λ-terms will be computation, which we view as the evaluation of expressions. The first notion of evaluation we consider is that of β-reduction.

The term λx.M is an expression for the function which maps x to the term M. Here x may occur in M. In the context of programming languages, x is called the parameter of M. If the function λx.M is applied to the value N, then the result is M, with all occurrences of x replaced by N.

Suppose that f is the addition operation, written in prefix form, so that f 2 3 evaluates to 5. Then λx.fxx is the function which maps x to x + x, thus the “times two” function. If we evaluate (λx.fxx)3, we obtain f 3 3, or 6.

We write M[x := N] for the result of replacing the free variable x by N in the term M. This is the substitution of N for x in M, and is formally defined below.

Definition. The result of substituting N for the free occurrences of x in M, written M[x := N], is defined as follows.

1. x[x := N] = N

2. y[x := N] = y, if x ≠ y

3. (λy.M_{1})[x := N] = λy.(M_{1}[x := N]), provided x ≠ y and y ∉ F V (N).

4. (M_{1}M_{2})[x := N] = (M_{1}[x := N])(M_{2}[x := N])

The reason for the provision in (3) is that if we were to make the following substitution blindly:

λx.xyz[y := x] = λx.xxz

we would “gain” a bound variable. But this goes against the spirit of substitution, as the name of a bound variable should not affect the result of the substitution.

We solve this problem by introducing a new step in the substitution process. Because a name clash arises when we rename y as a bound variable, we first rename x as a variable which does not occur in λx.xyz. This new term is α-equivalent to the original one. Thus, the above substitution can be carried out as follows:

λx.xyz[y := x] =_{α} λk.kyz[y := x] = λk.kxz

2.3 β-Reduction

We define β-reduction with the idea of substitution and function application in mind.

We call a term of the form (λx.N)P a β-redex and say that it reduces to N[x := P]. We say that a term M ∈ Λ β-reduces to a term M′ in one step, written M →_{β} M′, if M′ is obtained from M by reducing a single redex that is a subterm of M. Formally, →_{β} is the smallest relation on terms such that

\begin{matrix}(λx.N)P →_{β} N[x := P] & \frac{M →_{β} M′}{MN →_{β} M′N} \\ \frac{N →_{β} N′}{MN →_{β} MN′} & \frac{M →_{β} M′}{λx.M →_{β} λx.M′} \end{matrix}

Definition. If M ∈ Λ has no subterms of the form (λx.N)P, then M is said to be in β-normal form.

Note that a normal form cannot be β-reduced any further.

We write →_{β}^{∗} for the reflexive transitive closure of →_{β}. Furthermore, we say that if M →_{β}^{∗} M′ and M′ is in normal form, then M′ is the result of β-reducing (or evaluating) M.

Recall our premise that the λ-calculus can be viewed as a prototype for functional programming languages. We now give examples of β-reduction which support this premise.

Example 2.1 (Identity). The term λx.x is the identity function.

Proof. This claim is only true if (λx.x)M →_{β}^{∗} M for all M ∈ Λ.

(λx.x)M →_{β} x[x := M] = M □

Example 2.2 (Boolean Values, Conditional). The λ-terms λx.(λy.x) and λx.(λy.y) correspond to the boolean values true and false, respectively. Let B be a boolean value. Then BMN is the conditional, meaning “if B is true then evaluate M, else evaluate N.”

Recall that BMN = (BM)N and means “first evaluate the application of B to M; apply that result to N”.

Proof. We first show that (λx.(λy.x))MN →_{β}^{∗} M for all terms M and N:

(λx.(λy.x))MN
→_{β} ((λy.x)[x := M])N = (λy.M)N
→_{β} M[y := N] = M

We must also show that (λx.(λy.y))MN →_{β}^{∗} N for all terms M and N.

(λx.(λy.y))MN
→_{β} ((λy.y)[x := M])N = (λy.y)N
→_{β} y[y := N] = N □

2.4 The Simply-Typed λ-Calculus

There are two main reasons for introducing types into a language: efficiency and safety. An example of efficiency occurs when one declares an array of 1,000 items. How can the computer know how much room to allocate for each item? If each item is to be an integer, then one would want 1,000 consecutive words; if each item is to be a double, then one would want 2,000 consecutive words. However, allocating memory “on the fly”, or allocating more memory than is necessary, is an inefficient solution. One declares the type of an array as an aid to the compiler.

Safety is another reason to introduce types. As an example, consider attempting to add anything other than two numbers. This results in undefined behavior, and so should not be allowed. A similar situation occurs when the types of parameters given to a function do not match those the function expects to receive. By providing a typechecking algorithm, the compiler can ensure that no such errors occur.

We present a type system for the λ-calculus which is known as the simply-typed system. This name distinguishes it from more powerful systems, such as polymorphic ones.

Definition. Let T be a countably infinite set of type variables (written α, β, …). The set of type expressions (written A, B, …) is given by the grammar:

Types A, B ::= α int bool A → B

Thus, types are built from type variables via the → constructor.

Recall that λx.M is a function which maps x to M. We recognize this by assigning λ-terms of this form the type A → B, where x is of type A and M is of type B. We introduce a notation to discuss types. The foundation of this notation is an assumption, which is written x : A and read “x is of type A”.

Definition. A context is a list of assumptions x_{1} : A_{1}, …, x_{n} : A_{n}, such that no variable appears more than once. We denote contexts by Γ. Typing judgments are written Γ ⊢ M : A, where Γ is a context, M is a term and A is a type. Judgments are read “in the context Γ, M is of type A.”

¿From these definitions we are able to make typing rules, which denote an if-then relationship between judgments. For example, the rule

\frac{x : A, Γ ⊢ M : B}{Γ ⊢ λx.M : A → B}

is read “if the assumption x : A and context Γ make M of type B, then Γ makes λx.M of type A → B.”

The simply-typed λ-calculus introduces a new restriction on function application. Recall that in the untyped λ-calculus function applications of the form MN can be done between any M and N. However, once one takes the consideration of types into account, such applications are only logical if M is of type A → B and N is of type A. That is, M must be of function type and N must be of the same type as M’s domain.

Table 1: Typing rules for the simply-typed λ-calculus

\overline{x : A, Γ ⊢ x : A}  \frac{x : A, Γ ⊢ M : B}{Γ ⊢ λx.M : A → B}  \frac{Γ ⊢ M : A → B  Γ ⊢ N : A}{Γ ⊢ MN : B}

The result of this application is a term of type B. Thus, we have the following judgment regarding application:

\frac{Γ ⊢ M : A → B Γ ⊢ N : A}{Γ ⊢ MN : B}

The complete typing rules for the simply-typed λ-calculus are showing in Table 1. A type derivation is the derivation of a typing judgment by repeated use of the

typing rules.

Definition. We say that a term M in the simply-typed λ-calculus is well-typed if there exists some typing derivation with Γ ⊢ M : A as its conclusion for some Γ and A.

The types that we have presented in this system are “implicit”. This means that terms are not annotated explicitly with their types; types are instead treated as a property of the terms, and it is possible for a term to have several types. For example, the identity function has the type A → A for any type A. One can even consider α → α to be the most general type of this term, and the types A → A and (B → C) → (B → C) to be instances of it. As we will see in Section 3.3, these concepts provide the foundation for type inference algorithms.

We now give examples of terms and their types. Example 2.3 (The Type of λx.(λy.x)).Recall from Example 2.2 that λx.(λy.x) represents the boolean value true. We wish to ascertain whether or not this term is welltyped. We first apply the rule for abstractions:

\frac{x : A, Γ ⊢ λy.x : B}{Γ ⊢ λx.(λy.x) : A → B}

Since λx.(λy.x) is an abstraction, it must have the type A → B for some types A and B. But this will only hold if λy.x has type B under the assumptions x : A and Γ. But as λy.x is a function, and the x in λy.x is bound to the outermost λ, the type B must in fact be C → A for some type C.

\frac{y : C, x : A, Γ ⊢ x : A}{\frac{x : A, Γ ⊢ λy.x : C → A}{Γ ⊢ λx.(λy.x) : A → (C → A)}}

Thus, λy.x has the type C → A if, under the assumption y : C, we have x : A, Γ ⊢ x : A. As all of the subterms of λx.(λy.x) can be assigned a type, the term is well-typed. □

Example 2.4 (An Untypable Term). Not all terms are well-typed; an example is the term xx. Recall that for applications MN the term M must have type A → B while the term N must have type A. But for M = N = x, this is impossible, as a variable may not have more than one type in the same expression. □

2.5 Summary

We can now list three ways in which the untyped λ-calculus relates to functional programming:

1. Functions can be passed as arguments to other functions. One example is

(λx.xy)(λz.z) →_{β} xy[x := λz.z] = (λz.z)y →_{β} z[z := y] = y.

2. Functions can be the results of functions or programs. One example is

(λz.z)(λx.xy) →_{β} z[z := λx.xy] = λx.xy.

The result is a function which takes one argument.

3. Functions can be passed (or returned) with some of their arguments already instantiated. For example, the λ-term

(λr.rs)((λxy.xy)z)

passes the function λxy.xy, with the variable x already mapped to z, to the function λr.rs.

3 The Language PPL

3.1 Definition of the Language

We now introduce the small functional language PPL. We denote PPL expressions by M or N. Their abstract syntax is given by the following grammar:

PPL Terms M, N ::= x n true false λx.M MN
let x = M in N let rec x = M in N
() (M_{1}, …, M_{n}) pi_{n}^{j} M

in_{n}^{j} M case M of in_{n}^{1} x_{1} ⇒ N_{1} | … | in_{n}^{n} x_{n} ⇒ N_{n}

We give a brief explanation of these terms. The term x denotes a variable and the term n denotes an integer constant. The terms true and false are the boolean values.

The term λx.M represents the function that maps x to M. The term MN denotes the application of the function M to the argument N.

The term (let x = M in N) is a binding, defining x to be an abbreviation for M inside the term N. The term (let rec x = M in N) is similar to (let x = M in N), except it also allows x to occur recursively in M.

The term () represents the unique 0-tuple. The PPL term (M_{1}, …, M_{n}) represents an n-tuple of PPL terms, where n ≥ 2. The term pi_{n}^{j} M is the j^{th} projection of the ntuple M. The term in_{n}^{j} M is a tagged term; it denotes the injection of M into the j^{th} component of n disjoint sets.

(case M of in_{n}^{1} x_{1} ⇒ N_{1} | … | in_{n}^{n} x_{n} ⇒ N_{n}) represents a case distinction on the tag of M. If M evaluates to a tagged term in_{n}^{j} P, then evaluate N_{j}, where x_{j} is replaced by P.

We now list the ways in which a variable occurs bound in PPL:

1. x occurs bound in the term λx.M. The scope of x is M.

2. x occurs bound in the term (let x = M in N). Here the scope of x is N.

3. x occurs bound in the term (let rec x = M in N). Here the scope of x is both M and N.

4. In the term

case M of in_{n}^{1} x_{1} ⇒ N_{1} | … | in_{n}^{n} x_{n} ⇒ N_{n},

the variables x_{1}, …, x_{n} occur bound in the terms N_{1}, …, N_{n}, respectively.

All other occurrences of variables in PPL terms are free.

We define α-equivalence and substitution for PPL terms by analogy with the λcalculus. We omit the formal definitions here. As before, we identify terms up to α-equivalence.

3.2 Call-by-Name Reduction

For the evaluation of PPL terms we adopt the call-by-name (CBN) strategy. The rules for this evaluation strategy are given in Table 2.

Definition. The PPL terms x, n, true, false, λx.M, (), (M_{1}, …, M_{n}) and in_{n}^{j} M are in CBN normal form.

Note that a CBN normal form, like a β-normal form, does not reduce any further. There are terms, however, which have no reduction but are not normal forms (e.g. M = pi_{n}^{j} true). We say that such terms cause run-time errors, and we write M →_{n} error.

The difference between CBN and β-reduction lies in when one reduces. In βreduction one reduces whenever possible. But in CBN reduction one never reduces inside an abstraction, injection, let, let rec, tuple, or the right-hand-side of a case. Also, in the term MN we reduce only M, and not N.

This last rule is the reason this semantics is called call-by-name (as opposed to callby-value). In passing N as a parameter to λx.M, we substitute N as an unevaluated term into the body of the function, rather than first ascertaining its value.

Table 2: Evaluation rules for PPL

(1)  (λx.M)N →_{n} M[x := N]

(2)  \frac{M →_{n} M′}{MN →_{n} M′N}

(3)  let x = M in N →_{n} N[x := M]
(4)  let rec x = M in N →_{n} N[x := let rec x = M in M]
(5)  pi_{n}^{i}(M_{1}, …, M_{n}) →_{n} M_{i}

(6)  \frac{M →_{n} M′}{pi_{n}^{i} M →_{n} pi_{n}^{i} M′}

(7) (case in_{n}^{i} M of in_{n}^{1} x_{1} ⇒ N_{1} | … | in_{n}^{n} x_{n} ⇒ N_{n}) →_{n} N_{i}[x_{i} := M]

(8)  \frac{M →_{n} M′}{case M of … →_{n} case M′ of …}

In the presence of predefined function symbols there will be additional evaluation rules and normal forms. For instance, if we add a basic function plus we need to add the following rules:

\frac{M →_{n} M′}{plusMN →_{n} plusM′N}  \frac{N →_{n} N′}{plusm N →_{n} plusm N′}  \overline{plusm n →_{n} m + n}

The meaning of these rules is as follows. Given the expression (plusMN), repeatedly reduce M until it is in normal form (an integer). Then reduce N until it is in normal form (an integer). When both arguments are in normal form, add them.

We also need to add terms of the form (plus) and (plusM) to the definition of CBN normal form, as they are functions which are both legal and should not be evaluated further. The situation for other function symbols is analogous.

We use →_{n}^{∗} to represent the reflexive transitive closure of →_{n}. We write M ⇓ M′ if M →_{n}^{∗} M′ and M′ is a normal form. We write M ⇓ error if there exists M′ such that M →_{n}^{∗} M′ and M′ →_{n} error.

The reduction rules have the following interesting property:

Lemma 3.1. The evaluation rules for PPL are deterministic. That is, for all PPL terms M there exists at most one M′ such that M →_{n} M′.

Proof. The proof is by induction on the structure of M.

Suppose M = x, n, λx.N, true, false, (), (M_{1}, …, M_{n}), or in_{n}^{j}. Then M is a normal form and does not reduce any further. The claim follows trivially.

In all other cases, suppose M →_{n} M′.

If M = NP then M′ is determined by the structure of N. If N is a normal form then M can only reduce by rule (1) from Table 2. Thus, M′ is uniquely determined by N and P. If N is not a normal form, then M can only reduce by rule (2). Thus, M′ = N′P, where N →_{n} N′. By the induction hypothesis, N′ is uniquely determined by N. It follows that M′ is uniquely determined as well.

If M = (let x = N in P), then M can only reduce by rule (3). Thus, M′ is uniquely determined. Similarly, if M = (let rec x = N in P) then the only rule that applies to M is (4). In this case M′ is uniquely determined as well.

If M = pi_{n}^{i} N then M′ is determined by the structure of N. If N is a normal form then M can only reduce by rule (5). Thus, M′ is uniquely determined by N. If N is not a normal form, then M can only reduce by rule (6). Thus, M′ = pi_{n}^{i} N′, where N →_{n} N′. By the induction hypothesis, N′ is uniquely determined by N. Thus, M′ is uniquely determined as well.

If M = (case N of …), then M′ is determined by the structure of N. If N is a normal form then M can only reduce by rule (7). Thus, M′ is uniquely determined by N. If N is not a normal form, then M can only reduce by rule (8). Thus, M →_{n} M′, where M′ = (case N′ of …). By the induction hypothesis, N′ is uniquely determined by N. Thus, M′ is uniquely determined as well. □

It follows from the lemma and the above remarks that for any term M, there exists at most one M′ ∈ (Terms ∪ error) such that M ⇓ M′.

3.3 Types In PPL

We introduce a type system for PPL which is analogous to the one defined for the simply-typed λ-calculus in Section 2.4.

Definition. Let T be a countably infinite set of type variables (written α, β, …). The set of type expressions for PPL is given by the grammar:

A, B ::= α int bool A → B A_{1} ∗ … ∗ A_{n} 1 A_{1} + … + A_{n} 0,

where n ≥ 2.

The type A → B is the type of functions which map values of type A to values of type B. The type A_{1} ∗ … ∗ A_{n} is the product of the types A_{1} through A_{n}. Its elements are n-tuples of the form (M_{1}, …, M_{n}), where each M_{i} is of type A_{i}. The type A_{1} + … + A_{n} is the disjoint union of the types A_{1} through A_{n}. Its elements are tagged terms of the form in_{n}^{j} M, where M is of type A_{j}. The type 0 is the empty type. The type 1 is the unit type; it has the unique element ().

The typing rules for simply-typed PPL are given in Table 3.

Note that some PPL expressions are well-typed in the empty context. For example, true is always of type bool, and similarly for false. As with the simply-typed λ-calculus, typing is implicit. This translates to programmers not needing to state the type of a variable or function; he need only use it consistently.

Table 3: The typing rules for PPL

\overline{x : A, Γ ⊢ x : A}

\frac{n ∈ IN}{Γ ⊢ n : int}

\overline{Γ ⊢ true : bool} Γ ⊢ false : bool

\frac{x : A, Γ ⊢ M : B}{Γ ⊢ λx.M : A → B}

\frac{Γ ⊢ M : A → B Γ ⊢ N : A}{Γ ⊢ MN : B}

\frac{Γ ⊢ M : A x : A, Γ ⊢ N : B}{Γ ⊢ let x = M in N : B}

\frac{x : A, Γ ⊢ M : A x : A, Γ ⊢ N : B}{Γ ⊢ let rec x = M in N : B}

\frac{Γ ⊢ M : A_{1} ∗ … ∗ A_{n}}{Γ ⊢ pi_{n}^{i} M : A_{i}}

\overline{Γ ⊢ () : 1}

\frac{Γ ⊢ M_{1} : A_{1} … Γ ⊢ M_{n} : A_{n}}{Γ ⊢ (M_{1}, …, M_{n}) : A_{1} ∗ … ∗ A_{n}}

\frac{Γ ⊢ M : A_{i}}{Γ ⊢ in_{n}^{i} M : A_{1} + … + A_{n}}

\frac{Γ ⊢ M : A_{1} + … + A_{n} x_{i} : A_{i}, Γ ⊢ N_{i} : C (for 1 ≤ i ≤ n)}{Γ ⊢ case M of in_{n}^{1} x_{1} ⇒ N_{1} | … | in_{n}^{n} x_{n} ⇒ N_{n} : C}

In the presence of predefined function symbols, there will be additional typing rules giving the type of each such symbol. For example,

Γ ⊢ plus : int → int → int

is the typing rule for plus.

3.4 The polymorphic type system

We now extend the type system with a form of polymorphism known as ML-polymor-phism. The polymorphic type system rests on the notion of type schemas, which are expressions of the form

∀α_{1} … ∀α_{n}.A.

Here α_{1}, …, α_{n} are type variables, n ≥ 0, and A is a type expression. Notice that quantifiers can occur only at the top level in a type schema; there are no quantifiers inside A. The type variables α_{1}, …, α_{n} are bound in the above type schema, and we identify type schemas up to the renaming of bound variables. We sometimes denote type schemas by S, T, . . ..

We write A[α_{1} := A_{1}, …, α_{n} := A_{n}] for the result of simultaneously substituting the type expressions A_{1}, …, A_{n} for the type variables α_{1}, …, α_{n}, respectively, in the type expression A. We say that the type expression B is a generic instance of the type schema ∀α_{1} … ∀α_{n}.A if B = A[α_{1} := A_{1}, …, α_{n} := A_{n}] for some A_{1}, …, A_{n}.

Before stating the polymorphic typing rules, we first generalize the definition of a context Γ. A polymorphic context is a list x_{1} : S_{1}, …, x_{n} : S_{n} of pairs of a variable and a type schema. The old definition of a context is a special case of this newer one, since a simple type (in the sense of the simply-typed system) is just a type schema with zero quantifiers. A polymorphic typing judgment is an expression of the form Γ ⊢ M : A, where Γ is a polymorphic context, M is a PPL term, and A is a type expression. It is important to note that quantifiers may occur in Γ, but not in A.

The rules for the polymorphic type system are identical to those for the simple type system, except for the rules for variables, “let”, and “let rec”. These modified rules are shown in Table 4. In the rules for “let” and “let rec”, α_{1}, …, α_{n} are type variables that are not free in Γ.

This type of polymorphism was first described for the programming language ML by Milner and Damas [2]. An important feature of this type system is that it is decidable. That is, a compiler can decide whether or not a given program is typable in this system. We describe such an algorithm for PPL programs in Section 4.

3.5 Type Soundness

We now formally state the relationship between PPL’s type system and the CBN reduction strategy. Before proceeding, we first prove a property about the substitution of PPL terms.

Table 4: The polymorphic typing rules for PPL

(1)  \overline{x : S, Γ ⊢ x : A} , where A is a generic instance of S

(2)  \frac{Γ ⊢ M : A  x : ∀α_{1} … ∀α_{n}.A, Γ ⊢ N : B}{Γ ⊢ let x = M in N : B} , where α_{1}, …, α_{n} not free in Γ

(3) \frac{x : A, Γ ⊢ M : A  x : ∀α_{1} … ∀α_{n}.A, Γ ⊢ N : B}{Γ ⊢ let rec x = M in N : B} , where α_{1}, …, α_{n} not free in Γ

Lemma 3.2 (Substitution). If x : ∀α_{1} … ∀α_{n}.B, Γ ⊢ N : A and Γ ⊢ P : B, where α_{1}, …, α_{n} ∉ F V (Γ), then Γ ⊢ N[x := P] : A.

Proof. The proof is by induction on N.

Suppose that N = x. We have assumed that x : ∀α_{1} … ∀α_{n}.B, Γ ⊢ x : A. From rule (1) in Table 4, it follows that A is a generic instance of ∀α_{1} … α_{n}.B. Thus, A = B[α_{1} := A_{1}, …, α_{n} := A_{n}] for some type expressions A_{1}, …, A_{n}.

We have also assumed that Γ ⊢ P : B. Since α_{1}, …, α_{n} are not free in Γ, we can replace all free occurences of α_{1}, …, α_{n} by the type expressions A_{1}, …, A_{n} in the derivation of Γ ⊢ P : B. The result is a derivation of Γ ⊢ P : A. Thus, Γ ⊢ x[x := P] : A as desired.

Suppose that N = n, true, false, () or y, where y is a variable and y ≠ x. Then x ∉ F V (N), and so N = N[x := P]. The claim follows.

Suppose that N = λy.M and N : ∀α_{1} … ∀α_{n}.B, Γ ⊢ x : A. Without loss of generality, y ≠ x. From Table 3 we know that A = C → D and y : C, x : ∀α_{1} … ∀α_{n}.B, Γ ⊢ M : D for some type expressions C and D. We want to show that Γ ⊢ (λy.M)[x := P] : C → D. But (λy.M)[x := P] = λy.(M[x := P]), and we know that y : C, Γ ⊢ M[x := P] : D from the induction hypothesis. Thus, Γ ⊢ (λy.M)[x := P] : C → D, as desired. The remaining cases are similar to this. □

Lemma 3.3 (Subject Reduction). If Γ ⊢ M : A and M →_{n} M′, then Γ ⊢ M′ : A.

Proof. The proof is by induction on the derivation of M →_{n} M′. There is a case for each rule in Table 2.

Suppose that M = (λx.N)P and M′ = N[x := P]. By assumption, Γ ⊢ (λx.N)P : A. From the typing rules in Table 3, it follows that x : B, Γ ⊢ N : A and Γ ⊢ P : B for some type expression B. It follows from Lemma 3.2 that Γ ⊢ N[x := P] : A.

Suppose that M = NP and M′ = N′P, where N →_{n} N′. From the rules in Table 3, we can tell that Γ ⊢ N : B → A and Γ ⊢ P : B for some type expression B. From the induction hypothesis we know that Γ ⊢ N′ : B → A. It follows that Γ ⊢ M′ : A.

Suppose that M = (let x = P in N) and M′ = N[x := P]. By assumption, Γ ⊢ (let x = P in N) : A. From the polymorphic typing rules in Table 4, we know that Γ ⊢ P : B and x : ∀α_{1} … ∀α_{n}.B, Γ ⊢ N : A, for some α_{1}, …, α_{n} not free in Γ. It follows from Lemma 3.2 that Γ ⊢ N[x := P] : A.

Suppose that M = (let rec x = P in N) and M′ = N[x := (let rec x = P in P)]. By assumption, Γ ⊢ (let rec x = P in N) : A. From the typing rules in Table 4, we can tell that x : B, Γ ⊢ P : B and x : ∀α_{1} … ∀α_{n}.B, Γ ⊢ N : A, for some α_{1}, …, α_{n} not free in Γ. Also from Table 4, we know that Γ ⊢ (let rec x = P in P) : B. It follows from Lemma 3.2 that Γ ⊢ N[x := (let rec x = P in P)] : A.

The remaining cases are similar to the ones proved above. □

Lemma 3.4 (Correctness). If ⊢ M : A then M ↛_{n} error.

Proof. The proof is by induction on M.

Suppose that ⊢ M : A and M is not a normal form. We must show that M →_{n} M′ for some M′. Since M is not a normal form, we only need to consider the cases where M is an application, a “let” or “let rec” expression, a projection or a case distinction.

Suppose that M = NP. By assumption, ⊢ M : A. From the typing rules in Table 3 we know that ⊢ N : B → A and ⊢ P : B for some type expression B. If N is not a normal form, we have N →_{n} N′ for some N′ by the induction hypothesis. In this case, M reduces by rule (2) in Table 2. If N is a normal form, however, it follows from Table 3 that N must be a variable or a λ-abstraction. But since ⊢ N : B → A in the empty context, N cannot be a variable. Thus, N = λx.Q for some variable x and term Q. It follows that M →_{n} Q[x := P].

Suppose that M = (let x = P in N). Then M can be reduced directly from rule 3 in Table 2. The case for M = (let rec x = P in N) is similar to this.

Suppose that M = pi_{n}^{i} N. By assumption, ⊢ M : A. If N is not a normal form, we have N →_{n} N′ for some N′ by the induction hypothesis. In this case, M reduces by rule (6) in Table 2. If N is a normal form, however, then from the rules in Table 3 we know that ⊢ N : A_{1} ∗ … ∗ A_{n} and A_{i} = A. Table 3 also tells us that the only normal forms of type A_{1} ∗ … ∗ A_{n} are variables and n-tuples. But since ⊢ N : A_{1} ∗ … ∗ A_{n} in the empty context, N cannot be a variable. Thus, N = (N_{1}, …, N_{n}). It follows that M →_{n} N_{i}.

Suppose that M = (case N of in_{n}^{1} x_{1} ⇒ N_{1} | … | in_{n}^{n} x_{n} ⇒ N_{n}). Recall that ⊢ M : A. From the rules in Table 3 we know that ⊢ N : A_{1} + … + A_{n}. If N is not a normal form, we have N →_{n} N′ for some N′ by the induction hypothesis. In this case, M reduces by rule (8) in Table 2. If N is a normal form, however, then Table 3 tells us that N must be a variable or injection. But since ⊢ N : A_{1} + … + A_{n} in the empty context, N cannot be a variable. Thus, N = in_{n}^{j} P for some j. It follows that M →_{n} N_{i}[x_{i} := M]. □

Proposition 3.5 (Type Soundness). If ⊢ M : A then M ↛_{c}^{∗} error.

Proof. Suppose that ⊢ M : A and M →_{c}^{∗} M′. By repeated application of Lemma 3.3, we know that ⊢ M′ : A. From Lemma 3.4 we have M′ ↛_{n} error. Therefore M ↛_{c}^{∗} error. □

4 Type Inference

Recall that PPL is both a polymorphic and a functional language. In Section 1 we demonstrated that this combination allows one to create very robust higher-order functions. However, these functions raise a problem of their own: explicitly annotating their types is an error-prone endeavor.

For example, consider the exists2 function in ML. This function takes a list a of type α and a list b of type β, as well as a function of type α → β → bool. The function returns true if f a_{i} b_{i} = true for any a_{i} and b_{i}, where a_{i} is the i^{th} element of list a. It returns false otherwise. The type signature of this function is

             (α → β → bool ) → α list → β list → bool .

Now consider writing a function xorexists2 which, given two lists a and b, and two functions f_{1} and f_{2}, returns true if (exists2 f a b) holds for exactly one of f = f_{1} and f = f_{2}. The code for this function is very succinct:

let xorexists2 f1 f2 a b = xor (exists2 f1 a b)
                                   (exists2 f2 a b),

where xor is the exclusive or function. But this function generates the following signature:

    (α → β → bool ) → (α → β → bool ) → α list → β list → bool

The signature for this function is as long as (and more opaque than) the function itself! Writing such signatures is something which one would like to avoid if possible.

In Section 2.4 we discussed the importance of a compiler knowing the type signature of functions. We have just shown, however, that it is not always desirable to have the programmer explicitly declare the signature of her functions. The ideal situation appears to involve the compiler inferring the types of the variables and functions in a program. This would give us both the safety which type-checking algorithms provide as well as free us from writing extensive annotations. There is also the hope that the compiler can infer more general types than the programmer can.

4.1 Unification

We begin our discussion of polymorphic type inference with a few definitions.

Definition. A type substitution is a function σ from variables to type expressions. If A is a type expression, then we write σ(A) for the result of applying σ to each type variable in A. If σ and τ are type substitutions, we define their composition τ ∘ σ to be the type substitution which maps each type variable α to τ(σ(α)). A unifier of two type expressions A and B is a substitution σ such that σ(A) = σ(B).

Here are some examples of unifiers:

1. Let A = α → β and B = β → γ. Then a unifier for A and B is σ = {α ↦ α, β ↦ α, γ ↦ α}. Verify that σ(A) = σ(B) = α → α.

2. Let A = (β ∗ β) → β and B = α → (γ ∗ γ). Then a unifier for A and B is σ = {α ↦ ((γ ∗ γ) ∗ (γ ∗ γ)), β ↦ (γ ∗ γ), γ ↦ γ}. Verify that σ(A) = σ(B) = ((γ ∗ γ) ∗ (γ ∗ γ)) → (γ ∗ γ).

3. Let A = α and B = α → β. Then A and B do not have a unifier. A proof of this can be done by contradiction. Suppose σ is a unifier of A and B. Then σ(α) = C and σ(β) = D for some type expressions C and D. But σ(A) = C while σ(B) = C → D. Thus, σ(A) ≠ σ(B).

4. Let A = α ∗ β and B = γ → δ. Then A and B do not have a unifier. A proof of this can be done by contradiction. Suppose σ is a unifier of A and B. Then σ(α) = C, σ(β) = D, σ(γ) = E and σ(δ) = F for some type expressions C, D, E and F. But σ(A) = C∗D while σ(B) = E → F. Thus, σ(A) ≠ σ(B).

As the above examples show, not all terms can be unified.

If σ and σ′ are type substitutions, then we say that σ is more general than σ′ if there exists a substitution τ such that σ′ = τ ∘ σ. A unifier σ of A and B is called a most general unifier of A and B if it is more general than any other unifier of A and B.

An interesting property of most general unifiers is that if two type expression have any unifier, then they also have a most general one. Furthermore, the most general unifier can be computed efficiently by a unification algorithm which was originally published by Robinson [9].

4.2 Polymorphic Type Inference

Let S be a type schema. We write σ(S) for the result of applying σ to each free type variable in S. Here we rename bound variables as necessary to avoid name clashes, as was done for λ-terms in Section 2.2. If Γ is a context, we write σ(Γ) for the application of σ to each type schema in Γ.

The behavior of the type inference algorithm is as follows: when given a PPL program M, it returns the most general type A such that M : A is well-typed. If no such type A exists, the algorithm fails.

More generally, we provide an algorithm for the following problem: given a typing judgment Γ ⊢ M : A, find a substitution σ such that σ(Γ) ⊢ M : σ(A) is well-typed according to the polymorphic typing rules. If such a substitution exists, return the most general one. Otherwise, fail. This algorithm for polymorphic type inference was first discovered by Milner [7] and was later refined by Damas and Milner [2].

The algorithm works by recursion on the structure of the term M. In each case, the algorithm corresponds to a “bottom-up” reading of the typing rules in Tables 3 and 4. Given a typing judgment Γ ⊢ M : A, we find the substitution σ as follows. Here we list the cases for M = true, false, n, x, λx.N, NP, (let x = N in P) and (let rec x = N in P). The other cases proceed by similar reasoning. If any of the mentioned unifiers do not exist, the algorithm fails.

1. If M = true or false, then let σ be the most general unifier of A and bool.

2. If M = n, n ∈ IN, then let σ be the most general unifier of A and int.

3. If M = x, find the assumption x : S in Γ. Let B be a fresh generic instance of the schema S, and let σ be the most general unifier of A and B.

4. If M = λx.N, let α and β be fresh type variables. Applying the algorithm recursively, find the most general substitution τ such that τ makes Γ, x : α ⊢ N : β well-typed. Let τ′ be the most general unifier of A and α → β. Let σ = τ′ ∘ τ.

5. If M = NP, let β be a fresh type variable. Applying the algorithm recursively, find the most general substitution τ such that τ makes Γ ⊢ N : β → A welltyped. Let Γ′ = τ(Γ) and B′ = τ(β). Let τ′ be the most general substitution that makes Γ′ ⊢ P : B′ well-typed. Let σ = τ′ ∘ τ.

6. If M = (let x = N in P), let γ be a fresh type variable. Applying the algorithm recursively, find the most general substitution τ such that τ makes Γ ⊢ N : γ well-typed. Let Γ′ = τ(Γ), A′ = τ(A), C′ = τ(γ) and let β_{1}, …, β_{n} be the type variables which are free in C′ but not in Γ′. Let τ′ be the most general substitution that makes x : ∀β_{1} … β_{n}.C′, Γ′ ⊢ P : A′ well-typed. Let σ = τ′∘τ.

7. If M = (let rec x = N in P), let γ be a fresh type variable. Applying the algorithm recursively, find the most general substitution τ such that τ makes x : γ, Γ ⊢ N : γ well-typed. Let Γ′ = τ(Γ), A′ = τ(A), C′ = τ(γ) and let β_{1}, …, β_{n} be the type variables which are free in C′ but not in Γ′. Let τ′ be the most general substitution that makes x : ∀β_{1} … β_{n}.C′, Γ′ ⊢ P : A′ well-typed. Let σ = τ′ ∘ τ.

5 Abstract Machine Interpretation

5.1 Definition of the Abstract Machine

We now discuss a method of implementing the language PPL. As a functional language, we wish to be able to pass functions as parameters to other functions with some (possibly none) of their parameters instantiated. The syntax of an earlier example of β-reduction leads us to an idea of how to accomplish this:

(λr.rs)((λxy.xy)z) →_{β} rs[r := (λxy.xy)z] = ((λxy.xy)z)s.

That is, when given the function λr.rs we evaluate the term rs while also keeping track of what its free variables map to.

Our implementation of PPL is based on the above idea. But rather than directly keep track of what the variables map to, we instead use a level of indirection, and keep a list of pointers (addresses) to what the variables map to. We first describe the implementation in terms of an abstract machine, in the style of Krivine [5].

Definition. Let A be a countable set of addresses (written a_{1}, a_{2}, …). A term closure is a pair {M, σ}, where M is a PPL term, σ is a partial function from variables to A, and F V (M) ⊆ dom(σ). A match closure is a pair {(in_{n}^{1} x_{1} ⇒ N_{1} | … | in_{n}^{n} x_{n} ⇒ N_{n}), σ} with analogous definitions and provisions for σ.

A heap is a partial function from addresses to closures. A stack is a list whose elements are addresses, integers or labels.

A state of the abstract machine is a triple ⟨{M, σ}, h, κ⟩, where {M, σ} is a closure, h is a heap and κ is a stack.

We write nil for the empty stack and t :: κ for the stack with topmost element t and remaining elements κ. We write σ(x ↦ a) for the partial function which is identical to σ except that it also maps x to a. We use a similar notation for heaps and write |κ| for the size of the stack κ.

The transition relation of the abstract machine is denoted by →_{m}. We write

⟨{M, σ}, h, κ⟩ →_{m} ⟨{M′, σ′}, h′, κ′⟩

if the abstract machine can go from the state ⟨{M, σ}, h, κ⟩ to the state ⟨{M′, σ′}, h′, κ′⟩ in a single step.

We write ⟨{M, σ}, h, κ⟩ →_{m} halt(s) when the abstract machine halts with result s. The possible results are:

s ::= n true false “fn” “()” “n-tuple” “inj/n”,

where n and j are integers.

Table 5 contains the list of the abstract machine rules for all PPL terms. Here we explain the rules for λx.M, MN and x.

The rules for evaluating a λ-abstraction λx.M are:

⟨{λx.M, σ}, h, a :: k⟩ →_{m} ⟨{M, σ(x ↦ a)}, h, κ⟩.
⟨{λx.M, σ}, h, nil⟩ →_{m} halt(“fn”).

In order to evaluate λx.M, the machine pops the address for a closure from the stack and evaluates M under σ plus the additional binding (x ↦ a). Here a is the address of x’s closure. If the stack is empty, however, the program halts. In this case the result of the program is a function. Thus, abstractions can be thought of as pops from the stack.

The rules for evaluating an application MN are:

⟨{MN, σ}, h, κ⟩ →_{m} ⟨{M, σ}, h(a ↦ {N, σ}), a :: κ⟩, where a is fresh.

⟨{Mx, σ}, h, κ⟩ →_{m} ⟨{M, σ}, h, a :: κ⟩, where σ(x) = a.

Recall that we are using the call-by-name reduction strategy. The abstract machine implements this by not evaluating N before the function call. Since N may be evaluated later, however, we must save the mapping of its variables. We accomplish this by building a closure for N on the heap and pushing its address onto the stack. Thus, applications can be thought of as pushes onto the stack. In the case where the argument is a variable x, we use an optimization: instead of building a new closure for x, we use the already existing closure σ(x).

The rule for evaluating a variable is:

⟨{x, σ}, h, κ⟩ →_{m} ⟨{M, τ}, h, κ⟩, where σ(x) = a and h(a) = {M, τ}.

Variables in PPL only have meaning in the context of a closure. Evaluating a variable can be thought of as a jump to its closure.

Table 5: Abstract machine rules for PPL

• ⟨{x, σ}, h, κ⟩ →_{m} ⟨{M, τ}, h, κ⟩, where σ(x) = a, and h(a) = {M, τ}.

• ⟨{Mx, σ}, h, κ⟩ →_{m} ⟨{M, σ}, h, a :: κ⟩, where σ(x) = a.

• ⟨{MN, σ}, h, κ⟩ →_{m} ⟨{M, σ}, h(a ↦ {N, σ}), a :: κ⟩, where N is not a variable and a is fresh.

• ⟨{λx.M, σ}, h, a :: κ⟩ →_{m} ⟨{M, σ(x ↦ a)}, h, κ⟩.

• ⟨{λx.M, σ}, h, nil⟩ →_{m} halt(“fn”).

• ⟨{let x = M in N, σ}, h, κ⟩ →_{m} ⟨{N, σ(x ↦ a)}, h(a ↦ {M, σ}), κ⟩, where a is fresh.

• ⟨{let rec x = M in N, σ}, h, κ⟩ →_{m} ⟨{N, σ(x ↦ a)}, h(a ↦ {M, σ(x ↦ a)}), κ⟩, where a is fresh.

• ⟨{n, σ}, h, nil⟩ →_{m} halt(n).

• ⟨{true, σ}, h, nil⟩ →_{m} halt(true).

• ⟨{false, σ}, h, nil⟩ →_{m} halt(false).

• ⟨{(), σ}, h, nil⟩ →_{m} halt(“()”).

• ⟨{(M_{1}, …, M_{n}), σ}, h, nil⟩ →_{m} halt(“n-tuple”).

• ⟨{(M_{1}, …, M_{n}), σ}, h, j :: κ⟩ →_{m} ⟨{M_{j}, σ}, h, κ⟩, if 1 ≤ j ≤ n.

• ⟨{pi_{n}^{j} M, σ}, h, κ⟩ →_{m} ⟨{M, σ}, h, j :: κ⟩.

• ⟨{in_{n}^{j} M, σ}, h, nil⟩ →_{m} halt(“inj/n”).

• ⟨{in_{n}^{j} M, σ}, h, a :: κ⟩ →_{m} ⟨{λx_{j}.N_{j}, τ}, h(b ↦ {M, σ}), b :: κ⟩, where h(a) = {in_{n}^{1} x_{1} ⇒ N_{1} | … | in_{n}^{n} x_{n} ⇒ N_{n}, τ} and b is fresh.

• ⟨{case M of in_{n}^{1} x_{1} ⇒ N_{1} | … | in_{n}^{n} x_{n} ⇒ N_{n}, σ}, h, κ⟩ →_{m} ⟨{M, σ}, h(a ↦ {in_{n}^{1} x_{1} ⇒ N_{1} | … | in_{n}^{n} x_{n} ⇒ N_{n}, σ}), a :: κ⟩, where a is fresh.

Table 6: Abstract machine rules for “plus”

1. ⟨{plus, σ}, h, κ⟩ →_{m} halt(“fn”) , where |κ| < 2.

2. ⟨{plus, σ}, h, a :: b :: κ⟩ →_{m} ⟨{M, τ}, h, plus_{1} :: b :: κ⟩ , where h(a) = {M, τ}.

3. ⟨{m, σ}, h, plus_{1} :: b :: κ⟩ →_{m} ⟨{N, τ}, h, plus_{2} :: m :: κ⟩ , where h(b) = {N, τ}.

4. ⟨{n, σ}, h, plus_{2} :: m :: κ⟩ →_{m} ⟨{m + n, σ}, h, κ⟩.

In addition to the rules shown in Table 5, there are also rules for each basic function symbol; a list of all basic function symbols can be found in Section 7. We give the rules for “plus” in Table 6.

Rule 1 allows “plus” and “plus M” to be legal PPL programs. Both of these expression are functions and cannot be evaluated further.

One need not pass a number to “plus”; one can also pass a term which evaluates to a number. Rule 2 states that if {plus, σ} is to be evaluated with the addresses a and b as the topmost elements of the stack, the machine pops a from the stack, pushes the label “plus_{1}” onto it, and evaluates the closure which a points to. Let the result of evaluating M be m. At this point Rule 3 applies, and the machine pops the label “plus_{1}” and the address b from the stack and pushes m and the label “plus_{2}” onto it. The machine then evaluates the closure which b points to. Let the result of evaluating N be n. At this point Rule 4 applies. The machine then pops two elements from the stack and computes m + n.

5.2 Properties of the Abstract Machine

The transition rules for the abstract machine given in Table 5 are deterministic. That is, for each state there is at most one successor or “halt” state.

We write ⟨{M, σ}, h, κ⟩ →_{m} error when ⟨{M, σ}, h, κ⟩ cannot legally halt or move to another state. We write →_{m}^{∗} for the reflexive transitive closure of →_{m}.

Examples of terms M for which ⟨{M, σ}, h, κ⟩ →_{m}^{∗} error are M = pi_{2}^{1} false and M = 5 true. The reader should verify that these terms result in an error, and also that they are not well-typed. In fact, we can state the relationship between the abstract machine and CBN-reduction more generally as follows:

Proposition 5.1. Let M be a closed PPL program, and let σ and h be arbitrary. Then

1. ⟨{M, σ}, h, nil⟩ →_{m}^{∗} halt(n) iff M →_{c}^{∗} n.

2. ⟨{M, σ}, h, nil⟩ →_{m}^{∗} halt(true) iff M →_{c}^{∗} true.

3. ⟨{M, σ}, h, nil⟩ →_{m}^{∗} halt(false) iff M →_{c}^{∗} false.

4. ⟨{M, σ}, h, nil⟩ →_{m}^{∗} halt(“fn”) iff M →_{c}^{∗} λx.N, for some N (or, in the presence of basic functions, M →_{c}^{∗} M′ for some other normal form M′ of function type, such as (plus N)).

5. ⟨{M, σ}, h, nil⟩ →_{m}^{∗} halt(“()”) iff M →_{c}^{∗} ().

6. ⟨{M, σ}, h, nil⟩ →_{m}^{∗} halt(“n-tuple”) iff M →_{c}^{∗} (N_{1}, …, N_{n}) for some N_{1}, …, N_{n}.

7. ⟨{M, σ}, h, nil⟩ →_{m}^{∗} halt(“inj/n”) iff M →_{c}^{∗} in_{n}^{j} N, for some j, n and N.

8. ⟨{M, σ}, h, nil⟩ →_{m}^{∗} error iff M →_{c}^{∗} error.

Corollary 5.2. If Γ ⊢ M : A then ⟨{M, σ}, h, nil⟩ ↛_{m}^{∗} error.

Proof. From Proposition 3.5 we know that Γ ⊢ M : A implies M ↛_{c}^{∗} error. From Proposition 5.1 we know that ⟨{M, σ}, h, nil⟩ →_{m}^{∗} error if and only if M →_{c}^{∗} error. Thus, ⟨{M, σ}, h, κ⟩ ↛_{m}^{∗} error. □

6 A Compiler for PPL

6.1 An Idealized Assembly Language

In this section we describe an idealized assembly language which will be the target language of the compiler. Its chief differences to actual assembly languages are that we assume an infinite number of registers, and that we have a built-in opcode for dynamic memory allocation. (This is usually realized by an operating system call). We denote registers by V, C, r_{1}, r_{2}, . . .. Each register can hold an integer or a pointer. There is a special register SS which holds the current stack size.

Memory locations can also be denoted by symbolic labels, which we denote by l_{1}, l_{2}, . . .. An l-value (assignable value) is either a register or a memory location [r, n] specified by a register r and an integer offset n. The expression [r, n] refers to the memory location n words after that pointed to by the register r. A symbolic value is either an l-value or a literal value. A literal value is either an integer constant, written #n, or a label l.

The assembly language has the following opcodes. Here v ranges over values, lv over l-values, and s ranges over results (as defined for the abstract machine above).

Opcode  Meaning
jump v  Jump to address v
exit s  Exit and print string s
exitint v Exit with integer result v
exitbool v Exit with boolean result false (v = 0) or true (v ≠ 0).
load lv, v Store the value v in location lv
pop lv  Pop a value from the stack and store it in lv
push v  Push the value v onto the stack
alloc lv, v Allocate v words of memory and store a pointer to the first one in lv
cmp v_{1}, v_{2} Compare the two values, and remember the result
be v  If cmp results in equal, jump to location v
bg v  If cmp results in greater than, jump to location v
bl v  If cmp results in less than, jump to location v
bne v  If cmp results in not equal, jump to location v
bge v  If cmp results in greater than or equal jump to location v
ble v  If cmp results in less than or equal to, jump to location v

There are also opcodes to calculate basic functions, such as add lv, v (add v to lv) and mult lv, v (multiply lv by v).

6.2 The Translation of PPL Terms

We now explain how PPL terms are represented in the assembly language. An address is represented as a pointer, and integers are represented as themselves. Boolean values are also represented as integers, where false = 0 and true = 1. The heap is represented as allocated memory, and the stack of the abstract machine is represented by the stack in the assembly language. A PPL term is represented as assembly code.

A closure {M, σ} (where F V (M) = x_{1}, …, x_{k}) is represented by a data structure that occupies k + 1 words of memory. At offset 0 is a pointer to the code for M, and at offsets 1 through k are pointers to the values for x_{1}, …, x_{k}, respectively.

A match closure {(in_{n}^{1} x_{1} ⇒ N_{1} | … | in_{n}^{n} x_{n} ⇒ N_{n}), σ} (where the free variables of the matching are x_{1}, …, x_{k}) is represented as a data structure that occupies k + n words of memory. At offsets 0 through (n − 1) are pointers to the code for λx_{i}.N_{i}, where 1 ≤ i ≤ n. At offsets n through (n + k − 1) are pointers to the values for x_{1}, …, x_{k}, respectively.

A term closure is invoked by putting a pointer to the closure into the special register C, and then jumping to the address at offset 0. Similarly, the j^{th} branch of a match closure is invoked by putting a pointer to the match closure into C and then jumping to the address at offset j − 1.

If an integer or a boolean value has been computed, the convention is to put the value in the special register V and then jump to the address on top of the stack. If the stack is empty, then V is the final result of the program.

The translation of a PPL term M is defined relative to a symbol table s, which is a function that maps the free variables of M to symbolic values. The notation [[M]]_{s} means “the assembly code for the PPL expression M, where the value of each free variable x is found in s(x).”

The definition of [[M]]_{s} for all PPL terms M is given in the Appendix. Compare these definitions with the abstract machine rules for PPL terms given in Table 5. Here we explain the definition of [[λx.M]]_{s}.

[[λx.M]]_{s} =

cmp SS, #0
bg l
exit  “fn”

l : pop r [[M]]_{s(x↦r)}

where l is a new label and r is a new register.

Recall that if the stack is empty then λx.M is the result of the program, and the program returns “fn”. If the stack is not empty, however, the machine pops the address of a closure from the stack and evaluates M under the current symbol table plus the mapping of x to the address just popped.

Note that all assembly code consists of a number of labeled segments, each of which ends in a jump or an exit .

The code for the basic functions is more complex than that of the basic PPL terms. We give the assembly code for plus in Table 7. Its relation to the abstract machine rules is as follows.

The code for “plus” checks to see if there are less than two elements on the stack; if so, it halts with the result “fn”. Thus, “plus” tests if rule 1 from Table 6 applies.

If rule 1 does not apply then rule 2 does; rule 2 is realized at the label plus_{0}. Here the machine pops the address of the closure for the first argument to the register C. It then pushes the label plus_{1} onto the stack and invokes the closure pointed to by C. The label plus_{1} functions as the return address of a subroutine call; the machine will jump to the label plus_{1} after it is done evaluating the first closure. By convention, the value which the closure evaluates to is now stored in the register V.

Now the machine pops the address of the closure for the second argument to plus to the register C, and saves the value of V and the return address plus_{2} on the stack. Then it invokes the closure at C. The reader should verify that this behavior corresponds to Rule 3.

When the second closure has been evaluated, the value of the second argument is in the register V, and the value of the first argument is on top of the stack. The code pops the topmost element from the stack, adds the two arguments, and puts the result in V. This behavior corresponds to Rule 4. Finally, the code follows the standard convention for integer results by jumping to the return address on top of the stack, or halting if the stack is empty.

Table 7: The assembly code for plus

[[plus]]_{s} =
cmp SS, #2
bge  plus_{0}
exit “fn”
plus_{0} : pop C
push  plus_{1}
jump [C, 0]
plus_{1} : pop C
push V
push  plus_{2}
jump [C, 0]
plus_{2} : pop A
add V, A
cmp SS, #1
bge  plus_{3}
exitint V
plus_{3} : pop r
jump r

7 A Guide to the Implementation

7.1 Introduction

A prototypical implementation of PPL is available on the world-wide web at [6]. It is written in Objective Caml. The main component of the implementation is the executable program ppl, which reads a PPL program and outputs compiled pseudo assembly code. For reasons of portability, the “assembly code” generated by ppl is actually realized as a set of pre-processor macros in the C language; thus, the output of ppl can be compiled by any C-compiler on the target machine.

The concrete syntax for PPL is slightly different than the syntax introduced in the paper. For example, we write pi1/2 instead of pi_{2}^{1}, as sub- and superscripts are not supported in ASCII. Our implementation reads \ as λ, thus \x.x is the identity function. Our implementation also supports some syntactic sugar. For example, \x y z.M is interpreted as \x.(\y.(\z.M)). One can also write let fun x y z = M instead of let fun = \x.\y.\z.M. Both of these expressions represent the function which takes x, y and z as variables and maps them to the term M.

Our implementation is also lenient with match statements in case distinctions. We allow cases to occur in an arbitrary order, with some cases duplicated or missing. In such cases our compiler issues a warning, and if a missing case is encountered at runtime, the program exits with an error message. We have also added several basic functions to the language. We list them together with their type signatures in Table 8.

Table 8: The Basic Functions of PPL

Function Type Signature
plus  int → int → int
minus  int → int → int
mult  int → int → int
divide  int → int → int
mod  int → int → int
greater int → int → bool
geq  int → int → bool
less  int → int → bool
leq  int → int → bool
equal  int → int → bool
neq  int → int → bool
and  bool → bool → bool
or  bool → bool → bool
not  bool → bool
if  ∀α.bool → α → α → α

The implementation of “and” and “or” are lazy - the second argument is only evaluated if necessary. Similarly, in the term “ifM N Q”, only one of N or Q is evaluated. The other basic functions evaluate all of their arguments.

7.2 User Manual

We now present information necessary to use our PPL compiler. There are several flags which affect the compiler’s behavior, and their behavior is summarized in Table 9. The default behavior of the compiler is realized by typing ppl filename, where filename is a file which contains a PPL program. The compiler will read the program, write its most general type to the terminal, and write its assembly code translation to a file.

The --parse flag will cause the compiler to read and type-check a PPL program but not compile it. The --reduce and --step flags cause the compiler to apply CBN reduction to the input term. In --reduce mode, the final normal form is printed, whereas in --step mode, the entire reduction sequence is printed, one term per line. The --typeinfo mode flag alters the amount of type information that is displayed to the terminal. mode must be one of none, all, top, let or an integer nesting depth n. The compiler then gives type information on, respectively, no variables, all variables, all variables defined on the top-level, variables defined only by let and letrec constructs, and variables defined up to a depth of n. The --untyped flag causes the compiler to not type-check the PPL program. The --optimize flag will cause the compiler to β-reduce certain redexes before compilation, yielding more efficient assembly code.

Table 9: Command line options for the compiler

Flag  Effect

(no options)

The default behavior. Read input from file, print most general type to terminal, and write compiled program to file.

--parse, -p

Do not compile; parse and type-check only.

--step, -s  Print CBN reduction sequence.
--reduce, -r  Reduce term to CBN normal form.
--untyped, -u  Omit type-checking.
--optimize, -z  Create optimized compiled code.

--typeinfo mode,
-i mode

Print additional type information depending on mode.

--term term  Use term as input.

--stdin  Read input from terminal.
--stdout  Write output to terminal.

--output filename,
-o filename

Write output to specified file.

--help, -h  Print help message and exit.
--version, -v  Print version info and exit.

   The --term, --stdin, --stdout, and --output filename flags affect

where the compiler looks for input and where it writes the output. When using the --term term flag one may find it useful to enclose term in quotation marks. This will prevent shell substitutions.

The --help flag provides a list and brief description of all PPL options. The --version flag gives information on which version of the compiler is in use.

There is also a graphical user interface for PPL called ppli. It is written in Tcl/Tk. In Unix, it can be accessed by typing wish ppli at the command line. ppli provides a window to type a PPL program or load a program from a file, together with various buttons for compiling, reducing, and stepping through the reduction sequence of a term.

7.3 Example Programs

Our compiler for PPL comes with several example programs. The file programs.txt, also available at [6], contains a list and description of each program.

8 Future Work

We have implemented PPL as a self-contained example of a functional programming language. It is intended as a basis for experimenting with improved implementation techniques, as well as new language features. Features that we would like to add in the future include improved source level optimization, closure optimization, general recursive types, non-local control features in the style of the λμ-calculus, and a run-time system with a proper garbage collector. We are also interested in extending automatic type inference to recursive types, and the interaction of lazy evaluation and continuations.

9 Appendix: Assembly Code For PPL Terms

[[x]]_{s} =

load C, s(x) jump [C, 0]

[[λx.M]]_{s} =

cmp SS, #1 bge l

exit  “fn”

l : pop r [[M]]_{s(x↦r)}

(where l is a new label and r is a new register.)

[[Mx]]_{s} =

push s(x) [[M]]_{s}

[[MN]]_{s} =

; build closure for N alloc r, #(n + 1) load [r, 0], l load [r, 1], s(x_{1}) … load [r, n], s(x_{n}) push r [[M]]_{s}

l : [[N]]_{(x_{1}↦[C,1],... ,x_{n}↦[C,n])}

(where l is a new label, r is a new register, N is not a variable, and F V (N) = {x_{1}, …, x_{n}}.)

[[let x = M in N]]_{s} =

; build closure for M alloc r, #(n + 1) load [r, 0], l load [r, 1], s(x_{1}) … load [r, n], s(x_{n}) [[N]]_{s(x↦r)}

l : [[M]]_{(x_{1}↦[C,1],... ,x_{n}↦[C,n])}

(where l is a new label, r is a new register, and F V (M) = {x_{1}, …, x_{n}}.)

[[let rec x = M in N]]_{s} =

; build closure for M alloc r, #(n + 1) load [r, 0], l load [r, 1], s′(x_{1}) … load [r, n], s′(x_{n}) [[N]]_{s′}

l : [[M]]_{(x_{1}↦[C,1],... ,x_{n}↦[C,n])}

(where l is a new label, r is a new register, F V (M) = {x_{1}, …, x_{n}}, and s′ = s(x ↦ r).)

[[n]]_{s} =

load V, #n
cmp SS, #1
bge l
exitint V

l : pop r

jump r

(where l is a new label and r is a new register.)

[[true]]_{s} =

load V, #1 cmp SS, #1 bge l exitbool V l : pop r jump r

(where l is a new label and r is a new register.)

[[false]]_{s} =

load V, #0 cmp SS, #1 bge l exitbool V l : pop r jump r

(where l is a new label and r is a new register.)

[[()]]_{s} =

exit  “()”

[[(M_{1}, …, M_{n})]]_{s} =

cmp SS, #1 bge l_{1}

exit  “n-tuple”

l_{1} : pop r cmp r, #1 bne l_{2} [[M_{1}]]_{s}

l_{2} : cmp r, #2 bne l_{3} [[M_{2}]]_{s}

l_{3} : …

… l_{n} : [[M_{n}]]_{s}

(where n ≥ 2, l_{1}, …, l_{n} are new labels, and r is a new register.)

[[pi_{n}^{j} M]]_{s} =

push #j [[M]]_{s} [[in_{n}^{j} M]]_{s} =

cmp SS, #1
bge l_{1}
exit  “inj/n”

l_{1} : ; build closure for M alloc r, #(n + 1) load [r, 0], l_{2} load [r, 1], s(x_{1}) … load [r, n], s(x_{n}) pop C push r ; invoke jth branch of match closure jump [C, j − 1] l_{2} : [[M]]_{(x_{1}↦[C,1],... ,x_{n}↦[C,n])}

(where l_{1}, l_{2} are new labels, r is a new register, and F V (M) = {x_{1}, …, x_{n}}.)

[[case M of in_{k}^{1} y_{1} ⇒ N_{1} | … | in_{k}^{k} y_{k} ⇒ N_{k}]]_{s} =

; build match closure alloc r, #(k + n) load [r, 0], l_{1} … load [r, k − 1], l_{k} load [r, k], s(x_{1}) … load [r, k + n − 1], s(x_{n}) push r [[M]]_{s}

l_{1} : [[λy_{1}.N_{1}]]_{(x_{1}↦[C,k],... ,x_{n}↦[C,k+n−1])}

… l_{k} : [[λy_{k}.N_{k}]]_{(x_{1}↦[C,k],... ,x_{n}↦[C,k+n−1])}

(where l_{1}, …, l_{k} are new labels, r is a new register, and F V (in_{k}^{1} y_{1} ⇒ N_{1} | … | in_{k}^{k} y_{k} ⇒ N_{k}) = {x_{1}, …, x_{n}}.)

References

[1] H. P. Barendregt. The Lambda Calculus, its Syntax and Semantics. North Holland, 2nd edition, 1984.

[2] L. Damas and R. Milner. Principal type-schemes for functional programs. In 9th Symposium on Principles of Programming Languages, pages 207–212, 1982.

[3] C. Hankin. Lambda Calculi: A Guide For Computer Scientists. Oxford University Press, 1994.

[4] S. C. Kleene. λ-definability and recursiveness. Duke Mathematical Journal, 2:340–353, 1936.

[5] J.-L. Krivine. Un interpreteur du lambda-calcul. Draft, available from ftp://ftp.logique.jussieu.fr/pub/distrib/interprt.dvi, 1996.

[6] A. Lamstein and P. Selinger. Implementation of the programming language PPL, Oct. 2000. Available from http://theory.stanford.edu/˜ selinger/ppl/.

[7] R. Milner. A theory of type polymorphism in programming. Journal of Computer and System Sciences, 17:348–375, 1978.

[8] G. E. Revesz. Lambda-Calculus, Combinators, and Functional Programming. Tracts in Theoretical Computer Science 4. Cambridge University Press, 1998.

[9] J. A. Robinson. A machine-oriented logic based on the resolution principle. Journal of the Association for Computing Machinery, 12:23–41, 1965.