Part 3 of the 'implementation of Hob' saga.
« Part 2 | Index | Part 4 »

Type inference, a first attempt

Here I'll give a practical description of Hindley-Milner type inference as applied to (an early incarnation of) Hob. For a more complete treatment of the technique, see for example this paper.

To do type inference, a program walks an abstract syntax tree and assigns types to every expression it contains, giving up when it encounters a type error. It does this, conceptually, by constructing a system of constraints based on the way terms are used, and then solving this system. If we have, for example, the following Hob program...

def succ x -> x + 1
succ 5

We start with the information that + is an operator that takes two numbers and returns a number. This means that x in x + 1 must have type number, and the result of computing x + 1 is also a number. Thus, succ has the type number -> number. Then, looking at succ 5, it concludes that succ is a function that takes a number. These conclusions can all be reconciled, yielding a typing for the program. If the second line had said succ "foo" instead, that would have given rise to the constraint that succ takes a string argument. This would conflict with the other constraints, meaning we have a type error.

Because, when the type-checker gets to an element, there is not always enough information to assign a complete type to it, a type-checking function can not simply return the type of the expression it was given. For example, mutually recursive functions require both functions to be checked before their type is known. This kind of merging of information is done through an algorithm called 'unification'.

We define types to be either a 'type variable' or an instance of some type constructor, with zero or more types as arguments. Thus, 1 has the type number, [1, 2] the type list number, and fn x -> x, in a polymorphic type system, has the type a -> a, where -> is a type constructor, and a is a type variable.

The typecheck function takes an abstract syntax tree and an environment mapping (non-type) variables to types, and returns a type — which may contain variables that later get instantiated — for that tree. It works approximately like this:

In this description, unification has side effects. Unifying a variable with some type instance replaces all occurrences of that variable with the type instance. Implementing this initially stumped me. There can be references to that variable scattered throughout (inside other types, or stored as the type of some expression), and when replacing the variable, all these references should be updated.

My first (rather naive) approach was to always keep types in 'boxes' — mutable structures with one field — and update both boxes to refer to the same type after they were unified. But this didn't even resemble a working solution. (Unifying box a and box b set both boxes to hold, say, a. Then unifying the second box with box number left the first box still pointing at a.) Next I devised a complicated scheme where types were referred to through tags, and each type kept a list of tags referring to it. Updating a type caused all these tags to also be updated. This worked, but was rather clunky.

In the eventual solution, type variable structures have a ref field, which refers to nil for unbound variables, to a type for bound variables, and to another variable for variables structures that are equivalent to another variable. Thus, these structures form chains, with the variable at the end being the 'canonical' variable — it's ref field being either a type instance indicating that all the variables referring to it are bound, or nil. This requires 'resolving' of types before unifying them, but otherwise left the code free of noise and blatant inefficiencies.

The system described so far, though, is not polymorphic. In an expression like this:

let id = fn x -> x
  print (id 5)
  print (id "foo")

If type-checking of let is implemented along the lines of the other constructs, the first application will unify the type of id (a -> a) with number -> b, resulting in number -> number. Applying id to "foo" then yields a type error. This is not what we want. Having polymorphic functions (functions with type variables in their type) is only useful if they can actually be used in a polymorphic way.

The (usual) solution to this problem is to treat the types of variables introduced by let (and similar) specially. Instead of normal types, they get assigned a 'type closure', and every time they are used (outside of their definition) a new type get instantiated based on this type closure, meaning a copy of the type is made, with all the unbound type variables that were introduced by the variable's definition given new names. But only those — any type variables that were introduced outside of the definition must keep their identity. In this it works a lot like ordinary closures. (I didn't really 'get' this technique until I read chapter 31 of Programming Languages: Application and Interpretation). So now the rule for let type-checking looks something like this:

I extended the type 'resolver' that already dealt with chains of type variables to instantiate any type closures it ran across. The new type-checker handles the above example (if print is given a type), assigning the type number to id 5, and string to id "foo".

You probably noticed the asymmetry of only applying this technique to let. Function definitions also introduce variables. With the new code, we still can't type-check something like this:

(fn id -> print (id 5); print (id "baz"))
  (fn x -> x)

The id variable gets bound by the function, and thus holds a regular type, not a type closure. Thus, the above is a type error. It won't type-check in Haskell either ((\id -> (id 5, id "baz")) (\x -> x)). The problem with such a function is that type-checker knows nothing about the kind of id value that might get passed to it, and thus it is hard to assign a closure for it. (Also, depending on the way in which type polymorphism is implemented, such functions might be very hard to compile.)

Another relevant issue is infinite types, as in the expression fn x -> x x. This infers the type of x to be a function type with its own type as the argument type, which sends our type-checker into an infinite recursion of type expansions. The simplest way to prevent this is to fail when trying to replace a variable with a term that contains that same variable.

A very necessary extension to a type-system like this is Haskell-style type classes. For example, if we want to make integers and floating-point numbers a different type, we also need to use different operators for addition or multiplication (see O'Caml), and a generic print function as used in the examples is impossible. I am leaving this for later.

Having a parser and a type-checker for our language makes it feel somewhat real already. Programs can not be executed yet, unfortunately, but at least we can get some feedback on whether they make sense.

Part 3 of the 'implementation of Hob' saga.
« Part 2 | Index | Part 4 »

© Marijn Haverbeke, created February 18, 2009, last modified on March 25, 2009