Skip to content

Programming Language Theory: From Machine Code to Type Systems and Paradigms

Prerequisite:What an Operating System Does: Abstraction, Scheduling and Virtual Memory

Raw
  • 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.1Encoding 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 opcode

Removing 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.

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 # 0x00008067

What 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
The staircase from source code to execution. Above the dashed arrow is notation written by humans, below it are representations handled by machines

Definition 3.1Translator and interpreter

Consider a language SS (the source language), a language TT (the target language) and a language II (the implementation language).

A compiler is a program CC written in II which, given a program pp of SS as input, outputs a program C(p)C(p) of TT such that for every input dd the result of applying pp to dd agrees with the result of applying C(p)C(p) to dd.

An interpreter is a program JJ written in II which takes a pair consisting of a program pp of SS and an input dd, and directly outputs the result of applying pp to dd.

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 pp”, 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”.

AspectCompiledInterpreted
When errors are foundSyntax and type errors are detected at translation time (before execution)Detected when the line in question is reached
Execution speedFast, since optimization can be done at translation timeCarries the overhead of interpreting each instruction
StartupThe translation time must be paid firstRuns immediately
PortabilityMust be retranslated for each target machineThe same code runs wherever there is an interpreter
Use of run-time informationLimited to what is statically knownCan optimize on the basis of the path actually taken

Remark 3.2

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.1Syntax and reduction relation of the language L

Terms tt are given by the following grammar.

t::=truefalseif t then t else t0succ tpred tiszero tt ::= \mathtt{true} \mid \mathtt{false} \mid \mathtt{if}\ t\ \mathtt{then}\ t\ \mathtt{else}\ t \mid \mathtt{0} \mid \mathtt{succ}\ t \mid \mathtt{pred}\ t \mid \mathtt{iszero}\ t

Numeric values nvnv and values vv are defined by

nv::=0succ nv,v::=truefalsenvnv ::= \mathtt{0} \mid \mathtt{succ}\ nv, \qquad v ::= \mathtt{true} \mid \mathtt{false} \mid nv

The one-step reduction relation ttt \to t' is the least relation generated by the following rules.

(E-IfTrue)if true then t2 else t3t2(E-IfFalse)if false then t2 else t3t3(E-If)t1t1if t1 then t2 else t3if t1 then t2 else t3(E-Succ)ttsucc tsucc t(E-PredZero)pred 00(E-PredSucc)pred (succ nv)nv(E-Pred)ttpred tpred t(E-IszeroZero)iszero 0true(E-IszeroSucc)iszero (succ nv)false(E-Iszero)ttiszero tiszero t\begin{aligned} &\text{(E-IfTrue)} && \mathtt{if}\ \mathtt{true}\ \mathtt{then}\ t_2\ \mathtt{else}\ t_3 \to t_2 \\ &\text{(E-IfFalse)} && \mathtt{if}\ \mathtt{false}\ \mathtt{then}\ t_2\ \mathtt{else}\ t_3 \to t_3 \\ &\text{(E-If)} && \frac{t_1 \to t_1'}{\mathtt{if}\ t_1\ \mathtt{then}\ t_2\ \mathtt{else}\ t_3 \to \mathtt{if}\ t_1'\ \mathtt{then}\ t_2\ \mathtt{else}\ t_3} \\ &\text{(E-Succ)} && \frac{t \to t'}{\mathtt{succ}\ t \to \mathtt{succ}\ t'} \\ &\text{(E-PredZero)} && \mathtt{pred}\ \mathtt{0} \to \mathtt{0} \\ &\text{(E-PredSucc)} && \mathtt{pred}\ (\mathtt{succ}\ nv) \to nv \\ &\text{(E-Pred)} && \frac{t \to t'}{\mathtt{pred}\ t \to \mathtt{pred}\ t'} \\ &\text{(E-IszeroZero)} && \mathtt{iszero}\ \mathtt{0} \to \mathtt{true} \\ &\text{(E-IszeroSucc)} && \mathtt{iszero}\ (\mathtt{succ}\ nv) \to \mathtt{false} \\ &\text{(E-Iszero)} && \frac{t \to t'}{\mathtt{iszero}\ t \to \mathtt{iszero}\ t'} \end{aligned}

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 pred false\mathtt{pred}\ \mathtt{false} matches the shape of neither E-PredZero nor E-PredSucc, and E-Pred cannot be used either because false\mathtt{false} 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.2Following an evaluation sequence to the end

Let us reduce the term if (iszero (pred (succ 0))) then 0 else succ 0\mathtt{if}\ (\mathtt{iszero}\ (\mathtt{pred}\ (\mathtt{succ}\ \mathtt{0})))\ \mathtt{then}\ \mathtt{0}\ \mathtt{else}\ \mathtt{succ}\ \mathtt{0}, noting on each line the rules used.

 if (iszero (pred (succ 0))) then 0 else succ 0 if (iszero 0) then 0 else succ 0premise of E-If derived by E-Iszero, whose premise is E-PredSucc (nv=0) if true then 0 else succ 0premise of E-If derived by E-IszeroZero 0E-IfTrue\begin{aligned} &\ \mathtt{if}\ (\mathtt{iszero}\ (\mathtt{pred}\ (\mathtt{succ}\ \mathtt{0})))\ \mathtt{then}\ \mathtt{0}\ \mathtt{else}\ \mathtt{succ}\ \mathtt{0} \\ \to&\ \mathtt{if}\ (\mathtt{iszero}\ \mathtt{0})\ \mathtt{then}\ \mathtt{0}\ \mathtt{else}\ \mathtt{succ}\ \mathtt{0} && \text{premise of E-If derived by E-Iszero, whose premise is E-PredSucc (} nv = \mathtt{0} \text{)} \\ \to&\ \mathtt{if}\ \mathtt{true}\ \mathtt{then}\ \mathtt{0}\ \mathtt{else}\ \mathtt{succ}\ \mathtt{0} && \text{premise of E-If derived by E-IszeroZero} \\ \to&\ \mathtt{0} && \text{E-IfTrue} \end{aligned}

Since 0\mathtt{0} 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',)) <- stuck

Lemma 4.3Basic properties of values

(1) If nvnv is a numeric value, then there is no term tt' with nvtnv \to t'. (2) If vv is a value, then there is no term tt' with vtv \to t'.

Proof(Lemma 4.3)

We prove (1) by induction on the structure of nvnv. When nv=0nv = \mathtt{0}, none of the rules of Definition 4.1 has a left-hand side of the form 0\mathtt{0} (the left-hand side of E-PredZero is pred 0\mathtt{pred}\ \mathtt{0}, not 0\mathtt{0}). Hence no reduction is possible.

When nv=succ nv1nv = \mathtt{succ}\ nv_1, the only rule whose left-hand side begins with succ\mathtt{succ} is E-Succ. To use E-Succ we need the premise nv1t1nv_1 \to t_1', but by the induction hypothesis nv1nv_1 cannot be reduced, so the premise cannot be met. Hence succ nv1\mathtt{succ}\ nv_1 cannot be reduced either.

(2) A value is true\mathtt{true}, false\mathtt{false} or a numeric value. No rule has true\mathtt{true} or false\mathtt{false} on its left-hand side (the left-hand side of E-IfTrue is a term beginning with if\mathtt{if}), and for numeric values the claim is (1).

Theorem 4.4Determinism of reduction

For every term tt of the language L, if ttt \to t' and ttt \to t'' then t=tt' = t''.

Proof(Theorem 4.4)

We argue by induction on the derivation of ttt \to t', splitting into cases on the shape of tt.

Case t=if t1 then t2 else t3t = \mathtt{if}\ t_1\ \mathtt{then}\ t_2\ \mathtt{else}\ t_3. If t1=truet_1 = \mathtt{true}, the only applicable rule is E-IfTrue: using E-If would require the premise t1t1t_1 \to t_1', and by Lemma 4.3 (2) true\mathtt{true} cannot be reduced, so E-If is unavailable. Hence t=t=t2t' = t'' = t_2. The case t1=falset_1 = \mathtt{false} is the same. If t1t_1 is neither true\mathtt{true} nor false\mathtt{false}, then neither E-IfTrue nor E-IfFalse matches, so both reductions come from E-If, with premises t1s1t_1 \to s_1 and t1s1t_1 \to s_1' respectively. By the induction hypothesis s1=s1s_1 = s_1', whence t=tt' = t''.

Case t=pred t1t = \mathtt{pred}\ t_1. If t1=0t_1 = \mathtt{0}, only E-PredZero applies (the premise 0\mathtt{0} \to \cdot of E-Pred fails by Lemma 4.3 (1)), and t=t=0t' = t'' = \mathtt{0}. If t1=succ nvt_1 = \mathtt{succ}\ nv with nvnv a numeric value, E-PredSucc applies, while E-Pred would demand the premise succ nv\mathtt{succ}\ nv \to \cdot, which fails by Lemma 4.3 (1). Hence t=t=nvt' = t'' = nv. This is where part (1) of the lemma does essential work. If t1t_1 has the form succ u\mathtt{succ}\ u with uu 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 t1t_1, only E-Pred is available.

The case t=iszero t1t = \mathtt{iszero}\ t_1 is argued exactly as for pred\mathtt{pred}. When t=succ t1t = \mathtt{succ}\ t_1, only E-Succ applies and the claim follows from the induction hypothesis. When tt 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]).

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.1The typing relation of the language L

Let the types be T::=BoolNatT ::= \mathtt{Bool} \mid \mathtt{Nat}. The relation t:T\vdash t : T (“the term tt has type TT”) is the least relation generated by the following rules.

(T-True)true:Bool(T-False)false:Bool(T-Zero)0:Nat(T-If)t1:Boolt2:Tt3:Tif t1 then t2 else t3:T(T-Succ)t:Natsucc t:Nat(T-Pred)t:Natpred t:Nat(T-Iszero)t:Natiszero t:Bool\begin{aligned} &\text{(T-True)} && \vdash \mathtt{true} : \mathtt{Bool} \qquad &&\text{(T-False)} && \vdash \mathtt{false} : \mathtt{Bool} \qquad &&\text{(T-Zero)} && \vdash \mathtt{0} : \mathtt{Nat} \\[4pt] &\text{(T-If)} && \frac{\vdash t_1 : \mathtt{Bool} \quad \vdash t_2 : T \quad \vdash t_3 : T}{\vdash \mathtt{if}\ t_1\ \mathtt{then}\ t_2\ \mathtt{else}\ t_3 : T} &&\text{(T-Succ)} && \frac{\vdash t : \mathtt{Nat}}{\vdash \mathtt{succ}\ t : \mathtt{Nat}} \\[4pt] &\text{(T-Pred)} && \frac{\vdash t : \mathtt{Nat}}{\vdash \mathtt{pred}\ t : \mathtt{Nat}} &&\text{(T-Iszero)} && \frac{\vdash t : \mathtt{Nat}}{\vdash \mathtt{iszero}\ t : \mathtt{Bool}} \end{aligned}

Note that T-If demands the same TT for t2t_2 and t3t_3. This one spot is the principal source of the incompleteness of type systems that we come to below.

Lemma 5.2Canonical forms

(1) If vv is a value and v:Bool\vdash v : \mathtt{Bool}, then v=truev = \mathtt{true} or v=falsev = \mathtt{false}. (2) If vv is a value and v:Nat\vdash v : \mathtt{Nat}, then vv is a numeric value.

Proof(Lemma 5.2)

(1) A value is true\mathtt{true}, false\mathtt{false} or a numeric value. Suppose vv were a numeric value. If v=0v = \mathtt{0}, the only rule assigning it a type is T-Zero, so only v:Nat\vdash v : \mathtt{Nat} is derivable, contradicting v:Bool\vdash v : \mathtt{Bool}. If v=succ nvv = \mathtt{succ}\ nv, the only rule assigning it a type is T-Succ, and again the conclusion is Nat\mathtt{Nat}. Hence vv is not a numeric value, and is therefore true\mathtt{true} or false\mathtt{false}.

(2) Similarly, if v=truev = \mathtt{true} then T-True yields only Bool\mathtt{Bool}, and if v=falsev = \mathtt{false} then T-False yields only Bool\mathtt{Bool}. Both contradict the hypothesis v:Nat\vdash v : \mathtt{Nat}, so vv is a numeric value.

Theorem 5.3Progress

A term tt with t:T\vdash t : T is either a value or there exists a term tt' with ttt \to t'. That is, a well-typed term is not a stuck term.

Proof(Theorem 5.3)

We argue by induction on the derivation of t:T\vdash t : T.

If T-True, T-False or T-Zero was used last, then tt is true\mathtt{true}, false\mathtt{false} or 0\mathtt{0} respectively, all of which are values.

In the case of T-If we have t=if t1 then t2 else t3t = \mathtt{if}\ t_1\ \mathtt{then}\ t_2\ \mathtt{else}\ t_3 with t1:Bool\vdash t_1 : \mathtt{Bool}. By the induction hypothesis t1t_1 is either a value or reducible. If it is reducible, then E-If lets the whole of tt reduce. If t1t_1 is a value, then t1:Bool\vdash t_1 : \mathtt{Bool} and Lemma 5.2 (1) make t1t_1 equal to true\mathtt{true} or false\mathtt{false}, so E-IfTrue or E-IfFalse applies.

In the case of T-Succ we have t=succ t1t = \mathtt{succ}\ t_1 with t1:Nat\vdash t_1 : \mathtt{Nat}. By the induction hypothesis t1t_1 is a value or reducible. If reducible, E-Succ reduces tt. If a value, then by Lemma 5.2 (2) t1t_1 is a numeric value, so succ t1\mathtt{succ}\ t_1 is itself a numeric value and hence a value.

In the case of T-Pred we have t=pred t1t = \mathtt{pred}\ t_1 with t1:Nat\vdash t_1 : \mathtt{Nat}. If t1t_1 is reducible we use E-Pred. If t1t_1 is a value then by Lemma 5.2 (2) it is a numeric value, and a numeric value has the form 0\mathtt{0} or succ nv\mathtt{succ}\ nv. 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 pred false\mathtt{pred}\ \mathtt{false} never arises here, because false:Nat\vdash \mathtt{false} : \mathtt{Nat} 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.4Preservation

If t:T\vdash t : T and ttt \to t', then t:T\vdash t' : T. That is, reduction does not change the type.

Proof(Theorem 5.4)

We argue by induction on the derivation of ttt \to t', checking each rule. Throughout, the typing rules are determined uniquely by shape, so from the shape of tt we can read off the typing rule used last (the inversion lemma).

E-IfTrue: t=if true then t2 else t3t2t = \mathtt{if}\ \mathtt{true}\ \mathtt{then}\ t_2\ \mathtt{else}\ t_3 \to t_2. The derivation of t:T\vdash t : T ends with T-If, so t2:T\vdash t_2 : T is among its premises. Hence t:T\vdash t' : T. For E-IfFalse the premise t3:T\vdash t_3 : T is available in the same way.

E-If: tif t1 then t2 else t3t \to \mathtt{if}\ t_1'\ \mathtt{then}\ t_2\ \mathtt{else}\ t_3 with premise t1t1t_1 \to t_1'. From the premises of T-If we have t1:Bool\vdash t_1 : \mathtt{Bool}, t2:T\vdash t_2 : T and t3:T\vdash t_3 : T. By the induction hypothesis t1:Bool\vdash t_1' : \mathtt{Bool}. Applying T-If to these three gives t:T\vdash t' : T.

E-Succ: by T-Succ, T=NatT = \mathtt{Nat} and t1:Nat\vdash t_1 : \mathtt{Nat}. By the induction hypothesis t1:Nat\vdash t_1' : \mathtt{Nat}, and T-Succ gives succ t1:Nat\vdash \mathtt{succ}\ t_1' : \mathtt{Nat}.

E-PredZero: pred 00\mathtt{pred}\ \mathtt{0} \to \mathtt{0}. By T-Pred, T=NatT = \mathtt{Nat}, and by T-Zero, 0:Nat\vdash \mathtt{0} : \mathtt{Nat}.

E-PredSucc: pred (succ nv)nv\mathtt{pred}\ (\mathtt{succ}\ nv) \to nv. By T-Pred, T=NatT = \mathtt{Nat} and succ nv:Nat\vdash \mathtt{succ}\ nv : \mathtt{Nat}. The only rule that can derive this is T-Succ, so nv:Nat\vdash nv : \mathtt{Nat} is among its premises.

E-Pred: apply the induction hypothesis to the premise t1:Nat\vdash t_1 : \mathtt{Nat} of T-Pred and reapply T-Pred.

E-IszeroZero: iszero 0true\mathtt{iszero}\ \mathtt{0} \to \mathtt{true}. By T-Iszero, T=BoolT = \mathtt{Bool}, and by T-True, true:Bool\vdash \mathtt{true} : \mathtt{Bool}. 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.5Type soundness

If t:T\vdash t : T, then for every reduction sequence tt1tnt \to t_1 \to \cdots \to t_n starting from tt, the term tnt_n is not a stuck term. That is, a well-typed program never gets stuck in mid-execution.

Proof(Corollary 5.5)

We induct on nn. Applying Theorem 5.4 nn times yields tn:T\vdash t_n : T. Applying Theorem 5.3 to this tnt_n shows that tnt_n is either a value or further reducible. Since a stuck term is by definition a normal form that is not a value, tnt_n 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 if true then 0 else false\mathtt{if}\ \mathtt{true}\ \mathtt{then}\ \mathtt{0}\ \mathtt{else}\ \mathtt{false} reduces by E-IfTrue to 0\mathtt{0} 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.6Incompleteness of sound static checking

Let L\mathcal{L} be a Turing-complete programming language and let PP be the set of all programs that do not halt with a type error when executed. If CLC \subseteq \mathcal{L} is decidable (whether a given program belongs to CC can always be determined in finite time) and sound (CPC \subseteq P), then CPC \subsetneq P. That is, there is necessarily a program that is safe but is not accepted by CC.

Proof(Theorem 5.6)

First we show that PP is undecidable, by reduction from the halting problem. Given a pair consisting of a Turing machine MM and an input ww, we construct mechanically the following program pM,wp_{M,w}.

body of p(M, w):
simulate M on w # may never halt
evaluate 1 + true # reaching here is always a type error

Since L\mathcal{L} is Turing complete, a simulator for MM can be written in L\mathcal{L}. Then pM,wp_{M,w} raises a type error precisely when the simulation halts and the second line is reached. Hence

pM,wP    M does not halt on wp_{M,w} \in P \iff M \text{ does not halt on } w

holds. If PP were decidable, this correspondence would make the halting problem decidable as well, contradicting Turing’s result. So PP is undecidable.

Now CC is decidable and PP is undecidable, so CPC \ne P. Since CPC \subseteq P by hypothesis, CPC \subsetneq P follows. The elements of PCP \setminus C are exactly the programs that are safe yet not accepted.

all programssafe programs (undecidable)pass the type checkerif true then 0 else false(safe but ill-typed)
The reach of sound static type checking. Only the inside of the heavy curve consists of well-typed programs, and safe programs always remain outside it
AspectStatic typing (Java, OCaml, Rust, TypeScript)Dynamic typing (Python, Ruby, JavaScript)
Error detectionBefore execution; errors on unreached paths are found tooAt run time; a test that exercises the code is needed
Programs rejectedRejects programs that are safe but ill-typedRejects nothing; you find out by running
PerformanceTypes are fixed, so value representations can be optimizedCarries the cost of checking type tags at run time
MaintainabilityTypes act as a machine-checked specificationThe specification depends on documents and tests
Flexibility of expressionExtra code is sometimes needed to satisfy the checkerPrototypes and exploratory code can be written briefly
RefactoringThe type checker points out call sites you forgot to updateOmissions surface only when the code is run

Example 5.7Carrying 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.

twice=λf. λx. f (f x)\mathtt{twice} = \lambda f.\ \lambda x.\ f\ (f\ x)

Let the type of ff be a type variable α\alpha and the type of xx be β\beta.

  1. For the inner application f xf\ x to be typable, ff must be a function taking β\beta. Writing γ\gamma for the result type gives the constraint α=βγ\alpha = \beta \to \gamma.
  2. In the outer application f (f x)f\ (f\ x), ff receives γ\gamma. Writing δ\delta for the result gives the constraint α=γδ\alpha = \gamma \to \delta.
  3. From the two constraints, βγ=γδ\beta \to \gamma = \gamma \to \delta. An equation between function types decomposes into equations between arguments and between results, so β=γ\beta = \gamma and γ=δ\gamma = \delta.
  4. Hence γ=δ=β\gamma = \delta = \beta and α=ββ\alpha = \beta \to \beta.

The type of the whole is (ββ)ββ(\beta \to \beta) \to \beta \to \beta, and since no constraint remains on β\beta we quantify universally to obtain β. (ββ)ββ\forall \beta.\ (\beta \to \beta) \to \beta \to \beta. 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 λ\lambda-calculus of the 1930s. Its terms are just three: variables xx, abstractions λx. t\lambda x.\ t, and applications t1 t2t_1\ t_2; and its rule of computation is just one, β\beta-reduction (λx. t) ut[x:=u](\lambda x.\ t)\ u \to t[x := u]. This minimal system has the same computational power as the Turing machine.

Theorem 6.1Church–Rosser theorem (confluence)

For a term tt of the λ\lambda-calculus, if tt1t \twoheadrightarrow t_1 and tt2t \twoheadrightarrow t_2 (where \twoheadrightarrow is the reflexive transitive closure of β\beta-reduction), then there exists a term ss with t1st_1 \twoheadrightarrow s and t2st_2 \twoheadrightarrow s.

Remark 6.2

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.3Referential 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.4Soundness of common subexpression elimination

In a referentially transparent language, suppose an expression ee evaluates to the value vv under an environment ρ\rho. Then replacing both of two occurrences of ee inside a larger expression by vv does not change the result of evaluating the whole.

Proof(Proposition 6.4)

Suppose the first evaluation of ee returned the value vv. By Definition 6.3, evaluation does not alter the state, so the environment ρ\rho is the same before and after that first evaluation. The second evaluation therefore takes place under the same environment ρ\rho. Evaluation of the same expression in the same environment returns the same value, by the assumption of determinism. Hence the second result is vv as well. Since both occurrences evaluate to the same value vv, replacing them by vv leaves the value of the whole unchanged.

This proposition justifies the optimization of computing ee once and reusing the result (common subexpression elimination). Conversely, without referential transparency the optimization is unsound.

Example 6.5Side 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 = 0
a = f()
print(a + a) # 1 + 1 = 2

The 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.0

Which 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.6Subtyping and the subsumption rule

We require the relation STS \le T between types (”SS is a subtype of TT”) to be a relation for which the following subsumption rule is sound.

Γt:SSTΓt:T\frac{\Gamma \vdash t : S \qquad S \le T}{\Gamma \vdash t : T}

That is, wherever a TT is expected, a value of type SS 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.7Variance of function types

Subtyping of function types is given by

S1S2T1T2T1S1 and S2T2S_1 \to S_2 \le T_1 \to T_2 \quad\Longleftarrow\quad T_1 \le S_1 \ \text{and}\ S_2 \le T_2

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 ff be a function of type S1S2S_1 \to S_2 and suppose we use it where a T1T2T_1 \to T_2 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 T1T2T_1 \to T_2 and so passes arbitrary values of type T1T_1. Since ff can accept only values of type S1S_1, every value of type T1T_1 must pass as a value of type S1S_1. That is precisely T1S1T_1 \le S_1.

For the result: ff returns a value of type S2S_2, and the caller treats it as a T2T_2, so every value of type S2S_2 must pass as a value of type T2T_2. That is S2T2S_2 \le T_2.

Hence, under these two conditions, the use of ff causes no type error.

Example 6.8Covariance 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 covariant
objs[0] = Integer.valueOf(42); // at compile time this is an Object[], so it passes

At 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.

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 functionIt suffices to add one new function
Object-oriented (classes + dynamic dispatch)It suffices to add one new classA 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.

Exercise 7.1Easy

Reduce the term pred (succ (succ 0))\mathtt{pred}\ (\mathtt{succ}\ (\mathtt{succ}\ \mathtt{0})) 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 pred u\mathtt{pred}\ u with u=succ (succ 0)u = \mathtt{succ}\ (\mathtt{succ}\ \mathtt{0}). Since uu is succ nv\mathtt{succ}\ nv with nv=succ 0nv = \mathtt{succ}\ \mathtt{0} a numeric value, E-PredSucc applies.

pred (succ (succ 0))succ 0(E-PredSucc, nv=succ 0)\mathtt{pred}\ (\mathtt{succ}\ (\mathtt{succ}\ \mathtt{0})) \to \mathtt{succ}\ \mathtt{0} \qquad (\text{E-PredSucc},\ nv = \mathtt{succ}\ \mathtt{0})

Since succ 0\mathtt{succ}\ \mathtt{0} 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 uuu \to u' fails by the same lemma.

As for typing: T-Zero gives 0:Nat\vdash \mathtt{0} : \mathtt{Nat}, two applications of T-Succ give succ (succ 0):Nat\vdash \mathtt{succ}\ (\mathtt{succ}\ \mathtt{0}) : \mathtt{Nat}, and finally T-Pred gives pred (succ (succ 0)):Nat\vdash \mathtt{pred}\ (\mathtt{succ}\ (\mathtt{succ}\ \mathtt{0})) : \mathtt{Nat}. Just as Theorem 5.4 asserts, the reduct succ 0\mathtt{succ}\ \mathtt{0} also has type Nat\mathtt{Nat}, 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 t=if (iszero 0) then 0 else falset = \mathtt{if}\ (\mathtt{iszero}\ \mathtt{0})\ \mathtt{then}\ \mathtt{0}\ \mathtt{else}\ \mathtt{false}.

It is not typable. The only rule that could give tt a type is T-If, and its premises demand 0:T\vdash \mathtt{0} : T and false:T\vdash \mathtt{false} : T for the same TT. The only rule typing 0\mathtt{0} is T-Zero, so T=NatT = \mathtt{Nat}; the only rule typing false\mathtt{false} is T-False, so T=BoolT = \mathtt{Bool}. Since NatBool\mathtt{Nat} \ne \mathtt{Bool} these are incompatible, and tt has no type.

It reaches a value. Deriving the premise of E-If by E-IszeroZero gives tif true then 0 else falset \to \mathtt{if}\ \mathtt{true}\ \mathtt{then}\ \mathtt{0}\ \mathtt{else}\ \mathtt{false}, and then E-IfTrue gives 0\to \mathtt{0}. And 0\mathtt{0} is a value.

Why there is no contradiction. Corollary 5.5 is the one-way implication “typable \Rightarrow not stuck”; it does not assert the converse “not stuck \Rightarrow 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 false\mathtt{false} 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 t=iszero t1t = \mathtt{iszero}\ t_1 in the proof of Theorem 4.4, making explicit where Lemma 4.3 is used.

Solution

We split into cases on the shape of t1t_1.

(a) t1=0t_1 = \mathtt{0}. The candidate rules are E-IszeroZero and E-Iszero. Using E-Iszero would require the premise 0t1\mathtt{0} \to t_1', but 0\mathtt{0} is a numeric value, so by Lemma 4.3 (1) the premise fails. Hence only E-IszeroZero applies and t=t=truet' = t'' = \mathtt{true}.

(b) t1=succ nvt_1 = \mathtt{succ}\ nv with nvnv a numeric value. Here t1t_1 is itself a numeric value, so again by Lemma 4.3 (1) the premise of E-Iszero fails. Only E-IszeroSucc applies and t=t=falset' = t'' = \mathtt{false}.

(c) t1=succ ut_1 = \mathtt{succ}\ u with uu not a numeric value. E-IszeroSucc requires the nvnv 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 t1st_1 \to s and t1st_1 \to s' respectively. By the induction hypothesis s=ss = s', and therefore t=iszero s=iszero s=tt' = \mathtt{iszero}\ s = \mathtt{iszero}\ s' = t''.

(d) t1t_1 of none of the above shapes (true\mathtt{true}, false\mathtt{false}, or a term beginning with if\mathtt{if} or pred\mathtt{pred}). Neither E-IszeroZero nor E-IszeroSucc matches, so both reductions are by E-Iszero and the argument of (c) gives t=tt' = t''. Note that when t1t_1 is true\mathtt{true} or false\mathtt{false} the premise of E-Iszero also fails, by Lemma 4.3 (2), so tt cannot be reduced at all and the case does not arise under the hypothesis ttt \to t'.

Exercise 7.4Hard

Suppose the subtyping rule for function types were declared covariant in the argument as well: S1S2T1T2S_1 \to S_2 \le T_1 \to T_2 whenever S1T1S_1 \le T_1 and S2T2S_2 \le T_2. 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 gg be the function “take a cat and make it do something a cat does”: g:CatUnitg : \mathtt{Cat} \to \mathtt{Unit}, whose body invokes an operation available only on Cat (say “sharpen its claws”).

Under the hypothetical covariant rule, CatAnimal\mathtt{Cat} \le \mathtt{Animal} and UnitUnit\mathtt{Unit} \le \mathtt{Unit} yield

CatUnit  AnimalUnit\mathtt{Cat} \to \mathtt{Unit} \ \le\ \mathtt{Animal} \to \mathtt{Unit}

So by the subsumption rule of Definition 6.6, gg may be used as a value of type AnimalUnit\mathtt{Animal} \to \mathtt{Unit}.

Now pass gg to a higher-order function hh that takes an AnimalUnit\mathtt{Animal} \to \mathtt{Unit} as its argument, and inside hh apply it to a value of type Dog\mathtt{Dog}. Since DogAnimal\mathtt{Dog} \le \mathtt{Animal}, this application passes the type checker. At run time, however, the body of gg 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 CatUnitAnimalUnit\mathtt{Cat} \to \mathtt{Unit} \le \mathtt{Animal} \to \mathtt{Unit} would require AnimalCat\mathtt{Animal} \le \mathtt{Cat}, 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.

  • 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 ISAhttps://riscv.org/technical/specifications/ the primary source for instruction formats and encodings.

Report an error in this article ・Operated by: Mugen Giken LLCPricingTermsLegal notice

© 2026 夢現技研合同会社 ・Feeding the text to an LLM is welcome. Code samples are MIT licensed.