Lecture 18: Type inference and Unification

In our SML programming we've been writing down types in function declarations. But if we leave the types off, the SML type-checker is able to figure out what the right type annotations should have been. This is called type inference. In this lecture we'll see how type inference works.  Notice that even a simple type checker does type inference in some sense; we don't have to write down types on every expression because it figures out a lot of the types itself. But it turns out that we can type-check the core of SML without any type declarations!
- val f = fn z => z+2
val f = fn : int->int
- val ident = fn x => x
val ident = fn : 'a -> 'a
- let fun square z = z*z
  in
    fn f => fn x => fn y =>
     if f x y then f (square x) y
     else f x (f x y)
  end
val it = fn : (int->bool->bool)->int->bool->bool

To see how this works, we'll start with a simple type checker for an ML-like language and extend it to support inference. There are several things to notice about this code:


<% ShowSMLFile("type-checker.sml") %>

The key idea behind the unification-based type inference algorithm is to introduce type variables to stand in place of types that we don't know yet. If we need to introduce a new variable (for example, in a let expression), we can add it to the environment but bound to a type variable. During type checking, the type variables will be solved for as necessary. The only time that the type checker above generates any constraints on types is when they are compared for equality. We can build a type inference algorithm by having the test for equality also simultaneously solve for type variables as needed to unify the two types being compared: that is, make them equal.

For example, if we compare the two types T1->bool and (int->T3)->T2, where T1, T2, and T3 are type variables, we can see that we can make these two types equal by picking types T1=int->T3 and T2=bool. We can think of these two equations as substitutions that, if applied to the types being compared, make them equal to one another. In this case, the result of applying the substitutions to both types is (int->T3)->bool.

There are many substitutions that would make these two types equal, because we can add T3=t to the substitution for any arbitrary type t and still unify the two types. Comparing these two types doesn't give us any information about T3, so we wouldn't want to do that. Therefore we are looking for the weakest substitution that unifies the two types. A substitution is weaker than another if the stronger substitution can be described as applying the weaker substitution, followed by another non-trivial substitution. For example, any substitution of the form (T1=int->t, T2=bool,T3=t) can be achieved by first doing a substitution (T1=int->T3, T2=bool) and then a substitution (T3=t). The unification algorithm should find the weakest unifying substitution:  (T1=int->T3, T2=bool).

The downside of unification is that it can lead to confusing error messages when an expression is not well-typed. For example:

- fn z => let val (x,y)=z in z(x) end
stdIn:2.4-24.5 Error: operator is not a function [tycon mismatch]
  operator: 'Z * 'Y
  in expression:
    z x

The types 'Z and 'Y are the way that SML reports an unsolved type variable in an error message. This error message tells us that SML tried to unify a tuple type 'Z*'Y against a function type and failed (as we would expect). SML hadn't figured out what the types of the tuple elements were, so it just reported the type variables instead.

A nice way to implement unification-based type inference is to represent type variables using ref cells. For an unsolved type variable, the ref cell points to NONE; once it is solved and set equal to some type t, the cell is updated to point to SOME(t). Here is an implementation of type inference using that technique:


<% ShowSMLFile("type-inference.sml") %>
This implementation of type inference can be extended with a little more effort to provide SML-style polymorphism (called let-polymorphism) in which variables can have polymorphic type. Consider using the type inference algorithm above to find the type of a variable. If that type contains unsolved type variables that don't appear anywhere else in the program, they clearly can be replaced with any type we want. Therefore the variable with that type can actually be used with different type bindings in different places. For example, if we write
let fun ident(x) => x
in ident(ident)(2) end

then the second ident has type int->int and the first ident has type (int->int)->(int->int). The type inference algorithm will find by checking the declaration of ident that it has some type 'X->'X for a type variable 'X that is used nowhere else in the program (the type checker can tell this by looking in the type environment to see whether 'X appears there). At each use of ident, it replaces 'X with new type variables (say, 'Y and 'Z respectively). This decoupling permits them to be solved independently, as desired, to obtain 'Y = (int->int)->(int->int) and 'Z=int->int.

Below is some code that implements type inference with let-polymorphism. There are a few changes from the simple type inference just given. The environment no longer maps identifiers to types; it maps them to type schemas. A type schema is a type along with a list of type variables that can be substituted differently at every use of the identifier. In declcheck, the types that are determined for variables are abstracted by schema to construct type schemas. Then, when type-checking an identifier, the instantiate function is used to replace all the type parameters identified by schema with fresh type variables.


<% ShowSMLFile("let-poly.sml") %>