CS312 Lecture 6:  Specifying Abstractions

Modular programming is a valuable technique for building medium-sized and large programs because it allows a program to be broken up into loosely coupled pieces that can be developed largely in isolation. It facilitates local reasoning: the programmer can think about the implementation of a piece of a program without full knowledge of the rest of the program. Rather, the rest of the program only needs to be understood abstractly, at the level of detail presented by the interfaces to the various modules on which the piece of code being worked on depends. This abstraction makes the programmer's job much easier; it is helpful even when there is only one programmer working on a moderately large program, and it is crucial when there is more than one programmer.

Modules and interfaces are instantiated in SML by structures and signatures, but they are also found in other modern programming languages in different form. In Java, interfaces, classes, and packages facilitate modular programming. All three of these constructs can be thought to provide interfaces in the more general sense that we are using in this course. The interface to a Java class or package consists of its public components. The Java approach is to use the javadoc tool to extract this interface into a readable form that is separate from the code. This makes life a little easier for the implementer because a separate interface does not have to be written as in SML. However, automatic interface extraction is also dangerous, because changes to any public components of the class will implicitly change the interface and possibly break client code that depends on that interface. The discipline provided by explicit interfaces is useful in preventing these program development problems for larger projects.

We said that abstraction is the removal of detail. There are really two kinds of abstraction with which we are concerned:

In this lecture we are primarily concerned with abstraction by specification. This methodology is still missing a crucial piece as described thus far. Suppose that an implementer provides a module N with the following signature:

structure N :> M = struct ... end

signature M = sig
    val sqr: real -> real
end

If we are programming in a modular style, we expect that we don't have to see any of the code in the ... in order to understand how to use M.sqr in code. But clearly this isn't the case; we can't tell what sqr is supposed to do. We might guess that it's a square root function, but even assuming that is correct, the reader 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). The obvious approach, and the one we will follow in this course, is to add a human-readable comment in the signature that describes the behavior of the function. This comment, in conjunction with the type declaration for the function, is the specification of the function.

Another approach is to use a system for software verification. These systems (e.g., Larch-C) allow annotations to be attached to a program that formally state abstractly what the functions do; a theorem prover can then use the abstract specifications to check in a modular fashion that the program as a whole actually does what it says it does. This second approach has not proven popular among programmers because it is difficult to formally prove programs correct and the annotations are tedious to write. In practice human-language specifications seem to be more useful.

Specifying functions

How might we add a specification to M.sqr, assuming that it is a square-root function? First, we need to write a clause describing its result. We will call this the returns clause because it describes the result that the function returns. It is also known as a postcondition. Here is an example:

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

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

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

This specification doesn't make sense yet because the square root does not exist for some x of type real. We have several ways to deal with this issue. A simple approach is to rule it out by adding a requires clause establishing when the function may be called. This is also called a precondition:

(* 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. The spec is establishing a contract between the implementer and the user. 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.

Handling partial functions

The problem here is that we need to write functions that are partial: that is, defined over only part of their domain. Total functions (functions defined over their entire domain) are easier for the user to deal with because their behavior is always defined. How can we convert sqr into a total function? One approach that is 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 problem of handling the error has been pushed into 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 explicit testing 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, often 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 alternative for making functions total is to raise an exception. 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. Thus, we write:

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

Checked preconditions provide a handy way to make functions total without adding distracting error-handling logic to the user's code. 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. Also, a function whose specification includes a requires clause may still be implemented by throwing an exception, as if the specification contained the corresponding checks clause. Why is this in accordance with the specification?

Nondeterministic specifications

Suppose we want a function that searches an int list for a particular integer and returns its position in the list; that is, a function index : int list * int -> int. Here is a first cut at the specification:

(* index(lst, x) is the index at which x is
 * is found in lst.
 * Requires: x is in lst *)
index: int list * int -> int

Notice that we have included a precondition 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:

(* index(lst, x) is an index in lst at which x is
 * found; that is, nth(lst, index(lst, x)) = x.
 * Requires: x is in lst *)
index: 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 works. 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.

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

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 (permissive) 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.

Specifying interfaces

The specification methodology just described can be applied to module interfaces (i.e., signatures in SML). A signature defines a collection of types and values, where the latter are typically functions. The goal of the implementer is to write a signature that permits the module to be used without looking at any code other than the signature itself. This can be achieved by writing specifications for each of the values defined in the signature. In addition, there must be a description of the abstract type(s) provided by the signature. Importantly, this description should not contain any implementation details; it should describe only aspects of the type that are observable through the interface provided. This restriction ensures that the signature can be reimplemented by another structure without changing the signature specification. For example, consider the POLYNOMIAL interface from the last lecture:

signature POLYNOMIAL =
    sig
      type poly
      val zero:poly
      val singleton : int*int -> poly
      val degree: poly -> int
      val evaluate: poly*int -> int
      val coeff: poly*int -> int
      val plus: poly*poly -> poly
      val minus: poly*poly -> poly
      val times: poly*poly -> poly
    end

The user of this module might make a reasonable guess at the significance of these operations, but real specifications will make it clearer. We must first start by specifying the abstract type poly. We will follow the Java convention of putting specifications before the item being defined.

signature POLYNOMIAL =
    sig
      (* A poly is a polynomial with integer coefficients,
       * e.g., 2 + 3*x - x^3.
       * )
      type poly
      ...

It is often useful to provide an example of an abstract value, as is done here. This requires notation for talking about the values of the abstract type being defined, and this notation can then be used in writing the specifications of the operations of the abstract type. Using this notation, we can specify the rest of the signature:

      (* zero is the polynomial 0 *)
      val zero:poly

      (* singleton(c,d) is the polynomial c*x^d.
       * Requires: d >= 0 *)
      val singleton : int*int -> poly

      (* degree(p) is the degree of the polynomial:
       * the largest exponent of the polynomial with
       * a nonzero coefficient *)
      val degree: poly -> int

      (* evaluate(p,x) is p evaluated at x *)
      val evaluate: poly*int -> int

      (* coeff(p,n) is the coefficient c of the term
       * of form c*x^n, or zero if there is no such
       * term. *)
      val coeff: poly*int -> int

      (* plus, minus, times are +, -, * on polynomials,
       * respectively *)
      val plus: poly*poly -> poly
      val minus: poly*poly -> poly
      val times: poly*poly -> poly
    end

There are a few things to notice here. The singleton and coeff operations are both partial functions because they are not defined for negative exponents. This is about the level of verbosity and formalism that works for most programmers -- it is possible to be more precise about the meaning of these functions but it comes at too high a cost in readability. In the specifications for plus, minus, times, we rely on the reader's understanding of polynomials to avoid writing tedious specifications of the form, "plus(p,q) is p+q", etc. It is acceptable and even a good idea to rely on the reader's likely knowledge to avoid long specifications. However, as with all writing tasks, this requires a judgement about your likely reader. If that reader is yourself (perhaps at some time in the future), it is relatively easy to assess what will be comprehensible! But when writing code for an organization more care must be taken.