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