In the previous several posts, I’ve formalized the notion of lattices, which
are an essential ingredient to formalizing the analyses in Anders Møller’s
lecture notes. However, there can be no program analysis without a program
to analyze! In this post, I will define the (very simple) language that we
will be analyzing. An essential aspect of the language is its
semantics, which
simply speaking explains what each feature of the language does. At the end
of the previous article, I gave the following inference rule which defined
(partially) how the if
-else
statement in the language works.
Like I mentioned then, this rule reads as follows:
If the condition of an
if
-else
statement evaluates to a nonzero value, then to evaluate the statement, you evaluate itsthen
branch.
Another similar — but crucially, not equivlalent – rule is the following:
This time, the English interpretation of the rule is as follows:
If the condition of an
if
-else
statement evaluates to one, then to evaluate the statement, you evaluate itsthen
branch.
These rules are certainly not equivalent. For instance, the former allows
the “then” branch to be executed when the condition is 2
; however, in
the latter, the value of the conditional must be 1
. If our analysis were
intelligent (our first few will not be), then this difference would change
its output when determining the signs of the following program:
x = 2
if x {
y = - 1
} else {
y = 1
}
Using the first, more “relaxed” rule, the condition would be considered “true”,
and the sign of y
would be -
. On the other hand, using the second,
“stricter” rule, the sign of y
would be +
. I stress that in this case,
I am showing a flow-sensitive analysis (one that can understand control flow
and make more specific predictions); for our simplest analyses, we will not
be aiming for flow-sensitivity. There is plenty of work to do even then.
The point of showing these two distinct rules is that we need to be very precise about how the language will behave, because our analyses depend on that behavior.
Let’s not get ahead of ourselves, though. I’ve motivated the need for semantics, but there is much groundwork to be laid before we delve into the precise rules of our language. After all, to define the language’s semantics, we need to have a language.
The Syntax of Our Simple Language
I’ve shown a couple of examples our our language now, and there won’t be that
much more to it. We can start with expressions: things that evaluate to
something. Some examples of expressions are 1
, x
, and 2-(x+y)
. For our
specific language, the precise set of possible expressions can be given
by the following Context-Free Grammar:
The above can be read as follows:
An expression is one of the following things:
- Some variable [importantly is a placeholder for any variable, which could be
x
ory
in our program code; specifically, is a metavariable.]- Some integer [once again, can be any integer, like 1, -42, etc.].
- The addition of two other expressions [which could themselves be additions etc.].
- The subtraction of two other expressions [which could also themselves be additions, subtractions, etc.].
Since expressions can be nested within other expressions — which is necessary
to allow complicated code like 2-(x+y)
above — they form a tree. Each node
is one of the elements of the grammar above (variable, addition, etc.). If
a node contains sub-expressions (like addition and subtraction do), then
these sub-expressions form sub-trees of the given node. This data structure
is called an Abstract Syntax Tree.
Notably, though 2-(x+y)
has parentheses, our grammar above does not include
include them as a case. The reason for this is that the structure of an
abstract syntax tree is sufficient to encode the order in which the operations
should be evaluated. Since I lack a nice way of drawing ASTs, I will use
an ASCII drawing to show an example.
Expression: 2 - (x+y)
(-)
/ \
2 (+)
/ \
x y
Expression: (2-x) + y
(+)
/ \
(-) y
/ \
2 x
Above, in the first AST, (+)
is a child of the (-)
node, which means
that it’s a sub-expression. As a result, that subexpression is evaluated first,
before evaluating (-)
, and so, the AST expresents 2-(x+y)
. In the other
example, (-)
is a child of (+)
, and is therefore evaluated first. The resulting
association encoded by that AST is (2-x)+y
.
To an Agda programmer, the one-of-four-things definition above should read quite similarly to the definition of an algebraic data type. Indeed, this is how we can encode the abstract syntax tree of expressions:
The only departure from the grammar above is that I had to invent constructors
for the variable and integer cases, since Agda doesn’t support implicit coercions.
This adds a little bit of extra overhead, requiring, for example, that we write
numbers as # 42
instead of 42
.
Having defined expressions, the next thing on the menu is statements. Unlike expressions, which just produce values, statements “do something”; an example of a statement might be the following Python line:
print("Hello, world!")
The print
function doesn’t produce any value, but it does perform an action;
it prints its argument to the console!
For the formalization, it turns out to be convenient to separate “simple” statements from “complex” ones. Pragmatically speaking, the difference is that between the “simple” and the “complex” is control flow; simple statements will be guaranteed to always execute without any decisions or jumps. The reason for this will become clearer in subsequent posts; I will foreshadow a bit by saying that consecutive simple statements can be placed into a single basic block.
The following is a group of three simple statements:
x = 1
y = x + 2
noop
These will always be executed in the same order, exactly once. Here, noop
is a convenient type of statement that simply does nothing.
On the other hand, the following statement is not simple:
while x {
x = x - 1
}
It’s not simple because it makes decisions about how the code should be executed;
if x
is nonzero, it will try executing the statement in the body of the loop
(x = x - 1
). Otherwise, it would skip evaluating that statement, and carry on
with subsequent code.
I first define simple statements using the BasicStmt
type:
Complex statements are just called Stmt
; they include loops, conditionals and
sequences —
[note:
The standard notation for sequencing in imperative languages is
. However, Agda gives special meaning to the semicolon,
and I couldn't find any passable symbolic alternatives.
]
is a sequence where is evaluated after .
Complex statements subsume simple statements, which I model using the constructor
⟨_⟩
.
For an example of using this encoding, take the following simple program:
var = 1
if var {
x = 1
}
The Agda version is:
Notice how we used noop
to express the fact that the else
branch of the
conditional does nothing.
The Semantics of Our Language
We now have all the language constructs that I’ll be showing off — because those are all the concepts that I’ve formalized. What’s left is to define how they behave. We will do this using a logical tool called inference rules. I’ve written about them a number of times; they’re ubiquitous, particularly in the sorts of things I like explore on this site. The section on inference rules from my Advent of Code series is pretty relevant, and the notation section from a post in my compiler series says much the same thing; I won’t be re-describing them here.
There are three pieces which demand semantics: expressions, simple statements, and non-simple statements. The semantics of each of the three requires the semantics of the items that precede it. We will therefore start with expressions.
Expressions
The trickiest thing about expression is that the value of an expression depends
on the “context”: x+1
can evaluate to 43
if x
is 42
, or it can evaluate
to 0
if x
is -1
. To evaluate an expression, we will therefore need to
assign values to all of the variables in that expression. A mapping that
assigns values to variables is typically called an environment. We will write
for “empty environment”, and for
an environment that maps the variable to 42, and the variable to -1.
Now, a bit of notation. We will use the letter to represent environments
(and if several environments are involved, we will occasionally number them
as , , etc.) We will use the letter to stand for
expressions, and the letter to stand for values. Finally, we’ll write
to say that “in an environment , expression
evaluates to value ”. Our two previous examples of evaluating x+1
can
thus be written as follows:
Now, on to the actual rules for how to evaluate expressions. Most simply,
integer literals like 1
just evaluate to themselves.
Note that the letter is completely unused in the above rule. That’s because no matter what values variables have, a number still evaluates to the same value. As we’ve already established, the same is not true for a variable like . To evaluate such a variable, we need to retrieve the value it’s mapped to in the current environment, which we will write as . This gives the following inference rule:
All that’s left is to define addition and subtraction. For an expression in the form , we first need to evaluate the two subexpressions and , and then add the two resulting numbers. As a result, the addition rule includes two additional premises, one for evaluating each summand.
The subtraction rule is similar. Below, I’ve configured an instance of
Bergamot to interpret these exact rules. Try
typing various expressions like 1
, 1+1
, etc. into the input box below
to see them evaluate. If you click the “Full Proof Tree” button, you can also view
the exact rules that were used in computing a particular value. The variables
x
, y
, and z
are pre-defined for your convenience.
The Agda equivalent of this looks very similar to the rules themselves. I use
⇒ᵉ
instead of , and there’s a little bit of tedium with
wrapping integers into a new Value
type. I also used a (partial) relation
(x, v) ∈ ρ
instead of explicitly defining accessing an environment, since
it is conceivable for a user to attempt accessing a variable that has not
been assigned to. Aside from these notational changes, the structure of each
of the constructors of the evaluation data type matches the inference rules
I showed above.
|
|
Simple Statements
The main difference between formalizing (simple and “normal”) statements is
that they modify the environment. If x
has one value, writing x = x + 1
will
certainly change that value. On the other hand, statements don’t produce values.
So, we will be writing claims like
to say that the basic statement , when starting in environment
, will produce environment . Here’s an example:
Here, we subtracted one from a variable with value 42
, leaving it with a new
value of 41
.
There are two basic statements, and one of them quite literally does nothing.
The inference rule for noop
is very simple:
For the assignment rule, we need to know how to evaluate the expression on the right side of the equal sign. This is why we needed to define the semantics of expressions first. Given those, the evaluation rule for assignment is as follows, with meaning “the environment but mapping the variable to value ”.
Those are actually all the rules we need, and below, I am once again configuring
a Bergamot instance, this time with simple statements. Try out noop
or some
sort of variable assignment, like x = x + 1
.
The Agda implementation is once again just a data type with constructors-for-rules.
This time they also look quite similar to the rules I’ve shown up until now,
though I continue to explicitly quantify over variables like ρ
.
Statements
Let’s work on non-simple statements next. The easiest rule to define is probably
sequencing. When we use then
(or ;
) to combine two statements, what we
actually want is to execute the first statement, which may change variables,
and then execute the second statement while keeping the changes from the first.
This means there are three environments: for the initial state before
either statement is executed, for the state between executing the
first and second statement, and for the final state after both
are done executing. This leads to the following rule:
We will actually need two rules to evaluate the conditional statement: one for when the condition evaluates to “true”, and one for when the condition evaluates to “false”. Only, I never specified booleans as being part of the language, which means that we will need to come up what “false” and “true” are. I will take my cue from C++ and use zero as “false”, and any other number as “true”.
If the condition of an if
-else
statement is “true” (nonzero), then the
effect of executing the if
-else
should be the same as executing the “then”
part of the statement, while completely ignoring the “else” part.
Notice that in the above rule, we used the evaluation judgement to evaluate the expression that serves as the condition. We then had an additional premise that requires the truthiness of the resulting value . The rule for evaluating a conditional with a “false” branch is very similar.
Now that we have rules for conditional statements, it will be surprisingly easy
to define the rules for while
loops. A while
loop will also have two rules,
one for when its condition is truthy and one for when it’s falsey. However,
unlike the “false” case, a while loop will do nothing, leaving the environment
unchanged:
The trickiest rule is for when the condition of a while
loop is true.
We evaluate the body once, starting in environment and finishing
in , but then we’re not done. In fact, we have to go back to the top,
and check the condition again, starting over. As a result, we include another
premise, that tells us that evaluating the loop starting at , we
eventually end in state . This encodes the “rest of the iterations”
in addition to the one we just performed. The environment is our
final state, so that’s what we use in the rule’s conclusion.
And that’s it! We have now seen every rule that defines the little object language
I’ve been using for my Agda work. Below is a Bergamot widget that implements
these rules. Try the following program, which computes the x
th power of two,
and stores it in y
:
x = 5; y = 1; while (x) { y = y + y; x = x - 1 }
As with all the other rules we’ve seen, the mathematical notation above can be directly translated into Agda:
|
|
Semantics as Ground Truth
Prior to this post, we had been talking about using lattices and monotone functions for program analysis. The key problem with using this framework to define analyses is that there are many monotone functions that produce complete nonsese; their output is, at best, unrelated to the program they’re supposed to analyze. We don’t want to write such functions, since having incorrect information about the programs in question is unhelpful.
What does it mean for a function to produce correct information, though?
In the context of sign analysis, it would mean that if we say a variable x
is +
,
then evaluating the program will leave us in a state in which x
is posive.
The semantics we defined in this post give us the “evaluating the program piece”.
They establish what the programs actually do, and we can use this ground
truth when checking that our analyses are correct. In subsequent posts, I will
prove the exact property I informally stated above: for the program analyses
we define, things they “claim” about our program will match what actually happens
when executing the program using our semantics.
A piece of the puzzle still remains: how are we going to use the monotone functions we’ve been talking so much about? We need to figure out what to feed to our analyses before we can prove their correctness.
I have an answer to that question: we will be using control flow graphs (CFGs). These are another program representation, one that’s more commonly found in compilers. I will show what they look like in the next post. I hope to see you there!