CS312 Lecture 6
Functional Specifications

We've been building very small programs. When a program is small enough, we can keep all of the details of the program in our heads at once. Real programs are 100 to 10000 times larger than any program you have likely written; they are simply too large and complex to hold all their details in our heads. To build large programs that work we must use abstraction to make it manageable to think about the program. Abstraction is simply the removal of detail. A well-written program has the property that we can think about its components (such as functions) abstractly, without concerning ourselves with all the details of how those components are implemented.

Abstraction mechanisms

We will be concerned with two kinds of abstraction:

We assume you are already familiar with abstraction by parameterization. In this course, we are primarily concerned with abstraction by specification. The goal is to allow a programmer to use some code without actually having to read the code in detail.

Suppose that we see function definitions for functions named sqr and find:

fun sqr(x:real): real = ...
fun find(lst: string list, x: string): int = ...

We can write code that type-checks without seeing the definition of these functions, but we can't be sure that it will work because we don't know enough about what the functions do. For example, we might guess that sqr is a square root function, but maybe it squares its input. Even assuming that it does compute a square root, the user cannot tell how accurate the result of the function is or whether the function can be called when the argument is negative (and what happens in that case). Similarly, we might guess that find returns the position of  x is in the list lst, but we don't know whether positions start from 0 or 1, and we don't know what happens if x is not in the list.

Thus, the type of a function, is not enough information to write code using that function. The obvious solution is to add a comment or comments that provide the needed information. The question is just what should be written in this comment. Programmers are often exhorted to write comments in their code; too often, this exhortation results in useless comments that simply obscure the code further. Particularly annoying to read is code that contains many interspersed comments (typically of questionable value), e.g.:

let val y = x+1 (* make y one greater than x *)

Another common practice that isn't very useful is choosing long, descriptive names for all variables, as in the following verbose code:

let val number_of_zeros =
   foldl (fn(list_element:int, accumulator:int) =>
          accumulator + (if list_element=0 then 1 else 0)) 0 the_list
in ...

It is better to choose short names, and if really necessary, add a comment explaining the purpose of the variable.

Function specifications

Our goal, then, is to provide a short comment describing just enough of the behavior of the function to allow a programmer to use the function without seeing how it is implemented. This comment, along with the machine-readable type of the function, is the specification of the function.

The specification is a contract between the user and the implementer of the function. It tells the user what can be relied upon when calling the function. The user should not assume anything about the behavior of the function that is not described in the specification. The specification also tells the implementer of the function what behavior must be provided. The implementer must meet the specification.

A specification is written for humans to read, not machines. As with anything you write, you need to be aware of your audience. Some users may need a more verbose specification than others. However, it is always worthwhile to be clear.

A well-written specification usually consists of several different parts. If you know what the usual ingredients of a specification are, you are less likely to forget to write down something important. We will now look at a recipe for writing specifications.

Returns clause

How might we add a specification to sqr, assuming that it is a square-root function? First, we need to describe its result. We will call this description the returns clause because it is a part of the specification that describes the result of a function call. It is also known as a postcondition : it describes a condition that holds after the function is called. Here is an example of a returns clause:

(* Returns: sqr(x) is the square root of x. ...

For numerical programming, we should probably add some information about how accurate it is.

(* Returns: sqr(x) is the square root of x.
 *   Its relative accuracy is no worse than 1.0*10^-6.
 *)

Similarly, we might write a returns clause for the find function. It is okay to leave the introductory "Returns:" implicit:

(* find(lst,x) is the index of x in the list lst, starting
 *   from zero.
 *)

What's wrong with this specification?

A good specification is concise but clear -- it should say enough that the reader understands what the function does, but without extra verbiage to plow through and possibly cause the reader to miss the point. Sometimes there is a balance to be struck between brevity and clarity.

These two specifications use a useful trick to make them more concise -- they talk about the result of applying the function being specified to some arbitrary arguments. Implicitly we understand that the stated postcondition holds for all possible values of any unbound variables (the argument variables).

Requires clause

The specification for sqr doesn't completely make sense because the square root does not exist for some x of type real. The mathematical square root function is a partial function that is defined over only part of its domain. A good function specification is complete with respect to the possible inputs; it provides the user with an understanding of what inputs are allowed and what the results will be for allowed inputs.

We have several ways to deal with partial functions. A straightforward approach is to restrict the domain so that it is clear the function cannot be legitimately used on some inputs. The specification rules out bad inputs with a requires clause establishing when the function may be called. This clause is also called a precondition because it describes a condition that must hold before the function is called. Here is a requires clause for sqr:

(* sqr(x) is the square root of x.
 *   Its relative accuracy is no worse than 1.0x10^-6.
 * Requires: x >= 0
 *)

This specification doesn't say what happens when x < 0, nor does it have to. Remember that the specification is a contract. This contract happens to push the burden of showing that the square root exists onto the user. If the requires clause is not satisfied, the implementation is permitted to do anything it likes: for example, go into an infinite loop or throw an exception. The advantage of this approach is that the implementer is free to design an algorithm without the constraint of having to check for invalid input parameters, which can be tedious and slow down the program. The disadvantage is that it may be difficult to debug if the function is called improperly, because the function can misbehave and the user has no understanding of how it might misbehave.

To make it easier to debug the function, we might like to guarantee that violations of the precondition will be caught at run time, and that the function will raise an exception in that case. We write a checks clause rather than a requires clause to indicate that the precondition is explicitly tested and that an Fail exception is raised otherwise. Violating the the checks clause is still understood to be a error on the part of the user, but the behavior of the program is (better) specified in this case. For example, we might write:

(* sqr(x) is the square root of x, with relative accuracy
 *   no worse than 1.0x10^-6.
 * Checks: x >= 0 
 *)

If a checks clause is provided, the user knows that precondition violations will be caught; there is a corresponding contractual obligation on the implementer to actually check the precondition. Note that the function does not need to raise Fail itself; it may satisfy the checks clause by calling other functions whose checked preconditions imply the checked preconditions of this function.

It is perfectly all right for a function whose specification includes a requires clause may still be implemented by checking the requires clause and throwing an exception if it fails, as if the specification contained the corresponding checks clause. Why is this in accordance with the specification?

In some program environments, it is possible to write code that can be turned off in the production version of the program. For performance, the code that implements checks clauses can be flagged as such once the program is believed to be sufficiently bug-free.

Exceptions

Another way to deal with partial functions is to convert them into total functions (functions defined over their entire domain). This approach is arguably easier for the user to deal with because the function's behavior is always defined; it has no precondition. However, it pushes work onto the implementer and may lead to a slower implementation.

How can we convert sqr into a total function? One approach that is (too) often followed is to define some value that is returned in the cases that the requires clause would have ruled; for example:

(* sqr(x) is the square root of x if x >= 0,
 *   with relative accuracy no worse than 1.0x10^-6.
 *   Otherwise, a negative number is returned. 
 *)

This practice is not recommended because it tends to encourage broken, hard-to-read user code. Almost any correct user of this abstraction will write code like this if the precondition cannot be argued to hold:

if sqr(a) < 0.0 then ... else ...

The error must still be handled in the if (or case) statement, so the job of the user of this abstraction isn't any easier than with a requires clause: the user still needs to wrap an explicit test around the call in cases where it might fail. If the test is omitted, the compiler won't complain, and the negative number result will be silently treated as if it were a valid square root, likely causing errors later during program execution. This coding style has been the source of innumerable bugs and security problems in the Unix operating systems and its descendents (e.g., Linux).

A better way to make functions total is to have them raise an exception other than Fail when the expected input condition is not met. We reserve Fail for conditions that are truly errors. Exceptions avoid the necessity of distracting error-handling logic in the user's code. If the function is to be total, the specification must say what exception is raised and when. This can go in the returns clause. For example, we might make our square root function total as follows:

(* sqr(x) is the square root of x
 *   with relative accuracy no worse than 1.0x10^-6.
 *   Raises Negative if x < 0. 
 *)
exception Negative
fun sqr(x: real): real = ...

Note that the implementation of this sqr function must check whether x>=0, even in the production version of the code, because some client may be relying on the check.

Examples

Depending on the audience, it may be useful to provide an illustrative example as part of a specification. Usually this is not necessary if the specification is clear and well written, but here is how one would give one or more examples as a separate clause of the specification:

(* find(lst,x) is the index of x in the list lst, starting
 *   from zero.
 * Example: find(["b","a","c"], "a") = 1 *)

Nondeterminism

Let's take a look at the function find again. Here is an attempt at a specification:

(* find(lst, x) is the index at which x is
 *   is found in lst, starting from zero.
 * Requires: x is in lst *)
val find: string list * string -> int

Notice that we have included a requires clause to ensure that x can be found in the list at all. However, this specification still has a problem. The phrase "the position" implies that x has a unique position. We could strengthen the precondition to require that there be exactly one copy of x in the list, but probably we'd like this function to do something useful in the case where x is duplicated. A good alternative is to fix the specification so that it doesn't say which position of x is found if there are more than one:

(* find(lst, x) is an index in lst at which x is
 * found; that is, nth(lst, find(lst, x)) = x.
 * Requires: x is in lst *)
val find: int list * int -> int

This is an example of a nondeterministic specification. It states some useful properties of the result that is returned by the function, but does not fully define what that result should be. Nondeterministic specifications force the user of the abstraction to write code that works regardless of the way in which the function is implemented. They are sometimes called weak specifications because they avoid pinning down the implementations (and the implementers). The user cannot assume anything about the result beyond what is stated in the specification. This means that implementers have the freedom to change their implementation to return different results as long as the new implementation still satisfies the specification. Nondeterministic specifications make it easier to evolve implementations.

How much nondeterminism is appropriate? A good specification should restrict the behavior of the specified function enough that any implementation must provide the functionality needed by the clients of the function. On the other hand, it should be general (weak) enough that it is possible to find acceptable implementations. Clearly these two criteria are in tension. And of course, a good specification is brief, precise, and comprehensible.

Equational specifications

The specification of find actually says the same thing in two different ways; first, in English, and second, via the equational specification nth(lst,find(lst,x)) = x. Any implementation that always satisfies this equation behaves just as described by the informal language. Equational specifications are often a compact and clear tool for writing returns clauses, whether deterministic or nondeterministic.

Here we are relying on the convention given earlier: when an equation is given with unbound variables, the equation is meant to hold for all possible values of those variables. This is another way to write the returns clause that is given earlier, though probably the earlier version is more readable for most programmers.

Refinement

Sometimes one specification is stronger than another specification. For example, consider two possible specifications for find:

A: (* find(lst, x) is an index at which x is
    *   is found in lst; that is, nth(lst, find(lst, x)) = x
    * Requires: x is in lst *)
B: (* find(lst, x) is the first index at which x is
    *   is found in lst, starting from zero
    * Requires: x is in lst *)

Here specification B is strictly stronger than specification A: given a particular input to function as specified by B, the set of possible results or outcomes is smaller than it is for A. Compared to A, specification B reduces the amount of nondeterminism. In this case we say that specification B refines specification A.

There are other ways to refine a specification. For example, if specification A contains a requires clause, and specification B is identical but changes the requires clause to a checks clause, B refines A: it more precisely describes the behavior of the specified function.

We can think of the actual code implementing the function as another specification of the computation to be performed. This implementation-derived specification must be at least as strong as the specification written in the comment; otherwise, the implementation may do things that the specification forbids. In other words, any correct implementation must refine its specification

Automatic verification

We've been looking at how to write human-readable specifications. It is possible to write specifications in a formal language that permits the computer to read them. These machine-readable specifications can be used to perform formal verification of the program. Examples of systems that do this include ESC Java and Larch-C. Using a formal specification, an automatic theorem prover can prove that the program as a whole actually does what it says it does. Formal program verification is an attractive technology because it can be used to guarantee that programs do not contain bugs! However, it has not proven popular among programmers because it is difficult to formally prove programs correct and because the specifications are tedious to write in a form that the machine can understand. In practice machine-readable type specifications are very useful, but it seems to work better to put the rest of the specification in human-readable form. We will see soon that the specifications that we are talking about writing can be used to manually construct proofs that programs work, too.