Programming Language Theory: From Machine Code to Type Systems and Paradigms
Prerequisite:What an Operating System Does: Abstraction, Scheduling and Virtual Memory
0. Key points
Section titled “0. Key points”- All a CPU can execute is a fixed-length string of bits (machine code). Assembly language merely attaches human-readable names to those bit strings; between assembly and a high-level language there is a qualitative break, namely a meaning-preserving translation.
- The difference between a compiler and an interpreter is a difference of implementation strategy, not of language. The same language can have both. Where the distinction really bites is whether errors are found before execution or during it.
- The value of a type system comes down to two theorems: progress and preservation. Together they guarantee that a well-typed program never gets stuck in mid-execution (type soundness).
- Static type checking, so long as it is sound, is necessarily incomplete. This is not immaturity of implementation but a limit of principle, derived from the undecidability of the halting problem.
- The central notion of functional programming is referential transparency, which makes the theorem “the same expression evaluates to the same value however often it is evaluated” true. The central notions of object-oriented programming are dynamic dispatch and subtyping, and the subtyping rule for function types is contravariant in the argument.
1. Motivation: why do we need a “language”?
Section titled “1. Motivation: why do we need a “language”?”As we saw in Definition 6.1[Computer Architecture and the Structure of a CPU] of Computer architecture and the structure of the CPU, what a CPU understands is nothing but strings of 32 or 64 bits. On the machines of the 1940s people really did write out those bit strings on paper and enter them with switches or punched cards.
The work was unbearable in two distinct ways. First, one makes mistakes. A single wrong bit yields a different instruction, and since that instruction is usually “valid” too, the machine cheerfully goes on computing the wrong thing. Second, nothing can be revised. Insert one instruction into the middle of a program and every branch target after it shifts.
The idea that emerged was: write in symbols and let the machine do the translating. There is a leap hidden here — the self-reference that the translator is itself a program. When Grace Hopper completed the A-0 system in 1952 and John Backus’s team at IBM completed the FORTRAN compiler in 1957, many engineers held that machine-generated code could never beat hand-written code. Today that scepticism has been thoroughly overturned.
But the moment translation is handed to a machine, a new question appears: what does it mean for a translation to be correct? To answer it we must first define “the meaning of a program” mathematically. This article starts from that definition and works up to the design principles of type systems and paradigms.
2. The staircase of abstraction: machine code and assembly
Section titled “2. The staircase of abstraction: machine code and assembly”2.1. Machine code is an encoding of instructions
Section titled “2.1. Machine code is an encoding of instructions”Take the 32-bit RISC-V instruction addi (add immediate) as our example. The convention fixing which instruction name corresponds to which arrangement of bits is the instruction set architecture of Definition 8.1[Computer Architecture and the Structure of a CPU]. This instruction has the layout called the I-format: from the top, a 12-bit immediate, a 5-bit source register number rs1, a 3-bit funct3, a 5-bit destination register number rd, and a 7-bit opcode.
Example 2.1(Encoding the addi instruction by hand)
Let us encode the instruction “add 1 to the value in register a0 and put it back in a0”. In RISC-V, a0 is register number 10, that is 01010 in binary. The opcode of addi is 0010011 and its funct3 is 000. The immediate 1 is 000000000001 in 12 bits.
Concatenating these from the top:
000000000001 01010 000 01010 0010011 imm = 1 rs1=10 funct3 rd=10 opcodeRemoving the separators and regrouping into blocks of four bits gives 0000 0000 0001 0101 0000 0101 0001 0011, that is 0x00150513 in hexadecimal. By the same procedure add a0, a0, a1 (R-format; the encoding of the R-format is also computed in Example 8.2[Computer Architecture and the Structure of a CPU]) comes out as 0x00B50533, and the return instruction ret (really jalr x0, 0(x1)) as 0x00008067.
So the single line of assembly addi a0, a0, 1 is nothing more than an alternative notation standing in one-to-one correspondence with the 32-bit integer 0x00150513.
That this correspondence is one-to-one is decisive. The assembler’s whole job is to look up instruction names in a table of opcodes, turn register names into numbers, resolve labels into addresses, and pack the bits. It introduces no new concepts.
2.2. What did high-level languages add?
Section titled “2.2. What did high-level languages add?”A single line of C, by contrast, often expands into several instructions.
int add1(int x) { return x + 1; }Compiled for RISC-V with optimization, this becomes two instructions.
add1: addi a0, a0, 1 # 0x00150513 ret # 0x00008067What happens here is not a mere substitution of symbols. It uses knowledge of the calling convention (“the argument x is in the first argument register a0”; “the return value also goes in a0”), of the correspondence between types and representations (“int is 32-bit two’s complement”), and of an optimization decision (“a local variable may be allocated to a register”). What high-level languages added is the abstraction that takes such decisions out of human hands.
flowchart TD A["high-level source (C, Python, OCaml)"] --> B["intermediate representation (syntax tree, IR)"] B --> C["assembly language (addi a0, a0, 1)"] C --> D["machine code (0x00150513)"] D --> E["the CPU executes"] B -.meaning-preserving translation.-> C
3. Compilers and interpreters
Section titled “3. Compilers and interpreters”Definition 3.1(Translator and interpreter)
Consider a language (the source language), a language (the target language) and a language (the implementation language).
A compiler is a program written in which, given a program of as input, outputs a program of such that for every input the result of applying to agrees with the result of applying to .
An interpreter is a program written in which takes a pair consisting of a program of and an input , and directly outputs the result of applying to .
What this definition is meant to bring out is that being compiled or interpreted is not an attribute of a language. Both achieve the same goal, “realize the meaning of ”, and differ only in the shape of their output. Indeed C has interpreters (Cling, for one), and Python has implementations that generate machine code (the JIT of PyPy). To say “Python is an interpreted language” means, accurately, “the principal implementation of Python, CPython, is a bytecode interpreter”.
| Aspect | Compiled | Interpreted |
|---|---|---|
| When errors are found | Syntax and type errors are detected at translation time (before execution) | Detected when the line in question is reached |
| Execution speed | Fast, since optimization can be done at translation time | Carries the overhead of interpreting each instruction |
| Startup | The translation time must be paid first | Runs immediately |
| Portability | Must be retranslated for each target machine | The same code runs wherever there is an interpreter |
| Use of run-time information | Limited to what is statically known | Can optimize on the basis of the path actually taken |
The last row is the reason JIT (just-in-time compilation) exists. The fact that “the last ten thousand times, this call site received objects of the same type” cannot be known statically; it can only be observed at run time. A JIT bets on such observations to generate specialized code and falls back to interpretation when the bet fails. Compilation and interpretation are not opposites; the accurate view is that they differ in the moment at which information becomes available.
4. Stating the meaning of a program precisely
Section titled “4. Stating the meaning of a program precisely”To say “the translation preserves meaning” we need a definition of meaning. Here we demonstrate, on a minimal language, the most widely used approach: operational semantics, which gives meaning by specifying how a term is rewritten one step at a time.
Definition 4.1(Syntax and reduction relation of the language L)
Terms are given by the following grammar.
Numeric values and values are defined by
The one-step reduction relation is the least relation generated by the following rules.
A term to which no rule applies is called a normal form. A normal form that is not a value is called a stuck term.
Being stuck is what “run-time error” means in this formalization. For instance matches the shape of neither E-PredZero nor E-PredSucc, and E-Pred cannot be used either because cannot be reduced; so no rule applies. Nor is it a value. On a real machine this is the situation in which an exception “pred cannot be applied to a Boolean” is raised.
Example 4.2(Following an evaluation sequence to the end)
Let us reduce the term , noting on each line the rules used.
Since is a value, we stop here. Note that on the second line it is the single rule E-If, and nothing else, that decides “evaluate the condition to the end first”. Without that rule one could equally well write a semantics in which the branch is taken with the condition still unevaluated. Giving a semantics is exactly the work of fixing such choices explicitly.
This set of rules transcribes directly into an executable program. What follows is a complete interpreter for the language L.
def is_numeric(t): if t == ("zero",): return True if t[0] == "succ": return is_numeric(t[1]) return False
class Stuck(Exception): pass
def step(t): """Reduce the term t by one step; raise Stuck if no rule applies.""" k = t[0] if k == "if": _, t1, t2, t3 = t if t1 == ("true",): return t2 # E-IfTrue if t1 == ("false",): return t3 # E-IfFalse return ("if", step(t1), t2, t3) # E-If if k == "succ": return ("succ", step(t[1])) # E-Succ if k == "pred": u = t[1] if u == ("zero",): return ("zero",) # E-PredZero if u[0] == "succ" and is_numeric(u): return u[1] # E-PredSucc return ("pred", step(u)) # E-Pred if k == "iszero": u = t[1] if u == ("zero",): return ("true",) # E-IszeroZero if u[0] == "succ" and is_numeric(u): return ("false",) # E-IszeroSucc return ("iszero", step(u)) # E-Iszero raise Stuck(t)
def evaluate(t): while True: try: t = step(t) except Stuck: return t # a normal form has been reached
term = ("if", ("iszero", ("pred", ("succ", ("zero",)))), ("zero",), ("succ", ("zero",)))print(evaluate(term)) # ('zero',)print(evaluate(("pred", ("false",)))) # ('pred', ('false',)) <- stuckLemma 4.3(Basic properties of values)
(1) If is a numeric value, then there is no term with . (2) If is a value, then there is no term with .
Proof(Lemma 4.3)
We prove (1) by induction on the structure of . When , none of the rules of Definition 4.1 has a left-hand side of the form (the left-hand side of E-PredZero is , not ). Hence no reduction is possible.
When , the only rule whose left-hand side begins with is E-Succ. To use E-Succ we need the premise , but by the induction hypothesis cannot be reduced, so the premise cannot be met. Hence cannot be reduced either.
(2) A value is , or a numeric value. No rule has or on its left-hand side (the left-hand side of E-IfTrue is a term beginning with ), and for numeric values the claim is (1).
Theorem 4.4(Determinism of reduction)
For every term of the language L, if and then .
Proof(Theorem 4.4)
We argue by induction on the derivation of , splitting into cases on the shape of .
Case . If , the only applicable rule is E-IfTrue: using E-If would require the premise , and by Lemma 4.3 (2) cannot be reduced, so E-If is unavailable. Hence . The case is the same. If is neither nor , then neither E-IfTrue nor E-IfFalse matches, so both reductions come from E-If, with premises and respectively. By the induction hypothesis , whence .
Case . If , only E-PredZero applies (the premise of E-Pred fails by Lemma 4.3 (1)), and . If with a numeric value, E-PredSucc applies, while E-Pred would demand the premise , which fails by Lemma 4.3 (1). Hence . This is where part (1) of the lemma does essential work. If has the form with not a numeric value, then E-PredSucc does not match, so both reductions are by E-Pred and the induction hypothesis settles the matter. For all other shapes of , only E-Pred is available.
The case is argued exactly as for . When , only E-Succ applies and the claim follows from the induction hypothesis. When is a value it cannot be reduced by Lemma 4.3, contradicting the hypothesis, so that case does not arise.
Determinism is the formal content of “the same program yields the same result however often it is run”. Introduce concurrency or randomness and the theorem fails, and at that moment the difficulty of debugging leaps upward (on the machinery for controlling order and fixing results under concurrent execution, see Definition 6.1[What an Operating System Does]).
5. Types: static and dynamic typing
Section titled “5. Types: static and dynamic typing”As we saw right after Definition 4.1, L has terms that get stuck. A type system is a mechanism for excluding some of those terms before execution.
Definition 5.1(The typing relation of the language L)
Let the types be . The relation (“the term has type ”) is the least relation generated by the following rules.
Note that T-If demands the same for and . This one spot is the principal source of the incompleteness of type systems that we come to below.
Lemma 5.2(Canonical forms)
(1) If is a value and , then or . (2) If is a value and , then is a numeric value.
Proof(Lemma 5.2)
(1) A value is , or a numeric value. Suppose were a numeric value. If , the only rule assigning it a type is T-Zero, so only is derivable, contradicting . If , the only rule assigning it a type is T-Succ, and again the conclusion is . Hence is not a numeric value, and is therefore or .
(2) Similarly, if then T-True yields only , and if then T-False yields only . Both contradict the hypothesis , so is a numeric value.
Theorem 5.3(Progress)
A term with is either a value or there exists a term with . That is, a well-typed term is not a stuck term.
Proof(Theorem 5.3)
We argue by induction on the derivation of .
If T-True, T-False or T-Zero was used last, then is , or respectively, all of which are values.
In the case of T-If we have with . By the induction hypothesis is either a value or reducible. If it is reducible, then E-If lets the whole of reduce. If is a value, then and Lemma 5.2 (1) make equal to or , so E-IfTrue or E-IfFalse applies.
In the case of T-Succ we have with . By the induction hypothesis is a value or reducible. If reducible, E-Succ reduces . If a value, then by Lemma 5.2 (2) is a numeric value, so is itself a numeric value and hence a value.
In the case of T-Pred we have with . If is reducible we use E-Pred. If is a value then by Lemma 5.2 (2) it is a numeric value, and a numeric value has the form or . E-PredZero applies in the first case, E-PredSucc in the second. The point is that these two cases exhaust the numeric values; a case such as never arises here, because is not derivable.
The case of T-Iszero has the same structure, with E-IszeroZero and E-IszeroSucc covering all shapes of numeric value.
Theorem 5.4(Preservation)
If and , then . That is, reduction does not change the type.
Proof(Theorem 5.4)
We argue by induction on the derivation of , checking each rule. Throughout, the typing rules are determined uniquely by shape, so from the shape of we can read off the typing rule used last (the inversion lemma).
E-IfTrue: . The derivation of ends with T-If, so is among its premises. Hence . For E-IfFalse the premise is available in the same way.
E-If: with premise . From the premises of T-If we have , and . By the induction hypothesis . Applying T-If to these three gives .
E-Succ: by T-Succ, and . By the induction hypothesis , and T-Succ gives .
E-PredZero: . By T-Pred, , and by T-Zero, .
E-PredSucc: . By T-Pred, and . The only rule that can derive this is T-Succ, so is among its premises.
E-Pred: apply the induction hypothesis to the premise of T-Pred and reapply T-Pred.
E-IszeroZero: . By T-Iszero, , and by T-True, . E-IszeroSucc is the same with T-False.
E-Iszero: apply the induction hypothesis to the premise of T-Iszero and reapply T-Iszero. This exhausts all the reduction rules.
Corollary 5.5(Type soundness)
If , then for every reduction sequence starting from , the term is not a stuck term. That is, a well-typed program never gets stuck in mid-execution.
Proof(Corollary 5.5)
We induct on . Applying Theorem 5.4 times yields . Applying Theorem 5.3 to this shows that is either a value or further reducible. Since a stuck term is by definition a normal form that is not a value, cannot satisfy both conditions at once, and so is not stuck.
This two-step scheme, “progress + preservation = soundness”, has been the standard proof obligation in designing a type system ever since Wright and Felleisen formulated it in 1994. Add a new language feature and you reprove these two theorems. That, in substance, is what “designing a type system” means.
5.1. Static checking is necessarily incomplete
Section titled “5.1. Static checking is necessarily incomplete”Corollary 5.5 guarantees only one direction. Well-typed implies safe, but not conversely. The term reduces by E-IfTrue to and finishes without trouble, yet it has no type, because T-If demands that the branches agree. Is this a defect of the toy language L? It is not.
Theorem 5.6(Incompleteness of sound static checking)
Let be a Turing-complete programming language and let be the set of all programs that do not halt with a type error when executed. If is decidable (whether a given program belongs to can always be determined in finite time) and sound (), then . That is, there is necessarily a program that is safe but is not accepted by .
Proof(Theorem 5.6)
First we show that is undecidable, by reduction from the halting problem. Given a pair consisting of a Turing machine and an input , we construct mechanically the following program .
body of p(M, w): simulate M on w # may never halt evaluate 1 + true # reaching here is always a type errorSince is Turing complete, a simulator for can be written in . Then raises a type error precisely when the simulation halts and the second line is reached. Hence
holds. If were decidable, this correspondence would make the halting problem decidable as well, contradicting Turing’s result. So is undecidable.
Now is decidable and is undecidable, so . Since by hypothesis, follows. The elements of are exactly the programs that are safe yet not accepted.
5.2. Comparing static and dynamic
Section titled “5.2. Comparing static and dynamic”| Aspect | Static typing (Java, OCaml, Rust, TypeScript) | Dynamic typing (Python, Ruby, JavaScript) |
|---|---|---|
| Error detection | Before execution; errors on unreached paths are found too | At run time; a test that exercises the code is needed |
| Programs rejected | Rejects programs that are safe but ill-typed | Rejects nothing; you find out by running |
| Performance | Types are fixed, so value representations can be optimized | Carries the cost of checking type tags at run time |
| Maintainability | Types act as a machine-checked specification | The specification depends on documents and tests |
| Flexibility of expression | Extra code is sometimes needed to satisfy the checker | Prototypes and exploratory code can be written briefly |
| Refactoring | The type checker points out call sites you forgot to update | Omissions surface only when the code is run |
Example 5.7(Carrying out type inference to the end)
The labour of writing types in a statically typed language can be cut down considerably by type inference. Let us determine the type of the function twice (which applies its argument f twice) by collecting and solving constraints.
Let the type of be a type variable and the type of be .
- For the inner application to be typable, must be a function taking . Writing for the result type gives the constraint .
- In the outer application , receives . Writing for the result gives the constraint .
- From the two constraints, . An equation between function types decomposes into equations between arguments and between results, so and .
- Hence and .
The type of the whole is , and since no constraint remains on we quantify universally to obtain . Without a single type annotation, the most general type was determined uniquely. This procedure is Hindley–Milner type inference, and it underlies OCaml, Haskell, and the local type inference of Rust.
6. Paradigms: functional and object-oriented
Section titled “6. Paradigms: functional and object-oriented”6.1. Functional: referential transparency generates theorems
Section titled “6.1. Functional: referential transparency generates theorems”The theoretical matrix of functional programming is the -calculus of the 1930s. Its terms are just three: variables , abstractions , and applications ; and its rule of computation is just one, -reduction . This minimal system has the same computational power as the Turing machine.
Theorem 6.1(Church–Rosser theorem (confluence))
For a term of the -calculus, if and (where is the reflexive transitive closure of -reduction), then there exists a term with and .
The standard proof is the one by Tait and Martin-Löf using parallel reduction, and a complete version appears in Chapter 3 of Barendregt’s textbook. Here it is enough to grasp what the statement means. Where Theorem 4.4 said that there is only one order of reduction, Church–Rosser says that there may be several orders but that the results converge. Hence a normal form is unique if it exists, and whichever part of an expression one evaluates first, the final result is the same. This is the ground on which parallel execution and lazy evaluation do not destroy meaning.
Definition 6.3(Referential transparency)
A language is called referentially transparent when its evaluation relation is deterministic, the result of evaluating an expression depends only on the bindings of its free variables, and evaluation does not alter the state of the machine (mutation of variables, input/output, the current time, and so on).
Proposition 6.4(Soundness of common subexpression elimination)
In a referentially transparent language, suppose an expression evaluates to the value under an environment . Then replacing both of two occurrences of inside a larger expression by does not change the result of evaluating the whole.
Proof(Proposition 6.4)
Suppose the first evaluation of returned the value . By Definition 6.3, evaluation does not alter the state, so the environment is the same before and after that first evaluation. The second evaluation therefore takes place under the same environment . Evaluation of the same expression in the same environment returns the same value, by the assumption of determinism. Hence the second result is as well. Since both occurrences evaluate to the same value , replacing them by leaves the value of the whole unchanged.
This proposition justifies the optimization of computing once and reusing the result (common subexpression elimination). Conversely, without referential transparency the optimization is unsound.
Example 6.5(Side effects break optimization)
In the following Python code, treating f() + f() as a “common subexpression” and collapsing it into a single call changes the result.
counter = 0
def f(): global counter counter += 1 return counter
print(f() + f()) # 1 + 2 = 3
counter = 0a = f()print(a + a) # 1 + 1 = 2The outputs are 3 and 2, which disagree. The assumption of Definition 6.3 that “evaluation does not alter the state” is violated, so the first step in the proof of Proposition 6.4 (that the environment is the same) fails. Functional languages isolate side effects with types, or forbid them, not out of asceticism but in order to use this kind of equational reasoning safely.
6.2. Object-oriented: dynamic dispatch and subtyping
Section titled “6.2. Object-oriented: dynamic dispatch and subtyping”At the centre of object orientation is not inheritance but dynamic dispatch: the receiver decides at run time which code is to be called. What it really amounts to is a record bundling data together with function pointers.
def make_circle(r): return {"area": lambda: 3.14159 * r * r, "name": lambda: "circle"}
def make_square(s): return {"area": lambda: s * s, "name": lambda: "square"}
shapes = [make_circle(1.0), make_square(2.0)]for sh in shapes: print(sh["name"](), sh["area"]()) # circle 3.14159 / square 4.0Which function sh["area"] denotes is not settled until we look inside sh. At the level of machine code this comes down to two instructions: read a function pointer from memory and jump indirectly to that address (the virtual function tables of C++ and Java have exactly this structure). Because the branch target is not statically determined, the branch prediction discussed in Computer architecture and the structure of the CPU works poorly, and this is what the cost of a virtual call really consists of (a misprediction forfeits the gain from pipelining; see Proposition 7.2[Computer Architecture and the Structure of a CPU]).
Definition 6.6(Subtyping and the subsumption rule)
We require the relation between types (” is a subtype of ”) to be a relation for which the following subsumption rule is sound.
That is, wherever a is expected, a value of type may be placed instead.
This is the type-theoretic version of Liskov’s substitution principle. What, then, is the subtyping relation between function types?
Proposition 6.7(Variance of function types)
Subtyping of function types is given by
That is, the argument position is contravariant (the direction is reversed) and the result position is covariant (the direction is preserved).
Proof(Proposition 6.7)
Let be a function of type and suppose we use it where a is expected. What Definition 6.6 demands is that no type error arise from that use.
For the argument: the caller believes it holds a and so passes arbitrary values of type . Since can accept only values of type , every value of type must pass as a value of type . That is precisely .
For the result: returns a value of type , and the caller treats it as a , so every value of type must pass as a value of type . That is .
Hence, under these two conditions, the use of causes no type error.
Example 6.8(Covariance breaks things: Java arrays)
Java makes arrays covariant: if String is a subtype of Object, then String[] is treated as a subtype of Object[]. In the terms of Proposition 6.7, writing into an array occupies an argument position (an operation that receives an Object), so soundness requires contravariance. As a result of choosing covariance, the following code compiles.
String[] names = new String[1];Object[] objs = names; // permitted, since arrays are covariantobjs[0] = Integer.valueOf(42); // at compile time this is an Object[], so it passesAt run time, however, the third line throws ArrayStoreException. This is a state in which type soundness in the sense of Corollary 5.5 is broken, and Java plugs the hole by adding a dynamic check of the array’s element type at run time. Generics (List<String> versus List<Object>) are designed to be invariant precisely so as not to repeat the same mistake.
6.3. Which one is “right”?
Section titled “6.3. Which one is “right”?”The two optimize different faces of the same problem. Consider a two-dimensional table whose axes are “kinds of shape” and “operations on shapes”: the directions of extension are orthogonal.
| New data (add a triangle) | New operation (add perimeter) | |
|---|---|---|
| Functional (algebraic data types + pattern matching) | A branch must be added to every existing function | It suffices to add one new function |
| Object-oriented (classes + dynamic dispatch) | It suffices to add one new class | A method must be added to every existing class |
This is what Wadler calls the expression problem. The direction that is easy for one is the painful direction for the other. Recent languages (Scala, Rust, Swift, Kotlin) provide both algebraic data types and traits or protocols so that one can choose whichever side is easier to extend, according to the nature of the subject. It is best to regard a paradigm not as a creed but as a design decision about which axis of extension to favour.
7. Exercises
Section titled “7. Exercises”Exercise 7.1Easy
Reduce the term of the language L to a normal form, naming the rules of Definition 4.1 used at each step. Verify also that this term is typable by Definition 5.1.
Solution
The outermost term has the form with . Since is with a numeric value, E-PredSucc applies.
Since is a numeric value it is a value, and by Lemma 4.3 (1) it cannot be reduced further. A normal form is reached in one step. Trying E-Pred first is impossible, because its premise fails by the same lemma.
As for typing: T-Zero gives , two applications of T-Succ give , and finally T-Pred gives . Just as Theorem 5.4 asserts, the reduct also has type , by T-Zero and T-Succ.
Exercise 7.2Standard
Exhibit a term of the language L that is not typable and yet reduces to a value (does not get stuck), and verify both facts. Explain also why this phenomenon does not contradict Corollary 5.5.
Solution
Take .
It is not typable. The only rule that could give a type is T-If, and its premises demand and for the same . The only rule typing is T-Zero, so ; the only rule typing is T-False, so . Since these are incompatible, and has no type.
It reaches a value. Deriving the premise of E-If by E-IszeroZero gives , and then E-IfTrue gives . And is a value.
Why there is no contradiction. Corollary 5.5 is the one-way implication “typable not stuck”; it does not assert the converse “not stuck typable”. And as Theorem 5.6 shows, no decidable type system for which the converse holds exists at all (for a Turing-complete language). The branch is never executed, but the type checker judges on the conservative assumption that either branch may be executed.
Exercise 7.3Standard
Write out in full the case in the proof of Theorem 4.4, making explicit where Lemma 4.3 is used.
Solution
We split into cases on the shape of .
(a) . The candidate rules are E-IszeroZero and E-Iszero. Using E-Iszero would require the premise , but is a numeric value, so by Lemma 4.3 (1) the premise fails. Hence only E-IszeroZero applies and .
(b) with a numeric value. Here is itself a numeric value, so again by Lemma 4.3 (1) the premise of E-Iszero fails. Only E-IszeroSucc applies and .
(c) with not a numeric value. E-IszeroSucc requires the on its right-hand side to be a numeric value, so it does not match; E-IszeroZero does not match either. Hence both reductions are by E-Iszero, with premises and respectively. By the induction hypothesis , and therefore .
(d) of none of the above shapes (, , or a term beginning with or ). Neither E-IszeroZero nor E-IszeroSucc matches, so both reductions are by E-Iszero and the argument of (c) gives . Note that when is or the premise of E-Iszero also fails, by Lemma 4.3 (2), so cannot be reduced at all and the case does not arise under the hypothesis .
Exercise 7.4Hard
Suppose the subtyping rule for function types were declared covariant in the argument as well: whenever and . Assuming that the types Cat and Dog are both subtypes of Animal, construct a program that passes the type checker under this rule but breaks at run time.
Solution
Let be the function “take a cat and make it do something a cat does”: , whose body invokes an operation available only on Cat (say “sharpen its claws”).
Under the hypothetical covariant rule, and yield
So by the subsumption rule of Definition 6.6, may be used as a value of type .
Now pass to a higher-order function that takes an as its argument, and inside apply it to a value of type . Since , this application passes the type checker. At run time, however, the body of demands “sharpen its claws” of the value it was given, and Dog has no such operation, so it breaks. A term was well typed and yet got stuck, so type soundness in the sense of Corollary 5.5 is lost.
Under the correct rule Proposition 6.7, deriving would require , which does not hold, so the very first step is blocked. The Java arrays of Example 6.8 are a real instance of exactly this violation of contravariance.
References
Section titled “References”- Benjamin C. Pierce, Types and Programming Languages, MIT Press, 2002 — Chapter 3 (semantics of arithmetic expressions), Chapter 8 (typed arithmetic expressions, progress and preservation), Chapter 15 (subtyping). The language L and the skeleton of the theorems in this article follow this book’s development.
- Robert Harper, Practical Foundations for Programming Languages, 2nd ed., Cambridge University Press, 2016 — a general treatment of structural operational semantics and type safety.
- A. Wright and M. Felleisen, “A Syntactic Approach to Type Soundness”, Information and Computation 115 (1994), 38–94 — the formulation of type soundness proofs via progress and preservation.
- H. P. Barendregt, The Lambda Calculus: Its Syntax and Semantics, revised ed., North-Holland, 1984 — Chapter 3 contains a complete proof of the Church–Rosser theorem.
- A. V. Aho, M. S. Lam, R. Sethi, J. D. Ullman, Compilers: Principles, Techniques, and Tools, 2nd ed., Addison-Wesley, 2006 — the standard textbook from lexical analysis through optimization and code generation.
- B. H. Liskov and J. M. Wing, “A Behavioral Notion of Subtyping”, ACM Transactions on Programming Languages and Systems 16(6) (1994), 1811–1841 — a rigorous formulation of the substitution principle.
- RISC-V International, The RISC-V Instruction Set Manual, Volume I: Unprivileged ISA — https://riscv.org/technical/specifications/ the primary source for instruction formats and encodings.
Report an error in this article ・Operated by: Mugen Giken LLC ・Pricing ・Terms ・Legal notice
© 2026 夢現技研合同会社 ・Feeding the text to an LLM is welcome. Code samples are MIT licensed.