Type Inference System
PPL 2021
In the previous lecture, we have introduced the Type Equations algorithm to perform Type Inference over Scheme (\(L5\)) expressions.
In this lecture, we present two distinct implementations of this algorithm - one which is a “literal” application of the algorithm, and one which is an optimized transformation of the algorithm relying on more compact data structures and less traversals of the program.
Architecture of the Type Equations Inference System
The Type Inference with Type Equations system builds on the L5 AST abstract syntax defined for the Type Checker. It introduces two new modules:
The Substitution ADT
The Substitution ADT is a direct implementation of the mathematical substitution object introduced in the previous lecture, adjusted to work on type variables and type expressions.
Type Substitution Definition
A type-substitution \(s\) is a mapping from a finite set of type variables to a finite set of type expressions, such that \(s(T)\) does not include \(T\).
As usual, when defining substitutions and partial functions (we have already implemented such data structures when talking about environments), we adopt an inductive implementation to define the Type Substitution data type, which is the union of two disjoint types:
- The empty sustitution
- Non-empty substitutions:
sub(tvars: TVar[], texps: TExp[])
The functional interface of the Substitution data type includes:
- Value constructors for the empty substitution
makeEmptySub
and non-empty substitutionssub(tvars, texps)
. - Value constructor for composing two substitutions
subCombine(sub1, sub2)
.
Both the functions sub(tvars, texps)
and subCombine(sub1, sub2)
return a non-empty substitution.
They also enforce the key invariant of substitutions: for any variable \(T\), \(sub(T)\) does not include \(T\).
This invariant check is performed by the function checkNoOccurrence(tvar, texp)
.
This function is a standard AST traversal of the Type expression Texp
looking for any instance of the Type variable Tvar
at any level.
This process is called occurrence check and is central to all unification based methods. It is a computationally expensive component of the algorithm.
// Purpose: when attempting to bind tvar to te in a sub - check whether tvar occurs in te.
// Return error if a circular reference is found.
export const checkNoOccurrence = (tvar: TVar, te: TExp): Result<true> => {
const check = (e: TExp): Result<true> =>
isTVar(e) ? ((e.var === tvar.var) ?
bind(unparseTExp(te),
up => makeFailure(`Occur check error - circular sub ${tvar.var} in ${up}`)) :
makeOk(true)) :
isAtomicTExp(e) ? makeOk(true) :
isProcTExp(e) ? bind(mapResult(check, e.paramTEs), _ => check(e.returnTE)) :
makeFailure(`Bad type expression ${e} in ${te}`);
return check(te);
};
Similarly to environments, we define the non-empty substitution as a linked-list of bindings mapping variables to values.
This value type is computed using the extend-sub(sub,tvar,texp)
method. Note that the method requires two “complications” in addition
to extending the base-substitution with the new binding:
- The existing right-hand-side of the base substitutions are updated with the new substitution (var->te)
- We perform occur check on the resulting substitution (by invoking make-sub which includes an occur-check).
// Purpose: extend a substitution with one pair (tv, te)
// Calls to makeSub to do the occur-check
export const extendSub = (sub: Sub, v: TVar, te: TExp): Result<Sub> =>
bind(makeSub([v], [te]), (sub2: Sub) => {
const updatedTEs = map((te) => applySub(sub2, te), sub.tes);
return includes(v.var, map(prop('var'), sub.vars)) ? makeSub(sub.vars, updatedTEs) :
makeSub(cons(v, sub.vars), cons(te, updatedTEs));
});
In addition, we explicitly “compile” extended substitutions which are the result of composing two substitutions into a flat representation,
using the value constructor subCombine
. Recall that substitution composition is defined as:
- s’ is applied to the type-expressions of s, i.e., for every variable T’ for which s’(T’) is defined, occurrences of T’ in type expressions in s are replaced by s’(T’).
- A variable T’ in s’, for which s(T) is defined, is removed from the domain of s’, i.e., s’(T) is not defined on it anymore.
- The modified s’ is added to s.
- Identity bindings, i.e., s(T) = T, are removed.
- If for some variable, (s o s’)(T) includes T, the combination fails.
This method is implemented literally in the subCombine(sub1, sub2)
procedure - which has a structure similar to a reduce of the extendSub operation over all the pairs in sub2:
// ============================================================
// Purpose: Returns the composition of substitutions s.t.:
// applySub(result, te) === applySub(sub2, applySub(sub1, te))
// The composition fails if it would lead to a circular substitution.
export const combineSub = (sub1: Sub, sub2: Sub): Result<Sub> =>
isEmptySub(sub1) ? makeOk(sub2) :
isEmptySub(sub2) ? makeOk(sub1) :
combine(sub1, sub2.vars, sub2.tes);
const combine = (sub: Sub, vars: TVar[], tes: TExp[]): Result<Sub> =>
isEmpty(vars) ? makeOk(sub) :
bind(extendSub(sub, first(vars), first(tes)),
(extSub: Sub) => combine(extSub, rest(vars), rest(tes)));
Type Equations Module
The type equations module follows the definition of the algorithm:
- Rename bound variables in e.
- Assign type variables to all sub-expressions.
- Construct type equations.
- Solve the equations.
Renaming of all bound variables in e
is performed in the same way as we introduced when discussing the Substitution model for Operational Semantics.
We do not repeat this code here, and instead make the assumption that in the following, all bound variables have distinct names
(the same variable name is never used twice in different scopes).
We implement the assignment of type variables to all sub-expressions by defining a data structure which we call the pool
which contains a list of pairs (exp Tvar)
for every node in the expression AST.
The pool is a list of pairs (expression-AST-node, type variable) which exhaustively includes all the sub-expressions in the AST.
It is built using the function expToPool
. As usual, this function is an AST traversal method.
Whenever a node in the AST is visited, we allocate a fresh Type Variable for it.
Pay attention to the way variable declarations and variable references are processed when constructing the pool.
- The method
extendPool(exp, pool)
generates a fresh new type variable (one that was never used before) and adds the mapping from exp to that new type variable to the pool. - When we enter a new scope in the expression (during its traversal), we need to keep track of the variable declarations - and map the variable name to the type of the variable declaration. Recall that when we parse an \(L5\) expression, we consider type annotations optional. If they are provided, the VarDecl node stores the declared type expression in the
VarDecl.texp
field. If they are not provided by the programmer, the parser generates a new fresh variable and associates it tovarDecl.texp
.
When we continue the traversal of the AST, and we later meet a VarRef node which refers to this VarDecl node, we want to associate it to the existing VarDecl type declaration (whether it is provided by the programmer or allocated by the parser).
To achieve this mechanism, when we meet a var-decl node, we use the procedure extendPoolVarDecl(varDecl, pool)
which
adds the pair (varRef(varDecl.var) varDecl.texp))
to the pool.
When we later reach a VarRef in the scope of this VarDecl, we find that the pair (VarRef tvar)
already exists in the pool, and we do not allocate any new fresh type variable for it.
This mechanism crucially depends on the fact that expToPool
traverses the expression ADT top-down (traverse the VarDecl before the corresponding VarRef
nodes are met) and the expression has been renamed before so that all VarRefs
with a given name refer to the single VarDecl
with the same name.
expToPool
uses the function reducePool
to accumulate the pairs (AST-node TVar) into the pool without repetitions.
This function is a variant of the reduce
family of higher-order functions.
Optional<T>
and maybe
We use in these functions the inPool
function which checks whether an expression is already present in the pool.
In case the expression is found, we return the associated TExp
, else we need to return a value that indicates the expression was not found.
This is a typical situation where we have a search operator which can fail. We adopt the standard Optional
generic type
to represent this return type in a type-safe manner.
In the same way as Result
represents a call which may fail, Optional
represents a call which may either return a value or the legitimate case
of a missing value (which should not be considered an error).
The two options are wrapped as Some<T>
and None
(parallel to Ok<T>
and Failure
for Result<T>
).
To manipulate Optional
values, we use the maybe
operator which passes the Optional<T>
two possible continuations:
a method receiving a T
value in case the value was found, and one receiving no argument in case none was found.
maybe
belongs to the family of fold operators in functional programming: it is used to reduce a data structure containing multiple values into a single one
(in the case of product-like types like arrays, reduce
belongs to the fold family; in the case of sum-like types (unions) like Result or Option,
a fold-operator provides one procedure for each case - as in the maybe
operator).
We also implement a bind
versions for Optional<T>
values with the same behavior as that we adopted for Result<T>
.
// Purpose: construct a pool with one additional pair
// [exp, fresh-tvar]
// @Pre: exp is not already in pool.
export const extendPool = (exp: A.Exp, pool: Pool): Pool =>
cons({e: exp, te: T.makeFreshTVar()}, pool);
// Purpose: construct a pool with one additional pair
// [VarRef(var), texp]
// from a VarDecl(var, texp) declaration.
// @Pre: var is not already in pool - which means
// that all bound variables have been renamed with distinct names.
const extendPoolVarDecl = (vd: A.VarDecl, pool: Pool): Pool =>
cons({e: A.makeVarRef(vd.var), te: vd.texp}, pool);
export const inPool = (pool: Pool, e: A.Exp): Opt.Optional<T.TExp> => {
const exp = R.find(R.propEq('e', e), pool);
return exp ? Opt.makeSome(R.prop('te')(exp)) : Opt.makeNone();
}
// Map a function over a list of expressions to accumulate
// matching sub-expressions into a pool.
// fun should construct a new pool given a new expression from exp-list
// that has not yet been seen before.
const reducePool = (fun: (e: A.Exp, pool: Pool) => Pool, exps: A.Exp[], result: Pool): Pool =>
isEmpty(exps) ? result :
Opt.maybe(inPool(result, first(exps)),
_ => reducePool(fun, rest(exps), result),
() => reducePool(fun, rest(exps), fun(first(exps), result)));
const reducePoolVarDecls = (fun: (e: A.VarDecl, pool: Pool) => Pool, vds: A.VarDecl[], result: Pool): Pool =>
isEmpty(vds) ? result :
Opt.maybe(inPool(result, A.makeVarRef(first(vds).var)),
_ => reducePoolVarDecls(fun, rest(vds), result),
() => reducePoolVarDecls(fun, rest(vds), fun(first(vds), result)));
// Purpose: Traverse the abstract syntax tree L5-exp
// and collect all sub-expressions into a Pool of fresh type variables.
// Example:
// bind(bind(p('(+ x 1)'), parseL5Exp), e => makeOk(TE.expToPool(e))) =>
// Ok([[AppExp(PrimOp(+), [VarRef(x), NumExp(1)]), TVar(16)],
// [NumExp(1), TVar(15)],
// [VarRef(x), TVar(14)],
// [PrimOp(+), TVar(13)]])
export const expToPool = (exp: A.Exp): Pool => {
const findVars = (e: A.Exp, pool: Pool): Pool =>
A.isAtomicExp(e) ? extendPool(e, pool) :
A.isProcExp(e) ? extendPool(e, reducePool(findVars, e.body,
reducePoolVarDecls(extendPoolVarDecl, e.args, pool))) :
A.isCompoundExp(e) ? extendPool(e, reducePool(findVars, A.expComponents(e), pool)) :
makeEmptyPool();
return findVars(exp, makeEmptyPool());
};
The post-condition met at the end of the pool construction is that every node in the AST is mapped to a type variable - while preserving scoping relations (different occurrences of the same varRef are all merged as a single pair mapping the varRef to its declared type - which can be a non-instantiated type variable).
Equations Generation
The next step of the algorithm consists of transforming the pool of {e:expression, te:TVar} pairs into a set of equations. This part of the algorithm is the one that encapsulates the semantics of the type system used in the programming language.
The procedure poolToequations
performs this mapping. This procedure accumulates the transformation of all pairs
{e:exp, te:Tvar}
in the pool to equations. The equation ADT is a pair left-hand-side / right-hand-side of type expressions (that is, each equation is encoded as a pair {left: TExp, right: TExp}
).
The heart of the typing algorithm is the operation makeEquationsFromExp
which covers the typing rules of the programming language. This implements the typing rules for procedure expressions, application expressions and atomic expressions.
For example, given a pair ( application-exp, TVar )
, the procedure derives a type equation which mandates that the type variable associated to the operator of the application must be made equal to the type expression (T1 * ... * Tn -> TVar)
where Ti
is the type variable associated to the ith argument of the application expression.
For the base cases of atomic types and primitive operators, we reuse the procedures typeof-bool-exp
, typeof-num-exp
and typeof-prim-op
which we defined in the type checker.
Observe that there is no processing of if-exp
- do you understand why?
// Signature: make-equation-from-exp(exp, pool)
// Purpose: Return a single equation
// @Pre: exp is a member of pool
export const makeEquationsFromExp = (exp: A.Exp, pool: Pool): Opt.Optional<Equation[]> =>
// An application must respect the type of its operator
// Type(Operator) = [T1 * .. * Tn -> Te]
// Type(Application) = Te
A.isAppExp(exp) ? Opt.bind(inPool(pool, exp.rator), (rator: T.TExp) =>
Opt.bind(Opt.mapOptional((e) => inPool(pool, e), exp.rands), (rands: T.TExp[]) =>
Opt.mapv(inPool(pool, exp), (e: T.TExp) =>
[makeEquation(rator, T.makeProcTExp(rands, e))]))) :
// The type of procedure is (T1 * ... * Tn -> Te)
// where Te is the type of the last exp in the body of the proc.
// and Ti is the type of each of the parameters.
// No need to traverse the other body expressions - they will be
// traversed by the overall loop of pool->equations
A.isProcExp(exp) ? Opt.bind(inPool(pool, exp), (left: T.TExp) =>
Opt.mapv(Opt.bind(safeLast(exp.body), (last: A.CExp) => inPool(pool, last)), (ret: T.TExp) =>
[makeEquation(left, T.makeProcTExp(R.map((vd) => vd. texp, exp.args), ret))])) :
// The type of a number is Number
A.isNumExp(exp) ? Opt.mapv(inPool(pool, exp), (left: T.TExp) => [makeEquation(left, T.makeNumTExp())]) :
// The type of a boolean is Boolean
A.isBoolExp(exp) ? Opt.mapv(inPool(pool, exp), (left: T.TExp) => [makeEquation(left, T.makeBoolTExp())]) :
// The type of a string is String
A.isStrExp(exp) ? Opt.mapv(inPool(pool, exp), (left: T.TExp) => [makeEquation(left, T.makeStrTExp())]) :
// The type of a primitive procedure is given by the primitive.
A.isPrimOp(exp) ? Opt.bind(inPool(pool, exp), (left: T.TExp) =>
Opt.mapv(Res.resultToOptional(TC.typeofPrim(exp)), (right: T.TExp) =>
[makeEquation(left, right)])) :
// Todo: define, let, letrec, set
Opt.makeNone();
Solving the Type Equations System
The last stage of the algorithm consists of solving the set of equations collected by the equations generator.
The procedure solve(equations, substitution)
is a direct implementation of the solve algorithm presented in the
last lecture. It computes the unifier of all the equations - that is, it computes a type substitution which when it
is applied to both sides of all the equations, makes the two sides identical.
This unifier substitution is computed incrementally, by processing each equation in turn.
Input: A set of type equations.
Output: A type substitution of FAIL.
Initialization:
substitution := { }
Order the set of input equations in any sequential order.
equation := te1 = te2, the first equation.
Loop:
1. Apply the current substitution to the equation:
equation := <te1 o substitution = te2 o substitution>
2. If [te1 o substitution] and [te2 o substitution] are atomic types:
if [te1 o substitution] != [te2 o substitution]:
substitution := FAIL
otherwise:
Do nothing.
3. Without loss of generality:
If [te1 o substitution] = T, i.e., a type variable, and [te1 o substitution] != [te2 o substitution]:
substitution := substitution o {T = [te2 o substitution]}.
That is, apply the equation to substitution, and add the equation to the substitution.
If the application fails (circular mapping), substitution := FAIL.
4. if [te1 o substitution] and [te2 o substitution] are composite types:
if they have the same type constructor:
Split [te1 o substitution] and [te2 o substitution] into component type expressions,
Create equations for corresponding components, and
Add the new equations to the pool of equations.
if they have different type constructors: substitution := FAIL
5. Without loss of generality:
if [te1 o substitution] is an atomic type and [te2 o substitution] is a composite type:
substitution := FAIL
6. if there is a next equation: equation := next(equation)
until substitution = FAIL or
there is no next equation.
Return: Substitution
This algorithm is implemented in the following procedure:
export const solveEquations = (equations: Equation[]): Res.Result<S.Sub> =>
solve(equations, S.makeEmptySub());
// Purpose: Solve the equations, starting from a given substitution.
// Returns the resulting substitution, or error, if not solvable
const solve = (equations: Equation[], sub: S.Sub): Res.Result<S.Sub> => {
const solveVarEq = (tvar: T.TVar, texp: T.TExp): Res.Result<S.Sub> =>
Res.bind(S.extendSub(sub, tvar, texp), sub2 => solve(rest(equations), sub2));
const bothSidesAtomic = (eq: Equation): boolean =>
T.isAtomicTExp(eq.left) && T.isAtomicTExp(eq.right);
const handleBothSidesAtomic = (eq: Equation): Res.Result<S.Sub> =>
T.isAtomicTExp(eq.left) && T.isAtomicTExp(eq.right) && T.eqAtomicTExp(eq.left, eq.right)
? solve(rest(equations), sub)
: Res.makeFailure(`Equation with non-equal atomic type ${eq}`);
if (isEmpty(equations)) {
return Res.makeOk(sub);
}
const eq = makeEquation(S.applySub(sub, first(equations).left),
S.applySub(sub, first(equations).right));
return T.isTVar(eq.left) ? solveVarEq(eq.left, eq.right) :
T.isTVar(eq.right) ? solveVarEq(eq.right, eq.left) :
bothSidesAtomic(eq) ? handleBothSidesAtomic(eq) :
T.isCompoundTExp(eq.left) && T.isCompoundTExp(eq.right) && canUnify(eq) ?
solve(R.concat(rest(equations), splitEquation(eq)), sub) :
Res.makeFailure(`Equation contains incompatible types ${eq}`);
};
The logic of the unification is implemented in the procedures canUnify
and splitEquation
.
These procedures transform an equation of the form:
[T1 * ... * Tn -> T] = [U1 * ... * Un -> U]
into \(n+1\) equations of the form:
[T1 = U1]
...
[Tn = Un]
[T = U]
// Signature: canUnify(equation)
// Purpose: Compare the structure of the type expressions of the equation
const canUnify = (eq: Equation): boolean =>
T.isProcTExp(eq.left) && T.isProcTExp(eq.right) &&
(eq.left.paramTEs.length === eq.right.paramTEs.length);
// Signature: splitEquation(equation)
// Purpose: For an equation with unifyable type expressions,
// create equations for corresponding components.
// Type: [Equation -> List(Equation)]
// Example: splitEquation(
// makeEquation(parseTExp('(T1 -> T2)'),
// parseTExp('(T3 -> (T4 -> T4))')) =>
// [ {left:T2, right: (T4 -> T4)},
// {left:T3, right: T1)} ]
// @Pre: isCompoundExp(eq.left) && isCompoundExp(eq.right) && canUnify(eq)
const splitEquation = (eq: Equation): Equation[] =>
(T.isProcTExp(eq.left) && T.isProcTExp(eq.right)) ?
R.zipWith(makeEquation,
R.prepend(eq.left.returnTE, eq.left.paramTEs),
R.prepend(eq.right.returnTE, eq.right.paramTEs)) :
[];
Solve Termination Argument
How do we know that the solve
algorithm terminates given a list of type expression equations?
The main loop of the algorithm has for state the current list of equations and the current substitution.
Let us consider the effect of one iteration through the main loop:
- Either we consume one equation from the current equations set and produce a more complex substitution (this happens when one of the sides of the first equation is an atomic type expression or a type variable).
- Or we replace one equation with multiple equations: this happens when both sides of the equation are composite type expressions with compatible structure. In this case, we replace one equation with AST trees of depth \(D\) with \(n\) equations of depth \(D-1\) where \(n\) is the number of children of the ASTs. In our case, composite ASTs in the type language are
procTExp
nodes which represent the type of procedures - with \(n\) children elements for the arguments of the procedure and one element for the return type. - Or we fail the solve process when we detect an incompatible equation.
The argument for completion is based on the characterization of the size of the input equation set as a pair \((D, N)\) where \(D\) is the maximum height of the ASTs that appear in any equation in the equation set and \(N\) is the number of equations in the set.
Each iteration in the loop changes the size to either \((D, N-1)\) or \((D-1, N+n)\). When \(D=1\), the transition is necessarily to \((1, N-1)\) because the only case where we add equations is for composite ASTs. Hence all transitions lead to the completion state of \((1, 0)\).
Putting all Steps Together: inferType
Putting all the steps of the algorithm together, we define the procedure infer
and verifyType
:
// ========================================================
// Purpose: Infer the type of an expression using the equations method
// Example: unparseTExp(inferType(parse('(lambda (f x) (f (f x)))')))
// ==> '((T_1 -> T_1) * T_1 -> T_1)'
export const inferType = (exp: A.Exp): Opt.Optional<T.TExp> => {
const pool = expToPool(exp);
const equations = poolToEquations(pool);
const sub = Opt.bind(equations, (eqns: Equation[]) => Res.resultToOptional(solveEquations(eqns)))
const texp = inPool(pool, exp);
return Opt.bind(sub, (sub: S.Sub) =>
Opt.mapv(texp, (texp: T.TExp) =>
T.isTVar(texp) ? S.subGet(sub, texp) :
texp));
};
// Type: [Concrete-Exp -> Concrete-TExp]
export const infer = (exp: string): Res.Result<string> =>
Res.bind(A.parseL5Exp(exp),
(exp: A.Exp) => Opt.maybe(inferType(exp),
(te: T.TExp) => T.unparseTExp(te),
() => Res.makeFailure("Infer type failed")));
This is invoked as follows:
> infer('(lambda (f g) (lambda (n) (f (g n))))')
{tag: 'Ok', ((T459051 -> T459052) * (T459049 -> T459051) -> (T459049 -> T459052))
Observe the usage of freshly generated type variable names which are different each time we invoke the procedure. This makes it difficult to test the procedure.
To resolve this difficulty, we introduce the procedure equivalentTEs?
in TExp.ts which verifies that two type expressions are equivalent up to type variable renaming. This allows us to write tests in a deterministic manner (see test-helpers.ts):
export const verifyTeOfExprWithEquations = (exp: string, texp: string): Result<boolean> => {
const e = bindResult(p(exp), parseL5Exp);
const expectedType = parseTE(texp);
const computedType = bindResult(e, (exp: Exp) => optionalToResult(inferType(exp), "Could not infer type"));
return bindResult(computedType, (ct: TExp) =>
mapv(expectedType, (et: TExp) =>
equivalentTEs(ct, et)));
};
This can be invoked as follows:
expect(verifyTeOfExprWithEquations("(lambda (x y) x)", "(T1 * T2 -> T1)")).toEqual(makeOk(true));
Type Inference with Direct Unification
The implementation described above based on type equations follows literally the type equations algorithm. It explicitly manipulates substitution data structures and type equations. In addition, it constructs a map of expression to type variables to ensure the exhaustive traversal of the program to be type-checked.
We present here an optimized version of this algorithm which relies on a slightly modified representation of the type variable data structure. Using this new data structure for type variables, we implement exactly the same algorithm but avoid creating explicit data structures for the pool, the equations and the substitutions. This leads to a more memory-efficient implementation, which also turns out to be more time efficient, as less traversals of the data structures are required, and operations performed eargerly in the type equations method are turned into lazy operations.
The complete code is available in L5-typeinference.ts.
Tests are in L5-typeinference.test.ts.
Type Variable with One-way Assignment
The extension to the TVar data type we introduce is implemented as follows:
// TVar: Type Variable with support for dereferencing (TVar -> TVar)
export type TVar = { tag: "TVar"; var: string; contents: Box<undefined | TExp>; };
export const isEmptyTVar = (x: any): x is TVar =>
(x.tag === "TVar") && unbox(x.contents) === undefined;
export const makeTVar = (v: string): TVar =>
({tag: "TVar", var: v, contents: makeBox(undefined)});
export const isTVar = (x: any): x is TVar => x.tag === "TVar";
export const eqTVar = (tv1: TVar, tv2: TVar): boolean => tv1.var === tv2.var;
In addition to the name of the type variable, we associate a boxed value, initialized to undefined
.
We use this new field in the TVar datatype to associate the TVar to another type expression,
when we derive a constraint that the variable must be bound to another type expression as part
of the type inference process.
We extend the TVar data type with the following methods:
export const tvarContents = (tv: TVar): undefined | TExp => unbox(tv.contents);
// @Pre: tv is empty (one-way assignment)
export const tvarSetContents = (tv: TVar, val: TExp): void =>
setBox(tv.contents, val);
export const tvarIsNonEmpty = (tv: TVar): boolean => tvarContents(tv) !== undefined;
The assignment managed by TVar is one-way - we can only assign a value to an empty type variable.
In many occurrences, we will bind a TVar to another TVar. When this is the case, we are interested in accessing the type expression to which the referenced TVar refers. That is, we create a graph of TVar references to other TVars which eventually lead to non-TVar expressions. We want to follow the path of references from any TVar to a non-TVar value (which may be empty).
The method VarDeref
performs this graph traversal:
export const tvarDeref = (te: TExp): TExp => {
if (! isTVar(te)) return te;
const contents = tvarContents(te);
if (contents === undefined)
return te;
else if (isTVar(contents))
return tvarDeref(contents);
else
return contents;
}
Unification-based CheckTvarEqualType
Using the one-way assignment TVar data structure, we return to the procedure checkEqualType
which
we defined in the Type Checker implementation. The original implementation applied the invariance check and
verified that two type expressions are identical (using the deepEqual
predicate).
When they were, the type checker proceeded - otherwise the type checking failed.
In the case of the type inference, we replace this type equal test with a different procedure, which instead of testing that two types are equal, attempts to make them equal when they contain type variables. The way two type expressions are made equal is by unifying them - that is, finding a substitution which when applied to both sides makes them equal.
But instead of representing the substitution as an explicit data structure (the sub
type we defined in
L5-substitution-adt.ts, we encode the substitution bindings within the TVar data structure. When Tvar1
is bound to a type expression s(TVar1)
, we
invoke tvarSetContents(tvar1, te)
.
The following procedure effectively computes the MGU (most general unifier) in-place given two expressions te1 and te2 which may contain type variables:
// Purpose: Make type expressions equivalent by deriving a unifier
// Return an error if the types are not unifiable.
// Exp is only passed for documentation purposes.
// te1 can be undefined when it is retrieved from a type variable which is not yet bound.
const checkEqualType = (te1: T.TExp | undefined, te2: T.TExp, exp: A.Exp): Result<true> =>
te1 === undefined ? bind(T.unparseTExp(te2), (texp: string) => makeFailure(`Incompatible types: undefined - ${JSON.stringify(texp, null, 2)}`)) :
T.isTVar(te1) && T.isTVar(te2) ? ((T.eqTVar(te1, te2) ? makeOk(true) : checkTVarEqualTypes(te1, te2, exp))) :
T.isTVar(te1) ? checkTVarEqualTypes(te1, te2, exp) :
T.isTVar(te2) ? checkTVarEqualTypes(te2, te1, exp) :
T.isAtomicTExp(te1) && T.isAtomicTExp(te2) ?
T.eqAtomicTExp(te1, te2) ? makeOk(true) : bind(T.unparseTExp(te1), (te1: string) =>
bind(T.unparseTExp(te2), (te2: string) =>
makeFailure<true>(`Incompatible atomic types ${te1} - ${te2}`))) :
T.isProcTExp(te1) && T.isProcTExp(te2) ? checkProcEqualTypes(te1, te2, exp) :
bind(T.unparseTExp(te1), (te1: string) =>
bind(T.unparseTExp(te2), (te2: string) =>
makeFailure<true>(`Incompatible types structure: ${te1} - ${te2}`)));
// Purpose: make two lists of equal length of type expressions equal
// Return an error if one of the pair of TExps are not compatible - true otherwise.
// Exp is only passed for documentation purposes.
const checkEqualTypes = (tes1: T.TExp[], tes2: T.TExp[], exp: A.Exp): Result<true> => {
const checks = zipWithResult((te1, te2) => checkEqualType(te1, te2, exp), tes1, tes2);
return bind(checks, _ => makeOk(true));
}
const checkProcEqualTypes = (te1: T.ProcTExp, te2: T.ProcTExp, exp: A.Exp): Result<true> =>
te1.paramTEs.length !== te2.paramTEs.length ? bind(T.unparseTExp(te1), (te1: string) =>
bind(T.unparseTExp(te2), (te2: string) =>
makeFailure<true>(`Wrong number of args ${te1} - ${te2}`))) :
checkEqualTypes(T.procTExpComponents(te1), T.procTExpComponents(te2), exp);
In exactly the same manner as we had to deal with the occur-check case in the substitution data type,
we must also avoid creating circular references in the graph of TVar references.
This is enforced in the checkTVarEqualTypes
procedure which binds a TVar to a value - and makes sure
the reference type expression does not include a reference to TVar:
// Purpose: check that a type variable matches a type expression
// Updates the var is needed to refer to te.
// Exp is only passed for documentation purposes.
const checkTVarEqualTypes = (tvar: T.TVar, te: T.TExp, exp: A.Exp): Result<true> =>
T.tvarIsNonEmpty(tvar) ? checkEqualType(T.tvarContents(tvar), te, exp) :
bind(checkNoOccurrence(tvar, te, exp), _ => { T.tvarSetContents(tvar, te); return makeOk(true); });
// Purpose: when attempting to bind tvar to te - check whether tvar occurs in te.
// Throws error if a circular reference is found.
// Exp is only passed for documentation purposes.
// Pre-conditions: Tvar is not bound
const checkNoOccurrence = (tvar: T.TVar, te: T.TExp, exp: A.Exp): Result<true> => {
const checkList = (tes: T.TExp[]): Result<true> =>
bind(mapResult(loop, tes), _ => makeOk(true));
const loop = (te1: T.TExp): Result<true> =>
T.isAtomicTExp(te1) ? makeOk(true) :
T.isProcTExp(te1) ? checkList(T.procTExpComponents(te1)) :
T.isTVar(te1) ? (T.eqTVar(te1, tvar) ? bind(A.unparse(exp), (exp: string) => makeFailure(`Occur check error - ${te1.var} - ${tvar.var} in ${exp}`)) : makeOk(true)) :
bind(A.unparse(exp), (exp: string) => makeFailure(`Bad type expression - ${JSON.stringify(te1)} in ${exp}`));
return loop(te);
}
Type Inference Algorithm
Surprisingly, the type inference algorithm is exactly the same code as the Type Checker - except for the transformation of the procedure checkEqualType
from a test of equality to the unification building version presented above.
The program that we obtain is in fact an implementation of the type equation algorithm - with the following transformations:
- There is no explicit pool representation - instead, we pre-allocate type variables in all possible VarDecl and procedure return positions as part of the expression parsing (in
parse
). - Application nodes and procedure nodes in the program AST are not explicitly annotated with type variables - but the type checking algorithm enforces exhaustive traversal of the AST in depth-first order. Each time an application or procedure node is encountered, the corresponding type equation is verified, and solved in place by invoking
checkEqualType
eagerly. Note that when we invokecheckEqualType
- the types may not yet be known, and an expression may still be attached to an unbound TVar. This happens for example when we infer types for the expression((lambda (x) x) 1)
– when the operator component of this application is analyzed - there is not sufficient information to derive the type of the parameter x. Later, when the typing rule of the application syntactic construct is applied (the top level node in the AST), the TVar associated to x will be bound to the type expression of the numeric atomic value. This will propagate the inferred information that x is a NumTexp type from the application to the procedure expression. This propagation of information was not necessary in the case of the type checking algorithm - because we could rely on the fact that all variable references (VarRef) are explicitly typed. - We do not explicitly represent substitutions, instead we rely on the graph of TVar references as a representation of the substitution object.
- We do not need the explicit renaming of the program as we can rely on the TEnv mechanism to capture scoping relations.
The implementation of unification through one-way variable assignment is a powerful technique, which we will revisit in Chapter 5 when we survey Logic Programming.
Summary
- We surveyed 2 implementations of the Type Inference algorithm:
- An explicit representation of type equations and substitutions.
- An optimized implementation relying on one-way type variables.
-
Both implementations rely critically on the occur-check mechanism to avoid creating circular substitutions.
- The type equation algorithm operates in 3 steps:
- Map all sub-expressions in the program to type variables and store this mapping in a pool data structure.
- Traverse the pool and apply the typing rules of the programming language to derive type equations for each application and procedure nodes in the AST of the program.
- Solve the resulting type equations system using the unification algorithm which computes a substitution for the whole program.
- The direct unification implemenation of this algorithm relies on one-way TVar data structure and exhaustive traversal of the AST. Each time an application or procedure nodes are met, the corresponding type equation is eagerly solved by assigning type variables to the corresponding type expressions.