[Compiler Construction in Ocaml] Hindley-Milner Type Inference System Implementation
I want to spend some time talking about implementing Hindley-Milner type inference system in Ocaml for a ML-ish langauge. The code is available at my Github, under ps6/mlish_type_check.ml.
PeterYaoNYU/CompilerInOCaml
This is part of the project for NYU graduate level capstone course by Prof. Joseph Tassarotti.
AST for ML
Before delving into the implementation of the type inference system, let me first provide you with the AST for the ML-ish language that we are doing type inference on:
1 | type var = string (* program variables *) |
It is not too complicated, but the type inference system is indeed complicated.
Recursive Type Check Function
Here is the main entrance of the type inference program:
1 | let rec tc (env: (var * tipe_scheme) list) (e: exp) = |
This is pretty basic, it is just doing pattern matching for the ast, and recursively call the typecheck program.
There are several tricky part in the program however, namely Forall, Unify, instantiate and generalize. These are the key functionalities that make up the polymorphism of the Hindley-Milner type system. I will dissect their implementaiton one by one.
Forall
Forall is defined in the ast. It is of this type:
1 | type tipe_scheme = Forall of (tvar list) * tipe |
The first part is a list of variables. They are the variables that should be instatiated to fresh guesses when applied as a vairable. The second part is the type after the substitution. Forall only applied to let expression, when we bound something to a variable, but would like to keep the polymorphism. So instantiate will only happen when type checking a variable.
Unify
1 | let rec unify (t1:tipe) (t2:tipe): bool = |
Unify is very hard to get it working correctly. What it is doing is to resolve guesses. Somewhere during execution, we figure out a reasonable type for a previous guess, and we unify it. It either succeeds or fails. If it fails, then there is a type error, meaning that somewhere in the program the types are not compatible. If it succeeds, then it means the program is potentially legal so far, but we may not always resolve all guesses. A function like fn id: x->x is essentially unresolvable standalone. Our best effort would be saying that it is of type Fn_t(a’ -> a’).
The part that I tripped on is 1. Occurs and 2. Handling the case of 2 Guess_t None types.
Occurs
We do not allow recursive types in the Hindley-Milner type system. I think this slide did a good job explaining why.
The answer to the last question in the slide would be it is an indefinitely long sequence. So we want to avoid that.
1 | let rec occurs (r: tipe option ref) (t:tipe) : bool = |
This situation will only happen when the there are two guesses, and one type has part of it another guess, and that somehow these two types are being unified. It will not happen when the two types are both Guess None: we got that covered in unify. So you want to check that for the first Guess, the reference address of its content is not the same as some part of the second t type (a guess wrapped around in t). And we do the type check recursively. This is a little mind bending right? Understanding occurs is hard, knowing when this happens exactly is harder.
Unify 2 Guesses
When I program, there is one test case that I cannot pass, however hard I tried. And it took me the whole afternoon plus evening to locate the cause.
Sorry it is a little blurry. I was taking a screenshot because it was so confusing to me. I forgot exactly how I found the cause of the problem, it was a fleeting moment of human intellectuality. But the problem is that when unifying 2 guess None, I treat them as equal and already unified. What we should do is that, check if their reference point to the same location, and if not, then they are not unified. If there are missing the unify step, then you would have the problem that I encountered, which is that the arguements have type (c * d), while the result has type (a * b). They are not unified properly.
So here is the part of the code where this problem is handled:
1 | let rec unify (t1:tipe) (t2:tipe): bool = |
| None -> (if r1 == r2 then true else (r1 := Some t2; true)) This handles the conflict.
Instantiate
The instantiate part is relatively straightforward. It replaces all type variables in the Forall’s list with fresh guesses, so that the polymorphism is achieved. I shall not elaborate on that, since it is easy. (Conceptually easier, but still hard)
Generalize
This part is mind bending!!! How to do generalize, and why we need to eliminate environment bound variables. It is tricky.
1 | let generalize env t = |
Guess of types gets out all the guesses in the type t. We want to replace them potentially with variable names (forall constructs), so that when they are encountered later, they can be instantiated with fresh guesses, and hence polymorphic. Implementing it is straightforward, if you are careful enough, unlike me. Just extract all guesses.
1 | let rec guesses_of_type t = match t with |
Next we need to take out the environemnt bound guesses. They should not be generalized. To be in the environment, you have to be in the Let clause, and you have to be a forall construct. You may ask, if they are in a forall structure, then they should already be generalized, and then there should be no bare guesses in the tipe part of the Forall construct. That, my friend, is a deep philosophical question. Think about this situation, where you compile a lambda function. The argument become a Forall([], guess_t ref None) in the env. Then it becomes involved in a Pair, say (fn * 1), and now you need to generalize (Guess_t * this pair). You should not generalize the guess for the lambda argument, because you just can’t: it is not part of a Let clause, and is not intended to be polymorphic. It might later be substituted with concrete Guess ref Some tipe. Later, when the argument finally arrives, the guess of the argument will be resolved. Please do not confuse a function with a function in a let clause, like: let thrd = fun x -> snd (snd x) in thrd (true, (1, (3 :: nil))). Thrd is indeed intended to be polymorphic.
Another good example of why environemnt bound variables should not be generalized is given by Prof. Joseph Tassarotti: \x -> let y = x in y. Without the generalize, we would have x be substituted by a forall construct, which should really be a guess type. Once substituted by a forall, the x will not have relation with the function that is calling it, and has any relation with y.
Well, I kinda have to admit that this is a really weird thing.
So this is the central question of generalize, everything else is conceptually easy. Take the difference of these two sets, replace the guesses with fresh vairables, and returna forall construct. You can check the code yourself.
What an amazing system!
[Compiler Construction in Ocaml] Hindley-Milner Type Inference System Implementation