CMPUT-325 · Lesson 3 of 12 · 34 min

Semantics: operational and denotational

Lesson 2 settled which strings are programs. This lesson asks what those programs mean. Usually a language's meaning is explained in English, in a reference manual, and usually that works well enough. But English is ambiguous, and when two compilers read a manual differently the same program can behave differently on each. Formal semantics gives a precise, mathematical account of meaning. It lets language designers find holes in their designs, lets implementers check their work, and lets anyone prove properties of programs. This lesson introduces the three classic styles, operational, denotational and axiomatic, using a tiny imperative language and Python implementations of each.

You met one piece of this already. CMPUT-272 lesson 12 used preconditions, postconditions and loop invariants to prove small programs correct; that is axiomatic semantics, which we revisit briefly at the end. Here we concentrate on operational semantics, which describes meaning by how programs run, because it maps most directly onto interpreters, the main artifact of this course.

Why formal semantics matters

Informal definitions leave gaps, and gaps cost money. The C standard lists over two hundred situations that are 'undefined behaviour', such as signed integer overflow or reading an uninitialized variable. Compilers are allowed to assume these never happen, and modern optimizers use that freedom aggressively, so a program with a hidden overflow may work in a debug build and fail mysteriously when optimized. JavaScript's early specification left the order of object property enumeration unspecified, and websites came to depend on one browser's order. Formal semantics forces every case to be decided.

Some languages have taken the formal route seriously. The Definition of Standard ML (1990, revised 1997) gives the whole language, static and dynamic semantics, as inference rules on a little over a hundred pages. WebAssembly, the low-level language that runs in web browsers, has a formal specification that was mechanized in the Isabelle proof assistant, and the mechanization found errors in the draft. The CompCert C compiler, written and proved correct in the Coq proof assistant, comes with a theorem that the compiled code behaves as the semantics of the source says. These are the strongest guarantees software has ever had.

A small language: IMP

To study semantics we need a language small enough to define completely. IMP, a standard teaching language, has arithmetic expressions (numbers, variables, +, -, *), Boolean expressions (true, false, <=, not, and) and commands: skip (do nothing), assignment x := a, sequencing c1; c2, conditionals if b then c1 else c2, and loops while b do c. A state maps each variable to an integer. The meaning of a command, informally, is how it changes the state. Despite its size, IMP is Turing-complete, so everything interesting about termination and loops already shows up.

Big-step operational semantics

Big-step semantics, also called natural semantics, defines a judgement written ⟨c, s⟩ ⇓ s′, read 'command c, started in state s, terminates in state s′'. The judgement is defined by inference rules. Each rule has premises above a line and a conclusion below it; if all premises hold, the conclusion holds. Rules with no premises are axioms. Here are the rules for assignment, sequencing and the two cases of while:

\[\frac{}{\langle x := a,\ s\rangle \Downarrow s[x \mapsto \mathcal{A}[\![a]\!]s]} \qquad \frac{\langle c_1, s\rangle \Downarrow s_1 \quad \langle c_2, s_1\rangle \Downarrow s_2}{\langle c_1; c_2,\ s\rangle \Downarrow s_2}\]
\[\frac{\mathcal{B}[\![b]\!]s = \mathit{false}}{\langle \mathtt{while}\ b\ \mathtt{do}\ c,\ s\rangle \Downarrow s} \qquad \frac{\mathcal{B}[\![b]\!]s = \mathit{true} \quad \langle c, s\rangle \Downarrow s_1 \quad \langle \mathtt{while}\ b\ \mathtt{do}\ c,\ s_1\rangle \Downarrow s_2}{\langle \mathtt{while}\ b\ \mathtt{do}\ c,\ s\rangle \Downarrow s_2}\]

Here \(\mathcal{A}[\![a]\!]s\) is the value of arithmetic expression a in state s, and \(s[x \mapsto v]\) is the state s updated so that x maps to v. The second while rule is recursive: to know where the loop ends up, run the body once and then run the whole loop again from the new state. If the loop never terminates, there is simply no finite derivation, so no final state is defined. A Python function that follows these rules case by case is a big-step interpreter, and it reads almost exactly like the rules.

A big-step interpreter for IMP; each branch of C is one inference rule
# IMP: a tiny imperative language, given a big-step (natural) semantics as a Python function.
# Abstract syntax as tuples:
#   a ::= n | x | ("+", a, a) | ("-", a, a) | ("*", a, a)
#   b ::= True | False | ("<=", a, a) | ("not", b) | ("and", b, b)
#   c ::= ("skip",) | (":=", x, a) | ("seq", c, c) | ("if", b, c, c) | ("while", b, c)
def A(a, s):
    if isinstance(a, int): return a
    if isinstance(a, str): return s[a]
    op, l, r = a
    return {"+": A(l, s) + A(r, s), "-": A(l, s) - A(r, s), "*": A(l, s) * A(r, s)}[op]

def B(b, s):
    if isinstance(b, bool): return b
    if b[0] == "<=": return A(b[1], s) <= A(b[2], s)
    if b[0] == "not": return not B(b[1], s)
    if b[0] == "and": return B(b[1], s) and B(b[2], s)

def C(c, s):
    """Big-step judgement <c, s> => s'.  Returns the final state (a new dict)."""
    tag = c[0]
    if tag == "skip": return s
    if tag == ":=":   return {**s, c[1]: A(c[2], s)}
    if tag == "seq":  return C(c[2], C(c[1], s))
    if tag == "if":   return C(c[2], s) if B(c[1], s) else C(c[3], s)
    if tag == "while":
        return C(c, C(c[2], s)) if B(c[1], s) else s

# factorial:  y := 1; while not (x <= 0) do (y := y * x; x := x - 1)
FACT = ("seq", (":=", "y", 1),
        ("while", ("not", ("<=", "x", 0)),
                  ("seq", (":=", "y", ("*", "y", "x")), (":=", "x", ("-", "x", 1)))))
for n in (0, 1, 5, 10):
    print(f"x = {n:2}:", C(FACT, {"x": n}))
Output
x =  0: {'x': 0, 'y': 1}
x =  1: {'x': 0, 'y': 1}
x =  5: {'x': 0, 'y': 120}
x = 10: {'x': 0, 'y': 3628800}
A derivation tree for the program x := 1; while x <= 2 do x := x + 1 starting from the empty state. The root conclusion is at the bottom. Above it, the sequencing rule has two premises: the assignment axiom giving x = 1, and a while-true rule. The while-true rule has premises for the test, the body giving x = 2, and another while-true node, which in turn leads to x = 3 and finally a while-false leaf.
A big-step derivation tree: the proof that a program terminates in a particular state

The structure of a big-step derivation is a tree, and the tree is a proof. A whole family of proofs about programs works by induction on the height of such derivation trees, using the structural induction you learned in CMPUT-272. One standard result is determinism: if ⟨c, s⟩ ⇓ s1 and ⟨c, s⟩ ⇓ s2 then s1 = s2. IMP is deterministic; a language with a nondeterministic choice operator or with threads would not be.

Small-step operational semantics

Big-step semantics only talks about programs that finish. It says nothing about an infinite loop beyond 'no derivation exists', and it cannot describe the interleaving of threads or the moment a program crashes halfway through. Small-step semantics, also called structural operational semantics after Gordon Plotkin's 1981 notes, describes computation as a sequence of single steps: ⟨c, s⟩ → ⟨c′, s′⟩, where c′ is what remains to be done. A program terminates when it reaches skip; it loops when the sequence of steps goes on forever.

\[\frac{\langle c_1, s\rangle \to \langle c_1^{\prime}, s^{\prime}\rangle}{\langle c_1; c_2,\ s\rangle \to \langle c_1^{\prime}; c_2,\ s^{\prime}\rangle} \qquad \frac{}{\langle \mathtt{skip}; c_2,\ s\rangle \to \langle c_2,\ s\rangle} \qquad \frac{}{\langle \mathtt{while}\ b\ \mathtt{do}\ c,\ s\rangle \to \langle \mathtt{if}\ b\ \mathtt{then}\ (c; \mathtt{while}\ b\ \mathtt{do}\ c)\ \mathtt{else\ skip},\ s\rangle}\]

The while rule is especially neat: a loop takes one step by unfolding itself into an if whose then branch runs the body and then the loop again. A small-step interpreter applies step repeatedly and can print every configuration along the way, which makes it a useful debugging and teaching tool.

A small-step interpreter for IMP, printing each configuration (command and state)
# Small-step (structural operational) semantics for IMP: one step at a time.
def A(a, s):
    if isinstance(a, int): return a
    if isinstance(a, str): return s[a]
    op, l, r = a
    return {"+": A(l, s) + A(r, s), "-": A(l, s) - A(r, s), "*": A(l, s) * A(r, s)}[op]
def B(b, s):
    if isinstance(b, bool): return b
    if b[0] == "<=": return A(b[1], s) <= A(b[2], s)
    if b[0] == "not": return not B(b[1], s)

def step(c, s):
    """One transition <c, s> -> <c', s'>.  ("skip",) is the finished command."""
    tag = c[0]
    if tag == ":=":
        return ("skip",), {**s, c[1]: A(c[2], s)}
    if tag == "seq":
        if c[1] == ("skip",):
            return c[2], s                                   # skip; c2  ->  c2
        c1, s1 = step(c[1], s)
        return ("seq", c1, c[2]), s1                         # step inside the left part
    if tag == "if":
        return (c[2] if B(c[1], s) else c[3]), s
    if tag == "while":                                       # unfold the loop once
        return ("if", c[1], ("seq", c[2], c), ("skip",)), s

def show(c):
    tag = c[0]
    if tag == "skip": return "skip"
    if tag == ":=": return f"{c[1]} := {c[2]}"
    if tag == "seq": return f"{show(c[1])}; {show(c[2])}"
    if tag == "if": return f"if {c[1]} then .. else .."
    if tag == "while": return "while .."

# x := 2; while not (x <= 0) do x := x - 1
prog = ("seq", (":=", "x", 2), ("while", ("not", ("<=", "x", 0)), (":=", "x", ("-", "x", 1))))
c, s, n = prog, {}, 0
while c != ("skip",):
    print(f"{n:2}  {show(c)[:46]:46}  {s}")
    c, s = step(c, s); n += 1
print(f"{n:2}  skip{'':42}  {s}")
Output
 0  x := 2; while ..                                {}
 1  skip; while ..                                  {'x': 2}
 2  while ..                                        {'x': 2}
 3  if ('not', ('<=', 'x', 0)) then .. else ..      {'x': 2}
 4  x := ('-', 'x', 1); while ..                    {'x': 2}
 5  skip; while ..                                  {'x': 1}
 6  while ..                                        {'x': 1}
 7  if ('not', ('<=', 'x', 0)) then .. else ..      {'x': 1}
 8  x := ('-', 'x', 1); while ..                    {'x': 1}
 9  skip; while ..                                  {'x': 0}
10  while ..                                        {'x': 0}
11  if ('not', ('<=', 'x', 0)) then .. else ..      {'x': 0}
12  skip                                            {'x': 0}

Small-step semantics is what abstract machines and debuggers implement, and it is the natural setting for concurrency: to model two threads, let either thread take the next step, and the set of all possible interleavings falls out of the rules. It is also the style used to state type soundness, which lesson 7 proves in the form 'a well-typed program never gets stuck'.

If we define a language twice, the definitions had better agree. For IMP one can prove that ⟨c, s⟩ ⇓ s′ exactly when ⟨c, s⟩ →* ⟨skip, s′⟩, where →* means zero or more steps. The proof is by induction, but we can at least test the claim. The program below generates thousands of random loop-free programs and compares the two interpreters; this kind of differential testing is how real language implementers catch bugs, and it is the core idea behind tools such as Csmith, which found hundreds of bugs in GCC and LLVM by comparing compilers on random C programs.

Differential testing: big-step and small-step interpreters agree on random programs
# The two semantics should agree.  Test that on many random small programs (seeded).
import random
rnd = random.Random(3)

def A(a, s):
    if isinstance(a, int): return a
    if isinstance(a, str): return s[a]
    op, l, r = a
    return {"+": A(l, s) + A(r, s), "-": A(l, s) - A(r, s)}[op]
def big(c, s):
    t = c[0]
    if t == "skip": return s
    if t == ":=": return {**s, c[1]: A(c[2], s)}
    if t == "seq": return big(c[2], big(c[1], s))
    if t == "if": return big(c[2] if A(c[1], s) > 0 else c[3], s)
def step(c, s):
    t = c[0]
    if t == ":=": return ("skip",), {**s, c[1]: A(c[2], s)}
    if t == "seq":
        if c[1] == ("skip",): return c[2], s
        c1, s1 = step(c[1], s); return ("seq", c1, c[2]), s1
    if t == "if": return (c[2] if A(c[1], s) > 0 else c[3]), s
def small(c, s):
    while c != ("skip",): c, s = step(c, s)
    return s

def rand_a(d):
    if d == 0 or rnd.random() < 0.3: return rnd.choice([rnd.randint(-3, 3), "x", "y"])
    return (rnd.choice("+-"), rand_a(d - 1), rand_a(d - 1))
def rand_c(d):
    k = rnd.random()
    if d == 0 or k < 0.35: return (":=", rnd.choice("xy"), rand_a(2))
    if k < 0.7: return ("seq", rand_c(d - 1), rand_c(d - 1))
    return ("if", rand_a(2), rand_c(d - 1), rand_c(d - 1))

trials = 2000
mismatches = sum(big(c, {"x": 1, "y": 2}) != small(c, {"x": 1, "y": 2})
                 for c in (rand_c(4) for _ in range(trials)))
print(f"{trials} random loop-free programs, mismatches between big-step and small-step: {mismatches}")
Output
2000 random loop-free programs, mismatches between big-step and small-step: 0

Denotational semantics

Denotational semantics, pioneered by Christopher Strachey and Dana Scott around 1970, takes a different view. It assigns to each phrase a mathematical object, its denotation, and requires the denotation of a compound phrase to be built only from the denotations of its parts. This requirement is called compositionality. The denotation of an arithmetic expression is a function from states to integers; the denotation of a command is a partial function from states to states, undefined on states where the command loops forever. The double square brackets in \(\mathcal{C}[\![c]\!]\) are the traditional notation for 'the meaning of c'.

Most cases are easy: \(\mathcal{C}[\![c_1; c_2]\!]\) is the composition of the two state transformers. The difficult case is while, because its meaning refers to itself. The solution is to define the meaning of the loop as the least fixed point of a functional F, where F(g) runs the body once and then behaves like g. Starting from the everywhere-undefined function, F applied once gives the meaning of the loop for states that need zero iterations, F applied twice handles up to one iteration, and so on. The loop's meaning is the limit of this chain of approximations. The program below computes the approximations for a summing loop.

Successive approximations to the meaning of a while loop, starting from the undefined function
# Denotational semantics: the meaning of a while loop is the least fixed point of a functional.
# A state transformer is a partial function from states to states; we represent it as a
# Python function returning None for "undefined" (does not terminate within this approximation).
def F(prev):
    """One unfolding of:  while x > 0 do (y := y + x; x := x - 1)"""
    def meaning(s):
        if s["x"] > 0:
            t = {"x": s["x"] - 1, "y": s["y"] + s["x"]}
            return prev(t)
        return s
    return meaning

bottom = lambda s: None          # the everywhere-undefined function
approx = bottom
start = {"x": 4, "y": 0}
for k in range(7):
    print(f"F^{k}(bottom) applied to {start}: {approx(start)}")
    approx = F(approx)
Output
F^0(bottom) applied to {'x': 4, 'y': 0}: None
F^1(bottom) applied to {'x': 4, 'y': 0}: None
F^2(bottom) applied to {'x': 4, 'y': 0}: None
F^3(bottom) applied to {'x': 4, 'y': 0}: None
F^4(bottom) applied to {'x': 4, 'y': 0}: None
F^5(bottom) applied to {'x': 4, 'y': 0}: {'x': 0, 'y': 10}
F^6(bottom) applied to {'x': 4, 'y': 0}: {'x': 0, 'y': 10}

After five unfoldings the approximation is defined on the state x = 4, because that state needs four iterations, and further unfoldings do not change the answer. Making this idea rigorous for all programs requires domain theory, which Scott developed for exactly this purpose. The pay-off of denotational semantics is equational reasoning: two programs are equivalent if their denotations are equal, and because meanings are compositional, equivalent parts can be swapped inside any larger program. Optimizing compilers rely on such equivalences every time they replace x * 2 with x + x or move a computation out of a loop.

Axiomatic semantics in brief

Axiomatic semantics, introduced by Tony Hoare in 1969 building on Robert Floyd's work, defines a language by what can be proved about its programs. A Hoare triple {P} c {Q} states that if precondition P holds before c runs and c terminates, then postcondition Q holds afterwards. Each construct has a proof rule; the rule for while needs a loop invariant, a property that holds before the loop and is preserved by each iteration. Tools such as Dafny, Frama-C and SPARK Ada build on this idea to verify real software, and Amazon Web Services uses related techniques to check parts of its storage and identity systems.

Testing candidate loop invariants: random tests cannot prove an invariant, but they quickly refute a wrong one
# Hoare logic: {P} c {Q}.  We cannot prove a triple by testing, but we can look for
# counterexamples quickly.  Check the loop invariant for summing 1..n.
import random
rnd = random.Random(7)

def run(n, check):
    i, total = 0, 0
    assert check(i, total, n), "invariant fails on entry"
    while i < n:
        i, total = i + 1, total + i + 1
        assert check(i, total, n), f"invariant fails after i={i}"
    return total

good = lambda i, t, n: t == i * (i + 1) // 2 and i <= n
bad  = lambda i, t, n: t == i * i                     # a wrong guess at an invariant

for name, inv in (("total = i(i+1)/2 and i <= n", good), ("total = i*i", bad)):
    try:
        for _ in range(500):
            n = rnd.randint(0, 50)
            assert run(n, inv) == n * (n + 1) // 2
        print(f"{name:30} survived 500 random tests")
    except AssertionError as e:
        print(f"{name:30} refuted: {e}")
Output
total = i(i+1)/2 and i <= n    survived 500 random tests
total = i*i                    refuted: invariant fails after i=2

Worked examples

Worked problem

Building a big-step derivation

Using the big-step rules, show that ⟨x := 3; y := x + 1, {}⟩ ⇓ {x ↦ 3, y ↦ 4}.

Show solution
  1. The command is a sequence, so use the sequencing rule; it needs two premises.
  2. First premise: ⟨x := 3, {}⟩ ⇓ {x ↦ 3} by the assignment axiom, since the value of 3 is 3.
  3. Second premise: ⟨y := x + 1, {x ↦ 3}⟩ ⇓ {x ↦ 3, y ↦ 4} by the assignment axiom, since x + 1 evaluates to 4 in that state.
  4. Both premises hold, so the sequencing rule concludes the judgement.

Answer: The derivation has the sequencing rule at the root and two assignment axioms as leaves, giving the final state {x ↦ 3, y ↦ 4}.

Worked problem

Counting small steps

How many small steps does ⟨while false do skip, s⟩ take to reach skip?

Show solution
  1. Step 1: the while rule unfolds the loop to if false then (skip; while false do skip) else skip.
  2. Step 2: the if rule evaluates the test to false and takes the else branch, giving skip.
  3. The configuration is now ⟨skip, s⟩, which is final.

Answer: Two steps, and the state is unchanged.

Worked problem

Using compositionality

Show that x := x + 0 and skip have the same denotation, and explain why that lets a compiler delete x := x + 0 anywhere it appears.

Show solution
  1. The denotation of x := x + 0 maps each state s to s with x set to s(x) + 0, which equals s(x).
  2. So it maps every state s to s itself, which is exactly the denotation of skip.
  3. Because denotations are compositional, the meaning of any larger program depends only on the meanings of its parts.
  4. Replacing a part by another with an equal denotation therefore leaves the meaning of the whole program unchanged.

Answer: Both denote the identity on states, so by compositionality x := x + 0 can be replaced by skip in any context.

Common mistakes

  • MistakeIf a language has a reference manual, its semantics is defined precisely.

    InsteadManuals are usually informal and leave gaps; formal semantics removes ambiguity.

  • MistakeBig-step semantics can describe non-terminating programs.

    InsteadA non-terminating program simply has no big-step derivation; small-step semantics shows the infinite sequence of steps.

  • MistakeRandom testing proves two semantics agree.

    InsteadTesting can find disagreements but cannot prove agreement; that needs an inductive proof.

  • MistakeA loop invariant must be true at every point inside the loop body.

    InsteadIt must hold before the loop and after each complete iteration; it may be broken temporarily inside the body.

  • MistakeDenotational semantics is only of theoretical interest.

    InsteadIts equational reasoning underlies compiler optimizations and program equivalence.

Summary and key terms

  • Formal semantics defines program meaning precisely, avoiding the gaps of informal manuals.
  • Big-step semantics relates a program and state to a final state with inference rules; a big-step interpreter mirrors the rules.
  • Small-step semantics describes single computation steps and handles non-termination and concurrency.
  • Different semantics of one language should agree; differential testing finds disagreements and proofs establish agreement.
  • Denotational semantics maps phrases to mathematical objects compositionally; loops are least fixed points.
  • Axiomatic semantics uses Hoare triples and loop invariants to prove correctness.
  • Standard ML, WebAssembly and CompCert show formal semantics used at real scale.
Operational semantics
A definition of meaning in terms of how programs execute.
Big-step semantics
Operational semantics relating a program and initial state directly to a final result.
Small-step semantics
Operational semantics describing computation one transition at a time.
Inference rule
A rule stating that a conclusion holds if its premises hold.
Configuration
A pair of the remaining program and the current state in small-step semantics.
Denotation
The mathematical object assigned to a phrase as its meaning.
Compositionality
The property that a phrase's meaning is determined by the meanings of its parts.
Fixed point
A value x with f(x) = x; loops are given meaning as least fixed points.
Hoare triple
A statement {P} c {Q} relating a precondition, a command and a postcondition.
UB (undefined behaviour)
A situation a language standard leaves unspecified, allowing any outcome.

Practice problems

Work each problem on paper first, then reveal the answer to check.

  1. Write the big-step rule for skip.

    Show answer

    An axiom with no premises: ⟨skip, s⟩ ⇓ s.

  2. Why does a non-terminating loop have no big-step derivation?

    Show answer

    A derivation is a finite tree; an infinite loop would need infinitely many applications of the while-true rule.

  3. Give the small-step rule for if when the test is true.

    Show answer

    ⟨if b then c1 else c2, s⟩ → ⟨c1, s⟩ when b evaluates to true in s.

  4. What does the notation s[x ↦ v] mean?

    Show answer

    The state that agrees with s except that x maps to v.

  5. What is differential testing?

    Show answer

    Running two implementations or semantics on the same inputs and comparing results to find disagreements.

  6. State the compositionality principle.

    Show answer

    The meaning of a compound phrase is a function of the meanings of its immediate parts.

  7. Why is the meaning of while defined as a fixed point?

    Show answer

    Its meaning refers to itself (the loop runs again after the body), and a fixed point is the solution of such a recursive equation.

  8. What is a loop invariant?

    Show answer

    A property that holds before the loop starts and is preserved by every complete iteration.

  9. Name two languages or tools with mechanized formal semantics.

    Show answer

    Examples: WebAssembly (mechanized in Isabelle), CompCert C (proved in Coq).

  10. Is IMP deterministic? What feature would make it non-deterministic?

    Show answer

    Yes; adding a choice operator or concurrent threads would make it non-deterministic.

Lesson quiz · 5 questions

Score 60% or more to complete this lesson.

Take the quiz