** 9. Typing Judgments ** ---------------------------------------------------------------------------- 9.1. We want to formalize the rules for when programs are legal, and what their semantics are if they are legal. One part is a formal specification of syntax; we've done that. A second part is a formal specification of when a syntactically legal program is semantically well-formed, i.e., when its names resolve properly and when it passes required typechecks. We'll explore this now. (We'll do a formal specification of the run-time evaluation rules later.) We will write the formal typechecking & name resolution rules as an inference system over *typing judgments*. Each typing judgment will have the form: Gamma |- e : tau (|- is called a turnstile) which is read "in the typing environment Gamma, syntactically well-formed expression e is semantically well-formed and evaluates to a value of type tau". Typing environments are just lists of id:tau pairs, extended just by writing comma-separated lists. Here's a grammar for Gamma, for the formalists: Gamma ::= {} (* the empty environment *) | Gamma, id:tau To define a given type system, we need to show rules for determining which typing judgments are true. We'll write these as inference rules with zero or more hypothesis or premise typing judgments and one consequent typing judgment, with a horizontal line separating the hypotheses from the consequent. E.g., the typing rule for the function application expression (e1 e2): Gamma |- e1 : tau1 -> tau2 Gamma |- e2 : tau1 -------------------- Gamma |- (e1 e2) : tau2 The meaning of such an inference rule is that, if one is able to prove all of the hypothesis judgments, with all identifiers replaced with particular values, in the given form, then one can prove the consequent judgment. The proof is just the proofs of the hypotheses + this rule, instantiated to the specific values of the identifers. One then constructs proof trees to show that particular syntactically well-formed expressions are semantically well-formed and have particular types, in particular typing environments. ---------------------------------------------------------------------------- 9.2. Here again is the syntax for the language of base types + function types: tau ::= int | bool | tau -> tau e ::= intconst | true | false | e + e | e < e | e = e | if e then e else e | lambda id:tau. e | e e | id We then go to each expression form and specify the conditions under which it is type-correct. Constant expressions are always type-correct, and yield the right type (inference rules with zero hypotheses are called axioms, and the horizontal line is sometimes omitted): -------------------- Gamma |- intconst : int -------------------- Gamma |- true : bool -------------------- Gamma |- false : bool Simple binary expressions are type-correct if the types of their arguments are appropriate (and of course the arguments are recursively semantically well-formed). Gamma |- e1 : int Gamma |- e2 : int -------------------- Gamma |- (e1 + e2) : int Gamma |- e1 : int Gamma |- e2 : int -------------------- Gamma |- (e1 < e2) : bool Equality is defined if the arguments are integers or booleans (but not other types). We can just give two ways for an = expression to be type-correct: Gamma |- e1 : int Gamma |- e2 : int -------------------- Gamma |- (e1 = e2) : bool Gamma |- e1 : bool Gamma |- e2 : bool -------------------- Gamma |- (e1 = e2) : bool Alternatively, we could write: Gamma |- e1 : tau Gamma |- e2 : tau -------------------- Gamma |- (e1 = e2) : bool But this would mean that = was defined over expressions of the same type, for any type (including arrow types and any other types we might add to our language). The rule we'd include depends on what we want = to mean. If expressions have to have boolean test arguments, and the then and else expressions have to be the same type, for any type: Gamma |- e1 : bool Gamma |- e2 : tau Gamma |- e3 : tau -------------------- Gamma |- (if e1 then e2 else e3) : tau Now we get to the fun part: functions. Applications we showed before: Gamma |- e1 : tau1 -> tau2 Gamma |- e2 : tau1 -------------------- Gamma |- (e1 e2) : tau2 Lambdas require us to add a binding to the type environment for the formal parameter when type-checking the body expression, and they figure out what the function's type is from the type of its formal and its body: Gamma, id:tau1 |- e : tau2 -------------------- Gamma |- (lambda id:tau1. e) : tau1->tau2 Note that the local nature of this type checking rule means that we need to have the user specify the type of the formal parameter (there's no way to figure it out uniquely otherwise), but that we can easily compute the type of the result from the type of the body expression. Alternatively, we could have designed the language with formals inferred as well, just by leaving the argument type less constrained: Gamma, id:tau1 |- e : tau2 -------------------- Gamma |- (lambda id. e) : tau1->tau2 The rule is still logically valid: if it's possible to show the hypothesis, for some instantiation of Gamma, id, tau1, e, and tau2, then the consequent is provable. But it's harder to turn this specification into a deterministic algorithm. ML type inference solves this, but uses a more global constraint-solving algorithm. Finally, we have to be able to look up formal parameter types: (id:tau) in Gamma -------------------- Gamma |- id : tau The lambda form adds bindings to the type environment, and the id form looks up these bindings added by lexically enclosing lambda forms. But what if two lambdas, one enclosing the other, bind the same id, perhaps to different types? We want the innermost binding to be the one taken. We could formalize it by writing more complicated lambda rule, which prunes out any old bindings for the formal before evaluating the body, or we could have a more complicated id rule that takes the rightmost binding of id. The latter approach is usually followed. One can just say that the "in" in "(id:tau) in Gamma" implicitly favors bindings later in Gamma over earlier ones, or we can make this explicit by writing more complicated id typing rules: -------------------- Gamma, id:tau |- id : tau id != id' Gamma |- id : tau -------------------- Gamma, id':tau |- id : tau Note that we have to say explicitly that id and id' are different in the second rule. The implicit quantification of the identiers in the rules are existential over the whole rule, so two occurrences of the same identifier in different parts of an inference rule are constrained to be instantiated to the same (i.e. structurally equal) thing, while different identifiers may or may not be bound to the same thing. -------------------------------------------------------------------------- 9.3. Example: is the following syntactically well-formed program also semantically well-formed in the empty typing environment? if so, what type of value does it compute? (lambda x:int. (lambda x:bool. ) (x = x)) 3 The following is a proof tree that shows that yes, this program is well-formed in the empty type environment, and that it computes a value of type bool. (Since the tree is big, I draw the tree over judgment names J1..., and separately define each judgment, just to fit it into the space of this page.) --- --- J12 J11 -------- --- --- J9 J10 J12 --------------- -- -- J8 J7 J7 -- ------ J6 J5 --------------- J4 -- -- J2 J3 ---------------- J1 J12= {},x:int,x:bool |- x : bool J11= {},x:int,x:bool |- true : bool J10= {},x:int,x:bool |- false : bool J9 = {},x:int,x:bool |- (x = true) : bool J8 = {},x:int,x:bool |- (if x = true then false else x) : bool J7 = {},x:int |- x : int J6 = {},x:int |- (lambda x:bool. if x = true then false else x) : bool->bool J5 = {},x:int |- (x = x) : bool J4 = {},x:int |- ((lambda x:bool. ...) (x = x)) : bool J2 = {} |- (lambda x:int. (lambda x:bool. ...) (x = x)) : int->bool J3 = {} |- 3 : int J1 = {} |- (lambda x:int. (lambda x:bool. ...) (x = x)) 3 : bool Each judgment is an instance of the general judgment form, and each occurrence of a horizontal line links 0 or more hypothesis judgment instances to a consequent judgment instance that satisfies the pattern of some inference rule defined above. In a more explicit proof tree, each horizontal line would be annotated with the name of one the corresponding inference rule (assuming they were named). This would then make it an easy task to verify that the proof actually is a proof in the logical system of inference defined above; basically just checking that the constraints of each inference rule are followed by the particular judgment instances above and below the lines. The bottom of the tree is what we're trying to prove, and the top of the tree are all inferences with no hypotheses (axioms, the base cases of the otherwise inductive proof). ---------------------------------------------------------------------------- 9.4. How does this proof tree relate to actual typechecking as performed by a compiler front-end? The inference system is a *non-deterministic* specification of what judgments are legal; given a candidate proof tree, the logical rules can be used to verify whether the tree is a valid proof. But to do typechecking we want to have an efficient, deterministic algorithm for constructing such proof trees. This algorithm is what we'd build into an actual typechecker. For the inference rules given above, there is a natural deterministic algorithm for computing the type of an expression in a typing environment, if it exists, and for reporting a type error otherwise. The basic idea is to read each inference rule in a circle, defining a case in a big recursive computeType function. Consider the application inference rule: Gamma |- e1 : tau1 -> tau2 Gamma |- e2 : tau1 -------------------- Gamma |- (e1 e2) : tau2 This rule logically just talks about what inferences are valid. Any error cases are simply left undefined; if one can't come up with a proof, then there's a type error in the input program, by definition. But we can read it as specifying a typechecker: given a type environment Gamma and an application expression of the form (e1 e2) as inputs, recursively call computeType on Gamma and e1 (the expression being applied), and verify that the result type is an arrow type. Also call computeType on Gamma and e2 (the argument expression), and verify that the result type is the same as the domain of the arrow type. If all this works out, then return the range of the arrow type as the result type of the application. In ML-like code: fun computeType (Gamma:TypeEnv, e:Expr):Type = case e of ... (* other expressions here *) | App(e1, e2) => let val tau_e1 = computeType (Gamma, e1) val tau_e2 = computeType (Gamma, e2) in case tau_e1 of Arrow(tau1, tau2) => if not typeEqual(tau1, tau_e2) then raiseTypeError("argument type not what function is expecting") else tau2 | _ => raise TypeError("invoking a non-function") end This deterministic reading works because the rules are written so that there's essentially only one way of proving that any given expression has a type, based solely on the input expression and type environment. So we start from the thing we want to prove and work our way backwards, with a unique subproblem to solve recursively at each step. The lambda expression with an explicit type for the formal parameter preserves this property. If we changed to having the system infer the type of the formal, then we'd lose this, since there would be possibly infinitely many different result types we could try to infer, and we have no local, deterministic way to decide what to do. ML's type inference will (conceptually) deterministically compute a set of constraints over types, instead of computing the types themselves, and then go into a post-pass trying to iteratively solve the constraints. ---------------------------------------------------------------------------- 9.5. The syntax for the full Core ML language (ignoring tuples and branded types, both of which can be treated as syntactic sugar for record types) is the following: tau ::= int | bool | tau -> tau | {id:tau, ... id:tau} | [id:tau, ..., id:tau] | rec id = tau | id e ::= intconst | true | false | if e then e else e | lambda p. e | e e | id | let val p = e in e | {id=e, ..., id=e} | #id e | [id=e]_tau | ?id e | %id e | case e of p => e "|" ... "|" p => e | fold e as tau | unfold e | rec id:tau = e p ::= id:tau | _:tau | {id=p, ..., id=p} | [id=p]_tau | fold p as tau We have a separate judgment for computing the type of a pattern, and also constructing a new set of type environment bindings for the identifiers bound by the pattern. This judgment will have the form |- p => Gamma : tau meaning "pattern p matches expressions of type tau, and binds identifiers to types as given by Gamma". Also, we'll handle predefined operators like + and < by starting typechecking in a non-empty initial value type environment, Gamma0, defined as follows: Gamma0 = {}, +:int*int->int, <:int*int->bool, ... We won't handle let-type expressions, at either the type or value level. To do this, we'd have to introduce an environment akin to Gamma that maps type identifiers to the types they're defined to be, and then invoke a helper judgment to process all tau's in source programs into the expanded form (processing away all the let-type shorthands to get a desugared form); we won't process away any of the type identifier references in bound by recursive types, however. We've added type annotations to all places that we need to, in order to ensure that we can read off a deterministic typechecking algorithm from these rules. This necessitated wildcard patterns and union introduction and pattern-matching rules to be extended with a specification of their type. The full typing rules are as follows: -------------------- Gamma |- intconst : int -------------------- Gamma |- true : bool -------------------- Gamma |- false : bool Gamma |- e1 : bool Gamma |- e2 : tau Gamma |- e3 : tau -------------------- Gamma |- (if e1 then e2 else e3) : tau Gamma |- p => Gamma' : tau1 Gamma,Gamma' |- e : tau2 -------------------- Gamma |- (lambda p. e) : tau1->tau2 Gamma |- e1 : tau1 -> tau2 Gamma |- e2 : tau1 -------------------- Gamma |- (e1 e2) : tau2 -------------------- Gamma,id:tau |- id : tau id != id' Gamma |- id : tau -------------------- Gamma,id':tau' |- id : tau Gamma |- id : tau -------------------- Gamma,{} |- id : tau Gamma |- e1 : tau1 Gamma |- p => Gamma' : tau1 Gamma,Gamma' |- e2 : tau2 -------------------- Gamma |- (let val p = e1 in e2) : tau2 Gamma |- e1 : tau1 ... Gamma |- eN : tauN -------------------- Gamma |- {id1=e1, ..., idN=eN} : {id1:tau1, ..., idN:tauN} Gamma |- e : {id1:tau1, ..., idN:tauN} -------------------- Gamma |- (#idi e) : taui tau = [id1:tau1, ..., idN:tauN] Gamma |- ei : taui -------------------- Gamma |- [idi=ei]_tau : tau Gamma |- e : [id1:tau1, ..., idN:tauN] -------------------- Gamma |- (?idi e) : bool Gamma |- e : [id1:tau1, ..., idN:tauN] -------------------- Gamma |- (%idi e) : taui Gamma |- e : tau Gamma |- p1 => Gamma1 : tau Gamma,Gamma1 |- e1 : tau' ... Gamma |- pN => GammaN : tau Gamma,GammaN |- eN : tau' -------------------- Gamma |- (case e of p1 => e1 ... pN => eN) : tau' tau = (rec id = tau') Gamma |- e : tau'[id := tau] -------------------- Gamma |- (fold e as tau) : tau Gamma |- e : (rec id = tau) -------------------- Gamma |- (unfold e) : tau[id := (rec id = tau)] Gamma,id:tau |- e : tau -------------------- Gamma |- (rec id:tau = e) : tau The pattern typing judgments are as follows: -------------------- |- (id:tau) => ({},id:tau) : tau -------------------- |- (_:tau) => {} : tau |- p1 => Gamma1 : tau1 ... |- pN => GammaN : tauN -------------------- |- {id1=p1,...,idN=pN} => (Gamma1,...,GammaN) : {id1:tau1, ..., idN:tauN} tau = [id1:tau1, ..., idN:tauN] |- pi => Gammai : taui -------------------- |- ([idi=pi]_tau) => (Gammai) : tau tau = (rec id = tau') |- p => Gamma : tau'[id := rec id = tau'] -------------------- |- (fold p as tau) => Gamma : tau