CS312 Lecture 8
Documenting Programs

 

Documenting Interfaces: Function Specifications

A well-designed specification removes unnecessary detail about the actual type or value being specified. The specification serves as a contract between the implementer and the user (client), making the job of both parties simpler and making the code more extensible and maintainable. This idea is also known as information hiding or encapsulation in the object-oriented world. 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.

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.

1) 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).

2) 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.

3) 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.

4) 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 *)

5) 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.

6) 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.

7) 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

 

Documenting Implementations

Let us now consider the use of comments in module implementations (in SML, structures). The first question we must ask ourselves is who is going to read the comments written in module implementations. Because we are going to work hard to allow module users to program against the module while reading only its interface, clearly users are not the intended audience. Rather, the purpose of implementation comments is to explain the implementation to other implementers or maintainers of the module. This is done by writing comments that convince the reader that the implementation correctly implements its interface.

It is inappropriate to copy the specifications of functions found in the module interface into the module implementation. Copying runs the risk of introducing inconsistency as the program evolves, because programmers don't keep the copies in sync. Copying code and specifications is a major source (if not the major source) of program bugs.  In any case, implementers can always look at the interface for the specification. This rule of thumb can be inconvenient to those using outdated editors that cannot view two files at a time, but the payoff is worth it. Thus, implementation comments are needed only if there are details of the implementation that are not obvious to the reader.

Implementation comments fall into two categories. The first category arises because a module implementation may define new types and functions that are purely internal to the module. If their significance is not obvious, these types and functions should be documented in much the same style that we have suggested for documenting interfaces. Often as the code is written it becomes apparent that the new types and functions defined in the module form an internal data abstraction or at least a collectin of functionality that makes sense as a module in its own right. This is a signal that the internal data abstraction might be moved to a separate module and manipulated only through its operations.

The second category of implementation comments is associated with abstract data types. More precisely, two pieces of information must be commented in the implementation: the abstraction function and the representation invariant for the abstract data type, discussed in the previous lecture. They provide the key pieces of information for other developers in charge of maintaining or extending this implementation. The abstraction function is the mapping from concrete implementation data structures to the abstract types they model (note: the abstraction function is not a piece of code, i.e., a function in the structure!). The representation invariant is the local invariant being maintained for the concrete data structure. The correctness of implemented operations depend on this invariant being maintained.

Consider the NATSET interface for sets of natural numbers, and the different implementations discussed last time (NatSet, NatSetNoDup, NatSetVec). The commented interface might look something like this:

signature NATSET = sig
  (* a "set" is a set of natural numbers: e.g., {1,11,0}, {}, and {1001}*)
  type set
 
  (* empty is the empty set *)
  val empty : set
 
  (* single(x) is {x}. Requires: x >= 0 *)
  val single : int -> set
 
  (* union is set union. *)
  val union : set*set -> set
 
  (* contains(x,s) is whether x is a member of s *)
  val contains: int*set -> bool
 
  (* size(s) is the number of elements in s *)
  val size: set -> int
end

The abstraction function is important for deciding whether an implementation is correct, and therefore it belongs as a comment in the implementation of any abstract data type. For example, in the NatSet structure, we could document the abstraction function as follows:

structure NatSet :> NATSET = struct
  type set = int list
  (* Abstraction function: the list [a1,...,an] represents the
   * smallest set containing all of a1,...,an. The list may
   * contain duplicates. The empty list represents the empty set.
   *)
  val empty = []
  ...

This comment explicitly points out that the list may contain duplicates, which is probably helpful as a reinforcement of the first sentence. Similarly, the case of an empty list is mentioned explicitly for clarity. The abstraction function for NatSetNoDups, however, hints at an important difference: we can write the abstraction function a bit more simply because we know that the elements are distinct:

structure NatSetNoDups :> NATSET = struct
  type set = int list
  (* Abstraction function: the list [a1,...,an] represents the set
   * {a1,...,an}. [] represents the empty set.
   *)
  val empty = []
  ...
We could also stick with the same abstraction function that we wrote for NatSet, because when applied to lists containing distinct integers, that abstraction function gives the same result as this one.

Finally, we can document the abstraction function of NatSetVec:

structure NatSetVec :> NATSET = struct
  type set = bool vector
  (* Abstraction function: the vector v represents the set
     of all natural numbers i such that sub(v,i) = true *)
  val empty:set = Vector.fromList []
  ...

Another option for defining the abstraction function is to give pseudo-code defining it; for example, in the case of NatSet we might write:

(* Abstraction Function:
    AF([]) = {}
    AF(h::t) = {h} U AF(t)    (where "U" is mathematical set union)
 *)

However, using English is generally recommended because some programmers find formalism difficult and because of the potential for confusion when the notation of the implementation (SML code) meets the notation of the abstract domain (mathematics, in this case).

There is a corresponding problem on the input side. Suppose that in writing NatSetNoDups we used the same abstraction function as for NatSet�which we argued above is fine because that abstraction function gives exactly the same result on all lists that the module implementation will ever produce:

(* Abstraction function: the list [a1,...,an] represents the
 * smallest set containing all of a1,...,an. The list may
 * contain duplicates. The empty list represents the empty set.
 *)

The second piece of information that must be documented is the representation invariant (or rep invariant). This is an internal constraints on the concrete implementation structures that must be maintained all the time. Structures that do not satisfy this constraints are invalid; they do not correspond (via the abstraction function) to any abstract type. The correct functioning of implemented operations depend on such constraints. For instance, The NatSetNoDups implementation requires no duplicates in the list. If this constraint is broken, functions such as size() will not return the correct answer.  Here is an example of how to document the rep invariant for the NatSetNoDups implementation:

(* Representation invariant: given the rep [a_1,...,a_n],
 * no elements are negative, and no two elements are equal. *)

The rep invariant is a condition that is intended to hold for all values of the abstract type (e.g., set). Therefore, in implementing one of the operations of the abstract data type, it can be assumed that any arguments of the abstract type satisfy the rep invariant.