The evaluators we saw for Slang were formulated in a "big-step"
style: they specify how a given expression can be evaluated to its final
value "all in one big step":
2 + 2 + 3 * 4 ⇓ 16
This style is simple and natural for many purposes — indeed, Gilles Kahn,
who popularized it, called it natural semantics. But there are some
things it does not do well. In particular, it does not give us a convenient
way of talking about concurrent programming languages, where the semantics
of a program — the essence of how it behaves — includes not just which
input states get mapped to which output states, but also the intermediate
states that it passes through along the way; this is crucial, since these
states can also be observed by concurrently executing code.
Another shortcoming of the big-step style is more technical but equally
critical in many situations. Suppose we want to define a variant of our
expression language where a value could be either a number or a list of
numbers. In the syntax of this extended language, it will be possible to
write strange expressions like 2 + nil, and our semantics for arithmetic
expressions will then need to say something about how such expressions
behave. One possibility is to maintain the convention that every arithmetic
expression evaluates to some number by choosing some way of viewing a list
as a number — e.g., by specifying that a list should be interpreted as 0
when it occurs in a context expecting a number. But this would be a bit of
a hack.
A much more natural approach is simply to say that the behavior of the
expression 2 + nil is undefined — i.e., it doesn't evaluate to any
result at all. And we can easily do this: we just have to formulate aeval
and beval as inductive propositions rather than functions, so that we can
make them partial functions instead of total ones.
Now, however, we encounter a subtlety that will become important once we
move to a full programming language with looping.
There, a program might fail to produce a result
for two quite different reasons: either because the execution gets into an
infinite loop or because, at some point, the program tries to do an
operation that makes no sense, such as adding a number to a list, so that
none of the evaluation rules can be applied.
These two outcomes — nontermination vs. getting stuck in an erroneous
configuration — should not be confused. In particular, we want to allow
the first (because permitting the possibility of infinite loops is the price
we pay for the convenience of programming with general looping constructs)
but prevent the second (which is just wrong), for example by
adding some form of typechecking to the language. Indeed, this will be a
major topic of the next chapter, on types. As a first step, we need a way
of presenting the semantics that allows us to distinguish nontermination
from erroneous "stuck states."
So, for lots of reasons, we'd like to have a finer-grained way of defining
and reasoning about program behaviors. This is the topic of the present
chapter. Our goal is to replace the "big-step" Eval relation with a
"small-step" relation that specifies, for a given program, how its atomic
steps of computation are performed. In the small-step style, we show how
to "reduce" an expression to a simpler form by performing a single step of
computation:
To save space, we start with an incredibly simple language of just
constants and addition. (We use single-letter constructors c and p
— for Constant and Plus — for brevity.) The same techniques scale up to
richer languages.
inductiveTmwhere|c(n:Nat)-- Constant|p(t₁t₂:Tm)-- Plus
Here is the same evaluator, written in exactly the same style, but formulated as an
inductively defined relation. We use the notation t ⇓ n for "t evaluates to n."
The notation command below is how that is declared: it introduces ⇓ as
infix syntax for the Eval relation defined with it, with a precedence saying
how tightly it binds.
This is the lightweight way to name a relation; later chapters, where a whole
object language needs a grammar rather than a single operator, reach for
declare_syntax_cat instead.
------- (const)
c n ⇓ n
t₁ ⇓ n₁
t₂ ⇓ n₂
----------------- (plus)
p t₁ t₂ ⇓ n₁ + n₂
We are defining a single reduction step, in which just one p node is
replaced by its value.
Each step finds the leftmostp node that is ready to go (both of its
operands are constants) and rewrites it in place. The first rule tells
how to rewrite this p node itself; the other two rules tell how to
find it.
A term that is just a constant cannot take a step.
Let's pause and check a couple of examples of reasoning with the step relation.
If t₁ steps to t₁', then p t₁ t₂ steps to p t₁' t₂.
We will be working with several different single-step relations, so it is
helpful to generalize a bit and state a few definitions and theorems about
relations in general. (The optional chapter Rel in Logical Foundations
develops some of these ideas in a bit more detail; reviewing that chapter
may be useful if the treatment here feels too terse.)
A binary relation on a type X is a family of propositions parameterized
by two elements of X — i.e., a proposition about pairs of elements of
X.
defRelation(X:Type):=X→X→Prop
Our main examples of such relations in this chapter will be the
single-step reduction relation, ⟶, and its multi-step variant, ⟶*,
defined below, but there are many other examples — e.g., the "equals,"
"less than," "less than or equal to," and "is the square of" relations on
numbers, and the "prefix of" relation on lists and strings.
One simple property a relation may have is being deterministic: like
Slang's big-step evaluation, each element is related to at most one other.
Theorem: For each t, there is at most one t' such that t steps to
t'. We prove it by induction on the derivation of the first step.
Proof sketch: We show that if x steps to both y₁ and y₂, then y₁
and y₂ are equal, by induction on a derivation of x ⟶ y₁. There are
several cases, depending on the last rule used in this derivation and the
last rule in the given derivation of x ⟶ y₂.
If both are plus, the result is immediate.
The cases when both derivations end with plusLeft or plusRight follow by
the induction hypothesis.
It cannot happen that one is plus and the other is plusLeft/plusRight,
since this would imply that x has the form p t₁ t₂ where both t₁
and t₂ are constants (by plus) and one of t₁ or t₂ has the
form p _.
Similarly, it cannot happen that one is plusLeft and the other is
plusRight, since this would imply that x has the form p t₁ t₂ where
t₁ has both the form p t₁₁ t₁₂ and the form c n.
Having introduced the idea of values, we can use it in the definition of
the ⟶ relation to write the plusRight rule in a slightly more elegant way.
------------------------------ (plus)
p (c n₁) (c n₂) ⟶ c (n₁ + n₂)
t₁ ⟶ t₁'
------------------- (plusLeft)
p t₁ t₂ ⟶ p t₁' t₂
IsValue v₁
t₂ ⟶ t₂'
------------------- (plusRight)
p v₁ t₂ ⟶ p v₁ t₂'
Again, the variable names in the informal presentation carry important
information: by convention, v₁ ranges only over values, while t₁ and
t₂ range over arbitrary terms.
(Given this convention, the explicit IsValue hypothesis is arguably
redundant, since the naming convention tells us where to add it when
translating the informal rule to Lean. We'll keep it for now, to maintain
a close correspondence between the informal and Lean versions of the rules,
but later on we'll drop it in informal rules for brevity.)
As a sanity check on this change, let's re-verify determinism. Here's an
informal proof:
Proof sketch: We must show that if x steps to both y₁ and y₂, then
y₁ and y₂ are equal. Consider the final rules used in the derivations
of x ⟶ y₁ and x ⟶ y₂.
If both are plus, the result is immediate.
The cases when both derivations end with plusLeft or plusRight follow by
the induction hypothesis.
It cannot happen that one is plus and the other is plusLeft/plusRight,
since this would imply that x has the form p t₁ t₂ where both t₁
and t₂ are constants (by plus) and one of t₁ or t₂ has the
form p _.
Similarly, it cannot happen that one is plusLeft and the other is
plusRight, since this would imply that x has the form p t₁ t₂ where
t₁ both has the form p t₁₁ t₁₂ and is a value (hence has the form
c n).
Most of this proof is the same as the one above. But to get maximum
benefit from the exercise you should try to write your formal version from
scratch and just use the earlier one if you get stuck. The impossible
cross-cases now also use the fact that a IsValue (a c n) cannot step.
The definition of single-step reduction for our toy language is fairly
simple, but for a larger language it would be easy to forget one of the
rules and accidentally create a situation where some term cannot take a
step even though it has not been completely reduced to a value. The
following theorem shows that we did not, in fact, make such a mistake here.
Theorem (Strong Progress): If t is a term, then either t is a value
or else there exists a term t' such that t ⟶ t'.
Proof: By induction on t.
Suppose t = c n. Then t is a value.
Suppose t = p t₁ t₂, where (by the IH) t₁ either is a value or can
step to some t₁', and where t₂ is either a value or can step to some
t₂'. We must show p t₁ t₂ is either a value or steps to some t'.
If t₁ and t₂ are both values, then t can take a step, by
plus.
If t₁ is a value and t₂ can take a step, then so can t, by
plusRight.
If t₁ can take a step, then so can t, by plusLeft.
This important property is called strong progress, because every term
either is a value or can "make progress" by stepping to some other term.
(The qualifier "strong" distinguishes it from a more refined version that
we'll see in later chapters, called simply progress.)
The idea of "making progress" can be extended to tell us something
interesting about values in this language: they are exactly the terms that
do not make progress in this sense. Let's give a name to "terms that
cannot make progress." We'll call them normal forms.
Note that this definition specifies what it is to be a normal form for an
arbitrary relation R over an arbitrary type X, not just for the
particular single-step reduction relation over terms that we are interested
in at the moment. We'll re-use the same terminology for talking about
other relations later in the course.
We can use this terminology to generalize the observation we made in the
strong progress theorem: in this language (though not necessarily, in
general), normal forms and values are actually the same thing.
Why is this interesting? Because IsValue is a syntactic concept — it is
defined by looking at the way a term is written — while IsNormalForm is a
semantic one — it is defined by looking at how the term steps.
It is not obvious that these concepts should characterize the same set of terms!
Indeed, we could easily have written the definitions (incorrectly) so that
they would not coincide.
Suppose, for example, we define IsValue so that it includes some terms that
are not finished reducing. (Even if you don't work the exercise
value_not_same_as_normal_form1 below and the following ones, make sure you
can think of an example of such a term.)
Or we might (again, wrongly) define Step so that it permits something
designated as a value to reduce further. We again lose the property that
values are the same as normal forms.
namespaceTemp2inductiveIsValue:Tm→Propwhere|const(n:Nat):IsValue(.cn)-- Original definitioninductiveStep:Tm→Tm→Propwhere|funny(n:Nat):Step(.cn)(.p(.cn)(.c0))-- <--- NEW|plus(n₁n₂:Nat):Step(.p(.cn₁)(.cn₂))(.c(n₁+n₂))|plusLeft(t₁t₁'t₂:Tm)(h:Stept₁t₁'):Step(.pt₁t₂)(.pt₁'t₂)|plusRight(v₁t₂t₂':Tm)(hv:IsValuev₁)(h:Stept₂t₂'):Step(.pv₁t₂)(.pv₁t₂')
Quiz
With this definition, to how many different terms does the following term
step (in exactly one step)?
Finally, we might define IsValue and Step so that there is some term that
is not a value but that also cannot take a step. Such terms are said to
be stuck. In this case, this is caused by a mistake in the semantics, but
we will also see situations where, even in a correct language definition, it
makes sense to allow some terms to be stuck. (Note that plusRight is missing
below.)
We've been working so far with the single-step reduction relation ⟶,
which formalizes the individual steps of an abstract machine for executing
programs. We can use the same machine to reduce programs to completion —
to find out what final result they yield. This can be formalized as
follows:
First, we define a multi-step reduction relation⟶*, which relates
terms t and t' if t can reach t' by any number (including zero)
of single reduction steps.
Then we define a "result" of a term t as a normal form that t can
reach by multi-step reduction.
Since we'll want to reuse the idea of multi-step reduction many times with
many different single-step relations, let's define the concept generically.
Given a relation R (e.g., the step relation ⟶), we define a new relation
Multi R, called the multi-step closure of R, as follows.
The relation Multi R has several crucial properties.
First, it is obviously reflexive (a term can execute to itself by taking zero
steps). That is just what the Multi.refl constructor says, so such a goal can
always be closed with exact .refl _. It comes up often enough that it is
worth registering the constructor as a reflexivity lemma, with the @[refl]
attribute. The rfl tactic then closes a zero-step execution exactly as it
closes x = x:
Second, it containsR — single-step reductions are a particular case of
multi-step executions. (It is this fact that justifies the word "closure"
in "multi-step closure of R.")
We have already seen that, for our language, single-step reduction is
deterministic — i.e., a given term can take a single step in at most one
way. It follows that, if t can reach a normal form, then this normal form
is unique.
In other words, we can actually pronounce IsNormalFormOf t t'
as "t' is the normal form of t."
Indeed, something stronger is true for this language (though not for all the
languages we will see): the reduction of any term t will eventually
reach a normal form in a finite number of steps — i.e., IsNormalFormOf is
a total function. We say the Step relation is normalizing. To prove
it, we need a couple of congruence lemmas.
With these lemmas in hand, the main proof is a straightforward induction.
Theorem: The Step relation is normalizing — i.e., for every t there
exists some t' such that t reduces to t' and t' is a normal form.
Proof sketch: By induction on terms. There are two cases:
t = c n for some n. Here t doesn't take a step, and we have
t' = t. We derive the left-hand side by reflexivity and the right-hand
side by observing (a) that values are normal forms (by
nf_same_as_value) and (b) that t is a value (by const).
t = p t₁ t₂ for some t₁ and t₂. By the IH, t₁ and t₂ reduce to
normal forms t₁' and t₂'. Recall that normal forms are values (by
nf_same_as_value); we therefore know that t₁' = c n₁ and t₂' = c n₂
for some n₁ and n₂. We combine the ⟶* derivations for t₁ and
t₂ using multistep_congr_1 and multistep_congr_2 to prove that
p t₁ t₂ reduces in many steps to t' = c (n₁ + n₂). Finally,
c (n₁ + n₂) is a value, which is in turn a normal form.
Having defined the operational semantics of our tiny programming language in
two different ways (big-step and small-step), it makes sense to ask whether
these definitions actually define the same thing!
They do, though it takes
a little work to show it. The details are left as an exercise. We consider
the two implications separately. First, big-step evaluation implies
multi-step reduction to a value.
The key ideas in the proof can be seen in the following picture:
p t₁ t₂ ⟶ (by plusLeft)
p t₁' t₂ ⟶ (by plusLeft)
p t₁'' t₂ ⟶ (by plusLeft)
...
p (c n₁) t₂ ⟶ (by plusRight)
p (c n₁) t₂' ⟶ (by plusRight)
p (c n₁) t₂'' ⟶ (by plusRight)
...
p (c n₁) (c n₂) ⟶ (by plus)
c (n₁ + n₂)
That is, the multi-step reduction of a term of the form p t₁ t₂ proceeds in
three phases:
First, we use plusLeft some number of times to reduce t₁ to a normal
form, which must (by nf_same_as_value) be a term of the form c n₁ for
some n₁.
Next, we use plusRight some number of times to reduce t₂ to a normal
form, which must again be a term of the form c n₂ for some n₂.
Finally, we use plus one time to reduce p (c n₁) (c n₂) to
c (n₁ + n₂).
To formalize this intuition, you'll need the congruence lemmas from above,
plus some basic properties of ⟶* (that it is reflexive, transitive, and
includes ⟶).
Write a detailed informal version of the proof of multistep_of_eval. (A
paper exercise — there is no Lean proof to fill in here.)
For the converse, we need one lemma, which establishes a relation between
single-step reduction and big-step evaluation. A single step preserves the
big-step value.
The fact that small-step reduction implies big-step evaluation is now
straightforward to prove, once we have factored out the observation that
every normal form is a value. The proof proceeds by induction on the
multi-step reduction sequence that is buried in the hypothesis
IsNormalFormOf t t'. (Make sure you understand the statement before you
start to work on the proof.)
Remember that we also defined big-step evaluation of terms as a function
evalF. Prove that it is equivalent to the relational semantics. (Hint: we
just proved that Eval and multistep are equivalent, so logically it
doesn't matter which you choose. One will be easier than the other, though!)
Now for a more serious example: a small-step semantics for the richer
arithmetic and boolean expressions of the Slang chapter (with subtraction,
multiplication, and the boolean operators) rather than the two-constructor
toy language we have used so far.
The small-step reduction relations for these expressions are straightforward
extensions of the tiny language we've been working up to now. To make them
easier to read, we introduce the symbolic notations ⟶a and ⟶b for the
arithmetic and boolean step relations.
We work in the Slang namespace, reusing the arithmetic and boolean expression
syntax (Aexp, Bexp) and the big-step evaluator (Aexp.eval) from the
Slang chapter:
Here is the small-step relation for arithmetic expressions. A compound
expression reduces its left operand first; once that is a value, it reduces
its right operand; once both are values, it computes the result. (We show
the rules for + in full; those for − and × have exactly the same
shape.)
Notice that AStep has exactly the shape Aexp → Aexp → Prop — i.e., it is a
Relation Aexp in the sense of the Relations section above. So the generic
vocabulary from that section (Deterministic, IsNormalForm, the multi-step
closure Multi, ...) applies to it directly.
Here is a one-step reduction: since the left operand 3 is already a value,
the right operand is the one that takes a step.
Every arithmetic expression is either a value or can take a step — the same
strong progress property we proved for the toy language, now for the richer
Slang arithmetic expressions.
The small-step relation for boolean expressions reduces the arithmetic
subexpressions of a comparison (using ⟶a) and then applies the comparison,
and it short-circuits ¬ and ∧ on boolean literals.
We are not actually going to bother to define boolean values, since they
aren't needed in the definition of ⟶b below (why?), though they might be if
our language were a bit more complicated (why?).
Again we show a
representative sample; neq, le, and gt follow the same pattern as eq.
Which of these properties does this small-step semantics for Slang
expressions satisfy? (Yes or No for each.)
determinism
strong progress (every non-value takes a step)
values and normal forms coincide (i.e., there are no "stuck" terms)
the step relation is normalizing (i.e., evaluation always terminates)
Show solution
Yes to all four. Expression evaluation always terminates, so ⟶a (and ⟶b) are normalizing.
Let us make good on the first of those answers. Both step relations are
deterministic: the value guards on the "step the right operand" rules mean
that at most one rule ever applies to a given term.
Exercise★★★(astep_deterministic)
The arithmetic step relation is deterministic. (Structurally this is the
value-based determinism proof from the toy language, repeated for +, −,
and ×; the impossible cross-cases close because a value num n cannot step.)
The boolean step relation is deterministic too. The comparison cases (eq,
neq, le, gt) reduce their operands with ⟶a, so they inherit determinism
from astep_deterministic; ¬ and the short-circuiting ∧ contribute only
base cases.
The relation ⟶a above bakes in a left-to-right evaluation order: the rule
plusRight can fire only once the left operand is already a value (IsAValue v₁).
But nothing about the meaning of + requires that order — we could just as
well reduce the right operand first, or interleave the two. Different orders
are exactly what a concurrent or optimizing implementation might choose, so it
is natural to ask whether the choice can affect the final answer.
Let's find out. We define a second small-step relation, ⟶n, that is
identical to ⟶a except that we drop the IsAValue side-condition: either
operand may take a step at any time.
Remarkably, this nondeterminism does not affect the final answer. The key
observation is that a single step never changes the big-step value of an
expression — whichever operand we advance, eval is preserved.
Exercise★★(anstep_preserves_eval)
Prove that one nondeterministic step leaves the big-step value unchanged.
Hint: induction on the step derivation; each case is immediate from eval
and, where present, the induction hypothesis.
Finally we can compare the two semantics. The deterministic relation ⟶a is
a special case of ⟶n: every ⟶a step is also an ⟶n step (it merely
happens, in addition, to respect the IsAValue guard).
Now put the pieces together: prove that the deterministic and nondeterministic
semantics always compute the same final result. That is, if a fully
reduces to .num n₁ under ⟶a and to .num n₂ under ⟶n, then n₁ = n₂.
Hint: both .num n₁ and .num n₂ are reachable by ⟶n (use
multi_astep_imp_anstep for the first), and ⟶n preserves eval.
So even though ⟶n is genuinely nondeterministic, the value it eventually
produces is completely determined — and it is the same value the deterministic
machine (and the big-step evaluator) computes. This confluence to a unique
result is exactly the property one wants when reordering or parallelizing the
evaluation of pure expressions.
Our last example is a small-step semantics for a stack machine that evaluates
arithmetic expressions. The machine's instructions push a constant or combine the
top two stack entries. The machine's behavior should match the big-step Aexp.eval
function defined earlier.
A program is a list of instructions, and the stack is a list of numbers.
Prove the compiler correct: running the compiled program from the empty stack
reduces, in some number of steps, to a stack holding exactly the value of the
expression.
Hint: this will not go through by a direct induction — the induction
hypothesis is too weak. Prove a more general statement first, about running
compile a followed by any leftover program p, starting from any stack
stk. (Reassociating the ++s with List.append_assoc, and chaining steps
with multi_trans/multi_single, are the moves you need.)
When experimenting with definitions of programming languages
in Lean, we often want to see what a particular term steps
to - i.e., we want to find proofs for goals of the form t ⟶* t'.
Consider, for example, reducing an arithmetic expression using the small-step
relation AStep.
Proofs that one term normalizes to another must repeatedly apply
Multi.step until the term reaches a normal form, with some very simple
intermediate steps along the way. Thankfully, we can automate this process
with a new tactic: solve_by_elim. When supplied with a list of
constructors, solve_by_elim [c₁, c₂, c₃, ...] will attempt to apply
these constructors repeatedly to a goal. It will also automatically
attempt to use simple tactics like rfl, trivial, congr and hypotheses
from the context in order to solve simple goals. So, for example,
the proof above also be written:
This one script would suffice to prove most concrete reduction sequences
for this simple language. To make it work for others, we would need to supply
constructors for those other languages to solve_by_elim. The languages we
will study in this book can grow to a large number of constructors for their Step
relations, so we'd like a way to supply all of them to solve_by_elim more easily.
Luckily, Lean supports this. We can register a constructor (or lemma) for use with
solve_by_elim with an attribute command:
Attributes
The command below tags all of these constructors with the SimpleArith attribute,
which we can then use to automatically pull all of these constructors in when we use
solve_by_elim. However, due to a limitation of Lean, this attribute needs to be
pre-declared in a different file; we can't create it here and then immediately use it.
For this book, we've predeclared all the attributes we'll use in a file called
AttributeDecls.lean, following the typical pattern from libraries like Mathlib.
We can package all this up into a dedicated tactic for solving reduction sequences,
which we'll call normalize:
syntax"normalize"" using "ident,+:tacticmacro_rules|`(tactic|normalizeusing$xs,*)=>`(tactic|first|applyMulti.refl|(applyMulti.step·solve_by_elim(maxDepth:=15)(constructor:=false)onlyusing$xs,*·normalizeusing$xs,*))