Mutable Queues
This example aims to provide a formal specification for a mutable queue data
structure, similar to OCaml's standard library Queue.
This work is adapted from the article GOSPEL -Providing OCaml with a Formal Specification Language1, which also features an implementation of the following specification that was formally verified using Why3gospel.
Modeling the Queue Datastructure
Let's start by defining the type of queues. The 'a t type represents a
polymorphic queue, storing elements of type 'a. To enable reasoning about the
elements of a queue, we attach one model to its type declaration:
type 'a t
(*@ mutable model view: 'a sequence *)
The model view represents the mathematical sequence of elements stored in
the queue. The type 'a sequence is the type of logical sequences defined in the
Gospel standard library and is for specifications only. The mutable
keyword states that the view field can change over time.
This shows how Gospel annotations provide extra insight and are also relevant
for documentation. The mutable keyword states that the type 'a t is mutable,
which cannot be deduced from its OCaml declaration alone.
Pushing Into the Queue
Now let's declare and specify a push operation for these queues:
val push: 'a -> 'a t -> unit
(*@ push v q
modifies q
ensures q.view = Sequence.cons v (old q.view) *)
- The first line of the specification is the header; it names the two arguments
of
push:vandq. - The
modifiesclause states that the functionpushmay mutate the contents ofq. - Finally, the
ensuresclause introduces a postcondition that describes the modelviewofqafter a call topush. The newviewextends the old value ofviewwith the elementvat the front. We use the keywordoldto refer to the value of an expression (here,q.view) in the prestate, i.e., before the function call.
Note that the module Sequence is part of the Gospel standard library and
should not be confused with module Seq from the OCaml standard library.
Various Flavors of pop
Now let's move to another function, pop, and illustrate three ways of
handling assumptions from the client in Gospel specifications.
Exceptional Version
In this version, similar to the one provided by the OCaml standard library,
pop raises an Empty exception if its argument is an empty queue. We specify
this behaviour as follows:
exception Empty
val pop: 'a t -> 'a
(*@ v = pop q
modifies q
ensures old q.view = q.view ++ Sequence.cons v Sequence.empty
raises Empty -> q.view = old q.view = Sequence.empty *)
We have two postconditions:
- The first one, introduced with
ensures, states the postcondition that holds whenever the functionpopreturns a valuev. - The second one, introduced by
raises, states the exceptional postcondition that holds whenever the call raises the exceptionEmpty.
Similarly to the push case, the clause modifies indicates that this function
call may mutate q. Note that this applies to the exceptional case as well, and
that's why we state that q is both empty and not modified.
Unsafe Version
Now, let us consider an unsafe variant of pop that should only be called on a
non-empty queue, leaving the responsibility of that property to the client code.
The function does not raise Empty anymore but instead expects a non-empty
argument. We can thus add the following precondition to the contract using the
keyword requires:
val unsafe_pop: 'a t -> 'a
(*@ v = unsafe_pop q
requires q.view <> Sequence.empty
modifies q
ensures old q.view = q.view ++ (Sequence.cons v Sequence.empty) *)
Defensive Version
Instead of assuming that the precondition is guaranteed by the caller, we can
also adopt a more defensive approach where pop raises Invalid_argument
whenever an empty queue is provided. Gospel provides a way to declare such a
behavior, using checks instead of requires:
val pop: 'a t -> 'a
(*@ v = pop q
checks q.view <> Sequence.empty
modifies q
ensures old q.view = q.view ++ (Sequence.cons v Sequence.empty) *)
The checks keyword means that function itself checks the pre-condition
q.view <> empty and raises Invalid_argument whenever it does not hold. Note
that q.view is just a logical model and may not exist at all in the
implementation. However, the function checks a property that, from a logical
point of view, results in q.view not being empty.
A Small Break: is_empty
Our next function needs a simpler specification. Consider the following declaration for an emptiness test, together with its contract:
val is_empty: 'a t -> bool
(*@ b = is_empty q
ensures b <-> q.view = Sequence.empty *)
The postconditions capture that the function returns true if and only if the
queue is empty.
Although very simple, the above specification implies an important
property. Since no modifies clause is present, the argument q is read-only.
We know that a call to is_empty does not modify q.view.
Creating Queues
The next function features the creation of a queue. Its declaration and specification are as follows:
val create: unit -> 'a t
(*@ q = create ()
ensures q.view = Sequence.empty *)
The newly created queue has no elements. Its view model equals the empty
sequence, as stated by the postcondition.
It's worth mentioning that the specification implicitly assumes q to be
disjoint from every previously allocated queue. This is an important design
choice of Gospel that follows this general principle: writing functions that
return non-fresh, mutable values is considered a bad practice in OCaml.
Merging Queues
Let's conclude this specification with a function to merge two queues. Several approaches are possible, so we'll showcase three of them.
In-Place Transfer
Start with a concatenation that transfers all elements from one queue to another:
val transfer: 'a t -> 'a t -> unit
(*@ transfer src dst
modifies src, dst
ensures src.view = Sequence.empty
ensures dst.view = old dst.view ++ old src.view *)
Here the contract states that both queues are modified. The queue src is
emptied after the call and its elements are appended to the queue dst. Notice
the use of old in the second postcondition. This helps us refer to the queues'
state before they're passed to the function.
Destructive Operations
One could think of a slightly different version of transfer:
destructive_transfer, which invalidates src when called. In other words, the
value of src should be considered dirty, so it must not be used in the
rest of the program. Such use cases are frequent in system programming, like
when dealing with file descriptors after closing them. Gospel
provides consumes clauses to capture this semantic:
val destructive_transfer: 'a t -> 'a t -> unit
(*@ destructive_transfer src dst
consumes src
modifies dst
ensures dst.view = old dst.view ++ old src.view *)
Note that we don't need to give information on src in the poststate, since
its value must not be used anymore.
A Constructive Version
Finally, perhaps a simpler version may consist in a concat function that
creates a fresh queue with the elements of the queues passed as arguments:
val concat: 'a t -> 'a t -> 'a t
(*@ new = concat q1 q2
ensures new.view = q1.view ++ q2.view *)
In this version, no modifies appears. This means that none of the arguments
are modified by the function, so specifying their models in the poststate isn't
necessary.
- Arthur Charguéraud, Jean-Christophe Filliâtre, Cláudio Lourenço, Mário Pereira. GOSPEL -Providing OCaml with a Formal Specification Language. FM 2019 - 23rd International Symposium on Formal Methods, Oct 2019, Porto, Portugal. ⟨hal-02157484⟩↩