SE 507
Algebraic Specifications

Perpetually under construction.

The idea behind algebraic specifications is that an abstract data type (henceforth, ADT) should be characterized only by the behaviors of its members (i.e., which are observable via results yielded when certain operations are applied to them) without saying anything about their representation/implementation. To this end, we describe an ADT by giving

  1. a signature, which is a list of the names and types of the ADT's operations (an operation's type is given by its domain and range), and
  2. a set of equations (called axioms), which conveys the intended meaning (i.e., semantics) of those operations. Not surprisingly, the ADT's operations are modeled by (mathematical) functions.

Stacks

To illustrate, we use the ADT stack as an example. A stack is a "container" in which items are held one on top of another (so to speak) and in which both insertions (push) and deletions (pop) of items occur at the same end, called the top. Also, only the item occupying the top of the stack is observable. This kind of structure has a LIFO ("last-in-first-out") pattern with respect to the relative order in which items arrive and depart.

Stack <Elem>:           --the specification is parameterized by the data type
                        --of the elements to be stored in the stack
Signature:

  isEmpty : Stack<Elem> → bool         --answers "Is the stack empty?"
  topOf   : Stack<Elem> → Elem         --yields item at top of the stack
  empty   :       → Stack<Elem>        --yields the empty stack
  pop     : Stack<Elem> → Stack<Elem>        --yields stack obtained by removing top item
  push    : Stack<Elem> × Elem → Stack<Elem> --yields stack obtained by placing item at top

Notice that we chose to make the range of pop be simply Stack, rather than Stack × Elem. The latter would be the more appropriate way to model a method (in Java, say) that not only removed the element at the top of the stack but also returned its value. However, to keep things simple, we will not do that here.

It is useful to classify operations as being either constructors or observers. A constructor is one that yields as a result an object of the type of interest (hereafter, ToI), i.e., the type being defined, which here is Stack. Hence, here the constructors are empty, push, and pop, as all of them have Stack as their ranges.

Note: A zero-argument function, such as empty, is called a nullary operation. Note that a zero-argument function is, in effect, a constant. That is, a constant, such as 5, can be viewed as a nullary operation: it takes no argument and always yields the same result. By treating constants and functions in a uniform way, things tend to be simpler. End of Note.

An observer is any operation that is not a constructor. In most cases, an observer yields information about the state of an object belonging to the ToI. That is, its domain ought to include at least one instance of the ToI, but its range is usually something other than the ToI. (An exception occurs in the case of an observer that yields an embedded instance of the ToI, such as a sublist within a list or a subtree within a tree.)

In particular, every operation listed among those in the specification for type T should be such that T appears either among the domain types or the range type. (Any operation not meeting this condition ought to be part of the (separate) specification for some other type S that is used in the specification of type T.)

In the specification above, isEmpty and topOf are the observers.

At this point, we postpone giving the axioms in order to illustrate how applications of these operations can be understood as describing stacks or as observing properties of stacks. Using function composition (and assuming that Elem is, say, Z (the integers)), we can form (syntactically valid) expressions such as

push(push(empty, -4), 6)

push(push(pop(push(push(empty, 3), 9)), 5), 2)

isEmpty(pop(push(pop(push(empty,5)),2)))

The first one is intended to denote the stack containing, in order from bottom to top, -4 and 6. The second one denotes the stack containing 3, 5, and 2. (The 9 was pushed, but then popped.) Hence, a somewhat simpler expression intended to denote the same stack is

push(push(push(empty,3),5),2)

Indeed, it seems natural to expect that, for any particular stack, the "simplest" expression denoting it should include no applications of pop. Say you want to describe a stack containing v1, v2, ..., vn, from bottom to top. Then the simplest expression for it would be

push(push( ... (push(push(empty, v1), v2), ...), vn-1), vn)

The result of the third expression above should be either true or false, according to whether the expression serving as the argument of isEmpty denotes the empty stack. (Clearly, it does, as the stack obtained from pushing 5 onto an empty stack, then popping, then pushing 2, and then popping is, indeed, empty.) So the result ought to be true.

The purpose of the axioms is to describe the intended meanings of the operations. This is done by, vaguely speaking, showing how they relate to one another. Rather than try to explain what that means, it may be more useful to illustrate the idea using examples. Here are the axioms for Stack:

Axioms:

  (1) isEmpty(empty) = true
  (2) isEmpty(push(s,x)) = false
  (3) topOf(push(s,x)) = x  
  (4) pop(push(s,x)) = s

It's to be understood that each axiom is universally quantified by all variables appearing in it. That is, (4) should be understood as an abbreviation for

(∀s:Stack<Elem>, x:Elem |: pop(push(s,x)) = s)

If you understand what a stack is, each of the axioms should be transparent. Axiom (1), for example, simply says that the stack described by the expression empty has the property of being empty! Axiom (2) says that any stack obtained by pushing some element x onto some stack s is not empty. Obviously! Axiom (3) says that the element x is on top of the stack obtained by pushing x onto any stack s. Duh! Finally, Axiom (4) simply says that, if we pop a stack obtained by pushing some element x onto some stack s, we get the stack s as the result.

By omitting an axiom of the form

topOf(empty) = ...

we have left this expression, in some sense, undefined. Another way to deal with such unwanted applications (corresponding, in this case, to the fact that we intend that topOf never be applied to an empty stack) is to introduce the notion of an "error" value. We shall not do so here.

Notice, too, that there is no axiom of the form

topOf( pop(s) ) = ...

Is this because we never want to allow topOf to be applied to a stack that can be described by an expression of the form pop(s)? No! Rather, it's because we intend for our axioms to be such that every stack (in the entire universe of possible stacks) be describable either by the expression empty or by an expression of the form push(s,x). That is, we intend for empty and push to be the generators of our specification, with pop playing the "lesser" role of an extension. (That is, we classify each constructor as either a generator or an extension.)

Is it, indeed, the case that our axioms are such that every possible stack is describable via an expression that is pop-free? We could prove this rigorously, but for now let's just do an example that should convince you of it. Take the expression

pop(push(push(pop(push(push(empty, 5), 2)), 0), 3))

Notice that the highlighted subexpression has the form pop(push(s,x)). (In gory detail, this follows by instantiating s by push(empty, 5) and x by 2.) By Axiom (4), this subexpression is equivalent to s (i.e., push(empty,5)). As we may replace any subexpression by one that is equivalent to it (this is the so-called Leibniz rule), the above expression is found to be equivalent to

pop(push(push(push(empty, 5), 0), 3))

Examining this expression, we see that it, too, has the form pop(push(s,x)). (Here the instantiations are s : push(push(empty, 5), 0) and x : 3.) Applying axiom (4), we get that it is equivalent to

push(push(empty, 5), 0)

We have succeeded in removing both occurrences of pop from the original expression! Assuming that the reader did not need these things to be spelled out in such detail, we would have written the above "transformation" as follows:

    pop(push(push(pop(push(push(empty, 5), 2)), 0), 3))

 =     < Axiom 4, with s := push(empty, 5) and x := 2 >

    pop(push(push(push(empty, 5), 0), 3))

 =     < Axiom 4, with s := push(push(empty, 5), 0) and x := 3 >

    push(push(empty, 5), 0)

Now consider any stack-expression E. We prove that E is equivalent to a stack-expression F that is either pop-free or has pop(empty) as a subexpression. (In the latter case, we could view E as being a "semantically invalid" stack-expression. Or, if we would prefer there not to be any such thing, we could add the axiom pop(empty) = empty, in which case we could show that all stack-expressions reduce to one without any applications of pop.) The proof is by mathematical induction on the number of applications k of pop in E.

Basis: k=0 (i.e., E has no occurrences of "pop"). Then E is pop-free. As (trivially) E is equivalent to itself, we take F to be E and we're done.

Induction Step: k>0. As an induction hypothesis, assume that every stack-expression with fewer than k occurrences of "pop" is equivalent to some stack-expression F that is either pop-free or has pop(empty) as a subexpression. If E contains pop(empty) as a subexpression, take F to be E and we're done. Otherwise, E must contain a subexpression of the form pop(push(G,H)), where G is a stack-expression and H is an Elem-expression. By axiom (4), E is equivalent to G. But G has fewer than k applications of "pop", so, by the induction hypothesis, it is equivalent to some expression F as described above. As E = G and G = F, we also have E = F (equivalence is transitive). End of proof.

As another example of axiom application, we "evaluate" the expression topOf(E), where E is the stack-expression considered above:

    topOf(pop(push(push(pop(push(push(empty, 5), 2)), 0), 3)))

 =     < axiom (4), with s := push(pop(push(push(empty, 5), 2)), 0), x := 3 >

    topOf(push(pop(push(push(empty, 5), 2)), 0))

 =     < axiom (3), with s:= pop(push(push(empty, 5), 2)), x := 0 >

    0

Queues

Let's devise an algebraic specification for queues. A queue is similar to a stack, but differs in that insertions ("enqueue") and deletions ("dequeue") occur at opposite ends (called rear and front, respectively). Let's adopt the convention that only the item at the front is observable. A queue is analogous to a waiting line (e.g., in a grocery store). In Great Britain (and probably most of the non-U.S. English-speaking part of the world), the term "queue" is commonly used, rather than "(waiting) line". Arrivals and departures from a queue follow a FIFO ("first-in-first-out") pattern.

Queue<Elem>   // To avoid clutter, we shall here abbreviate
              // Queue<Elem> to, more simply, Queue

Signature:

  isEmpty : Queue → bool         --answers "Is queue empty?"
  frontOf : Queue → Elem         --yields item at front of queue
  empty   :       → Queue        --yields the empty queue
  deq     : Queue → Queue        --yields queue obtained by removing front item
  enq     : Queue × Elem → Queue --yields queue obtained by placing item at rear

For purposes of brevity, we've abbreviated "dequeue" as "deq" and "enqueue" as "enq". Before giving the axioms, we note that the signature for Queue is isomorphic to the one we developed for Stack earlier. That is, they are "structurally identical" in that to get one from the other requires only that we rename (some of) the operations (e.g., "topOf" becomes "frontOf", "pop" becomes "deq") and replace each occurrence of "Stack" by "Queue". This illustrates quite clearly that the signature, taken by itself, falls woefully short of providing a clear description of the intended meaning of the operations.

The axioms for Queue are a little trickier than those for Stack:

Axioms:

  (1) isEmpty(empty) = true
  (2) isEmpty(enq(q,x)) = false
  (3) frontOf(enq(q,x)) = { x           if isEmpty(q)
                          { frontOf(q)  otherwise
  (4) deq(enq(q,x)) = { empty          if isEmpty(q)
                      { enq(deq(q),x)  otherwise

Axiom (3) reflects the fact that inserting an item into a queue has no effect upon which item is at the front of the queue, unless the queue had been empty at the time of the insertion (in which case the inserted item is now at the front!).

Axiom (4) reflects the fact that, for a non-empty queue, doing an insertion followed by a deletion has the same effect as doing them in the opposite order. (The same sequence of operations applied to an empty queue leaves it empty, of course.)

In axioms (3) and (4) we see the use of cases. Sometimes it is more convenient to express this in a linear way. To do so, we use the if function, whose signature is

if : bool × T × T → T

where T is any type at all. (Hence, it is really a family of functions.) By definition,

if(true, a, b) = a   and   if(false, a, b) = b

In other words, it's a 3-argument function that yields the value of either its 2nd or its 3rd argument according to whether its 1st argument is true or false, respectively.

Using if, we could rewrite axioms (3) and (4) as follows:

  (3) frontOf(enq(q,x)) = if(isEmpty(q), x, frontOf(q))
  (4) deq(enq(q,x))     = if(isEmpty(q), empty, enq(deq(q),x))

Here is an example of "evaluating" a queue-expression:

   enq(deq(deq(enq(enq(enq(empty,2),0),7))),3)

=    < axiom (4), with q := enq(enq(empty,2),0) and x := 7,
       and using fact that isEmpty(q) is false by axiom (2) >

   enq(deq(enq(deq(enq(enq(empty,2),0)),7)),3)

=    < axiom (4), with q := enq(empty,2) and x := 0,
       and using fact that isEmpty(q) is false by axiom (2) >

   enq(deq(enq(enq(deq(enq(empty,2)),0),7)),3)

=    < axiom (4), with q := empty and x := 2,
       and using fact that isEmpty(q) is true by axiom (1) >

   enq(deq(enq(enq(empty,0),7)),3)

=    < axiom (4), with q := enq(empty,0) and x := 7,
       and using fact that isEmpty(q) is false by axiom (2) >

   enq(enq(deq(enq(empty,0)),7),3)

=    < axiom (4), with q := empty and x := 0,
       and using fact that isEmpty(q) is true by axiom (1) >

   enq(enq(empty,7),3)

(Finite) Bags and Sets

Now let's specify the Bag ADT, also called the Multiset (although the former term seems to be more popular these days). A bag is similar to a set, which can be thought of as a collection of elements taken from some universe of elements. The distinction between the two is that, with respect to a set, each element of the universe is simply either a member, or not a member. With a bag, we have the notion that an element may occur in it any number of times (i.e., zero or more). For example, the set given by the enumeration

{ 4, 2, 0, 2, 3, 4, 7 }

is exactly the same as the one given by

{ 2, 4, 7, 3, 0 }

However, viewed as bags, they differ because the first contains two occurrences of both 2 and 4, while the second contains only one of each. (In order to make more clear whether an enumeration represents a set or a bag, here we augment the curly braces with vertical bars when enumerating a bag, as follows: {| 4, 2, 0, 2, 3, 4, 7 |}. )

Note that in neither bags nor sets is there a notion of the elements occurring in any particular order. That is, for example, the enumerations { 2, 4, 7, 3, 0 } and { 0, 2, 3, 4, 7 } denote the same set.

Here is a specification for Bags:

Bag (Elem)
signature:
  isEmpty : Bag         --> bool   --answers "Is bag empty?"
  #Occ    : Bag × Elem  --> Nat    --# of occurrences of an item in bag
  size    : Bag         --> Nat    --total # of occurrences of all items
  empty   :             --> Bag    --yields bag with no members
  insert  : Bag × Elem  --> Bag    --yields bag obtained by inserting an elem
  delete  : Bag × Elem  --> Bag    --yields bag obtained by deleting an elem
axioms: For all b in Bag and x,y in Elem:
(1) isEmpty(empty) = true
(2) isEmpty(insert(b,x)) = false
(3) #Occ(empty, x) = 0
(4) #Occ(insert(b,x),y)) = { #Occ(b,y) + 1  if x = y
                           { #Occ(b,y)      otherwise
(5) size(empty) = 0
(6) size(insert(b,x)) = size(b) + 1
(7) delete(empty,y) = empty
(8) delete(insert(b,x),y)) = { b                      if x = y
                             { insert(delete(b,y),x)  otherwise

Here is a specification for Sets:

Set (Elem)
signature:
  isEmpty : Set         --> bool   --answers "Is set empty?"
  isIn    : Set × Elem  --> bool   --is item member of set
  size    : Set         --> Nat    --# of items in set
  empty   :             --> Set    --yields set with no members
  insert  : Set × Elem  --> Set    --yields set obtained by inserting an elem
  delete  : Set × Elem  --> Set    --yields set obtained by deleting an elem
axioms: For all s in Set and x,y in Elem:
(1) isEmpty(empty) = true
(2) isEmpty(insert(s,x)) = false
(3) isIn(empty,x) = false
(4) isIn(insert(s,x),y)) = { true       if x = y
                           { isIn(s,y)  otherwise
(5) size(empty) = 0
(6) size(insert(s,x)) = { size(s) + 1  if !isIn(s,x)
                        { size(s)      otherwise
(7) delete(empty,y) = empty
(8) delete(insert(s,x),y)) = { delete(s,y)            if x = y
                             { insert(delete(s,y),x)  otherwise
Why is axiom (6) of Set more complicated than the corresponding axiom from Bag? Because inserting an element into a bag necessarily yields a bag containing one more item, whereas the same is not true for a set. Indeed, insert(s,x) denotes the same set as s unless x is not a member of s. For example, the set given by the expression

insert(insert(empty, 9), 9)

is the same as the one given by the expression

insert(empty, 9)

Both expressions correspond to the set containing 9 as a member, but nothing else. We would usually write this set as { 9 }.

Regarding axiom (8) of Set, we see that in the case x = y, it is more complicated than the corresponding axiom of Bag. This is due, again, to the fact that an insertion into a set may yield the same set again. More precisely, to guarantee that isIn(delete(s,x),x) yields false (as we would expect), we must ensure that delete(s,x) evaluates to a set-expression in which NO insertions of x appear. Hence, it is not enough to remove the "outermost" insertion of x; all nested insertions of x must be removed as well. To illustrate, let's do evaluations of two set-expressions: delete(E,3) and delete(F,3), where

E : insert(insert(insert(empty, 3), 8), 3)
F : insert(insert(empty, 3), 8)

As E and F both describe the set that we would normally write as {3, 8}, evaluation of each of delete(E,3) and delete(F,3) should yield an expression corresponding to the set that we would usually write as {8}.

Here goes:

   delete(E,3)

=     < defn of E >

   delete(insert(insert(insert(empty, 3), 8), 3), 3)

=     < axiom (8), with s := insert(insert(empty, 3), 8), x := 3, y := 3 >

   delete(insert(insert(empty, 3), 8), 3)

=     < axiom (8), with s := insert(empty, 3), x := 8, y := 3 >

   insert(delete(insert(empty, 3), 3), 8)

=     < axiom (8), with s := empty, x := 3, y := 3 >

   insert(delete(empty, 3), 8)

=     < axiom (7), with y := 3 >

   insert(empty, 8)

As expected, the resulting expression corresponds to the set we would usually write as { 8 }. As an exercise, evaluate delete(F,3). You should get the same expression.

Now suppose that we consider delete(E,3) and delete(F,3) as bag-expressions. Using the bag axioms, delete(F,3) should rewrite to insert(empty, 8), exactly as did the set-expression delete(F,3). On the other hand, the bag-expression delete(E,3) should be transformed into insert(insert(empty, 3), 8).

Initial and Final Algebras

Suppose that E and F are expressions of the ToI type. Under what conditions should we consider that E = F (i.e., the objects of type ToI described by E and F, respectively, are one and the same)? One point of view, called initial semantics, says that E = F holds only if we can prove (using the axioms, of course) that E = F (i.e., we can transform E into F or vice versa). The other view, called final semantics, says that E = F holds unless it can be proved (using the axioms of course) otherwise. As it is unusual to find axioms asserting that two expressions are non-equivalent, usually to show that E and F refer to distinct objects we must show that, for some observer o (and some x), o(E,x) is distinct from o(F,x) (where x denotes any remaining arguments to o).

For example, take E and F to be the set-expressions insert(insert(empty,3),5) and insert(insert(empty,5),3), respectively. Does E = F hold? As we intend for both of them to correspond to the set that we would usually write as { 3, 5 }, we would say YES. However, note that there is no way to transform E to F (or vice versa) using our set axioms. Hence, the initial algebra induced by our axioms has E and F denoting distinct objects! To fix this, we could add an axiom that says, in effect, that the order in which insertions occur is irrelevant. This is expressed by

insert(insert(s,x),y) = insert(insert(s,y),x).

By applying this axiom repeatedly, we could transform any set-expression of the form insert(insert(....(insert(empty,x1), x2, x3), ..., xn) into one of the same form in which the xi's appear in any order we like.

Viewed from the final algebra point of view, we don't need to add this axiom in order to get that E = F. Rather, the facts that size(E) = size(F) holds and isIn(E,x) = isIn(F,x) holds for all x (namely, for x = 3 and x = 5 both yield true and for any other value of x they both yield false) is enough for us to assert that E = F.

stuff to come: sufficient completeness: every expression of the form f(g(...)), where f is an observer and g is a generator for ToI, should simplify to an expression not involving the ToI.

inconsistency: if E = F, where E and F are ToI-expressions, then we should not have an observer f such that f(E) and f(F) are distinct values.