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
:v
andq
. - The
modifies
clause states that the functionpush
may mutate the contents ofq
. - Finally, the
ensures
clause introduces a postcondition that describes the modelview
ofq
after a call topush
. The newview
extends the old value ofview
with the elementv
at the front. We use the keywordold
to 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 functionpop
returns 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⟩↩