Part II: Simple Types Chapter List
Chapter 11: Simple Extensions
11.5 Let Bindings
When writing a complex expression, it is often useful-both for avoiding repetition and for increasing readability-to give names to some of its subexpressions. Most languages provide one or more ways of doing this. In ML, for example, we write let x=t1in t2 to mean "evaluate the expression t1 and bind the name x to the resulting value while evaluating t2. Our let-binder (summarized in Figure 11-4) follows ML's in choosing a call-by-value evaluation order, where the let-bound term must be fully evaluated before evaluation of the let-body can begin. The typing rule T-LET tells us that the type of a let can be calculated by calculating the type of the let-bound term, extending the context with a binding with this type, and in this enriched context calculating the type of the body, which is then the type of the whole let expression.
Figure 11-4: Let Binding
11.5.1 Exercise [Recommended, ???]
The letexercise typechecker (available at the book's web site) is an incomplete implementation of let expressions: basic parsing and printing functions are provided, but the clauses for TmLet are missing from the eval1 and typeof functions (in their place, you'll find dummy clauses that match everything and crash the program with an assertion failure). Finish it.
Can let also be defined as a derived form? Yes, as Landin showed; but the details are slightly more subtle than what we did for sequencing and ascription. Naively, it is clear that we can use a combination of abstraction and application to achieve the effect of a let-binding:
But notice that the right-hand side of this abbreviation includes the type annotation T1, which does not appear on the left-hand side. That is, if we imagine derived forms as being desugared during the parsing phase of some compiler, then we need to ask how the parser is supposed to know that it should generate T1 as the type annotation on the λ in the desugared internal-language term.
The answer, of course, is that this information comes from the typechecker! We discover the needed type annotation simply by calculating the type of t1. More formally, what this tells us is that the let constructor is a slightly different sort of derived form than the ones we have seen up till now: we should regard it not as a desugaring transformation on terms, but as a transformation on typing derivations (or, if you prefer, on terms decorated by the typechecker with the results of its analysis) that maps a derivation involving let
Thus, let is "a little less derived" than the other derived forms we have seen: we can derive its evaluation behavior by desugaring it, but its typing behavior must be built into the internal language.
In Chapter 22 we will see another reason not to treat let as a derived form: in languages with Hindley-Milner (i.e., unification-based) polymorphism, the let construct is treated specially by the typechecker, which uses it for generalizing polymorphic definitions to obtain typings that cannot be emulated using ordinary λ-abstraction and application.
11.5.2 Exercise [??]
Another way of defining let as a derived form might be to desugar it by "executing" it immediately-i.e., to regard let x=t1 in t2 as an abbreviation for the substituted body [x ? t1]t2. Is this a good idea?
< Free Open Study >
11.6 Pairs
Most programming languages provide a variety of ways of building compound data structures. The simplest of these is
pairs, or more generally tuples, of values. We treat pairs in this section, then do the more general cases of tuples and
labeled records in §11.7 and §11.8.[4]
The formalization of pairs is almost too simple to be worth discussing-by this point in the book, it should be about as easy to read the rules in Figure 11-5 as to wade through a description in English conveying the same information. However, let's look briefly at the various parts of the definition to emphasize the common pattern.
Figure 11-5: Pairs
Adding pairs to the simply typed lambda-calculus involves adding two new forms of term-pairing, written {t1,t2}, and projection, written t.1 for the first projection from t and t.2 for the second projection-plus one new type constructor, T1 × T2, called the product (or sometimes the cartesian product) of T1 and T2. Pairs are written with curly braces[5] to emphasize the connection to records in the §11.8.
For evaluation, we need several new rules specifying how pairs and projection behave. E-PAIRBETA1 and
E-PAIRBETA2 specify that, when a fully evaluated pair meets a first or second projection, the result is the appropriate component. E-PROJ1 and E-PROJ2 allow reduction to proceed under projections, when the term being projected from has not yet been fully evaluated. E-PAIR1 and E-PAIR2 evaluate the parts of pairs: first the left part, and then-when a value appears on the left-the right part.
The ordering arising from the use of the metavariables v and t in these rules enforces a left-to-right evaluation strategy for pairs. For example, the compound term
{pred 4, if true then false else false}.1 evaluates (only) as follows:
{pred 4, if true then false else false}.1 → {3, if true then false else false}.1 → {3, false}.1
→ (λx:Nat × Nat. x.2) {3, pred 5} → (λx:Nat × Nat. x.2) {3,4} → {3,4}.2
→ 4
The typing rules for pairs and projections are straightforward. The introduction rule, T-PAIR, says that {t1,t2} has type T1 × T2 if t1 has type T1 and t2 has type T2. Conversely, the elimination rules T-PROJ1 and T-PROJ2 tell us that, if t1 has a product type T11 × T12 (i.e., if it will evaluate to a pair), then the types of the projections from this pair are T11 and T12.
[4]
The fullsimple implementation does not actually provide the pairing syntax described here, since tuples are more general anyway.
[5]
The curly brace notation is a little unfortunate for pairs and tuples, since it suggests the standard mathematical notation for sets. It is more common, both in popular languages like ML and in the research literature, to enclose pairs and tuples in parentheses. Other notations such as square or angle brackets are also used.
< Free Open Study >
11.7 Tuples
It is easy to generalize the binary products of the previous section to n-ary products, often called tuples. For example, {1,2,true} is a 3-tuple containing two numbers and a boolean. Its type is written {Nat,Nat,Bool}.
The only cost of this generalization is that, to formalize the system, we need to invent notations for uniformly
describing structures of arbitrary arity; such notations are always a bit problematic, as there is some inevitable tension between rigor and readability. We write {tiiÎ1..n} for a tuple of n terms, t1 through tn, and {TiiÎ1..n} for its type. Note that n here is allowed to be 0; in this case, the range 1..n is empty and {tiiÎ1..n} is {}, the empty tuple. Also, note the difference between a bare value like 5 and a one-element tuple like {5}: the only operation we may legally perform on the latter is projecting its first component.
Figure 11-6 formalizes tuples. The definition is similar to the definition of products (Figure 11-5), except that each rule for pairing has been generalized to the n-ary case, and each pair of rules for first and second projections has become a single rule for an arbitrary projection from a tuple. The only rule that deserves special comment is E-TUPLE, which combines and generalizes the rules E-PAIR1 and E-PAIR2 from Figure 11-5. In English, it says that, if we have a tuple in which all the fields to the left of field j have already been reduced to values, then that field can be evaluated one step, from tj to t′j. Again, the use of metavariables enforces a left-to-right evaluation strategy.
Figure 11-6: Tuples
11.8 Records
The generalization from n-ary tuples to labeled records is equally straightforward. We simply annotate each field tj with a labelli drawn from some predetermined set L. For example, {x=5} and {partno=5524,cost=30.27} are both record values; their types are {x:Nat} and {partno:Nat,cost:Float}. We require that all the labels in a given record term or type be distinct.
The rules for records are given in Figure 11-7. The only one worth noting is E-PROJRCD, where we rely on a slightly informal convention. The rule is meant to be understood as follows: If {li=viiÎ1..n} is a record and lj is the label of its jth field, then {li=viiÎ1..n}.lj evaluates in one step to the jth value, vj. This convention (and the similar one that we used in E-PROJTUPLE) could be eliminated by rephrasing the rule in a more explicit form; however, the cost in terms of readability would be fairly high.
Figure 11-7: Records
11.8.1 Exercise [? ?]
Write E-PROJRCD more explicitly, for comparison.
Note that the same "feature symbol," {}, appears in the list of features on the upper-left corner of the definitions of both tuples and products. Indeed, we can obtain tuples as a special case of records, simply by allowing the set of labels to include both alphabetic identifiers and natural numbers. Then when the ith field of a record has the label i, we omit the label. For example, we regard {Bool,Nat,Bool} as an abbreviation for {1:Bool,2:Nat,3:Bool}. (This convention actually allows us to mix named and positional fields, writing {a:Bool,Nat,c:Bool} as an abbreviation for {a:Bool,2:Nat,c:Bool}, though this is probably not very useful in practice.) In fact, many languages keep tuples and records notationally distinct for a more pragmatic reason: they are implemented differently by the compiler.
Programming languages differ in their treatment of the order of record fields. In many languages, the order of fields in both record values and record types has no affect on meaning—i.e., the terms {partno=5524,cost=30.27} and
{cost=30.27,partno=5524} have the same meaning and the same type, which may be written either {partno:Nat,cost:Float} or {cost:Float, partno:Nat}. Our presentation chooses the other alternative: {partno=5524,cost=30.27} and
{cost=30.27,partno=5524} are different record values, with types {partno:Nat,cost:Float} and {cost:Float, partno:Nat}, respectively. In Chapter 15, we will adopt a more liberal view of ordering, introducing a subtype relation in which the types {partno:Nat,cost:Float} and {cost:Float,partno:Nat} are equivalent—each is a subtype of the other—so that terms of one type can be used in any context where the other type is expected. (In the presence of subtyping, the choice between ordered and unordered records has important effects on performance; these are discussed further in §15.6.
Once we have decided on unordered records, though, the choice of whether to consider records as unordered from the beginning or to take the fields primitively as ordered and then give rules that allow the ordering to be ignored is purely a question of taste. We adopt the latter approach here because it allows us to discuss both variants.)
11.8.2 Exercise [???]
In our presentation of records, the projection operation is used to extract the fields of a record one at a time. Many high-level programming languages provide an alternative pattern matching syntax that extracts all the fields at the same time, allowing some programs to be expressed much more concisely. Patterns can also typically be nested, allowing parts to be extracted easily from complex nested data structures.
We can add a simple form of pattern matching to an untyped lambda calculus with records by adding a new syntactic category of patterns, plus one new case (for the pattern matching construct itself) to the syntax of terms. (See Figure 11-8.)
Figure 11-8: (Untyped) Record Patterns
The computation rule for pattern matching generalizes the let-binding rule from Figure 11-4. It relies on an auxiliary "matching" function that, given a pattern p and a value v, either fails (indicating that v does not match p) or else yields a substitution that maps variables appearing in p to the corresponding parts of v. For example, match({x,y}, {5,true}) yields the substitution [x ? 5, y ? true] and match(x, {5,true}) yields [x ? {5,true}], while match({x}, {5,true}) fails. E-LETV uses match to calculate an appropriate substitution for the variables in p.
The match function itself is defined by a separate set of inference rules. The rule M-VAR says that a variable pattern always succeeds, returning a substitution mapping the variable to the whole value being matched against. The rule M-RCD says that, to match a record pattern {li=piiÎ1..n} against a record value {li=viiÎ1..n} (of the same length, with the same labels), we individually match each sub-pattern pi against the corresponding value vi to obtain a substitution σi, and build the final result substitution by composing all these substitutions. (We require that no variable should appear more than once in a pattern, so this composition of substitutions is just their union.)
Show how to add types to this system.
Give typing rules for the new constructs (making any changes to the syntax you feel are necessary in the process).
1.
Sketch a proof of type preservation and progress for the whole calculus. (You do not need to show full proofs—just the statements of the required lemmas in the correct order.)
2.
11.9 Sums
Many programs need to deal with heterogeneous collections of values. For example, a node in a binary tree can be either a leaf or an interior node with two children; similarly, a list cell can be either nil or a cons cell carrying a head and a tail,[6] a node of an abstract syntax tree in a compiler can represent a variable, an abstraction, an application, etc. The type-theoretic mechanism that supports this kind of programming is variant types.
Before introducing variants in full generality (in §11.10), let us consider the simpler case of binary sum types. A sum type describes a set of values drawn from exactly two given types. For example, suppose we are using the types PhysicalAddr = {firstlast:String, addr:String};
VirtualAddr = {name:String, email:String};
to represent different sorts of address-book records. If we want to manipulate both sorts of records uniformly (e.g., if we want to make a list containing records of both kinds), we can introduce the sum type[7]
Addr = PhysicalAddr + VirtualAddr;
each of whose elements is either a PhysicalAddr or a VirtualAddr.
We create elements of this type by tagging elements of the component types PhysicalAddr and VirtualAddr. For example, if pa is a PhysicalAddr, then inl pa is an Addr. (The names of the tags inl and inr arise from thinking of them as functions
inl : PhysicalAddr → PhysicalAddr+VirtualAddr inr : VirtualAddr → PhysicalAddr+VirtualAddr
that "inject" elements of PhysicalAddr or VirtualAddr into the left and right components of the sum type Addr. Note, though, that they are not treated as functions in our presentation.)
In general, the elements of a type T1+T2 consist of the elements of T1, tagged with the token inl, plus the elements of T2, tagged with inr.
To use elements of sum types, we introduce a case construct that allows us to distinguish whether a given value comes from the left or right branch of a sum. For example, we can extract a name from an Addr like this:
getName = λa:Addr. case a of inl x ⇒ x.firstlast | inr y ⇒ y.name;
When the parameter a is a PhysicalAddr tagged with inl, the case expression will take the first branch, binding the variable x to the PhysicalAddr; the body of the first branch then extracts the firstlast field from x and returns it. Similarly, if a is a VirtualAddr value tagged with inr, the second branch will be chosen and the name field of the VirtualAddr returned. Thus, the type of the whole getName function is Addr→String.
The foregoing intuitions are formalized in Figure 11-9. To the syntax of terms, we add the left and right injections and the case construct; to types, we add the sum constructor. For evaluation, we add two "beta-reduction" rules for the case construct-one for the case where its first subterm has been reduced to a value v0 tagged with inl, the other for a value v0 tagged with inr; in each case, we select the appropriate body and substitute v0 for the bound variable. The other evaluation rules perform evaluation in the first subterm of case and under the inl and inr tags.
Figure 11-9: Sums
The typing rules for tagging are straightforward: to show that inl t1 has a sum type T1+T2, it suffices to show that t1 belongs to the left summand, T1, and similarly for inr. For the case construct, we first check that the first subterm has a sum type T1+T2, then check that the bodies t1 and t2 of the two branches have the same result type T, assuming that their bound variables x1 and x2 have types T1 and T2, respectively; the result of the whole case is then T. Following our conventions from previous definitions, Figure 11-9 does not state explicitly that the scopes of the variables x1 and x2 are the bodies t1 and t2 of the branches, but this fact can be read off from the way the contexts are extended in the typing rule T-CASE.
11.9.1 Exercise [??]
Note the similarity between the typing rule for case and the rule for if in Figure 8-1: if can be regarded as a sort of degenerate form of case where no information is passed to the branches. Formalize this intuition by defining true, false, and if as derived forms using sums and Unit.