Most programming languages include the notion of a type system. This is because types help uncover logical errors. Although typechecking can be done either statically (at compile time) or dynamically (at run time), static typechecking has the advantage that if your program typechecks, you know that there will be no type violations on any run; if typechecking is done dynamically, the fact that one run produced no type errors generally provides no guarantees about what will happen on other runs.
There are two different approaches to static typechecking:
Both static and strong typing require type inference: a technique that determines the type of every expression (possibly given declarations of the types for some variables and some user-defined functions). The goal of static typing is to assign a monotype (a single type) to each expression; in contrast, strong typing may assign some expressions a polytype (a type with type variables).
We will consider how to do strong typing in the presence of polymorphism, using Milner's polymorphic type inference algorithm, "Algorithm W" (see the paper by Lucca Cardelli on polymorphic typechecking).
Milner's algorithm was developed for the language ML. We'll use a simpler language (defined below). We'll start with an informal definition of how to do type-inference (in English), then we'll give a formal definition via axioms and rules of inference (so that an expression e has a type t iff there is a proof in this system). We'll see that algorithm W is:
The language we'll use is a subset of ML that is basically an enriched version of lambda calculus. A program will be an expression as defined by the following grammar:
exp | → | ID | |
| | literal | // int, bool, list, or pair | |
| | λ ID . exp | // function definition | |
| | exp (exp) | // function application | |
| | if exp then exp else exp | // normal if-then-else | |
| | let ID = exp in exp | // define a "macro" or a non-recursive fn | |
| | let rec ID = exp in exp | // define a recursive fn |
The primitive types are:
The primitive functions are:
We will assume that all functions are in curried form (i.e., take only one argument),we'll use functions instead of operators (e.g., "plus(1)(2)" instead of "1+2"), and we'll use square brackets for list literals (e.g., [1,2,3]).
The goal of polymorphic type inference is:
A definition of "most general type" appears below.
The interesting part of polymorphic type inference is that types can include type variables. A type is defined as follows:
T1 → T2 | // function |
T1 x T2 | // pair |
T1-list | // list of objects of type T1 |
(T1) | // as usual, parens can be used for grouping |
If a type contains a variable it is a polytype; otherwise it is a monotype.
Here are the types of some of our primitive functions:
function | type |
---|---|
succ | int → int |
iszero | int → boolean |
plus (uncurried form) | (int x int) → int |
plus (curried form) | int → (int → int) |
cons (uncurried form) | (α x α-list) → α-list |
cons (curried form) | α → (α-list → α-list) |
car | α-list → α |
pair | α → (β → (α x β)) |
Intuitively, a type variable means "any type", although if one type variable occurs multiple times in a type, then they all have to refer to the same type. For example, since we've restricted our attention to homogeneous lists, cons is restricted to operate on an object of some type and a list of objects of that same type, rather than an arbitrary object and an arbitrary list. Therefore, the type of cons is α → (α-list → α-list). We impose no such restriction on pair; its arguments can have unrelated types.
Recall that our goal is to find the most general type for each expression in a program. We'll use "T1 ⊇ T2" (where T1 and T2 are types) to mean: T1 is at least as general as T2, and we'll use "T1 ⊃ T2" to mean T1 is strictly more general than T2. Here's how the ordering is defined:
By definition:
So for example:
Let's start by looking at an example:
What do we know about the types of the expressions in this (incomplete) program?
Point 1 tells us that the type of L must be: α-list. Point 3 tells us that the type of length (cdr L) must be: int, and that the type of succ (length (cdr L)) is: int. The type of null(L) (namely bool) is consistent with point 5. Since both branches of the if have type int, the type of the whole if is int, and thus the type of length is: α-list → int.
Try the same, informal type-inference for the following function definition:
Below are (informal) type-inference rules (one set of rules for each kind of expression).
(1) | if cond then exp1 else exp2 | |
(a) the type of cond must be bool | ||
(b) the types of exp1 and exp2 must be the same | ||
(c) the type of the whole expression must be the same as the types of exp1 and exp2 | ||
(2) | function application: fn(arg) | |
(a) the type of fn must be α → β | ||
(b) the type of arg must be α | ||
(c) the type of the whole expression is β | ||
(3) | function abstraction: λ id.exp | |
(a) the type of id is α | ||
(b) the type of exp is β | ||
(c) the type of the whole expression is α → β | ||
(4) | let id = e1 in e2 | |
let rec id = e1 in e2 | ||
(a) inside e2 the type of id is the type of e1 | ||
(b) the type of the whole expression is the type of e2 |
Given these rules, here's an informal algorithm for how to typecheck an expression (i.e., how to infer the types of all subexpressions, and make sure that everything is consistent); we assume that we're given the abstract-syntax tree representation of the expression:
Below is the AST for the length function, annotated to show the result of typechecking (the type of each node is shown in parentheses). The type environment is also shown, as are the forced equalities discovered during typechecking (shown in a table at the bottom right, and also as ** xx = yy ** at the point in the tree where they are discovered). Note that some of the types in the type environment are "not quite right." That issue is explained in the next section.
let rec length = λ L. if null(L) then 0 else succ (length (cdr(L))) let rec ** t1 = α-list → int ** -------- / \ / \ (t1) length lambda (α-list → int) / \ / \ (t2) L if-then-else (int) / | \ / | \ ** t3 = int** **t2=α-list** (bool) apply 0 (int) apply (int) / \ / \ null L succ apply (t3) (α-list → bool) (t2) (int → int) / \ **t1:β-list → t3** / \ length apply (β-list) (t1) / \ **α = β** / \ cdr L (α-list) (β-list → β-list) type env equalities --------- ----------- * null: α-list → bool t2 = α-list succ: int → int α = β * cdr: β-list → β-list t1 = β-list → t3 t3 = int * length: t1 l: t2 (Note: * means not quite right)
In the example given above, the types for null, cdr, and length were marked as "not quite right". In this section we explain the problem and the solution.
As a motivating example, assume that length is a primitive function with type α-list → int. Consider the following function application (recall that list literals are enclosed in square brackets):
This expression should typecheck, and should be discovered to have type int. However, using the approach defined so far, this code will be rejected. Here's a partially annotated AST; what we'd get after typechecking the left subtree (the application of plus to the result of length([1,2,3])).
apply / \ (int → int) / \ apply apply / | | \ / | (int) | \ plus apply length [true,false] int → (int → int) / \ / \ length [1,2,3] (α-list → int) (int-list) ** α = int ** type env equalities ---------- ----------- plus: int → (int → int) α = int length: α-list → int
Note that typechecking length([1,2,3]) caused us to infer that α = int (since length, of type α-list→α is applied to an int-list). That means that length is actually of type int-list→int. This is clearly wrong (since length should be applicable to any kind of list), and when we try to typecheck the right subtree (the application of length to the list [true, false]), we're in trouble! The application of length to the list [true,false] will be rejected, since you can't apply a function of type int-list→int to an argument of type bool-list.
The solution is to use generic type variables for polymorphic functions. A generic type variable is one that appears inside a "forall" quantifier. For example, the type of length should be the generic type:
instead of the non-generic (unquantified) type we were using, and the type of pair should be:
When a function's type involves a generic type variable, it means that the type variable can take on different values each time the function occurs in the AST.
What are the correct types for the other primitive functions (plus and cons)?
Here's how generic and non-generic types are used during typechecking: First, we initialize our type environment with the correct generic types for the primitive functions. Then, when an identifier appears as a leaf of an abstract-syntax tree that is being typechecked, the type is looked up in the type environment. If it is a non-generic type T, the tree node for the identifier is given type T. If it is a generic type of the form
Here's the example from above, this time using a generic type for length:
apply / \ (int → int) / \ apply apply / | | \ / | (int) | \ plus apply length [true,false] int → (int → int) / \ (t2-list → int) (bool-list) / \ ** t2 = bool ** length [1,2,3] (t1-list → int) (int-list) ** t1 = int ** type env equalities ---------- ----------- plus: int → (int → int) t1 = int length: ∀ α.α-list → int t2 = boolNote that now the two occurrences of length have different types (one is t1-list→int and the other is t2-list→int), there is no conflict, and the whole expression will typecheck as it should.
What about user-defined functions? We certainly want to allow polymorphic user-defined functions (like length). However, we have to be careful. Consider the following expression, which defines an anonymous function whose argument, f, is also a function:
If f is given a generic type "∀ α. α", then we would infer: t1→(t2 x t3) as the type of the whole expression. That may seem OK, but now consider:
This expression should not typecheck (because succ applied to true is an error). However, if f gets a generic type, then typechecking will succeed (will fail to find the type error); we will find that t1 = int→int, and that the whole expression has type (t2 x t3):
apply (t2 x t3) / \ / \ ( t1 → (t2 x t3) ) λ succ (int → int) ...
To prevent this, the ID in an expression of the form
We might conclude that in general, for a let expression:
So to typecheck a let expression we should do the three steps listed above except that we do not add a "forall" to the front of T1 for a type variable that is the type of an ID in some enclosing lambda.
What about recursive definitions; expressions of the form: