Skip to main content

CSE 331: Procedural abstraction and specification

lecture

Course motto: Your job is to deliver code that you know works.

Sum, in pieces

Last lecture's Sum did all its work in main. Larger programs are built from methods instead, each with one job. The program below is Sum rebuilt that way: one method reads the numbers, and another adds them up.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
import java.util.ArrayList;
import java.util.List;
import java.util.Scanner;

public class Sum {
    public static void main(String[] args) {
        System.out.println("Enter some numbers.");
        List<Integer> list = readList();
        System.out.println("Got " + list);
        System.out.println("The sum of your list was: " + sumList(list));
    }

    static List<Integer> readList() {
        Scanner in = new Scanner(System.in);
        List<Integer> list = new ArrayList<>();
        while (in.hasNextInt()) {
            list.add(in.nextInt());
        }
        return list;
    }

    static int sumList(List<Integer> list) {
        int sum = 0;
        for (int i = 0; i < list.size(); i++) {
            sum += list.get(i);
        }
        return sum;
    }
}

The program takes last lecture's steps and spreads them over three methods. Only one step has moved: the program now builds the Scanner after it prints the prompt. A Scanner reads nothing until the program asks it for a number, so on every input the program prints exactly what last lecture's Sum printed. This program therefore meets the same specification as last lecture's Sum. We also checked by running both versions on every input last lecture tried. Their output matched every time.

Read main on its own, lines 6 to 11. main prints the prompt, gets a list from readList, prints the list, and prints what sumList returns for it. To argue that the program meets its specification, we need to know what readList and sumList do. main does not say. You could read both methods, as we read the loops last lecture. But rereading every method each time becomes impractical once a program has a thousand methods. And main may work only because of how sumList happens to be written. If so, anyone who rewrites sumList can break main without touching it. Instead, we write down what the code that calls each method can count on: the method's promises. Then we reason about each method with those promises standing in for the code of the methods it calls. This lecture is about those promises and what they let you do.

A specification for a method

Last lecture defined a specification as what a piece of code must do on every run, as seen from outside it. A method is a piece of code whose runs are its calls. So a method's specification says what every call must do, as seen from the code that makes the call. Code that calls a method is a client of the method. For example, main is a client of sumList.

A method's client differs from a program's user in one way: a client can be held to a promise. A whole program's user owes the program nothing. They may type anything at all, so a user manual has to say what happens on every input. A method's client is code, written by a programmer who can read the specification. So a method's specification comes in two parts: what it asks of its client, and what it promises in return.

Definition: method specification

A method specification has a precondition, an assertion at the point where the method is called, and a postcondition, an assertion at the point where it returns or throws. The client must make the precondition true. The implementer must make the postcondition true and may assume the precondition.

A method meets its specification if every call that begins with the precondition true ends with the postcondition true.

Homework 1 says that an implementation satisfies its specification where these notes say meets. The two words mean the same thing.

The precondition is a promise the client makes. Like any promise, it can be broken. On a call that breaks the precondition, the specification promises nothing: the method may return any value, throw any exception, or never return. The method still meets its specification. A whole program's specification cannot ask the same of its user, because no one can hold a user to a promise. That is why last lecture's exercise 7 rejected a specification for a whole program that began the user must type only whole numbers. Put the client in place of the user, and the same sentence is an ordinary precondition.

Last lecture's assertions supply most of the specifications of readList and sumList. The claim at (1) is what readList promises about the list it returns. Likewise, the claim at (2) about sum is what sumList promises about the number it returns. Their preconditions are short: readList asks nothing of its client, and sumList asks only for a list that is not null.

Writing it in Java

In Java, a method's specification goes just above the method, in a comment that begins with /**. The javadoc tool turns such comments into web pages. The pages that document ArrayList and Scanner were made that way. The comment opens with a sentence saying what the method does. Then each part of the specification begins with a tag, a word starting with @. This course uses six:

  • @param says what a parameter is for. A short precondition on that one parameter, such as must be non-null, can go here too.
  • @requires states a precondition that is too long for one @param or that relates several parameters to each other.
  • @return says what the method returns: the part of the postcondition about the result.
  • @throws says which exception the method throws, and when. The method promises to throw it in that case, so @throws is part of the postcondition too.
  • @modifies lists the parameters the method may change. The method promises to leave every other parameter alone.
  • @effects says how the parameters in @modifies change: the part of the postcondition about them.

@param, @return, and @throws are standard Javadoc. The other three, @requires, @modifies, and @effects, are this course's additions, taken from Liskov and Guttag in the further reading. Homework 1 uses all six. The specifications of readList and sumList need only two of them:

    /**
     * Reads whole numbers typed by the user.
     *
     * @return a list holding, in order, every number the user typed
     *     before the input ended or something that was not an int appeared
     */
    static List<Integer> readList()

    /**
     * Adds up a list of numbers.
     *
     * @param list the numbers to add; must be non-null
     * @return the sum of the elements of list, wrapped into the int range
     *     if the true sum falls outside it
     */
    static int sumList(List<Integer> list)

readList's specification talks about the user, and sumList's talks only about its parameter. A client does not need to know about Scanner, loops, or indices, so neither specification mentions them. sumList's specification also makes a promise by what it leaves out: it has no @modifies, so it promises not to change list. That promise is the second claim last lecture needed at (2): the list the user was shown is the list that got summed.

@modifies and @effects are needed when a method's job is to change something. Here is another way Sum could have read its numbers, into a list the client provides instead of into a new list:

    /**
     * Reads whole numbers typed by the user into a list.
     *
     * @param list the list to add to; must be non-null
     * @modifies list
     * @effects adds to the end of list, in order, every number the user
     *     typed before the input ended or something that was not an int
     *     appeared
     */
    static void readInto(List<Integer> list)

readInto returns nothing, so everything it promises is in @effects. @effects describes the new list in terms of the old: the elements already there stay, and the numbers the user typed come after them. A client that needs its list untouched learns from @modifies not to pass that list to readInto. Neither design is wrong. readList and readInto make different promises. Their specifications say exactly how the promises differ.

Reasoning across a call

The specifications of readList and sumList let us argue that main is correct without opening either method. Here are lines 7 to 10 of main, with a claim written in after line 8, in last lecture's double braces:

        System.out.println("Enter some numbers.");
        List<Integer> list = readList();
        {{ list holds, in order, every number the user typed before
           the input ended or something that was not an int appeared }}
        System.out.println("Got " + list);
        System.out.println("The sum of your list was: " + sumList(list));

readList has no precondition for main to make true. Its postcondition says what is in the returned list. Line 8 stores that list in list. So the claim holds after line 8. The claim is last lecture's assertion at (1). This time it came from a specification instead of a loop. The argument uses last lecture's P, code, Q rule, with a call as the code. The precondition is P, and the postcondition is Q. The specification promises that running the call from any state in which P is true ends in a state in which Q is true.

The same rule covers the call on line 10. readList promised a list, so list is not null, as the precondition of sumList requires. The call then returns the wrapped sum of the elements of list. sumList also promises not to change list, so those elements are still the numbers line 9 printed. Line 10 therefore prints their sum, wrapped as the specification of the whole program says. Each step relied on nothing but the promises of readList and sumList.

Definition: procedural abstraction

Procedural abstraction is using a method through its specification alone. The method's clients rely on nothing the specification does not promise, and its implementer may change anything the specification does not promise.

The specification is the line between a method's clients and its implementer. That line is often called an abstraction barrier. On one side, main relies on the promises and nothing else. On the other, the implementer of sumList is free to add up the list from the end instead of the start. Adding from the end keeps the same promises, so main needs neither a change nor a new argument. A large program is argued correct the same way, one method at a time, each against the specifications of the methods it calls.

Meeting a specification

We can often give a method more than one reasonable specification. Choosing the specification is part of designing the method. Here is a method that searches part of an array, with its specification cut off where the choices begin:

    /**
     * Searches for a value in part of an array.
     *
     * @param a the array to search; must be non-null
     * @param lo the index where the range to search begins
     * @param hi the index just past where the range to search ends
     * @param x the value to search for
     * ... and one of the specifications below ...
     */
    static int indexOf(int[] a, int lo, int hi, int x)

The five specifications differ in what they require and promise:

Spec A
  @requires 0 <= lo <= hi <= a.length, and a[i] == x for some i
            with lo <= i < hi
  @return   an index i with lo <= i < hi and a[i] == x

Spec B
  @requires 0 <= lo <= hi <= a.length
  @return   the smallest index i with lo <= i < hi and a[i] == x,
            or -1 if there is none

Spec C
  @requires 0 <= lo <= hi <= a.length
  @return   the largest index i with lo <= i < hi and a[i] == x,
            or -1 if there is none

Spec D
  @requires 0 <= lo <= hi <= a.length
  @return   some index i with lo <= i < hi and a[i] == x,
            or -1 if there is none

Spec E
  @requires 0 <= lo <= hi <= a.length
  @return   the smallest index i with lo <= i < hi and a[i] == x
  @throws   NoSuchElementException if there is none

And here are four implementations:

// Implementation 1
for (int i = lo; i < hi; i++) {
    if (a[i] == x) {
        return i;
    }
}
return -1;

// Implementation 2
for (int i = lo; i < hi; i++) {
    if (a[i] == x) {
        return i;
    }
}
throw new NoSuchElementException();

// Implementation 3
int i = lo;
while (a[i] != x) {
    i++;
}
return i;

// Implementation 4
for (int i = hi - 1; i >= lo; i--) {
    if (a[i] == x) {
        return i;
    }
}
return -1;

Which implementations meet which specifications? Work it out first.

  • Implementation 1 stops at the first match (an index in the range where x occurs), which is the smallest matching index. When there is none, it returns -1. So it meets B. The smallest matching index is in particular some matching index, so it meets D as well. A's precondition promises a match, so the -1 never comes up, and it meets A too. On any array with x twice in the range, it returns the first match where C asks for the last, so it fails C. Whenever x is absent, it returns -1 where E asks for an exception, so it fails E.
  • Implementation 2 is implementation 1 with an exception in place of the -1, so it meets E and A. Whenever x is absent, it throws where B, C, and D ask for -1, so it fails all three.
  • Implementation 3 never looks at hi. On a call where A's precondition is true, it stops at the first match, so it meets A. On a call where x is absent, it keeps going past hi. If hi is a.length, it then runs off the end of the array with an ArrayIndexOutOfBoundsException. It meets A and nothing else.
  • Implementation 4 searches from the end and stops at the last match, which is the largest matching index. When there is none, it returns -1. So it meets C. The largest matching index is in particular some matching index, so it meets D, and A as well. On any array with x twice in the range, it returns the last match where B asks for the first, so it fails B. Whenever x is absent, it returns -1 where E asks for an exception, so it fails E.

We ran each implementation on every array of up to four elements drawn from 0, 1, and 2, with every range and every x from 0 to 3. Each implementation failed exactly the specifications it fails above.

Stronger and weaker

Now read the answers by specification. B is met by one of the four implementations, D by two, and A by all four. A specification that promises more is met by fewer implementations. That fact gives a way to compare two specifications directly.

Definition: stronger and weaker

An assertion P is stronger than an assertion Q if Q is true whenever P is. A specification S1 is stronger than a specification S2 if every implementation that meets S1 also meets S2.

In either case the second is weaker than the first, and two that are neither stronger nor weaker than each other are incomparable.

Last lecture's exercise 3 made this comparison without the name. Every program that meets our specification for Sum also meets statement B from last lecture's exercise 2, it prints the numbers it read, and then a whole number. The reverse is not true. So our specification is the stronger of the two. Of the two assertions that exercise 3 compared at (2), the second is true whenever the first is, so the first is stronger.

When two specifications have the same precondition, we need to compare only their postconditions. B, C, D, and E all have the same precondition. The smallest index is in particular some index, so B's postcondition is stronger than D's. Every implementation that meets B also meets D. C is stronger than D in the same way. On an array with x twice in the range, the smallest and largest indices differ. Implementation 1 returns the smallest, so it meets B and not C. Implementation 4 returns the largest, so it meets C and not B. B and C are therefore incomparable. Where x is absent, B, C, and D promise -1, and E promises an exception. No implementation can do both, so E is incomparable with each of them.

A asks more of its client than D does. Where x occurs, A and D promise the same thing, a match. Where x does not occur, a call breaks A's precondition. A promises nothing about that call. An implementation that meets D handles such calls and all the others, so it meets A too. D is therefore stronger than A.

The two comparisons combine into one rule: S1 is stronger than S2 when S1's precondition is weaker than or the same as S2's, and, on every call where S2's precondition is true, S1's postcondition is stronger than or the same as S2's. By that rule each of B, C, D, and E is stronger than A, so every implementation that meets one of them meets A as well.

Which is better?

A stronger specification promises more, so the strongest specification can seem the best to write. But every promise binds the implementer. B obliges indexOf to find the first match. D, on the other hand, lets indexOf search from whichever end suits the arrays it will see. And as last lecture's exercise 3 noted, our specification for Sum rules out a version that reads long values or reports an overflow, either of which you might prefer. A weaker specification leaves the implementer more choices. Every choice it allows is a case the client must handle. A good specification rules out exactly the implementations its clients cannot use.

A change to a specification can break code on either side of the barrier. Which side depends on whether the change makes the specification stronger or weaker.

  • Strengthening a specification cannot break a client, since every client relied only on the old promises and the new specification keeps them all. It can break the implementation, which may not keep the new promises.
  • Weakening a specification cannot break the implementation, since the implementation kept all the old promises and the new specification makes no others. It can break a client that relied on a promise the new specification drops.

If a method's only clients are your own code, you can fix both sides at once. Its specification can then change in either direction. A method in a library with a thousand clients is different. No one can check all those clients for a promise they might have relied on, so its specification can only grow stronger.

Writing a client

Clients have an obligation too. A client is correct if it works with every implementation that meets the specifications it relies on. Working with the implementation it happens to run with is not enough. Here is a client of indexOf that counts how many times a value occurs in an array:

    static int count(int[] a, int x) {
        int c = 0;
        int i = indexOf(a, 0, a.length, x);
        while (i != -1) {
            c++;
            i = indexOf(a, i + 1, a.length, x);
        }
        return c;
    }

The first search in count, its first call to indexOf, starts at index 0. Each later search starts just past the last match found. Under B, smallest guarantees that no search skips a match, so count is correct against B. D allows a search to return any match. When a search returns a match other than the first in its range, the next search starts past the matches before it. Those matches are never counted. So against D, count is wrong, though it may pass every test you give it. Implementations 1 and 4 both meet D. On the array [5, 1, 5, 2, 5], count finds three 5s with implementation 1 and only one with implementation 4.

Consider a client that asks only whether x occurs at all, with indexOf(a, 0, a.length, x) != -1. B, C, and D all promise -1 exactly when x is absent, so this client is correct against any of them. When x is absent, the client's call breaks A's precondition. Under E, the same call throws where the client expects -1. So the client is correct against neither A nor E.

Homework 1's Task 4 asks for exactly this discipline: code that is correct with any correct implementation of the methods it calls. So the question to ask of a client is whether it works with every implementation its specifications allow.

Promises about parameters

So far, every method specification we compared promised something about what indexOf returns or throws. A method can also make promises about its parameters. @effects says how the parameters in @modifies change. @modifies makes a promise by what it leaves out: any parameter it does not list stays unchanged. Both promises describe the parameters at the point where the method returns or throws, so both belong to its postcondition. The rule from Stronger and weaker therefore covers these promises too.

Here is a method whose job is to change its parameter:

    /**
     * Sorts a list of numbers.
     *
     * @param list the list to sort; must be non-null
     * @modifies list
     * @effects rearranges the elements of list into ascending order
     */
    static void sort(List<Integer> list)

Suppose the specification said this in @effects instead:

  @effects after the call, list is in ascending order

The two specifications have the same precondition and the same @modifies, so we need to compare only their @effects. The original specification promises that after the call, list holds the elements it held before, each as many times as before, in ascending order (each element no larger than the next). The new specification promises only the ascending order. A list rearranged into ascending order is in particular in ascending order, so every implementation that meets the original also meets the new specification. The original is therefore stronger.

Does the new specification promise as much as the original? Try an implementation that throws the elements away:

list.clear();

Call sort on the list [3, 1, 2]. An implementation that meets the original leaves list holding [1, 2, 3]. list.clear() leaves list empty, as it does on every call. An empty list has no element larger than the next, so it is in ascending order. list.clear() meets the new specification! The original, on the other hand, asks for the same elements back. On every list that is not empty, list.clear() fails the original. So the new specification promises less than the original.

The two versions of @effects differ in what they describe. The new one describes only the list after the call. The original describes the list after the call in terms of the list before it, as readInto's @effects does.1 In general, an @effects that says nothing about a parameter's old value lets an implementation throw that value away.

In the same way, we can compare the promise that @modifies makes by what it leaves out. sumList's specification has no @modifies, so it promises not to change list. Suppose the specification kept its @return and added two lines:

  @modifies list
  @effects  rearranges the elements of list into some order

The two specifications have the same precondition and the same @return, so we need to compare only what they promise about list. The original specification promises that list is unchanged after the call. The new specification promises only that the call rearranges list. A list left unchanged has in particular been rearranged into some order, namely its old one, so every implementation that meets the original also meets the new specification. The original is therefore stronger.

Does the new specification promise as much as the original? Try an implementation that sorts list and then adds it up. The order of the numbers does not change their sum, so this implementation returns the right sum. Sorting rearranges list, so the implementation meets the new specification. On the list [3, 1, 2], though, it leaves list holding [1, 2, 3], a change the original does not allow. So the new specification promises less than the original. In general, listing a parameter in @modifies drops the promise to leave that parameter unchanged.

Homework 1's Task 2 asks for comparisons like these among five specifications of one method.

Knowing a program in pieces

Last lecture asked how anyone comes to know that a program works. It answered for a program small enough to hold in your head. For a larger program, specifications let us answer one method at a time. We come to know that each method meets its own specification, with the specifications of the methods it calls standing in for their code. In the same way, we come to know that each client works with every implementation those specifications allow. No one has to hold the whole program in their head at once. And when someone rewrites a method to keep the same promises, its clients can stay exactly as they are. These two payoffs are what procedural abstraction is for.

Exercises

Each answer is folded under its question. Write yours first.

1. Here is a method that finds the largest element of a list:

    static int maxList(List<Integer> list) {
        int max = list.get(0);
        for (int i = 1; i < list.size(); i++) {
            if (list.get(i) > max) {
                max = list.get(i);
            }
        }
        return max;
    }
  • Write its specification, with the tags from Writing it in Java.
  • Decide what the specification should say about an empty list, and why.
Answer
  • One specification:

    /**
     * Returns the largest element of a list.
     *
     * @param list the list to search; must be non-null
     * @requires list is not empty
     * @return the largest element of list
     */
    

    The method only reads list, so no @modifies is needed.

  • On an empty list, list.get(0) throws an IndexOutOfBoundsException. The specification above requires a nonempty list, so it promises nothing about an empty list. A call on an empty list is the client's mistake. A specification could instead say what happens on that call, replacing the @requires with @throws NoSuchElementException if list is empty. That specification asks less of the client and promises the same for every nonempty list, so it is stronger. It also spares the client a check for the empty list. In exchange, maxList would have to make that check itself. Liskov and Guttag, in the further reading, favor the @throws version for a method used beyond its immediate context, when the check is this cheap.

2. Here are three specifications for a method static int sign(int x), and three implementations of it. Which implementations meet which specifications?

Spec S1
  @return   1 if x > 0, -1 if x < 0, and 0 if x == 0

Spec S2
  @return   a positive number if x > 0, a negative number if x < 0,
            and 0 if x == 0

Spec S3
  @requires x != 0
  @return   1 if x > 0 and -1 if x < 0
// Implementation 1
return Integer.signum(x);

// Implementation 2
return x;

// Implementation 3
return x / Math.abs(x);
Answer
  • Implementation 1 meets all three. Integer.signum returns 1, -1, or 0 by the sign of x. That is exactly what S1 promises. S2 and S3 promise less, so it meets them as well.
  • Implementation 2 meets S2 only. It returns x itself, a number of the right sign, so it meets S2. But it returns 1 or -1 only when x is 1 or -1, so it fails S1 and S3.
  • Implementation 3 meets none, and S3 is the surprise. On 0 it divides by zero and throws an ArithmeticException, which S1 and S2 do not allow. S3 promises nothing about 0. For most other x, the quotient is 1 or -1. The trouble is the most negative int. Since \(2^{31}\) does not fit in an int, Math.abs(Integer.MIN_VALUE) is Integer.MIN_VALUE itself. The quotient is then 1, where S3 asks for -1. That one call keeps implementation 3 from meeting S3.

3. Compare the three specifications from exercise 2. Which is stronger than which, and which pairs are incomparable?

Answer
  • S1 is stronger than S2. They have the same precondition, none, and 1, -1, and 0 are in particular positive, negative, and zero.
  • S1 is stronger than S3. S1's precondition, none, is weaker than S3's, x != 0, and wherever S3's precondition is true, the two promise the same.
  • S2 and S3 are incomparable. Implementation 2 meets S2 and not S3. An implementation that returned 1 or -1 by the sign of x, and 42 for 0, would meet S3, which promises nothing about 0. It would fail S2, which asks for 0 there.

4. Here are two clients of sign, with their specifications. Against which of S1, S2, and S3 is each one correct?

    /**
     * Tells whether two numbers have the same sign.
     *
     * @return true if x and y are both positive, both negative, or
     *     both zero, and false otherwise
     */
    static boolean sameSign(int x, int y) {
        return sign(x) == sign(y);
    }

    /**
     * Tells whether a number is positive.
     *
     * @return true if x > 0, and false otherwise
     */
    static boolean isPositive(int x) {
        return sign(x) > 0;
    }
Answer
  • sameSign is correct only against S1. Against S2, sign(3) may be 3 and sign(5) may be 5, so sameSign(3, 5) may return false. Against S3, sameSign has no precondition, so sameSign(0, 0) must return true. Yet it calls sign(0), which breaks S3's precondition.
  • isPositive is correct against S1 and S2, since both promise a positive number exactly when x is positive. Against S3, isPositive(0) must return false. It too calls sign(0).

5. Suppose the specification of indexOf changes.

  • From D to B: which of the four implementations, and which of the two clients in Writing a client, does the change break?
  • From B to D: the same question.
Answer
  • D to B is a strengthening, so it breaks no client. count, wrong under D, becomes correct. The change does break implementation 4, which met D and does not meet B.
  • B to D is a weakening, so it breaks no implementation: implementation 1, the only implementation that met B, meets D. The change does break count, which relied on smallest. D still promises -1 exactly when x is absent, so the client that asks whether x occurs stays correct.

6. Last lecture's exercise 7 rejected a specification for a whole program that began the user must type only whole numbers. Why may the specification of sumList require a non-null list, when the specification of Sum may not require anything of its user?

Answer

A precondition is a promise. A promise can be asked only of someone who can be held to it. The client of sumList is code, written by a programmer who can read the specification and is responsible for keeping it. readList promises a list, so main keeps that precondition. The user of Sum is a person at a keyboard who never read its specification and owes the program nothing. So the edge of the program, where input arrives, has to handle every input. Preconditions belong inside the program, between methods whose authors can read each other's specifications.

Further reading

  • Specifications of procedures. Barbara Liskov and John Guttag, Program Development in Java (2001). Chapter 3 introduces the requires, modifies, and effects clauses this course's tags come from, and Chapter 4 advises, for a procedure used beyond its immediate context, an exception instead of a requires clause unless the condition cannot be checked or is very expensive to check.
  • A method's contract. Joshua Bloch, Effective Java, second edition (2008), Item 44, says what a method's documentation owes its client: what the method does rather than how, with its preconditions, postconditions, and side effects. Bloch reads a documented unchecked exception as marking a precondition the client broke, where this course's @throws is a promise.
  • Why the barrier pays. John Ousterhout, A Philosophy of Software Design (2018), Chapter 4, separates an interface's formal part, the signature the compiler checks, from its informal part, the behavior only a comment can state, and argues for modules that hide a lot behind a simple interface.

  1. The original @effects of sort follows Barbara Liskov and John Guttag's specification of a sort for arrays, in Program Development in Java (2001), section 3.3. They add that the specification of a method that changes a parameter has to relate the parameter's state when the method returns to its state when the method was called. ↩