(**
* Verification in Coq
-----
##
Topics:
- verification of functions
- extraction of OCaml code
- verification of data structures
- verification of compilers
##
-----
*)
Require Import List Arith Bool.
Import ListNotations.
(**
(**********************************************************************)
** Verification of Functions
A function is correct if it satisfies its specification. So to verify
a function in Coq, we need to
- code the function,
- state a theorem that says that function satisfies its specification, and
- prove the theorem.
*** Verifying Factorial
Let's try that with the factorial function. Here's an implementation
of it in Coq:
*)
Fixpoint fact (n : nat) :=
match n with
| 0 => 1
| S k => n * (fact k)
end.
(**
As we learned before, the function has to pattern match against
[n] and recursively call itself on [k] to demonstrate to Coq that
the recursive call will eventually terminate.
What would be a reasonable specification for [fact]? If we were
just going to document it in a comment, we might write
something like this:
<<
(** [fact n] is [n] factorial, i.e., [n!].
Requires: [n >= 0]. *)
>>
In OCaml, that precondition would be necessary. In Coq, since we
are computing on natural numbers, it would be redundant.
But how can we formally state in Coq that [fact n] is [n] factorial?
There is no factorial operator in most programming languages, including
Coq. So we can't just write something like the following:
<<
Theorem fact_correct : forall (n : nat), fact n = n!.
>>
Instead, we need another way to express [n!].
Whenever we want to define the meaning of an operator for use in a
logic, we need to write down _axioms_ and _inference rules_ for it.
We've already seen that in two ways:
- With logical connectives, like [/\], we saw that axioms and inference
rules could define how to introduce and eliminate connectives. For
example, from a proof of [A /\ B], we could conclude [A]. Hence
[A /\ B -> A].
- With rings and fields, we saw how axioms (we didn't need inference
rules) could define equalities involving operators. For example,
[0 * x = 0] allowed us to replace any multiplication by [0] simply
with [0] itself.
So, let's define the factorial operator in a similar way:
- [0! = 1].
- If [a! = b] then [(a+1)! = (a+1)*b].
The first line, which is an axiom, defines how the factorial
operator behaves when applied to zero. The second line, which
is an inference rule, defines hwo the operator behaves when
applied to a successor of a natural number.
Another way to think about that definition is that it defines
a relation. Call it the "factorial of" relation:
- The factorial of [0] is [1].
- If the factorial of [a] is [b], then the factorial of [a + 1] is
[a + 1] times [b].
Together, the axiom and inference rule give us a way to "grow"
the relation. We start from a "seed", which is the axiom:
we know that the factorial of [0] is [1]. From there we can
apply the inference rule, and conclude that the factorial of
[0+1] is [0 + 1] times [1], i.e., that the factorial of [1] is
[1]. We can keep doing that with the inference rule to determine
the factorial of any number.
Let's code up that relation in Coq. We're going
to define a proposition [factorial_of] that is parameterized on two
natural numbers, [a] and [b]. We want [factorial_of a b] to be a provable
proposition whenever [a! = b].
*)
Inductive factorial_of : nat -> nat -> Prop :=
| factorial_of_zero : factorial_of 0 1
| factorial_of_succ : forall (a b : nat),
factorial_of a b -> factorial_of (S a) ((S a) * b).
(**
This definition resembles the definition of an inductive type, which
we've done before. But here we are inductively defining a proposition.
That proposition, [factorial_of], is parameterized on two natural
numbers. There are two ways to construct an instance of this
parameterized proposition. The first is to use the [factorial_of_zero]
constructor, which corresponds to the axiom we talked about above.
The second is the [factorial_of_succ] constructor, which corresponds to the
inference rule.
Another way to think about this definition is in terms of evidence. The
[factorial_of_zero] constructor provides (by definition) the evidence
that the factorial of [0] is [1]. The [factorial_of_succ] constructor
provides (again by definition) a way of tranforming evidence that
the factorial of [a] is [b] into evidence that the factorial of [S a] is
[(S a) * b].
Now that we have a formalization of the factorial operation, we can
state a theorem that says [fact] satisfies its specification:
*)
Theorem fact_correct : forall (n : nat),
factorial_of n (fact n).
(**
In other words, the factorial of [n] is the same value that [fact n] computes.
So [fact] is computing the correct function. Note that we don't have
to mention the precondition because of the type of [n].
To prove the theorem, we'll need induction.
*)
Proof.
intros n.
induction n as [ | k IH].
- simpl. apply factorial_of_zero.
- simpl. apply factorial_of_succ. assumption.
Qed.
(**
That concludes our verification of [fact]: we coded it in Coq, wrote
a specification for it in Coq, and proved that it satisfies its specification.
*** A Reflection on Formalization
If you stop to reflect on what we just did, it has the potential to seem
unsatisfying. The skeptic might exclaim, "All you did was say the same thing
twice! You coded up [fact] once as a Coq program, a second time as a Coq
proposition, and proved that the two are the same. Isn't that rather
trivial and obvious?"
As a response, first, note that we did this verification for a very simple
function. It shouldn't be surprising that the formalization of a simple
function ends up looking relatively redundant with respect to the program
that computes the function.
Second, note that technically the skeptic is wrong: we didn't
say the _same_ thing twice. We expressed the idea of the factorial operation
in two subtly different ways. The first way, [fact], specifies a computation
that takes a (potentially large) natural number and continues to recurse on
smaller and smaller numbers until it reaches a case case. The second way,
[factorial_of], specifies a mathematical relation that starts with the
base case of [0] and can build up from there to reach larger numbers.
A lot of formal verification has that flavor: express a computation, express
a mathematical formalization of the computation, then prove that the two
are the same. Or, prove that the two are _similar enough_: often, the
exact details of the computation are irrelevant to the mathematical
formalization. It doesn't typically matter, for example, which order
the sides of a binary operator are evaluated in, so even though the computation
might be explicit, the mathematical formalization need not be. (Side effects
would, of course, complicate that analysis.)
Testing and verification are alike in that sense of potential redundancy.
With testing, you write down information---inputs and outputs---that you
hope is redundant, because the program already encodes the algorithm required
to transform those inputs into those outputs. It's only when you are
surprised, i.e., the test case fails to agree with the program, that you
appreciate the value of saying things twice. By saying the same thing twice,
but differently, you make it more likely to expose any errors because you
_detect the inconsistency_.
*** Verifying Tail-Recursive Factorial
Next, let's verify a different implementation of the factorial operation.
This is the tail-recursive implementation. As we learned much earlier,
this implementation is more space efficient than the naive recursive
implementation.
*)
Fixpoint fact_tr_acc (n : nat) (acc : nat) :=
match n with
| 0 => acc
| S k => fact_tr_acc k (n * acc)
end.
Definition fact_tr (n : nat) :=
fact_tr_acc n 1.
(**
To verify the correctness of [fact_tr], we'll prove the same kind of
theorem as we did for [fact]. For the most part, the proof proceeds
easily:
*)
Theorem fact_tr_correct : forall (n : nat),
factorial_of n (fact_tr n).
Proof.
intros n. unfold fact_tr.
induction n as [ | k IH].
- simpl. apply factorial_of_zero.
- simpl.
(**
At this point, we have a [k * 1] that we'd like to simplify to
just [k]. There's already a library theorem that can do the job
for us:
*)
Check mult_1_r. (* forall n : nat, n * 1 = n *)
(** We continue the proof using it: *)
rewrite mult_1_r.
destruct k as [ | m].
-- simpl. rewrite <- mult_1_r.
apply factorial_of_succ. apply factorial_of_zero.
--
(**
At this point we'd like to apply [factorial_of_succ], but
we're stuck: the goal doesn't have the right shape,
because the second argument to [fact_tr_acc] is not [1],
and there is no multiplication. We'd like to replace
[fact_tr_acc (S m) (S (S m))] with [(S (S m)) * fact_tr_acc (S m) 1].
Let's abort the current proof, and factor out a helper lemma for that purpose.
*)
Abort.
(**
Nothing about the lemma we just realized we needed is actually specific
to [S (S m)]: that expression might as well be any natural number, because
[fact_tr_acc] just uses it as the base value of the accumulator. So we
can state and prove a slightly more general lemma:
*)
Lemma fact_tr_acc_mult : forall (n m : nat),
fact_tr_acc n m = m * fact_tr_acc n 1.
(**
The proof starts off relatively easy. Just before we get to the point
of using the inductive hypothesis, we'll use a new tactic, [replace],
which replaces one expression with another, and generates a new subgoal
requiring us to prove that the two expressions are in fact equal.
*)
Proof.
intros n m. induction n as [ | k IH].
- simpl. ring.
- replace (fact_tr_acc (S k) m) with (fact_tr_acc k ((S k) * m)).
--
(**
Unfortunately we're now stuck and unable to use the inductive hypothesis.
The problem is that hypothesis is:
<<
IH: fact_tr_acc k m = m * fact_tr_acc k 1
>>
but the goal has the expression:
<<
fact_tr_acc k (S k * m)
>>
The left-hand side of the inductive hypothesis doesn't match that goal,
because [IH] has just [m], whereas the goal has [S k * m].
But, looking at [IH], there does seem to be hope. There's no reason
[IH] needs to be "hard-coded" for a specific [m]. It really would
hold for any [m]. The root of the problem is that _we really want
[m] to be univerally quantified in [IH]_, but we already used [intros]
to get rid of that quantification. So, let's start over, and not be
so eager to introduce [m].
*)
Abort.
Lemma fact_tr_acc_mult : forall (n m : nat),
fact_tr_acc n m = m * fact_tr_acc n 1.
Proof.
intros n.
induction n as [ | k IH].
- intros p. simpl. ring.
- intros p.
replace (fact_tr_acc (S k) p) with (fact_tr_acc k ((S k) * p)).
--
(**
This time when we get here in the proof, the inductive hypothesis
is more general than last time:
<<
IH: forall m : nat, fact_tr_acc k m = m * fact_tr_acc k 1
>>
And that means it's applicable, letting [m] be [S k * p].
*)
rewrite IH. simpl. rewrite mult_1_r.
(**
Now we'd again like to use [IH], this time on the right-hand side,
but [rewrite IH] just causes the left-hand side to change. We can
help Coq figure out where we want to use [IH] by telling it what
we want the universally quantified [m] to be; in this case, [S k].
The syntax for that is as follows:
*)
rewrite IH with (m := S k).
(** After that, the proof is quickly finished. *)
ring.
-- simpl. trivial.
Qed.
(** Using that lemma, we can successfully verify [fact_tr]: *)
Theorem fact_tr_correct : forall (n : nat),
factorial_of n (fact_tr n).
Proof.
intros n. unfold fact_tr.
induction n as [ | k IH].
- simpl. apply factorial_of_zero.
- simpl. rewrite mult_1_r.
destruct k as [ | m].
-- simpl. rewrite <- mult_1_r.
apply factorial_of_succ. apply factorial_of_zero.
-- rewrite fact_tr_acc_mult.
apply factorial_of_succ. assumption.
Qed.
(**
Our hypothetical skeptic from before is not likely to be so skeptical
of what we did here. After all, it's not so obvious that [fact_tr]
is correct, or that it computes the [factorial_of] relation. Nonetheless,
we have successfully proved its correctness.
*)
(**
*** Another Way to Verify Tail-Recursive Factorial
Our previous two verifications of factorial have both proved that
an implementation of the factorial operation is correct. Our technique
was to state a mathematical relation describing factorial, then
prove that the implementation computed that relation.
Let's explore another technique now; a technique that can be easier to
use. Instead of using the mathematical relation, let's just prove
that the two implementations are equivalent. That is, [fact] and
[fact_tr] compute the same function.
Before launching into that proof, let's pause to ask: what would
it accomplish? The answer is that we'd be showing that a
complicated and not-obviously-correct implementation, [fact_tr],
is equivalent to a simple and more-obviously-correct implementation,
[fact]. So if we believe that [fact] is correct, we could then also
believe that [fact_tr] is correct.
This technique of proving correctness with respect to a _reference
implementation_ is quite useful. (In fact, the #
verification of the seL4 microkernel# used it to great effect.)
Without further ado, here is the theorem and its proof. It uses
a helper lemma that we'll just go ahead and state first. You'll
notice how much easier these are to prove than our previous
verification of [fact_tr]!
*)
Lemma fact_helper : forall (n acc : nat),
fact_tr_acc n acc = (fact n) * acc.
Proof.
intros n.
induction n as [ | k IH]; intros acc.
- simpl. ring.
- simpl. rewrite IH. ring.
Qed.
Theorem fact_tr_is_fact: forall n:nat,
fact_tr n = fact n.
Proof.
intros n. unfold fact_tr. rewrite fact_helper. ring.
Qed.
(** That concludes our verification of the factorial operation. *)
(**********************************************************************)
(**
** Extraction
Coq makes it possible to _extract_ OCaml code (or Haskell or Scheme) from
Coq code. That makes it possible for us to
- write Coq code,
- prove the Coq code is correct, and
- extract OCaml code that can be compiled and run more efficiently
than the original Coq code.
Let's extract [fact_tr] as an example.
*)
Require Import Extraction.
Extraction Language OCaml.
Extraction "fact.ml" fact_tr.
(**
That produces the following file:
<<
type nat =
| O
| S of nat
(** val add : nat -> nat -> nat **)
let rec add n m =
match n with
| O -> m
| S p -> S (add p m)
(** val mul : nat -> nat -> nat **)
let rec mul n m =
match n with
| O -> O
| S p -> add m (mul p m)
(** val fact_tr_acc : nat -> nat -> nat **)
let rec fact_tr_acc n acc =
match n with
| O -> acc
| S k -> fact_tr_acc k (mul n acc)
(** val fact_tr : nat -> nat **)
let fact_tr n =
fact_tr_acc n (S O)
>>
As you can see, Coq has preserved the [nat] type in this extracted
code. Unforunately, computation on natural numbers is not efficient.
(Addition requires linear time; multiplication, quadratic!)
We can direct Coq to extract its own [nat] type to OCaml's [int]
type as follows:
*)
Extract Inductive nat =>
int [ "0" "succ" ] "(fun fO fS n -> if n=0 then fO () else fS (n-1))".
Extract Inlined Constant Init.Nat.mul => "( * )".
(**
The first command says to
- use [int] instead of [nat] in the extract code,
- use [0] instead of [O] and [succ] instead of [S]
(the [succ] function is in [Pervasives] and is [fun x -> x + 1]), and
- use the provided function to emulate pattern matching over the type.
The second command says to use OCaml's integer [( * )] operator instead of
Coq's natural-number multiplication operator.
After issuing those commands, the extraction looks cleaner:
*)
Extraction "fact.ml" fact_tr.
(**
<<
(** val fact_tr_acc : int -> int -> int **)
let rec fact_tr_acc n acc =
(fun fO fS n -> if n=0 then fO () else fS (n-1))
(fun _ -> acc)
(fun k -> fact_tr_acc k (( * ) n acc))
n
(** val fact_tr : int -> int **)
let fact_tr n =
fact_tr_acc n (succ 0)
>>
There is, however, a tradeoff. The original version we extracted worked
(albeit inefficiently) for arbitrarily large numbers without any error.
But the second version is subject to integer overflow errors. So the
proofs of correctness that we did for [fact_tr] are no longer completely
applicable: they hold only up to the limits of the types we subsituted
during extraction.
Do we truly care about the limits of machine arithmetic? Maybe, maybe not.
For sake of this little example, we might not. If we were verifying
software to control the flight dynamics of a space shuttle, maybe we
would. The Coq standard library does contain a module 31-bit
integers and operators on them, which we could use if we wanted to
precisely model what would happen on a particular architecture.
*)
(**********************************************************************)
(**
** Verification of Data Structures
We've now seen how to verify individual functions. But what about
a collection of related functions, e.g., a data structure? Now we
must be concerned with not just the individual functions, but also
how they interact. For example, we expect [push] and [peek] to
interact in certain ways with a stack, or [hd] and [cons] with
a list:
- [peek (push x s) = x]
- [hd (h :: t) = h]
We can specify the behavior of a data structure by writing down
equations like those. This style of specification is called
_algebraic specification_.
When we discussed testing earlier in the semester, we categorized
the operations of a data structure whose representation type is [t] into
- creators, which create values of type [t] from scratch,
- producers, which take values of type [t] as input and return values of
type [t] as output, and
- observers, which take values of type [t] as input and return values
of some other type as output.
With algebraic specification, we want to write down equations that
characterize all the possible interactions between creators,
producers, and observers.
*** Algebraic Specification of Lists
As an example, let's write an algebraic specification of lists,
then verify the correctness of Coq's list implementation with
respect to that specification.
Our only creator will be [nil]. The producers will be [::], [++], and [tl].
The observers will be [hd] and [length]. The [hd]
operation will take an extra argument compared to OCaml's [hd] operation,
which will be a "default" value to return if the list is empty.
We could, of course, include other producers and observers in our
specification, such as [map] or [mem], but the ones we have chosen
are enough for this example.
These are the equations we expect to hold:
<<
hd x nil = x
hd _ (x::_) = x
tl nil = nil
tl (_::xs) = xs
nil ++ xs = xs
xs ++ nil = xs
(x :: xs) ++ ys = x :: (xs ++ ys)
lst1 ++ (lst2 ++ lst3) = (lst1 ++ lst2) ++ lst3
length nil = 0
length (_ :: xs) = 1 + length xs
length (xs ++ ys) = length xs + length ys
>>
Below, we state each of those equations as a theorem, and
prove the theorem. The proofs themselves do not contain
any new concepts about Coq, so we pass over them without
much comment.
*)
(** [hd x nil = x] *)
Theorem hd_nil : forall (A:Type) (x:A),
hd x nil = x.
Proof. trivial. Qed.
(** [hd _ (h :: _) = h *)
Theorem hd_cons : forall (A:Type) (x h : A) (t : list A),
hd x (h::t) = h.
Proof. trivial. Qed.
(** [tl nil = nil] *)
Theorem tl_nil : forall (A:Type),
@tl A nil = nil.
Proof. trivial. Qed.
(** [tl (_ :: xs) = xs ] *)
Theorem tl_cons : forall (A:Type) (x : A) (xs : list A),
tl (x::xs) = xs.
Proof. trivial. Qed.
(** [nil ++ xs = xs] *)
Theorem nil_app : forall (A:Type) (xs : list A),
nil ++ xs = xs.
Proof. trivial. Qed.
(** [xs ++ nil = xs] *)
Theorem app_nil : forall (A:Type) (xs : list A),
xs ++ nil = xs.
Proof.
intros A xs.
induction xs as [ | h t IH]; simpl.
- trivial.
- rewrite IH. trivial.
Qed.
(** [(x :: xs) ++ ys = x :: (xs ++ ys) *)
Theorem cons_app : forall (A:Type) (x : A) (xs ys : list A),
x::xs ++ ys = x :: (xs ++ ys).
Proof. trivial. Qed.
(** [lst1 ++ (lst2 ++ lst3) = (lst1 ++ lst2) ++ lst3] *)
Theorem app_assoc : forall (A:Type) (lst1 lst2 lst3 : list A),
lst1 ++ (lst2 ++ lst3) = (lst1 ++ lst2) ++ lst3.
Proof.
intros A lst1 lst2 lst3.
induction lst1 as [ | h t IH]; simpl.
- trivial.
- rewrite IH. trivial.
Qed.
(** [length nil = 0] *)
Theorem length_nil : forall (A:Type),
@length A nil = 0.
Proof. trivial. Qed.
(** [length (_ :: xs) = 1 + length xs] *)
Theorem length_cons : forall (A:Type) (x:A) (xs : list A),
length (x::xs) = 1 + length xs.
Proof. trivial. Qed.
(** [length (xs ++ ys) = length xs + length ys] *)
Theorem length_app : forall (A:Type) (xs ys : list A),
length (xs ++ ys) = length xs + length ys.
Proof.
intros A xs ys.
induction xs as [ | h t IH]; simpl.
- trivial.
- rewrite IH. trivial.
Qed.
(**********************************************************************)
(**
*** Algebraic Specification of Stacks
As a second example, let's specify, implement, and verify stacks.
The creator is [empty], the producers are [push] and [pop], and the
observers are [is_empty], [peek], and [size]. (You might quibble with
whether [pop] is a producer or observer; it's not really important, though.)
<<
is_empty empty = true
is_empty (push _ _) = false
peek empty = None
peek (push x _) = Some x
pop empty = None
pop (push _ s) = Some s
size empty = 0
size (push _ s) = 1 + size s
>>
*)
Module MyStack.
(** AF: We will represent a stack as a list. The head of the list
is the top of the stack. *)
Definition stack (A:Type) := list A.
Definition empty {A:Type} : stack A := nil.
Definition is_empty {A:Type} (s : stack A) : bool :=
match s with
| nil => true
| _::_ => false
end.
Definition push {A:Type} (x : A) (s : stack A) : stack A :=
x::s.
Definition peek {A:Type} (s : stack A) : option A :=
match s with
| nil => None
| x::_ => Some x
end.
Definition pop {A:Type} (s : stack A) : option (stack A) :=
match s with
| nil => None
| _::xs => Some xs
end.
Definition size {A:Type} (s : stack A) : nat :=
length s.
(**
Now that we've implemented all the stack operations,
we'll verify their correctness. All the proofs are
trivial, because the implementation is so simple.
*)
(** [is_empty empty = true] *)
Theorem empty_is_empty : forall (A:Type),
@is_empty A empty = true.
Proof. trivial. Qed.
(** [is_empty (push _ _) = false] *)
Theorem push_not_empty : forall (A:Type) (x:A) (s : stack A),
is_empty (push x s) = false.
Proof. trivial. Qed.
(** [peek empty = None] *)
Theorem peek_empty : forall (A:Type),
@peek A empty = None.
Proof. trivial. Qed.
(** [peek (push x _) = Some x] *)
Theorem peek_push : forall (A:Type) (x:A) (s : stack A),
peek (push x s) = Some x.
Proof. trivial. Qed.
(** [pop empty = None] *)
Theorem pop_empty : forall (A:Type),
@pop A empty = None.
Proof. trivial. Qed.
(** [pop (push _ s) = Some s] *)
Theorem pop_push : forall (A:Type) (x:A) (s : stack A),
pop (push x s) = Some s.
Proof. trivial. Qed.
(** [size empty = 0] *)
Theorem size_empty : forall (A:Type),
@size A empty = 0.
Proof. trivial. Qed.
(** [size (push x s) = 1 + size s] *)
Theorem size_push : forall (A:Type) (x:A) (s : stack A),
size(push x s) = 1 + size s.
Proof. trivial. Qed.
End MyStack.
(**
To extract our stack implementation to OCaml, it will help
to additional declare to Coq that we want to extract its
booleans, options, and lists to OCaml's own built-in types
for those.
*)
Extract Inductive bool => "bool" [ "true" "false" ].
Extract Inductive option => "option" [ "Some" "None" ].
Extract Inductive list => "list" [ "[]" "(::)" ].
Extract Inlined Constant length => "List.length".
Extraction "mystack.ml" MyStack.
(**********************************************************************)
(**
** Verification of a Compiler
One of the big success stories of Coq verification is the #CompCert C compiler#. Its source
language is ISO C99. It is an optimizing compiler that targets
PowerPC, ARM, RISC-V, and x86 processors. The correctness proofs
establish that the executable code it produces will behave exactly
as it should according to the semantics of the C source code.
Let's get a sense of what would be required to verify a compiler.
We'll take a tiny source language, compile it into a tiny bytecode
language, and verify the correctness of that compilation. We'll
only worry here about the backend of the compiler, not about
the frontend (including parsing). CompCert originally only
was a verified backend, too, but in the last few years even
the front end has been verified.
*)
Module Compiler.
(**
As the source language, we'll use arithmetic expressions that
have only integer constants and addition:
<<
e ::= i | e + e
>>
In OCaml, we could represent that with this AST type:
<<
type expr =
| Const of int
| Plus of expr * expr
>>
In Coq, the type is very similar, though we'll use [nat] instead of [int]:
*)
Inductive expr : Type :=
| Const : nat -> expr
| Plus : expr -> expr -> expr.
(**
The _dynamic semantics_ of expressions is straightforward:
<<
i ==> i
e1 + e2 ==> i
if e1 ==> i1
and e2 ==> i2
and i = i1 + i2
>>
And it's easily implementable. Here's a big-step interpreter:
*)
Fixpoint eval_expr (e : expr) : nat :=
match e with
| Const i => i
| Plus e1 e2 => (eval_expr e1) + (eval_expr e2)
end.
(** Here are a couple test cases for our interpreter: *)
Example source_test_1 : eval_expr (Const 42) = 42.
Proof. trivial. Qed.
Example source_test_2 : eval_expr (Plus (Const 2) (Const 2)) = 4.
Proof. trivial. Qed.
(**
As a _target language_, let's use something similar to
what Java and OCaml use for bytecode. They are based on
a _stack machine_ model, in which bytecode instructions
manipulate a stack. Our tiny little bytecode language
will have the following instruction set:
<<
instr ::= PUSH i | ADD
>>
A program is just a sequence of instructions.
For example, the following program pushes [2] on the stack,
pushes [2] again, then adds the two values on the stack.
Adding causes two values to be popped, and the sum pushed
back onto the stack.
<<
PUSH 2
PUSH 2
ADD
>>
We'll implement this stack language in Coq as follows.
An [instr] is a machine instruction. A program [prog]
is a list of instructions. *)
Inductive instr : Type :=
| PUSH : nat -> instr
| ADD : instr.
Definition prog := list instr.
(**
Now we can write an interpreter for the target language.
Evaluation of a program takes in an initial stack,
and returns the final stack. But since evaluation
could fail (if we try to ADD when there aren't at
least two values on the stack), we wrap the return
in an option, and return None if an error occurs.
*)
Definition stack := list nat.
Fixpoint eval_prog (p : prog) (s : stack) : option stack :=
match p,s with
| PUSH n :: p', s => eval_prog p' (n :: s)
| ADD :: p', x :: y :: s' => eval_prog p' (x + y :: s')
| nil, s => Some s
| _, _ => None
end.
(** Here are a couple unit tests for the target language interpreter. *)
Example target_test_1 : eval_prog [PUSH 42] [] = Some [42].
Proof. trivial. Qed.
Example target_test_2 : eval_prog [PUSH 2; PUSH 2; ADD] [] = Some [4].
Proof. trivial. Qed.
(**
Now we're ready to translate from the source language to the
target language.
- To translate a constant [c], we just push [c] onto the stack.
- To translate an addition [e1 + e2], we translate [e2], translate [e1],
then append the instructions together, followed by an [ADD]
instruction.
The function below, [compile e], produces a program [p], such that
evaluation of [p] leaves a single new value at the top
of the stack, and that value would be the result of
evaluating [e].
*)
Fixpoint compile (e : expr) : prog :=
match e with
| Const n => [PUSH n]
| Plus e1 e2 => compile e2 ++ compile e1 ++ [ADD]
end.
(** Here are a couple unit tests for our compiler: *)
Example compile_test_1 : compile (Const 42) = [PUSH 42].
Proof. trivial. Qed.
Example compile_test_2 : compile (Plus (Const 2) (Const 3))
= [PUSH 3; PUSH 2; ADD].
Proof. trivial. Qed.
(**
Those tests demonstrate that the compiler produces
some programs that do seem to correspond to the
input expression. But we haven't really tested
the postcondition of [compile]: we want to know
whether both sides of the [=] in those test cases above
above evaluate to the same value. So let's check that, too.
*)
Example post_test_1 :
eval_prog (compile (Const 42)) [] = Some [eval_expr (Const 42)].
Proof. trivial. Qed.
Example post_test_2 :
eval_prog (compile (Plus (Const 2) (Const 3))) []
= Some [eval_expr (Plus (Const 2) (Const 3))].
Proof. trivial. Qed.
(**
So far, so good.
But as we know from Dijkstra, "testing can only prove the
presence of bugs, never their absence." How could we show
that the compiler is correct for every input expression?
WE PROVE IT!
The following theorem is a _specification_ that says
what it means for [compile] to be correct. In particular,
it says these two computations produce the same result:
- Compiling [e] then evaluating the resulting program
according to the semantics of the target language, starting
with the empty stack.
- Evaluating [e] according to the semantics of the source language,
then pushing the result on the empty stack and wrapping it
with [Some].
*)
Theorem compile_correct : forall (e:expr),
eval_prog (compile e) [] = Some [eval_expr e].
Abort.
(**
Proving the theorem will require a helper lemma about the associativity
of list append.
*)
Lemma app_assoc_4 : forall (A:Type) (l1 l2 l3 l4 : list A),
l1 ++ (l2 ++ l3 ++ l4) = (l1 ++ l2 ++ l3) ++ l4.
Proof.
intros A l1 l2 l3 l4.
replace (l2 ++ l3 ++ l4) with ((l2 ++ l3) ++ l4);
rewrite app_assoc; trivial.
Qed.
(**
We'll also need a helper lemma that generalizes the main theorem.
Specifically, it says that there could be additional instructions [p]
in the program, and additional values [s] on the stack, but those
won't keep the expression [e] from being compiled and executed
correctly. The proof uses the same technique of a generalized
inductive hypothesis, where we won't introduce all the variables
right away, as we used when verifying [fact_tr] above.
*)
Lemma compile_helper : forall (e:expr) (s:stack) (p:prog),
eval_prog (compile e ++ p) s = eval_prog p (eval_expr e :: s).
Proof.
intros e.
induction e as [n | e1 IH1 e2 IH2]; simpl.
- trivial.
- intros s p. rewrite <- app_assoc_4.
rewrite IH2. rewrite IH1. simpl. trivial.
Qed.
Theorem compile_correct : forall (e:expr),
eval_prog (compile e) [] = Some [eval_expr e].
Proof.
intros e.
induction e as [n | e1 IH1 e2 IH2]; simpl.
- trivial.
- repeat rewrite compile_helper. simpl. trivial.
Qed.
End Compiler.
(**
Now we have a verified compiler, and we can extract it (and the two
tiny interpreters we also wrote) to OCaml!
*)
Extract Inlined Constant Init.Nat.add => "( + )".
Extract Inlined Constant app => "( @ )".
Extraction "compiler.ml" Compiler.eval_expr Compiler.eval_prog Compiler.compile.
(**
** Summary
We've come to fulfillment of our purposes in learning Coq:
verification. To reach this point, we had to learn
how to program in Coq's functional programming language and
how to prove in Coq's logic. Along the way we also learned
about the correspondence between proofs and programs.
Forty years ago, verification techniques worked only
for really short programs written in toy languages, and
it was all done with pen and paper. Today, research projects
are able to verify compilers and operating systems, and the
computer can check the proofs. In another forty years, who knows?
Perhaps, at the end of this unit on formal methods, you find yourself
wondering why we spent so much time on it. One reason is that
it's an important (if niche) area in programming languages and
software engineering. To be well educated in this field requires
that you know something about it. Even if you never touch formal
methods again, you now can talk from first hand experience
with other people in industry. Another reason is that the future
of functional programming (hence programming in general) is headed
toward languages with ever richer type systems, like Coq's. Coq's
type system is sophisticated enough to express not just programs
but theorems. Before the end of your career, it's a good bet
there will be some mainstream language that has rich enough
types to express correctness properties that are beyond
today's type systems, even if not rich enough to state
arbitrary propositions.
But even more importantly, the final reason we covered formal
methods was to spend some time thinking about #what it means
for a program to be correct.# One perspective on that issue,
a perspective well covered by other introductory programming
courses as well as this course, is testing. Unit testing
is a cost effective way to ascertain whether a program
has faults. Now, you've experienced another perspective: proof.
Proving the correctness of a program is expensive, yet it
offers guarantees beyond unit tests.
That's not at all ---no, not at all!--- to claim that formal methods
are perfect. You might end up proving the wrong theorems.
You might make assumptions that turn out to be invalid.
There might even be faults in the programs you use to check
your proofs. All of those could make your formal efforts
futile.
But at the end of the day, we programmers (we happy programmers,
we band pursuing the craft of code) are creating artifacts that
are part proof and part art and if we do our jobs right
altogether beautiful. _Beauty is our business_. Never
lose sight of that.
** Terms and concepts
- algebraic specification
- axiom
- extraction
- generalized inductive hypothesis
- inference rule
- redundancy
- reference implementation
- relation
- specification
- testing
- verification
** Tactics
- [replace]
** Further reading
- _Software Foundations, Volume 1: Logical Foundations_.
#
Chapter 12 through 15: Imp, ImpParser, ImpCEvalFun, Extraction#.
- _Interactive Theorem Proving and Program Development_.
Chapters 9 through 11. Available
#
online from the Cornell library#.
- Notes by Robert McCloskey on #
algebraic specification#.
- The verified compiler section of the notes above is inspired by Adam
Chlipala's book #
Certified Programming with Dependent Types#.
*)