Type Constraints
Let's build up to the HM type inference algorithm by starting with this little language:
e ::= x | i | b | e1 bop e2
| if e1 then e2 else e3
| fun x -> e
| e1 e2
bop ::= + | * | <=
t ::= int | bool | t1 -> t2
That language is SimPL combined with the lambda calculus, but without let
expressions. It turns out let
expressions add a extra layer of complication,
so we'll come back to them later.
Since anonymous functions in this language do not have type annotations,
we have to infer the type of the argument x
. For example,
In
fun x -> x + 1
, argumentx
must have typeint
hence the function has typeint -> int
.In
fun x -> if x then 1 else 0
, argumentx
must have typebool
hence the function has typebool -> int
.Function
fun x -> if x then x else 0
is untypeable, because it would requirex
to have both typeint
andbool
, which isn't allowed.
A syntactic simplification
We can treat e1 bop e2
as syntactic sugar for ( bop ) e1 e2
. That is, we
treat infix binary operators as prefix function application. Let's introduce a
new syntactic class n
for names, which generalize identifiers and operators.
That changes the syntax to:
e ::= n | i | b
| if e1 then e2 else e3
| fun x -> e
| e1 e2
n ::= x | bop
bop ::= ( + ) | ( * ) | ( <= )
t ::= int | bool | t1 -> t2
We already know the types of those built-in operators:
( + ) : int -> int -> int
( * ) : int -> int -> int
( <= ) : int -> int -> bool
Those types are given; we don't have to infer them. They are part of the initial
static environment. In OCaml those operator names could later be shadowed by
values with different types, but here we don't have to worry about that because
we don't yet have let
.
Constraint-based inference
How would you mentally infer the type of fun x -> 1 + x
, or rather,
fun x -> ( + ) 1 x
? It's automatic by now, but we could break it down into
pieces:
- Start with
x
having some unknown typet
. - Note that
( + )
is known to have typeint -> (int -> int)
. - So its first argument must have type
int
. Which1
does. - And its second argument must have type
int
, too. Sot = int
. That is a constraint ont
. - Finally the body of the function must also have type
int
, since that's the return type of( + )
. - Therefore the type of the entire function must be
t -> int
. - Since
t = int
, that type isint -> int
.
The type inference algorithm follows the same idea of generating unknown types, collecting constraints on them, and using the constraints to solve for the type of the expression.
Inference relation
Let's introduce a new 4-ary relation env |- e : t -| C
, which should be read
as follows: "in environment env
, expression e
is inferred to have type t
and generates constraint set C
." A constraint is an equation of the form
t1 = t2
for any types t1
and t2
.
If we think of the relation as a type-inference function, the colon in the
middle separates the input from the output. The inputs are env
and e
: we
want to know what the type of e
is in environment env
. The function returns
as output a type t
and constraints C
.
The e : t
in the middle of the relation is approximately what you see in the
toplevel: you enter an expression, and it tells you the type. But around that is
an environment and constraint set env |- ... -| C
that is invisible to you.
So, the turnstiles around the outside show the parts of type inference that the
toplevel does not.
Inference of constants and names
The easiest parts of inference are constants:
env |- i : int -| {}
env |- b : bool -| {}
Any integer constant i
, such as 42
, is known to have type int
, and there
are no contraints generated. Likewise for Boolean constants.
Inferring the type of a name requires looking it up in the environment:
env |- n : env(n) -| {}
No constraints are generated.
If the name is not bound in the environment, the expression cannot be typed. It's an unbound name error.
Inference of if expressions
The remaining rules are at their core the same as the type-checking rules we saw previously, but they each generate a type variable and possibly some constraints on that type variable.
Here's the if
rule. We'll explain it below.
env |- if e1 then e2 else e3 : 't -| C1, C2, C3, t1 = bool, 't = t2, 't = t3
if fresh 't
and env |- e1 : t1 -| C1
and env |- e2 : t2 -| C2
and env |- e3 : t3 -| C3
To infer the type of an if
, we infer the types t1
, t2
, and t3
of each of
its subexpressions, along with any constraints on them. We have no control over
what those types might be; it depends on what the programmer wrote. But we do
know that the type of the guard must be bool
. So we generate a constraint that
t1 = bool
.
Furthermore, we know that both branches must have the same type—though, we
don't know in advance what that type might be. So, we invent a fresh type
variable 't
to stand for that type. A type variable is fresh if it has never
been used elsewhere during type inference. So, picking a fresh type variable
just means picking a new name that can't possibly be confused with any other
names in the program. We return 't
as the type of the if
, and we record two
constraints 't = t2
and 't = t3
to say that both branches must have that
type.
We therefore need to add type variables to the syntax of types:
t ::= 'x | int | bool | t1 -> t2
Some example type variables include 'a
, 'foobar
, and 't
. In the last, t
is an identifier, not a meta-variable.
Example.
{} |- if true then 1 else 0 : 't -| bool = bool, 't = int
{} |- true : bool -| {}
{} |- 1 : int -| {}
{} |- 0 : int -| {}
The full constraint set generated is {}, {}, {}, bool = bool, 't = int, 't = int
, but of
course that simplifies to just bool = bool, 't = int
. From that constraint set we can see
that the type of if true then 1 else 0
must be int
.
Inference of functions and applications
Anonymous functions. Since there is no type annotation on x
, its type must
be inferred:
env |- fun x -> e : 't1 -> t2 -| C
if fresh 't1
and env, x : 't1 |- e : t2 -| C
We introduce a fresh type variable 't1
to stand for the type of x
, and
infer the type of body e
under the environment in which x : 't1
. Wherever
x
is used in e
, that can cause constraints to be generated involving 't1
.
Those constraints will become part of C
.
Example. Here's a function where we can immediately see that x : bool
, but
let's work through the inference:
{} |- fun x -> if x then 1 else 0 : 't1 -> 't -| 't1 = bool, 't = int
{}, x : 't1 |- if x then 1 else 0 : 't -| 't1 = bool, 't = int
{}, x : 't1 |- x : 't1 -| {}
{}, x : 't1 |- 1 : int -| {}
{}, x : 't1 |- 0 : int -| {}
The inferred type of the function is 't1 -> 't
, with constraints 't1 = bool
and 't = int
. Simplifying that, the function's type is bool -> int
.
Function application. The type of the entire application must be inferred, because we don't yet know anything about the types of either subexpression:
env |- e1 e2 : 't -| C1, C2, t1 = t2 -> 't
if fresh 't
and env |- e1 : t1 -| C1
and env |- e2 : t2 -| C2
We introduce a fresh type variable 't
for the type of the application
expression. We use inference to determine the types of the subexpressions and
any constraints they happen to generate. We add one new constraint,
t1 = t2 -> 't
, which expresses that the type of the left-hand side e1
must
be a function that takes in an argument of type t2
and returns a value of type
't
.
Example. Let I
be the initial environment that binds the boolean
operators. Let's infer the type of a partial application of ( + )
:
I |- ( + ) 1 : 't -| int -> int -> int = int -> 't
I |- ( + ) : int -> int -> int -| {}
I |- 1 : int -| {}
From the resulting constraint, we see that
int -> int -> int
=
int -> 't
Stripping the int ->
off the left-hand side of each of those function types,
we are left with
int -> int
=
't
Hence the type of ( + ) 1
is int -> int
.
Solving constraint sets
We've now given an algorithm for generating types and constraint sets. But we've been waving our hands about how to solve the constraint set. The small examples we've seen so far have been easy to solve in our heads. For a large program, that won't be true. So let's turn our attention to that problem, next.