The basic idea here is that a program is a mathematical object, therefore it ought to be possible to prove things about its properties. It follows that if we can precisely say what it is we want the program to do, we can formally prove whether it works or not.
This problem has been studied by a variety of people, with a variety of goals. The strongest goal is the development of a methodology of programming; some people want to see a program developed in terms of formal statements of its behavior, and to create the program through a series of stepwise refinements, each of which is guaranteed to preserve the program's correctness. A more modest (but still ambitious) goal is to take an existing program, and rigorously demonstrate that it works correctly.
I'm happy with a more modest goal yet: if we have some notion of what the semantics of program statements are, then we can reason informally about the behavior of the program and find bugs. Will can also instrument the program with assertions we belief should hold true at strategic points in the execution of the program, and check them.
From the point of view of the correctness of the program, we need to have a clear idea of the effect of a program statement. There are three basic program structures we need to consider here: the assignment, the conditional, and the merge.
// P(e)
v = e;
// P(v)
What this means is that if there is some predicate P we can say
about the expression on the right hand side of an assignment
before the assignment takes place, then we can make the same
statement about the variable to be assigned after the
assignment, and vice versa.
Here's an example:
// i == 2
i = i + 1;
// i == 3
// P
if (C)
// P, C
else
// P, ~C
What this means is that if there is some assertion we can make
before we execute an if-else, we can know that both
that assertion, and the condition on which we decide to branch
are true when we take the if-clause, and that the original
assertion is true but the condition is false if we take the
else-clause.
Here's an example:
// i <= 5
if (j < 7)
// i <= 5, j < 7
else
// i <= 5, j >= 7
if ()
// P
else
// Q
// P or Q
What this means is that if we have a place in the code with two
paths to get there, the result at the place we end up is one of
the two assertions, but we don't know which one.
The semantics of a loop are derived from the semantics of the conditional and the merge. If we're going to be able to say anything useful about the loop, we have to have a single predicate that will be true on each iteration of the loop: this is called the loop invariant. The semantics are expressed as follows:
// I
while (C) {
// I, C
...code...
// I
}
// I, ~B
I is called the loop invariant because it is true for each iteration of the loop.
One of my favorite examples of a proof of correctness appears in an example I use frequently in CS 273: Euclid's Algorithm
There is an include file (required by ISO C) called
. It defines a macro called
assert, whose argument is an expression. If we say
something in our code like
assert(i <= j);
then, when we reach that line of the program, if the expression is not
true, the program will print an error message and terminate. If we
define a symbol NDEBUG the assertion isn't evaluated.