CS 3110 Lecture 6
Functional Specifications and Modular Programming

Modular programming

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 application programs are 100 to 10000 times larger than any program you have likely written or worked on; they are simply too large and complex to hold all their details in our heads. They are also written by multiple authors because otherwise it would take too long. To build large software systems requires techniques we haven't talked about so far.

The problem of dealing with complexity is not unique to computer science; it's something every engineering discipline has to deal with. But the problem is perhaps more painfully apparent in computer science because complexity is really the limiting factor in what can be accomplished. In fact, the ideas that computer scientists have developed for dealing with complexity have been adopted by other engineering disciplines. Thus, the discipline of systems engineering has borrowed heavily from software engineering.

The solution that computer scientists have developed is modular programming: the code is composed of many different code modules that are developed separately. This allows different developers to take on discrete pieces of the system and design and implement them without having to understand all the rest. However, to build large programs out of modules effectively, we need to be able to write code modules that we can convince ourselves are correct in isolation from the rest of the program. Rather than have to think about every other part of the program when developing a code module, we need to be able to use local reasoning: that is, reasoning about just the module and the contract it needs to satisfy with respect to the rest of the program. If everyone has done their job, separately developed code modules can be plugged together to form a working program without every developer needing to understand everything done by every other developer in the team. This is the idea of modular programming.

Therefore, 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.

Modules are abstracted by giving specifications of what they are supposed to do. A good module specification is clear, understandable, and give just enough information about what the module does for clients to successfully use it. 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.

Languages often contain mechanisms that support modules directly. Objective Caml is one of those languages, as we'll see shortly. Object-oriented languages support modular programming with classes. In general, a module specification is known as an interface, which abstracts the module implementation. The name “interface” should not be confused with the Java interface mechanism, which indeed can be used as an interface. But even just the public methods of a class constitute an interface in the more general sense -- an abstract description of what the module can do.

Once we have defined a module and its interface, developers working with the module take on distinct roles. Ideally, most developers are clients of the module who understand the interface but do not need to understand the implementation of the module. A developer who works on the module implementation is an implementer. The module interface is a contract between the client and the implementer, defining the responsibilities of both. Contracts are very important because they allow us to figure out whose fault it is when something goes wrong!

In modular programming, modules are used only through their declared interfaces, which the language may help enforce. This is true even when the client and the implementer are the same person. Modules decouple the system design and implementation problem into separate tasks that can be carried out largely independently. When modules are used only through their interface, the implementer has the flexibility to change the module as long as the module still satisfies its interface. The interface ensures that the module is loosely coupled to its clients. Loose coupling gives implementers and clients the freedom to work on their code mostly independently, and it also means that changes in one code module are less likely to require changes to others.

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:

let sqr(x:float): float = ...
let 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. Assuming that it does compute a square root, the user still 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.

The type of a function is usually 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.

How not to write comments

Programmers usually do not write enough comments in their code. However, this doesn't mean that adding more comments is always better. The wrong comments will simply obscure the code further. Shoveling as many comments into code as possible usually makes the code worse! Both code and comments are precise tools for communication (with the computer and with other programmers) that should be wielded carefully.

It is particularly annoying to read code that contains many interspersed comments (typically of questionable value), e.g.:

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

For complex algorithms, some comments may be necessary to explain how the code implementing the algorithm works. Programmers are often tempted to write comments about the algorithm interspersed through the code. But someone reading the code will often find these comments confusing because they don't have a high-level picture of the algorithm. It is usually better to write a paragraph-style comment at the beginning of the function explaining how its implementation works. Explicit points in the code that need to be related to that paragraph can then be marked with very brief comments, like (* case 1 *).

Another common but well-intentioned mistake is giving variables long, descriptive names, as in the following verbose code:

let number_of_zeros_in_the_list =
   fold_left (fun (accumulator:int) (list_element:int) ->
          accumulator + (if list_element=0 then 1 else 0)) 0 the_list
in ...

Code using such long names will be very verbose and hard to read. Instead of trying to embed a complete description of a variable in its name, use a short and suggestive name (e.g., zeroes or nz), and if necessary, add a comment at its declaration explaining the purpose of the variable.

A related bad practice is to encode the type of the variable in its name, e.g. naming a variable count a name like i_count to show that it's an integer. Instead, just write a type declaration. If the variable is so far from its type that you can't see the type declaration, the code should probably be restructured anyway.

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 (aka client) and the implementer of the function. It tells the client what can be relied upon when calling the function. The client 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 by providing an implementation consistent with it.

Clear specifications serve many important functions in software development teams. One important one is when something goes wrong, everyone can agree on whose job it is to fix the problem: either the implementer has not met the specification and needs to fix the implementation, or the client has written code that assumes something not guaranteed by the spec, and therefore needs to fix the using code. Or, perhaps the spec is wrong, and then the client and implementer need to decide on a new spec. This ability to decide whose problem a bug is prevents problems from slipping through the cracks.

The client should not assume more about the implementation than is given in the spec because that allows the implementation to change. The specification forms an abstraction barrier that protects the implementer from the client and vice versa. Making assumptions about the implementation that are not guaranteed by the specification is known as violating the abstraction barrier. The abstraction barrier enforces local reasoning. Further, it promotes loose coupling between different code modules. If one module changes, other modules are less likely to have to change to match.

Writing a function spec

A specification is written for humans to read, not machines. Specs can take time to write well, and it is time well spent. The main goal is clarity. It is also important to be concise, because client programmers will not always take the effort to read a long spec. As with anything we write, we need to be aware of your audience when writing specifications. Some readers may need a more verbose specification than others.

A well-written specification usually has several parts communicating different kinds of information about the thing specified. If we know what the usual ingredients of a specification are, we are less likely to forget to write down something important. Let's 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
let 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 *)