Type Inference
Type systems are very useful for exposing programmer mistakes. However, writing down type annotations may become a tedious burden, especially as type systems become more expressive. Type systems generally don't require programmers to annotate every term with a type, but some languages go further, inferring many more type annotations.
We've already seen an example in the Xi language where type inference could come in handy: the typing of array literals. The obvious typing rule for array literals requires that every element has the element type:
(ArrayLit) |
As long as {}
, where
a: int[][] = {}
Another classic place where type inference shows up is in statically typed functional programming languages.
For example, in OCaml, we can write an expression like fun x → x + 1
and the compiler
will figure out, even without a type declaration for the argument x
, that the expression has
type int → int
. If we look at the typing rule for function expressions (aka
lambda expressions), we see that it is also not syntax-directed:
(Lambda) |
The rule does not say how to generate the type
In both cases, type inference can be used to figure out the missing types automatically.
The idea is to introduce type variables that will be solved for as part of the
type-checking process. We represent these type variables by introducing a new metavariable syntax
for types appearing in typing rules:
For example, we can write the Xi array literal rule to introduce such a type variable:
| ||||
(ArrayLit) |
The third premise in blue is a set of constraints requiring that the element of the array be
equal to all of the individual element types. If
Similarly, we can write the rule for typing a variable declaration in a way that is amenable to type inference. Again the constraint premise left to be solved out of band is shown in blue.
| ||
(VarInit) |
The rules for checking functions and their uses can written in the same way:
(Lambda) |
(App) |
To see how these rules work, consider type-checking the example above. The rules are completely syntax-directed:
(ArrLit) | | ||
| (VarInit) |
After constructing the proof tree, we are left with the constraint
Unification
In general, the set of type constraints will not be so easy to solve. Solving type
equations can be posed as a problem of unification: given a set of
type equations
Here, a substitution is a function mapping variables to types:
Robinson's algorithm
Robinson's algorithm (1965) find a weakest substitution unifying a set of equations
Any unification problem that does not match one of these cases is a failure: there is no way to solve the equations.
It is not immediately obvious that this recursive definition is well founded. To see that it describes a terminating computation, observe that in each equation, either the total number of type variables decreases or stays the same, or else the set of equations becomes syntactically smaller. The last rule, which solves for and eliminates a variable, makes the real progress; the other rules simplify equations in a way that exposes type variables so the last rule can be applied.
Implementing unification
A simple and efficient way to implement unification is to use imperative update, which is not only simple but can also lead to an asymptotically more compact representation of the types that are solved for.
We represent each type variable as a mutable cell that is initially empty but can be made to contain a pointer to a type expression. When a type variable is solved for using the last rule above, its cell is updated to point to the corresponding type expression. In general, the type expression can be another type variable, in which case a pointer is created from one box to the next. A chain of such pointers may be created; when such a chain is traversed, path compression should be applied to make all the variables along the path point to the last expression in the path. With path compression, unification takes near-linear time even when the inferred types take exponential space when fully expanded!
For example, consider the singleton set of type equations
Unifying
The full solution can be read out by following the pointers at the end of the algorithm:
The Hindley–Milner algorithm
The algorithm above does not handle the style of parametric polymorphism present in functional languages like OCaml (called let-polymorphism). In these languages, we can define polymorphic values that are instantiated at their uses. For example, OCaml allows us to define a polymorphic identity function and apply it to multiple types:
let id x = x in id 42; id true
The algorithm interleaves unification with type checking to find polymorphic types. After
solving the known constraints after the declaration of variable id
, the
type of id
is known only to have form id
the type schema id
, the type schema is instantiated with
distinct fresh type variables—call them id
has the type
In practice, the Hindley–Milner algorithm takes near-linear time.