Lecture 4: IMP and Operational Semantics
In Lecture 2 we foreshadowed the need for a different style of semantics that could handle non-terminating programs. In Lecture 3 we started building some infrastructure that could deal with non-termination using transition systems, which allowed us to talk about intermediate states of a program and the steps between them. We saw that we could use this infrastructure to prove invariants about programs even if they didn't terminate.
However, when we were building transition systems for programs, we were doing a lot of manual work. For each program, we had to manually define the "states" it could reach. The choice of states was critical—make the wrong choice and we'd be able to prove invariants that weren't really invariant, or we wouldn't be able to prove anything at all. We discussed how this choice of state abstraction is somewhat of an art, with the "right" choice often depending on your intended use for the semantics.
In this lecture, we'll see how to automate converting a program into a transition system. This approach to modeling programs is known as operational semantics, and gives us a more general way to talk about intermediate states of any program in some language.
The IMP language
We're going to illustate operational semantics using a language called IMP. IMP is often used as an example of a simple but realistic programming language because it has the features that make up the essence of real languages—loops, conditionals, and assignment. IMP is short for "imperative", because IMP is an imperative language—that is, programs in IMP are made up of statements that mutate state, as opposed to the functional languages we've seen thus far (and will return to with the lambda calculus later this semester). You'll see a version of IMP in most PL textbooks; they vary in minor ways, but the idea is the same.
As always, to define a programming language, we need two things: syntax and semantics. Here's the syntax for IMP:
cmd := skip
| var <- expr
| cmd; cmd
| if expr then cmd else cmd
| while expr do cmd
Here, we're reusing the expr
language we saw at the end of Lecture 2, whose syntax looked like this:
expr := Const nat
| Var var
| Plus expr expr
| Times expr expr
What about semantics? Well, we saw in Lecture 2 that denotational semantics
wouldn't work well for cmd
, because we have loops that might not terminate.
Instead, we're going to define IMP's semantics operationally
as a transition system.
But we're going to keep our denotational semantics for expr
, because those worked great.
It's very common to see a combination of semantics like this in the real world;
we choose the best tool for each part of the job.
Small-step operational semantics
In Lecture 3, we had to redefine a new transition system for every program, because the way we defined states and steps was by staring at the program and handwaving about "iterations of the loop". How can we instead define a transition system for the entire language once and for all, rather than inventing one for each program?
We know we're defining a transition system, so we need to define three things:
- The set of states
. We saw at the end of Lecture 3 that, in addition to tracking variables, our states need some way to remember where we are in executing the program. We're going to do this by making our states be pairs , where is a valuation (a variable map) assigning values to each variable, and is acmd
reflecting the "rest" of the program that still needs to be executed. Informally, we can think of as analogous to the program counter most computer architectures have—it lets us track where we are in the program so that we know what the next step(s) will be. - The set of initial states
. Knowing how we define states, we can define the initial state as just an initial valuation and the entire program to execute . Again the analogy to computer architecture: when a program starts executing, the program counter is at the start of the program (the entire program remains to be executed), and the memory is either empty or all-zeros, depending on how you think about it. - The transition relation
. From our definition of states, we know this relation needs to tell us when we can step . Clearly, this is going to depend on and , but the rules for the relation no longer depend on the actual program we're trying to model, unlike in Lecture 3.
While cmd
,
we will need to consider cases for each constructor.
First, let's deal with assignments x <- e
.
Informally we know how this works:
we evaluate e
and then update the valuation to map x
to that value.
Here it is as an inference rule:
c
to step to,
we used skip
.
You can read skip
as meaning "no-op" or "do nothing".
The idea here is that skip
in this position means "terminated";
if we ever reach a state
Now let's look at sequence commands c1; c2
.
The intuition is that to execute two commands in sequence,
you first fully execute the left-hand side,
and then fully execute the right-hand side
starting from the state the left side ended in.
We're going to need two inference rules,
one for each of these two phases.
So first we'll need a rule like this that takes a step of c1
:
skip
means "terminated",
so the idea is that we're done with the left-hand side
when the skip
will never be able to step,
so in a state
How about conditionals if e then c1 else c2
?
Our intention is that conditionals can go two ways:
if e
is non-zero, then we want to execute c1
,
otherwise we want to execute c2
.
This structure suggests two inference rules,
one for each side of the conditional:
if
statement we can "get rid of".
If we want to enter the then
branch,
we throw away everything except
While loops while e do c
are conceptually similar to if
—we need
a rule for entering the loop and a rule for not entering the loop.
The big difference is that, when we enter the loop,
we need to retain the loop as code that needs to be executed in the future
because we want the whole loop to be evaluated again once the body has run once.
The sequence operation ;
gives us what we need to arrange this.
We have this rule for entering the loop:
e
evaluates to non-zero, then we know we need to execute the body of the loop c
one time,
and then re-execute the entire loop.
The case for not entering the loop just lets us throw the whole thing away:
Finally, what about skip
?
We've been using skip
to mean termination.
Termination means the program can't step any more.
So that means there's no rule with skip
on the left-hand side!
If we ever reach a state
We're done! We've defined the entire transition relation for IMP
as an inductively defined proposition (a set of inference rules whose conclusions are propositions about
An example execution
Last lecture we studied this program:
x = 5
while True:
x = x + 1
which we can translate into our IMP syntax like this:
x <- 5; while 1 do (x <- x + 1)
Let's see how this program executes under our small-step operational semantics.
We start from the state
Small-step semantics for concurrency
One power of small-step operational semantics is that we can study non-terminating and/or non-deterministic executions, which is a big step up over the denotational semantics we saw before. This is especially useful for concurrency, where we often talk about interleavings of concurrent tasks. We can reflect this idea in a small-step operational semantics. I'm not going to do the full development here—both Software Foundations and Formal Reasoning About Programs give good treatments of operational semantics for concurrency—but I can give the general idea.
Let's add a new parallel composition construct c1 || c2
to our IMP language.
The idea is that c1 || c2
represents two commands executing concurrently (on a shared memory).
At each step of the program,
we can choose which of c1
and c2
is the next one to step,
and in this way we're able to reach states that reflect interleavings of c1
and c2
.
Operationally, this is just two simple rules:
Notice that
As an example, consider this simple concurrent program:
x <- 5 || x <- 6
Here, we can think of each side of the ||
as being a separate thread.
Our semantics ensures that each thread can execute independently of the other,
but they share a memory, and so can see each other's state.
One possible series of steps this program can take is:
x
could be either 5
or 6
at the end of this program's execution.
That matches our intuition about how this program is racy:
depending on the order the threads execute in,
either assignment could "win".
Our model of concurrency here is pretty simple—real concurrency involves thinking about
ideas like memory consistency
and synchronization. But those bigger ideas are usually formalized in exactly this small-step operational style,
so while they're more complex, they'll hopefully be familiar now you've seen the idea.
Big-step operational semantics
Small-step operational semantics give us a general purpose way to talk about the behavior of a program. Every step makes only a small change to the state of the program, and so we can talk about fine grained invariants of the execution. This was especially great because it let us talk about the steps taken by non-terminating programs. The fine-grained steps also gave us a convenient way to model non-determinism, and especially concurrency—non-determinism is just having multiple rules that might apply to any individual state.
But small-step semantics are pretty tedious.
Most of the energy we spent on small-step proofs was not very interesting:
it was just crunching through transitions of the step relation,
and then every once in a while we'd get to actually "execute" a small piece of the program
(roughly speaking, the only interesting transitions were the ones where the valuation changed).
The rules for sequencing with ;
were especially annoying:
they required us to repeatedly disassemble the program into smaller pieces (the ;
operation
(this style of rule is known as a congruence rule, because it relates smaller steps to larger ones).
Is there a middle ground between tedious-but-powerful small-step operational semantics
and simple-but-inexpressive denotational semantics?
The answer is big-step operational semantics.
As the name suggests, big-step semantics take big steps,
and in particular,
a big step semantics relates any
Big-step semantics for IMP
Just like small-step semantics, we can define the big-step semantics relation
The first rule is already somewhat surprising, remembering what we said about skip
in the small-step case:
skip
can (big-)step,
whereas in the small-step semantics we said that any state where
The assignment rule isn't too different from its small-step version:
Here's another big difference from the small-step world: we only need one rule for ;
, like this:
;
as premises to the rule.
First, we reduce
The rules for if
look similar to their small-step counterparts,
except they follow the example of if
to run,
and then, assuming that side reduces to valuation
The rules for while
are the most complex,
because we have to somehow deal with running the remaining iterations of the loop:
while
command when the conditional evaluates to true,
we first evaluate the body a single time to get us to valuation
An example execution
Above we illustrated small-step semantics on the program x <- 5; while 1 do (x <- x + 1)
.
That's not going to work with big-step semantics, just like it didn't work with denotational semantics,
because there is no final valuation for this non-terminating program.
In fact, one definition of a program
Instead, let's study a simpler program:
x <- 5;
if x then y <- 1 else y <- 0
A convenient way to look at big-step semantics is using a proof tree, which is kind of like building an interpreter from the semantics and illustrating it with the rules that apply each time it recurses. Here, the tree looks like this, with apologies for the not-great online typesetting:
To conclude that our program evaluates to the final state
Big-step semantics as a relation
The idea of the big-step while
loops,
because the function we defined might recurse infinitely.
Here, we can still define while
loops
because it is a relation between while
,
with the understanding that for non-terminating programs
Similarly, the big-step semantics relation also still lets us talk about non-determinism;
it's OK for a relation to ascribe more than one value to an element of the domain.
If ||
operator.
Equivalence of big-step and small-step semantics
Now that we've given two different semantics for IMP, it would be nice to know that they are the same, for some suitable definition of equivalence. I'm going to elide the proofs because they're a bit tedious, but the idea goes something like this:
Theorem: If
Proof: By induction on the proposition
The other direction is also by induction, but needs a strengthened lemma to go through.
Lemma: If
Proof: By induction on the proposition
Theorem: If
Proof: Apply the previous lemma with
Big steps or small?
Why bother with both these approaches? My sense is that small-step operational semantics are the most common approach to modern programming language semantics, in large part because they can talk about concurrency and non-termination comfortably, and most real languages support both these behaviors. I also think small-step semantics is more interesting, because it gives as an additional dimension to think about when formalizing a language: how small should the steps be? The right answer to that question might often depend on what we want to use the semantics for, which is a Big PL Idea—we can design the semantics for the problem we want to solve, making our lives much easier.
But seeing both gives a sense of the spectrum of semantics approaches we've seen so far: big-step semantics sit somewhere between denotational and small-step in terms of both convenience and expressiveness. They give us the ability to at least formalize a semantics for a language that allows non-terminating programs, and to do some reasoning about individual programs in that language so long as they terminate.
On a less fundamental but still interesting note, big-step semantics are more convenient that denotational semantics from the perspective of a proof assistant like Coq, which leans heavily into (inductively defined) propositions as the way to formalize the world. Interpreters don't give rise to a natural definition in this style, but big-step semantics do. Big-step semantics are the canonical way to formalize interpreters in Coq, rather than the function-oriented approach we studied in Lecture 2.