Textbook - Guttag, Liskov - Program Development in Java
Textbook - Guttag, Liskov - Program Development in Java
Barbara Liskov
with John Guttag
Addison-Wesley
Boston San Francisco New York Toronto Montreal
. . . .
The authors and publisher have taken care in the preparation of this book, but make no expressed
or implied warranty of any kind and assume no responsibility for errors or omissions. No liability
is assumed for incidental or consequential damages in connection with or arising out of the use
of the information or programs contained herein.
All rights reserved. No part of this publication may be reproduced, stored in a retrieval system,
or transmitted, in any form or by any means, electronic, mechanical, photocopying, recording,
or otherwise, without the prior consent of the publisher. Printed in the United States of America.
Published simultaneously in Canada.
The publisher offers discounts on this book when ordered in quantity for special sales. For more
information, please contact:
ISBN 0201657686
Preface xv
Acknowledgments xix
1 — Introduction 1
1.1 Decomposition and Abstraction 2
1.2 Abstraction 4
1.2.1 Abstraction by Parameterization 7
1.2.2 Abstraction by Specification 8
1.2.3 Kinds of Abstractions 10
1.3 The Remainder of the Book 12
Exercises 13
vii
Contents
3 — Procedural Abstraction 39
3.1 The Benefits of Abstraction 40
3.2 Specifications 42
3.3 Specifications of Procedural Abstractions 43
3.4 Implementing Procedures 47
3.5 Designing Procedural Abstractions 50
3.6 Summary 55
Exercises 56
4 — Exceptions 57
4.1 Specifications 59
4.2 The Java Exception Mechanism 61
4.2.1 Exception Types 61
4.2.2 Defining Exception Types 62
4.2.3 Throwing Exceptions 64
4.2.4 Handling Exceptions 65
4.2.5 Coping with Unchecked Exceptions 66
4.3 Programming with Exceptions 67
4.3.1 Reflecting and Masking 67
4.4 Design Issues 68
4.4.1 When to Use Exceptions 70
4.4.2 Checked versus Unchecked Exceptions 70
4.5 Defensive Programming 72
4.6 Summary 74
Exercises 75
viii
Contents
5 — Data Abstraction 77
5.1 Specifications for Data Abstractions 79
5.1.1 Specification of IntSet 80
5.1.2 The Poly Abstraction 83
5.2 Using Data Abstractions 85
5.3 Implementing Data Abstractions 86
5.3.1 Implementing Data Abstractions in Java 87
5.3.2 Implementation of IntSet 87
5.3.3 Implementation of Poly 89
5.3.4 Records 90
5.4 Additional Methods 94
5.5 Aids to Understanding Implementations 99
5.5.1 The Abstraction Function 99
5.5.2 The Representation Invariant 102
5.5.3 Implementing the Abstraction Function
and Rep Invariant 105
5.5.4 Discussion 107
5.6 Properties of Data Abstraction Implementations 108
5.6.1 Benevolent Side Effects 108
5.6.2 Exposing the Rep 111
5.7 Reasoning about Data Abstractions 112
5.7.1 Preserving the Rep Invariant 113
5.7.2 Reasoning about Operations 114
5.7.3 Reasoning at the Abstract Level 115
5.8 Design Issues 116
5.8.1 Mutability 116
5.8.2 Operation Categories 117
5.8.3 Adequacy 118
5.9 Locality and Modifiability 120
5.10 Summary 121
Exercises 121
ix
Contents
x
Contents
9 — Specifications 207
9.1 Specifications and Specificand Sets 207
9.2 Some Criteria for Specifications 208
9.2.1 Restrictiveness 208
9.2.2 Generality 211
9.2.3 Clarity 212
9.3 Why Specifications? 215
9.4 Summary 217
Exercises 219
xi
Contents
13 — Design 301
13.1 An Overview of the Design Process 301
13.2 The Design Notebook 304
13.2.1 The Introductory Section 304
13.2.2 The Abstraction Sections 308
13.3 The Structure of Interactive Programs 310
13.4 Starting the Design 315
13.5 Discussion of the Method 323
13.6 Continuing the Design 324
13.7 The Query Abstraction 326
13.8 The WordTable Abstraction 332
xii
Contents
xiii
Contents
Glossary 409
Index 427
xiv
Preface
xv
Preface
xvi
Preface
Program Modules
This part of the book focuses on abstraction mechanisms. It discusses proce-
dures and exceptions, data abstraction, iteration abstraction, families of data
abstractions, and polymorphic abstractions.
Three activities are emphasized in the discussion of abstractions. The first
is deciding on exactly what the abstraction is: what behavior it is providing to
its users. Inventing abstractions is a key part of design, and the book discusses
how to choose among possible alternatives and what goes into inventing good
abstractions.
The second activity is capturing the meaning of an abstraction by giving a
specification for it. Without some description, an abstraction is too vague to be
useful. The specification provides the needed description. This book defines a
format for specifications, discusses the properties of a good specification, and
provides many examples.
The third activity is implementing abstractions. The book discusses how
to design an implementation and the trade-off between simplicity and per-
formance. It emphasizes encapsulation and the need for an implementa-
tion to provide the behavior defined by the specification. It also presents
techniques—in particular, the use of representation invariants and abstrac-
tion functions—that help readers of code to understand and reason about it.
Both rep invariants and abstraction functions are implemented to the extent
possible, which is useful for debugging and testing.
The material on type hierarchy focuses on its use as an abstraction
technique—a way of grouping related data abstractions into families. An im-
portant issue here is whether it is appropriate to define one type to be a
subtype of another. The book defines the substitution principle—a method-
ical way for deciding whether the subtype relation holds by examining the
specifications of the subtype and the supertype.
This book also covers debugging and testing. It discusses how to come up
with a sufficient number of test cases for thorough black box and glass box
tests, and it emphasizes the importance of regression testing.
xvii
Preface
The material on programming in the large covers four main topics. The
first concerns requirements analysis—how to develop an understanding of
what is wanted of the program. The book discusses how to carry out require-
ments analysis and also describes a way of writing the resulting requirements
specification, by making use of a data model that describes the abstract state
of the program. Using the model leads to a more precise specification, and
it also makes the requirements analysis more rigorous, resulting in a better
understanding of the requirements.
The second programming in the large topic is program design, which is
treated as an iterative process. The design process is organized around dis-
covering useful abstractions, ones that can serve as desirable building blocks
within the program as a whole. These abstractions are carefully specified
during design so that when the program is implemented, the modules that
implement the abstractions can be developed independently. The design is
documented by a design notebook, which includes a module dependency
diagram that describes the program structure.
The third topic is implementation and testing. The book discusses the need
for design analysis prior to implementation and how design reviews can be
carried out. It also discusses implementation and testing order. This section
compares top-down and bottom-up organizations, discusses the use of drivers
and stubs, and emphasizes the need to develop an ordering strategy prior to
implementation that meets the needs of the development organization and its
clients.
This book concludes with a chapter on design patterns. Some patterns
are introduced in earlier chapters; for example, iteration abstraction is a
major component of the methodology. The final chapter discusses patterns
not covered earlier. It is intended as an introduction to this material. The
interested reader can then go on to read more complete discussions contained
in other books.
Barbara Liskov
xviii
Acknowledgments
John Guttag was a coauthor of an earlier version of this book. Many chapters
still bear his stamp. In addition, he has made numerous helpful suggestions
about the current material.
Thousands of students have used various drafts of the book, and many
of them have contributed useful comments. Scores of graduate students have
been teaching assistants in courses based on the material in this book. Many
students have contributed to examples and exercises that have found their
way into this text. I sincerely thank all of them for their contributions.
My colleagues both at MIT and elsewhere have also contributed in im-
portant ways. Special thanks are due to Jeannette Wing and Daniel Jackson.
Jeannette Wing (CMU) helped to develop the material on the substitution
principle. Daniel Jackson (MIT) collaborated on teaching recent versions of
the course and contributed to the material in many ways; the most important
of these is the data model used to write requirements specifications, which is
based on his research.
In addition, the publisher obtained a number of helpful reviews, and I
want to acknowledge the efforts of James M. Coggins (University of North
Carolina), David H. Hutchens (Millersville University), Gail Kaiser (Columbia
University), Gail Murphy (University of British Columbia), James Purtilo (Uni-
versity of Maryland), and David Riley (University of Wisconsin at LaCrosse).
I found their comments very useful, and I tried to work their suggestions into
the final manuscript.
Finally, MIT’s Department of Electrical Engineering and Computer Science
and its Laboratory for Computer Science have supported this project in impor-
tant ways. By reducing my teaching load, the department has given me time
to write. The laboratory has provided an environment that enabled research
leading to many of the ideas presented in this book.
xix
This page intentionally left blank
Introduction 1
This book will develop a methodology for constructing software systems. Our
goal is to help programmers construct programs of high quality—programs
that are reliable, efficient, and reasonably easy to understand, modify, and
maintain.
A very small program, consisting of no more than a few hundred lines,
can be implemented as a single monolithic unit. As the size of the program
increases, however, such a monolithic structure is no longer reasonable be-
cause the code becomes difficult to understand. Instead, the program must be
decomposed into a number of small independent programs, called modules,
that together provide the desired function. We shall focus on this decomposi-
tion process: how to decompose large programming problems into small ones,
what kinds of modules are most useful in this process, and what techniques
increase the likelihood that modules can be combined to solve the original
problem.
Doing decomposition properly becomes more and more important as the
size of the program increases for a number of reasons. First, many people must
be involved in the construction of a large program. If just a few people are
working on a program, they naturally interact regularly. Such contact reduces
the possibility of misunderstandings about who is doing what and lessens the
seriousness of the consequences should misunderstandings occur. If many
people work on a project, regular communication becomes impossible because
1
Chapter 1 Introduction
it consumes too much time. Instead, the program must be decomposed into
pieces that the individuals can work on independently with a minimum of
contact.
The useful life of a program (its production phase) begins when it is deliv-
ered to the customer. Work on the program is not over at this point, however.
The code will probably contain residual errors that will need attention, and
program modifications will often be required to upgrade the program’s service-
ability or to provide services better matched to the user’s needs. This activity
of program modification and maintenance is likely to consume more than half
of the total effort put into the project.
For modification and maintenance, it is rarely practical to start from scratch
and reimplement the entire program. Instead, one must retrofit modifications
within the existing structure, and it is therefore important that the structure
accommodate change. In particular, the pieces of the program must be inde-
pendent, so that a change to one piece can be made without requiring changes
to all pieces.
Finally, most programs have a long lifetime. Programmers often have to deal
with programs long after they have first worked on them. Moreover, there is
likely to be substantial turnover of personnel over the life of any project, and
program modification and maintenance are typically done by people other
than the original implementors. All of these factors require that programs be
structured in such a way that they can be understood easily.
In the methodology we shall describe in this book, programs will be
developed by means of problem decomposition based on a recognition of
useful abstractions. Decomposition and abstraction, the two key concepts in
this book, form our next subject.
2
1.1 — Decomposition and Abstraction
3
Chapter 1 Introduction
be, or even the sense (but not the wording) of individual pieces of dialog.
After this has been done, the original problem (of writing all of the dialog)
remains, but it has been considerably simplified—perhaps even to the point
where it could be turned over to another or even several others. (Alexandre
Dumas père churned out novels in this way.)
The paradigm of abstracting and then decomposing is typical of the pro-
gram design process: decomposition is used to break software into components
that can be combined to solve the original problem; abstractions assist in mak-
ing a good choice of components. We alternate between the two processes until
we have reduced the original problem to a set of problems we already know
how to solve.
1.2 Abstraction
The process of abstraction can be seen as an application of a many-to-one
mapping. It allows us to forget information and consequently to treat things
that are different as if they were the same. We do this in the hope of simplifying
our analysis by separating attributes that are relevant from those that are not.
It is crucial to remember, however, that relevance often depends upon context.
In the context of an elementary school classroom we learn to abstract both
( 83 ) × 3 and 5 + 3 to the concept we represent by the numeral 8. Much later
we learn, often under unpleasant circumstances, that on many computing
machines this abstraction can get us into a world of trouble.
For example, consider the structure shown in Figure 1.1, in which the
concept is “mammal.” All mammals share certain characteristics, such as the
fact that females produce milk. At this level of abstraction, we focus on these
common characteristics and ignore the differences between the various types
of mammals.
At a lower level of abstraction, we might be interested in particular in-
stances of mammals. However, even here we can abstract by considering not
individuals, or even species, but groups of related species. At this level, we
would have groupings such as primates or rodents. Here again, we are in-
terested in common characteristics rather than the differences between, say,
humans and chimpanzees. Such differences are relevant at a still lower level
of abstraction.
4
1.2 — Abstraction
mammals
The abstraction hierarchy of Figure 1.1 comes from the field of zoology,
but it might well appear in a program that implemented some zoological
application. A more specifically computer-oriented example that is useful
in many programs is the concept of a “file.” Files abstract from raw storage
and provide long-term, online storage of named entities. Operating systems
differ in their realizations of files; for example, the structure of the filenames
differs from system to system, as does the way in which the files are stored on
secondary storage devices.
In this book, we are interested in abstraction as it is used in programs in
general. The most significant development to date in this area is high-level
languages. By dealing directly with the constructs of a high-level language,
rather than with the many possible sequences of machine instructions into
which they can be translated, the programmer achieves a significant simplifi-
cation.
In recent years, however, programmers have become dissatisfied with the
level of abstraction generally achieved even in high-level language programs.
Consider, for example, the program fragments in Figure 1.2. At the level of
abstraction defined by the programming language, these fragments are clearly
different: if there is an occurrence of e in a, one fragment finds the index of
the first occurrence and the other, the index of the last. If e does not occur in
a, one sets i to a.length and the other to −1. It is not improbable, however,
that both were written to accomplish the same goal: to set found to false if
there is no occurrence of e in a and, otherwise, to set found to true and z to
the index of some occurrence of e in a. If this is what we want, it is not evident
from the program fragments by themselves.
One approach to dealing with this problem lies in the invention of “very-
high-level languages” built around some fixed set of relatively general data
structures and a powerful set of primitives that can be used to manipulate
5
Chapter 1 Introduction
// search upwards
found = false;
for (int i = 0; i < a.length; i++)
if (a[i] == e) {
z = i;
found = true;
}
// search downwards
found = false;
for (int i = a.length-1; i >= 0; i--)
if (a[i] == e) {
z = i;
found = true;
}
them. For example, suppose a language provided isIn and indexOf as prim-
itive operations on arrays. Then we could accomplish the task outlined in
Figure 1.2 simply by writing
found = a.isIn(e);
if (found)
z = a.indexOf(e);
The flaw in this approach is that it presumes that the designer of the
programming language will build into the language most of the abstractions
that users of the language will want. Such foresight is not given to many; and
even if it were, a language containing so many built-in abstractions might well
be so unwieldy as to be unusable.
A preferable alternative is to design into the language mechanisms that al-
low programmers to construct their own abstractions as they need them. One
common mechanism is the use of procedures. By separating procedure defini-
tion and invocation, a programming language makes two important methods of
abstraction possible: abstraction by parameterization and abstraction by spec-
ification. These abstraction mechanisms are summarized in Sidebar 1.1.
6
1.2 — Abstraction
x∗x+y∗y
describes a computation that adds the square of the value stored in the variable
x to the square of the value stored in the variable y.
On the other hand, the lambda expression
λ x, y: int.(x ∗ x + y ∗ y)
describes the set of computations that square the value stored in some integer
variable, which we shall temporarily refer to as x, and add to it the square of
the value stored in another integer variable, which we shall temporarily call
y. In such a lambda expression, we refer to x and y as the formal parameters
and x ∗ x + y ∗ y as the body of the expression. We invoke a computation by
binding the formal parameters to arguments and then evaluating the body.
For example,
λ x, y: int.(x ∗ x + y ∗ y)(w, z)
is identical in meaning to
w∗w+z∗z
7
Chapter 1 Introduction
and the binding of actual to formal parameters and evaluation of the body by
the procedure call
u = squares(w, z);
8
1.2 — Abstraction
9
Chapter 1 Introduction
2. We can assume only those properties that can be inferred from the post-
condition.
The two rules mirror the two benefits of abstraction by specification. The first
asserts that users of the procedure need not bother looking at the body of the
procedure in order to use it. They are thus spared the effort of first understand-
ing the details of the computations described by the body and then abstracting
from these details to discover that the procedure really does compute an ap-
proximation to the square root of its argument. For complicated procedures,
or even simple ones using unfamiliar algorithms, this is a nontrivial benefit.
The second rule makes it clear that we are indeed abstracting from the
procedure body, that is, omitting some supposedly irrelevant information.
This insistence on forgetting information is what distinguishes abstraction
from decomposition. By examining the body of sqrt, users of the procedure
could gain a considerable amount of information that cannot be gleaned from
the postcondition and therefore should not be relied on—for example, that
sqrt(4) will return +2. In the specification, however, we are saying that this
information about the returned result is to be ignored. We are thus saying that
the procedure sqrt is an abstraction representing the set of all computations
that return “an approximation to the square root of x.”
In this book, abstraction by specification will be the major method used in
program construction. Abstraction by parameterization will be taken almost
for granted; abstractions will have parameters as a matter of course.
10
1.2 — Abstraction
The key thing to notice is that each of these statements deals with more
than one operation. We do not present independent definitions of each op-
eration, but rather define them by showing how they relate to one another.
The emphasis on the relationships among operations is what makes a data ab-
straction something more than just a set of procedures. The importance of this
distinction is discussed throughout this book.
In addition to procedural and data abstraction, we shall also deal with
iteration abstraction. Iteration abstraction is used to avoid having to say more
than is relevant about the flow of control in a loop. A typical iteration ab-
straction might allow us to iterate over all the elements of a MultiSet without
constraining the order in which the elements are to be processed.
11
Chapter 1 Introduction
12
Exercises
Exercises
1.1 Describe an abstraction hierarchy with which you are familiar.
1.2 Select a procedure that you have written or used and discuss how it supports
abstraction by specification and by parameterization.
13
This page intentionally left blank
Understanding Objects
in Java 2
Java is an object-oriented language. This means that most of the data manip-
ulated by programs are contained in objects. Objects contain both state and
operations; the operations are called methods. Programs interact with objects
by invoking their methods. The methods provide access to the state, allowing
using code to observe the current state of an object or to modify it. Sidebar 2.1
provides information about the origins of Java.
This chapter provides some basic information about Java and its support
for object-oriented programming and design. We shall concentrate primar-
ily on the language semantics, that is, on the meaning of constructs in the
language. For a complete, detailed description of the language, you should
consult a Java text or reference manual. A Java reference manual is available
online.
15
Chapter 2 Understanding Objects in Java
A method takes zero or more arguments and returns a single result. Its
header indicates this information. The arguments are often referred to as the
formal parameters or formals of the call. For example, gcd has two formals,
x and y, both of which are integers; it returns an integer result. A method
may also terminate by throwing an exception. Exceptions will be discussed
in detail in Chapter 4.
Because Java requires that every method have a result, a special form is
used when there is no result. Such a method indicates that its return type is
void. For example, suppose the Arrays class provides routines that are useful
in manipulating arrays, among them a way of sorting arrays:
16
2.2 — Packages
(The form int[ ] indicates that the argument is an array of integers of unspec-
ified length.) This method doesn’t return a result; instead, it sorts its argument
array in place. It can be called as follows:
2.2 Packages
Classes and interfaces are grouped into packages. Packages serve two purposes.
First, they are an encapsulation mechanism; they provide a way to share
information within the package while preventing its use on the outside.
Each class and interface has a declared visibility. Only classes and interfaces
declared to be public can be used by code in other packages—for example,
the Num class in Figure 2.1 can be used outside its package. The remaining
definitions can be used only within the package.
In addition, the declarations within a class have a declared visibility. Only
entities declared to be public, such as the gcd and isPrime methods in the
Num class, are accessible to code in other packages. Other kinds of declared
17
Chapter 2 Understanding Objects in Java
visibility limit the code that can access the entity—for example, to just its
class or just its package; the details will be discussed in later chapters.
The other use of packages is for naming. Each package has a hierarchical
name that distinguishes it from all other packages. Classes and interfaces
within the package have names that are relative to the package name. This
means that there are no name conflicts between classes and interfaces defined
in different packages.
Code in a package can refer to other classes and interfaces of its own pack-
age by using their class or interface name. For example, if the mathRoutines
package contains the class Num, code within that package can refer to that
class by using the name Num. Definitions in other packages can be referred to
using their fully qualified names—that is, their name appended to their pack-
age’s hierarchical name. For example, the fully qualified name for the Num class
might be mathRoutines.Num. It is also possible to use short names to refer to
definitions in other packages by using the import statement, to either import
all public definitions from a package, or to import specific public definitions
from a package. In either case, the imported definition can be referred to using
its class or interface name.
One problem with short names is the possibility of name conflicts. For
example, suppose two packages both define classes, named Num. In this case,
if code uses both classes, it cannot use a short name for each. Either it could
use a fully qualified name for each or it could import one of the classes and
use a long name for the other.
Sometimes there is a conflict between encapsulation and naming. It is
convenient to group many definitions in the same package because then code
outside the package has access to all of them by importing the whole package.
But this kind of grouping may be wrong from the point of encapsulation
because code within a package can sometimes access internal information of
other definitions within that package. In general, such a conflict should be
resolved in favor of encapsulation.
18
2.3 — Objects and Variables
Every variable has a declaration that indicates its type. Primitive types,
such as int (integers), boolean, and char (characters), define sets of values,
such as 3, false, or c. All other types define sets of objects. Some object types
will be provided for you, in packages defined by others. One such package
is java.lang, which provides a number of useful types such as String; the
types defined by this package can be used without importing the package.
Others you will define yourselves. Chapter 5 describes how to define new
types.
Variables of primitive types contain values. For example, the following:
int i = 6;
stores the value 6 in variable i. Variables of all other types, including strings
and arrays, contain references to objects that reside on the heap. Objects are
created on the heap by use of the new operator. Thus,
causes space for a new array of integers object, with room for three integers,
to be allocated on the heap and a reference to the new object to be stored
in a.
Local variables can be initialized when they are declared. They must be
initialized before their first use. Furthermore, if the compiler cannot prove
that a variable is initialized before first use, it will cause compilation to fail.
Objects and variables are illustrated in Figure 2.2a, which shows the stack
and heap after processing the following declarations:
19
Chapter 2 Understanding Objects in Java
int i = 6;
int j; // uninitialized
int [ ] a = {1,3,5,7,9}; // creates a 5-element array
int [ ] b = new int[3];
String s = "abcdef"; // creates a new string
String t = null;
Here all variables except j have been given an initial value. Variable t has been
initialized to null; this special value provides a way of initializing a variable
that will eventually refer to an object.
This example shows a number of object creations. Strings are created by
indicating their content; thus, s refers to a string object containing "abcdef".
Arrays can be created similarly, by indicating their elements; thus, a refers
to a five-element array. The assignment to b shows the usual way of creating
a new object, by calling the built-in new operator. This operator creates an
object of the indicated class on the heap and then initializes it by running
a special kind of method, called a constructor, for that class. For example,
the array constructor initializes each element of a new array of integers to 0.
Thus, b refers to a three-element array of integers, where each element of the
array is 0.
Every object has an identity that is distinct from that of every other object.
That is, when an object is created by a call to new, or through use of the
special forms such as "abcdef" for strings and {1,3,5,7,9} for arrays, what
is obtained is an object that is distinct from any other object in existence.
An assignment
v = e;
copies the value obtained by evaluating the expression e into the variable v.
If the expression evaluates to a reference to an object, the reference is copied.
This situation is illustrated in Figure 2.2b, which shows the results of the
following assignments:
j = i;
b = a;
t = s;
Note that in the case of the string and array variables, we now have two
variables pointing to the same object. Thus, assignment involving references
causes variables to share objects.
20
2.3 — Objects and Variables
2.3.1 Mutability
All objects are either immutable or mutable. The state of an immutable object
never changes, while the state of a mutable object can change.
Strings are immutable: there are no String methods that cause the state of a
String object to change. For example, strings have a concatenation operator
+, but it does not modify either of its arguments; instead, it returns a new
string whose state is the concatenation of the states of its arguments. If we did
the following assignment to t with the state shown in Figure 2.2b:
t = t + "g";
the result as shown in Figure 2.2c is that t now refers to a new String object
whose state is "abcdefg" and the object referred to by s is unaffected.
On the other hand, arrays are mutable. The assignment
a[i] = e;
causes the state of array a to change by replacing its ith element with the value
obtained by evaluating expression e. (The modification occurs only if i is in
bounds for a; otherwise, an exception is thrown.)
If a mutable object is shared by two or more variables, modifications made
through one of the variables will be visible when the object is used through
the other variable. For example, suppose the shared array in Figure 2.2b is
modified by
21
Chapter 2 Understanding Objects in Java
An object is mutable if its state can change. For example, arrays are mutable.
An object is immutable if its state never changes. For example, strings are immutable.
An object is shared by two variables if it can be accessed through either of them.
If a mutable object is shared by two variables, modifications made through one of the
variables will be visible when the object is used through the other.
b[0] = i;
This causes the zeroth element of the array to contain 6 (instead of the 1 it used
to contain), as shown in Figure 2.2c. Furthermore, the change is visible when
the array is used later, via either variable b or variable a; for example, in
if (a[0] == i) ...
the expression will evaluate to true, and therefore, the then branch will be
executed.
Sidebar 2.2 summarizes mutability and sharing.
22
2.3 — Objects and Variables
these objects are mutable, and the called procedure changes their state, these
changes are visible to the caller when it returns.
For example, suppose the Arrays class mentioned earlier contained a
method, multiples, that multiplies each element of its array argument a by
its multiplier argument m:
public static void multiples (int [ ] a, int m) {
if (a == null) return;
for (int i = 0; i < a.length; i++) a[i] = a[i]*m;
}
This method works on any size array; it uses a.length to determine the length
of the array. Figure 2.3 shows what happens when the method is called by
the following code:
int [ ] b = {1,3,5,7,9};
Arrays.multiples(b, 2);
Figure 2.3a shows the situation just before the call. Figure 2.3b shows the
situation just after the call has occurred; the stack now contains the activation
record for the call, and the formals have been initialized to contain the actuals.
Thus, the formal a of Arrays.multiples refers to the same array as b does.
Finally, Figure 2.3c shows the situation just after Arrays.multiples returns.
At this point, the activation record created for the call has been discarded.
However, the argument array has been modified, and this modification is
visible to the caller through variable b.
In a call e.m in which e is supposed to evaluate to an object, it is possible
that e might instead evaluate to null and thus not refer to any object. If this
happens, the call is not made, but instead the NullPointerException is raised
(exceptions are discussed in Chapter 4).
23
Chapter 2 Understanding Objects in Java
int y = 7;
int z = 3;
int x = Num.gcd (z, y);
When the compiler processes the call to Num.gcd, it knows that Num.gcd
requires two integer arguments, and it also knows that expressions z and y
are both of type int. Therefore, it knows the call of gcd is legal. Furthermore,
it knows that gcd returns an int, and therefore it knows that the assignment
to x is legal.
Java has an important property: legal Java programs (that is, those accepted
by the compiler) are guaranteed to be type safe. This means that there cannot
be any type errors when the program runs: it is not possible for the program
to manipulate data belonging to one type as if it belonged to a different type.
Type safety is achieved by three mechanisms: compile-time type checking,
automatic storage management, and array bounds checking. Type safety is
summarized in Sidebar 2.3.
24
2.4 — Type Checking
One important difference between Java and C and C++ is that Java provides type
safety. This is accomplished by three mechanisms:
.
Java is a strongly typed language. This means that type errors such as using a
pointer as an integer are detected by the compiler.
.
Java provides automatic storage management for all objects. In C and C++,
programs manage storage for objects in the heap explicitly. Explicit management is
a major source of errors such as dangling references, in which storage is deallocated
while a program still refers to it.
.
Java checks all array accesses to ensure they are within bounds.
These techniques ensure that type mismatches cannot occur at runtime. In this way
an important source of errors is eliminated from your code.
25
Chapter 2 Understanding Objects in Java
The use of a cast causes a check to occur at runtime; if the check succeeds, the
indicated computation is allowed, and otherwise, the ClassCastException
will be raised. In the example, the casts check whether o2’s actual type is the
26
2.4 — Type Checking
Java supports type hierarchy, in which one type can be the supertype of other types,
which are its subtypes. A subtype’s objects have all the methods defined by the
supertype.
All object types are subtypes of Object, which is the top of the type hierarchy.
Object defines a number of methods, including equals and toString. Every object is
guaranteed to have these methods.
The apparent type of a variable is the type understood by the compiler from information
available in declarations. The actual type of an object is its real type—the type it receives
when it is created.
Java guarantees that the apparent type of any expression is a supertype of its actual
type.
same as the indicated type String; these checks succeed, and therefore, the
assignment in the first statement or the method call in the second statement
is allowed.
Sidebar 2.4 summarizes this discussion.
char c = ’a’;
int n = c;
27
Chapter 2 Understanding Objects in Java
what conversions are legal, and what computations they involve, by consult-
ing a Java text.
In addition, Java allows overloading. This means that there can be several
method definitions with the same name. Most languages allow overloaded
definitions of operators; for example, + is defined for both integers and floats.
Java allows overloading of operators, but in addition, it allows programmers
to overload method names as well.
For example, consider a class C with the following methods:
static int comp(int, long) // defn. 1
static float comp(long, int) // defn. 2
static int comp(long, long) // defn. 3
In Java, an int can be widened to a long, and also a float can be widened
to a long. Therefore a call C.comp(x, y) could go to either the first definition
of comp (since here the types match exactly) or the third definition of comp
(by widening x to a long). The second definition is not possible since it isn’t
possible to widen a long to an int.
The rule used to determine which method to call when there are several
choices, as in this example, is “most specific.” A method m1 is more specific
than another method m2 if any legal call of m1 would also be a legal call of m2 if
more conversions were done. For example, the first definition of comp would
be selected for the call C.comp(x, y) since it is more specific than the third
definition.
If there is no most specific method, a compile time error occurs. For ex-
ample, all three definitions are possible matches for the call C.comp(x, x).
However, none of these is most specific, and therefore, the call is illegal.
The programmer can resolve the ambiguity in a case like this by making
the conversion explicit; for example, C.comp((long) x, x) selects the second
definition.
Overloading decisions also take into account assignments from sub- to
supertypes. For example, consider
28
2.5 — Dispatching
2.5 Dispatching
When a method is called on some object, it is essential that the call go to the
code provided by that object for that method, because only that code can do
the right thing. For example, consider
String t = "ab";
Object o = t + "c"; // concatenation
String r = "abc";
boolean b = o.equals(r);
Here the intention is to find out whether o’s value is the string "abc". This
desire will be satisfied if the call goes to the string’s code for equals, since this
will compare the values of the two strings. If instead the call goes to Object’s
code for equals, we will only learn whether o and r are the very same object.
The problem is that the compiler doesn’t necessarily know what code to
call at compile time because it only knows the apparent type of the object
and not its actual type. This is illustrated in the example: the compiler only
knows that o is an Object. If the apparent type were used to determine the
code to call, the wrong result would happen; for example, b would contain
false because o and r are distinct objects.
Therefore, we need a way to dispatch a method call to the code of the actual
object. This requires a runtime mechanism since the compiler cannot figure
out what to do at compile time.
Figure 2.4 illustrates one way that dispatching works. Each object contains
a reference to a dispatch vector. The dispatch vector contains an entry for each
of the object’s methods. The compiler generates code to access the location in
the vector that points to the code of the method being called and branch to
that code. The figure shows the situation for object o; a call to the equals
method would branch to the code referred to by the first location in the table,
and thus the call will go to the implementation provided by String.
29
Chapter 2 Understanding Objects in Java
2.6 Types
This section describes a few object types that are nonstandard (i.e., they don’t
appear in other languages) and that we will use throughout the book.
These types also provide methods to produce objects of their associated types
from strings. Thus,
int n = Integer.parseInt(s);
30
2.6 — Types
will return the int described by string s. For example, if s is the string "1024",
n will contain the integer 1024. If s cannot be interpreted as an integer, the
method will throw NumberFormatException.
The primitive object types have a number of other useful methods; consult
a Java text to learn about them. They are defined in the package java.lang.
This package defines a number of types that are so central to Java that the
package can be used without needing to import it. For example, the types
String and Object are defined in java.lang.
2.6.2 Vectors
Vectors are extensible arrays; they are empty when first created and can grow
and shrink on the high end. Vectors are defined in the java.util package.
Here we discuss some of their methods; for more information, consult a Java
text.
Like an array, a vector contains elements numbered from zero up to one less
than its current length. The length of a vector can be determined by calling
its size method.
Each element in the vector has the apparent type Object. This means that
vectors can be heterogeneous: different elements of a vector can be objects of
different types. However, vectors typically are used in a more limited ways,
so that all elements of a vector are of the same type or of a few closely related
types.
When a vector is created, it is empty, and its length is zero; for example,
Vector v = new Vector( ); // creates a new, empty Vector
if (v.size( )== 0) // true
A vector can be made to grow by using the add method to add an element to
its high end; for example,
v.add("abc");
This method increases the size of the vector by 1 and stores its argument in
the new location.
Vector elements can be accessed for legal indices. The get method fetches
the indexed element; for example,
String s = (String) v.get(0);
31
Chapter 2 Understanding Objects in Java
Note that get returns an Object, and the using code must then cast the result
to the appropriate type. If the given index is not within bounds, get throws
the IndexOutOfBoundsException.
String t = (String) v.get(1); // throws IndexOutOfBoundsException
Finally, the vector can be caused to shrink by using the remove method; for
example,
v.remove(0);
Because all elements of a vector must belong to types that are subtypes of
Object, vectors cannot contain elements of primitive types such as int and
char. Such values can be stored in a vector by using the associated object
types. For example,
v.add(3); // a compile time error
v.add(new Integer(3)); // legal
To use such an element later it must be both cast and converted to a value; for
example,
int x = ((Integer) v.get(2)).intValue( );
32
2.8 — Java Applications
Most methods on streams do error checking (e.g., to check for end of file
when data is being input from a file) and throw IOException if an error is
detected.
33
Chapter 2 Understanding Objects in Java
Since the name of the method is main, the program can be run from the
command line.
The next example reads an integer from an input stream and prints its
factorial to an output stream. It shows how to read and write integers from
streams; other built-in types can be read/written similarly.
Note that the code does not check directly for badly formatted input. In-
stead, it relies on the checking done within the call to the Integer.parseInt
method; recall that this method will throw the NumberFormatException if
there is a formatting problem. If this exception, or IOException, occurs, it is
handled by the try-catch construct (as discussed further in Chapter 4), and
the code produces an appropriate error message.
34
Exercises
Exercises
2.1 Consider the following code:
String s1 = "ace";
String s2 = "f";
String s3 = s1;
String s4 = s3 + s2; // concatenation
Illustrate the effect of the code on the heap and stack by drawing a diagram
similar to that in Figure 2.2.
2.2 Consider the code:
int[ ] a = {1,2,3};
int[ ] b = new int[2];
int[ ] c = a;
int x = c[0];
Illustrate the effect of this code on the heap and stack by drawing a diagram
similar to that in Figure 2.2.
2.3 Extend the diagram you produced in question 2.2 to show the effect of the
following code:
b[0] = x;
a[1] = 6;
x = b[1];
y = a[1];
35
Chapter 2 Understanding Objects in Java
This routine modifies its argument z so that when the routine returns, each
element z[i] contains the sum of the values z[0],...,z[i] as of the time of
the call. Show the effect of the following code:
by providing diagrams similar to those in Figure 2.3. Show the state of the
program right before the call of sums, right after the call of sums starts running,
and right after sums returns.
2.5 Consider the following code:
Object o = "abc";
int[ ] a = [1,2,3];
Object o = "123";
String t = "12";
String w = t + "3";
boolean b = o.equals(a);
boolean b2 = o.equals(t);
boolean b3 = o.equals(w);
boolean b4 = (o == w);
Show the effect of executing this code by means of a diagram similar to that
in Figure 2.2. Also explain how the the code arrived at the results in b, b1, b2,
and b3.
36
Exercises
For each of the following calls, determine which definitions would match a
particular call; also decide whether the call is legal, and if so, which of the
preceding definitions is selected:
m(v, a, b);
m(v, a, a);
m(v, b, a);
m(v, b, b);
m(o, b, b);
m(o, a, a);
37
This page intentionally left blank
Procedural Abstraction
3
In this chapter, we discuss the most familiar kind of abstraction used in pro-
gramming, the procedural abstraction, or procedure for short. Anyone who
has introduced a subroutine to provide a function that can be used in other
programs has used procedural abstraction. Procedures combine the methods
of abstraction by parameterization and specification in a way that allows us
to abstract a single action or task, such as computing the greatest common
demoninator (gcd) of two integers or sorting an array.
A procedure provides a transformation from input arguments to output
arguments. More precisely, it is a mapping from a set of input arguments to a set
of output results, with possible modifications of the inputs. The set of inputs
or outputs, or both, might be empty. For example, gcd has two inputs and one
output, but it does not modify its inputs. By contrast, a sort procedure might
have one input (the array to be sorted) and no output, and it does modify its
input (by sorting it).
We begin with the benefits of abstraction and, in particular, of abstraction
by specification. Next we discuss specifications and why they are needed.
Then we discuss how to specify and implement standalone procedures; these
are procedures that are independent of particular objects. We conclude with
some general remarks about their design.
39
Chapter 3 Procedural Abstraction
A Abstraction
I1 . . . I2 Implementations
40
3.1 — The Benefits of Abstraction
ing isPrime would continue to run correctly with this new implementation
(although some change in performance might be noticed). The implementa-
tions could even be written in different programming languages, provided
that the data types of the arguments are treated the same in these languages.
For example, in many systems implemented in higher-level languages, it is
common to implement some abstractions in machine language to improve
performance.
Abstraction by specification provides a method for achieving a program
structure with two advantageous properties. These benefits are summarized
in Sidebar 3.1. The first property is locality, which means that the implemen-
tation of one abstraction can be read or written without needing to examine
the implementation of any other abstraction. To write a program that uses an
abstraction, a programmer need understand only its behavior, not the details
of its implementation.
Locality is beneficial both when a program is being written and later
when someone wants to understand it or reason about its behavior. Because of
locality, different abstractions that make up a program can be implemented by
people working independently. One person can implement an abstraction that
uses another abstraction being implemented by someone else. As long as both
people agree on what the used abstraction is, they can work independently
and still produce programs that work together properly. Also, understanding a
program can be accomplished one abstraction at a time. To understand the code
that implements one abstraction, it is necessary to understand what the used
abstractions are, but not the code that implements them. In a large program,
the amount of information that is not needed can be enormous; we can ignore
not only the code of the used abstractions but also the code of any abstractions
they use, and so on.
41
Chapter 3 Procedural Abstraction
3.2 Specifications
It is essential that abstractions be given precise definitions; otherwise, the
advantages discussed in Section 3.1 cannot be achieved. For example, we can
replace one implementation of an abstraction by another only if everything
that was depended on by users of the old implementation is supported by the
new one. The entity depended on and supported is the abstraction. Therefore,
we must know what the abstraction is.
We shall define abstractions by means of specifications, which are written
in a specification language that can be either formal or informal. The advantage
of formal specifications is that they have a precise meaning. However, we shall
use informal specifications in this book, in which the behavior of the abstrac-
tion is given in English. Informal specifications are easier to read and write
than formal ones, but giving them a precise meaning is difficult because the
informal specification language is not precise. Despite this, informal specifi-
cations can be very informative and can be written in such a way that readers
will have little trouble understanding their intended meaning.
42
3.3 — Specifications of Procedural Abstractions
43
Chapter 3 Procedural Abstraction
and modifies clauses are optional. The clauses are shown as comments because
they should always appear in your code.
The clauses describe a relation between the procedure’s inputs and results.
For most procedures, the inputs are exactly the parameters that are listed in the
procedure header. However, some procedures have additional implicit inputs.
For example, a procedure might read a file and write some information on
System.out; the file and System.out are also inputs of the procedure.
The requires clause states the constraints under which the abstraction is
defined. The requires clause is needed if the procedure is partial—that is, if
its behavior is not defined for some inputs. If the procedure is total—that is,
if its behavior is defined for all type-correct inputs—the requires clause can
be omitted. In this case, the only restrictions on a legal call are those implied
by the header—that is, the number and types of the arguments.
The modifies clause lists the names of any inputs (including implicit inputs)
that are modified by the procedure. If some inputs are modified, we say the
procedure has a side effect. The modifies clause can be omitted when no inputs
are modified. The absence of the modifies clause means that none of the inputs
is modified.
Finally, the effects clause describes the behavior of the procedure for all
inputs not ruled out by the requires clause. It must define what outputs are
produced and also what modifications are made to the inputs listed in the
modifies clause. The effects clause is written under the assumption that the
requires clause is satisfied, and it says nothing about the procedure’s behavior
when the requires clause is not satisfied.
In Java, standalone procedures are defined as static methods of classes.
To use such a method, it is necessary to know its class. Therefore, we need
to include this information with the specification, giving us the expanded
template shown in Figure 3.3. We have simply added a little information
about the class: its name and a brief description of its purpose. Additionally,
the specification indicates the visibility of the class and each standalone
44
3.3 — Specifications of Procedural Abstractions
visibility cname {
// overview: This clause defines the purpose of the class as a whole.
procedure; the visibility of the class and the procedures usually will be public,
so that the standalone procedures can be used in other packages.
A partial specification of a class, Arrays, which provides a number of
standalone procedures that are useful for manipulating arrays of integers, is
given in Figure 3.4. Since the class and the methods are public, the methods
can be used by code outside the package containing the class definition.
In the specification, we can see that search and searchSorted do not
modify their inputs, but sort modifies its input, as indicated in the modifies
45
Chapter 3 Procedural Abstraction
clause. Note the use of an example in the sort specification. Examples can
clarify a specification and should be used whenever convenient.
Note also that sort and search are total, since their specifications do not
contain a requires clause. searchSorted, however, is partial; it only does its
job if its argument array is sorted. Note that the effects clause does not state
what searchSorted does if the argument does not meet this constraint. In
this case, the implementor can do whatever is convenient; for example, the
implementation could even run forever. Obviously, this is not a very desirable
situation, and therefore you should avoid the use of the requires clause as much
as possible. This issue is discussed further in Section 3.5.
When a procedure modifies the state of some input, the specification needs
to relate the state of the object at return with its state at the time of call. This
is what happens in the specification of sort. Writing such specifications can
be simplified by having notation to identify these different states explicitly.
We will make use of the following notation: the name of a formal argument—
for example, x denotes its state at the time of call and x_post denotes its state
at return. Thus, an alternative way of writing the specification for sort is
You might wonder whether boundArray could return its argument array if
none of its elements exceed n. However, this possibility is ruled out by the
specification, which indicates that boundArray must return a new object.
And obviously this requirement is important, since arrays are mutable: if
boundArray returned its argument, the using code is likely to notice the
sharing.
In Figure 3.4, all procedures use only formal parameters as inputs. Here is
an example of a specification of a procedure that has implicit inputs, namely
System.in and System.out:
46
3.4 — Implementing Procedures
Note that the specification describes what the procedure does to the implicit
inputs.
Typically, specifications are written first, in advance of writing the code
that implements them. At that point, the class should be given a skeleton
implementation, consisting of just the method headers and specifications. The
bodies of the routines will be missing; code will be provided for these bodies
at a later time.
47
Chapter 3 Procedural Abstraction
Figure 3.6 shows the sort implementation. Note that the quickSort and
partition routines are not declared to be public; instead, their use is limited
to the Arrays class. This is appropriate because they are just helper routines
and have little utility in their own right. Nevertheless, we have provided
specifications for them; these specifications are of interest to someone inter-
ested in understanding how quickSort is implemented but not to a user of
quickSort.
As another example, consider a class Vectors that is similar to Arrays but
instead provides useful routines for vectors (recall that vectors are extensible
arrays of objects). One routine provided by this class removes duplicates from
a vector. Figure 3.7 on page 50 contains the specification and implementation
of this routine. Note that the specification explains what “duplicate” means:
it is determined by using the equals method to compare elements of the
vector.
48
3.4 — Implementing Procedures
49
Chapter 3 Procedural Abstraction
50
3.5 — Designing Procedural Abstractions
51
Chapter 3 Procedural Abstraction
52
3.5 — Designing Procedural Abstractions
53
Chapter 3 Procedural Abstraction
A procedure is total if its behavior is specified for all legal inputs; otherwise, it is
partial. The specification of a partial procedure always contains a requires clause.
Partial procedures are less safe than total ones. Therefore, they should be used only
when the context of use is limited or when they enable a substantial benefit, such as
better performance.
When possible, the implementation should check the constraints in the requires clause
and throw an exception if they are not satisfied.
54
3.6 — Summary
But sometimes a constraint is not expensive to check; this is the case for
removeDupls, which requires all elements of the vector to be non-null. In
such a case it is a good idea to do the check and throw an expection if it fails.
Since such checks aren’t required by the specification, they can be disabled
later, when the program is in production use, if this becomes necessary to
achieve good performance.
Finally, it is worth noting that a specification is the only record of its
abstraction. Therefore, it is crucial that the specification be clear and precise.
How to write good specifications is the subject of Chapter 9.
3.6 Summary
This chapter has been concerned primarily with procedures: what they are,
how to describe their behavior, and how to implement them. We also discussed
two important benefits of abstraction and the need for specifications.
A procedure is a mapping from inputs to outputs, with possible modi-
fications of some of the inputs. Its behavior, like that of any other kind of
abstraction, is described by a specification, and we presented a form for in-
formal specifications of procedures. A procedure is implemented in Java by a
static method; in other languages, it would be implemented by a function or
subroutine.
Abstraction provides the two key benefits of locality and modifiability.
Both are based on the distinction between an abstraction and its implementa-
tions. Locality means that each implementation can be understood in isolation.
An abstraction can be used without having to understand how it is imple-
mented, and it can be implemented without having to understand how it is
used. Modifiability means that one implementation can be substituted for an-
other without disturbing the using programs.
To obtain these benefits, we must have a description of the abstraction
that is distinct from any implementation. To this end, we introduced the
specification, which describes the behavior of an abstraction using a special
specification language. This language can be formal or informal; we used
an informal language but with a fixed structure consisting of the requires,
modifies, and effects clauses. Users can assume the behavior described by
the specification, and implementors must provide this behavior. Thus, the
specification serves as a contract between users and implementors.
55
Chapter 3 Procedural Abstraction
Exercises
3.1 Computing the greatest common divisor by repeated subtraction (see Fig-
ure 2.1 in Chapter 2) is not very efficient. Reimplement gcd to use division
instead.
3.2 Specify and implement a method with the header
56
Exceptions
4
The caller of a partial procedure must ensure that the arguments are in the
permitted subset of the domain, and the implementor can ignore arguments
outside this subset. Thus, in implementing gcd, we could ignore the case of
nonpositive arguments.
Partial procedures are generally a bad idea, however, since there is no guar-
antee that their arguments are in the permitted subset and the procedure may
therefore be called with arguments outside the subset. When this happens,
the procedure is allowed to do anything: it might loop forever or return an
57
Chapter 4 Exceptions
erroneous result. The latter case is especially bad since it can lead to an ob-
scure error that is difficult to track down. For example, the calling code might
continue to run, using the erroneous result, and possibly damage important
databases.
Partial procedures lead to programs that are not robust. A robust program
is one that continues to behave reasonably even in the presence of errors. If
an error occurs, the program may not be able to provide exactly the same
behavior as if there were no error, but it should behave in a well-defined way.
Ideally, it should continue after the error by providing some approximation
of its behavior in the absence of an error; a program like this is said to provide
graceful degradation. At worst, it should halt with a meaningful error message
and without causing damage to permanent data.
A method that enhances robustness is to use total procedures: procedures
whose behavior is defined for all inputs in the domain. If the procedure is
unable to perform its “intended” function for some of these inputs, at least it
can inform its caller of the problem. In this way, the situation is brought to
the attention of the caller, which may be able to do something about it, or at
least avoid harmful consequences of the error.
How should the caller be notified if a problem arises? One possibility is
to use a particular result to convey the information. For example, a factorial
procedure might return zero if its argument is not positive:
public static int fact (int n)
// effects: If n > 0 returns n! else returns 0.
This solution is not very satisfactory. Since the call with illegal arguments is
probably an error, it is more constructive to treat this case in a special way, so
that a programmer who uses the procedure is less likely to ignore the error by
mistake. Also, returning a special result may be inconvenient for the calling
code, which then must check for it. For example, rather than writing:
z = x + Num.fact(y);
58
4.1 — Specifications
the value of the vector’s ith element, and that value can be any object or null.
Therefore, we can’t convey information about the index being out of bounds
by returning a particular object or by returning null.
What is needed is an approach that conveys information about unusual
situations in all cases, even when every value of the return type is a legiti-
mate result. In addition, it is desirable for the approach to distinguish these
situations in some way, so that users can’t ignore them by mistake. It would
also be nice if the approach allowed the handling of these situations to be
separated from the normal program control flow.
An exception mechanism provides what we want. It allows a procedure to
terminate either normally, by returning a result, or exceptionally. There can
be several different exceptional terminations. In Java, each exceptional termi-
nation corresponds to a different exception type. The names of the exception
types are selected by the definer of the procedure to convey some informa-
tion about what the problem is. For example, the get method of Vector has
IndexOutOfBoundsException.
In this chapter, we discuss how to specify, implement, and use procedures
with exceptions. We also discuss a number of related design issues.
4.1 Specifications
A procedure that can terminate exceptionally is indicated by having a throws
clause in its header:
For example,
states that fact can terminate by throwing an exception; and in this case, it
throws an object of type NonPositiveException.
A procedure can throw more than one type of exception; for example,
59
Chapter 4 Exceptions
60
4.2 — The Java Exception Mechanism
Throwable
Error Exception
RuntimeException (checked
exceptions)
(unchecked exceptions)
61
Chapter 4 Exceptions
Most exceptions that are defined by Java are unchecked (e.g., Null-
PointerException, IndexOutOfBoundsException) but others are checked
(e.g., IOException). User-defined exceptions can similarly be either checked
or unchecked.
There are two differences in how checked and unchecked exceptions can
be used in Java:
62
4.2 — The Java Exception Mechanism
type. The header of the class states that the new type, NewKindOfException,
is a subtype of type Exception; this is the meaning of
extends Exception
As illustrated in Figure 4.3, a class defining a new exception type need only
define constructors; recall that constructors are special methods that are used
to initialize newly created objects of the class. Defining a new exception type
requires very little work because most of the code for the new type is inherited
from the class that implements its supertype. We will discuss inheritance, and
also provide more detail about the special forms used in this definition, in
Chapter 7.
The exception type provides two constructors; in other words, the con-
structor name is overloaded as discussed in Section 2.4.2. The second con-
structor initializes the exception object to contain the string provided as its
argument; as we shall see in Section 4.2.3, this string will explain why the
exception was thrown. For example,
Exception e1 = new NewKindOfException("this is the reason");
causes exception object e1 to contain the string "this is the reason". The
first constructor initializes the object to contain the empty string, for example,
Exception e2 = new NewKindOfException( );
The string, together with the type of exception, can be obtained by calling
the toString method on the exception object. For example,
63
Chapter 4 Exceptions
String s = e1.toString( );
64
4.2 — The Java Exception Mechanism
try { x = Num.fact(y); }
catch (NonPositiveException e) {
// in here can use e
}
try { ...;
try { x = Arrays.search(v, 7); }
catch (NullPointerException e) {
throw new NotFoundException( ); }
} catch (NotFoundException b) { ... }
the catch clause in the outer try statement will handle NotFound
Exception if it is thrown by the call of Arrays.search or by the catch clause
for NullPointerException.
The catch clauses do not have to identify the actual type of an exception
object. Instead, the clause can list a supertype of the type. For example, in
65
Chapter 4 Exceptions
66
4.3 — Programming with Exceptions
try { x = y[n]; }
catch (IndexOutOfBoundsException e) {
// handle IndexOutOfBoundsException from the array access y[n]
}
i = Arrays.search(z, x);
67
Chapter 4 Exceptions
has changed. Another point is that before reflecting an exception, the caller
may need to do some local processing in order to satisfy its specification.
For example, many programs that iterate through arrays need to “prime”
the iteration by obtaining an initial value from the array. This is the case in the
min procedure shown in Figure 4.4. min simply fetches the zeroth element of the
array. If the array argument is null, the call will raise NullPointerException,
and this is reflected to the caller of min by being propagated automatically. If
the array is empty, the call will raise IndexOutOfBoundsException. It would
not make sense to reflect this exception to min’s caller, since we want excep-
tions that are related to the min abstraction rather than exceptions having to
do with how min is implemented. Instead, min throws EmptyException, which
is an exception that is meaningful for it. Note that the string in the exception
object identifies Arrays.min as the thrower.
A second possibility is that the caller masks the exception—that is, han-
dles the exception itself and then continues with the normal flow. This situ-
ation is illustrated in the sorted procedure in Figure 4.4. Again, the code is
priming the loop; but in this case, if the array is empty, it simply means it is
sorted.
One point to note about both examples is how we used exceptions to
control program flow. This is perfectly acceptable programming practice:
exceptions can be used to avoid other work. For example, in both min and
sorted, the code does not need to check the length of the array explicitly.
(However, depending on how the exception mechanism is implemented, it
may be expensive to handle exceptions, and you should weigh this cost against
the benefit of using exceptions to avoid the extra work.)
68
4.4 — Design Issues
that the caller should be informed about. We convey this information through
an exception because we want to distinguish it from the other possibility.
The classification of one possibility as normal and the others as exceptional is
somewhat arbitrary.
Also, even when an exception is associated with what appears to be an
error at a lower level of abstraction, the situation is not necessarily an error
at a higher one. For example, within the get method of Vector, it appears
to be erroneous if the given index isn’t within bounds. However, from the
perspective of the caller of get, this situation may simply indicate that a
loop should terminate. Thus, it can be just as “correct” for a call to terminate
with an exception as to terminate normally. Exceptions are simply a means for
allowing several kinds of behavior and informing the caller about the different
cases.
69
Chapter 4 Exceptions
70
4.4 — Design Issues
When the context of use is local, you need not use exceptions because you can easily
verify that requires clauses are satisfied by calls and that special results are used
properly.
However, when the context of use is nonlocal, you should use exceptions instead of
special results. And you should use exceptions instead of requires clauses unless a
requirement cannot be checked or is very expensive to check.
to handle a checked exception that is thrown by some call in your code, the
compiler will warn you so that you can get rid of the error.
However, unchecked exceptions will be implicitly propagated to the caller
even if they aren’t listed in the header. This means that procedures can raise
unchecked exceptions even when this isn’t mentioned in their header and
specification. For example, if search is implemented incorrectly so that it
accesses its argument array, a, out of bounds and doesn’t handle the resulting
IndexOutOfBoundsException, it will throw that exception to its caller, even
though that possibility is not mentioned in its specification.
It may seem that this really isn’t a problem, since using code can handle
the exception, for example, at the top level. But code isn’t very good at coping
with programmer errors, which are usually the reason unchecked exceptions
propagate. (Exceptions also propagate because of resource problems—for ex-
ample, the heap ran out of room; programs aren’t good at coping with those
errors either.)
Furthermore, there is a danger that the exception will be captured. For
example, in:
try { x = y[n]; i = Arrays.search(z, x); }
catch (IndexOutOfBoundsException e) {
// handle IndexOutOfBoundsException from use of array y
}
// code here continues assuming problem has been fixed
71
Chapter 4 Exceptions
the code after the catch clause will work in this case, and when the error is
finally discovered, it may be very difficult to track down.
Why does Java have unchecked exceptions when they are a problem? The
reason is that checked exceptions are also a problem: if your code is certain not
to cause one to be raised, you still must handle it! This is why many exceptions
defined by Java are in fact unchecked.
So there are good reasons on both sides here. This means that there is a
design issue: when you define a new exception type, you must think carefully
about whether it should be checked or unchecked.
Choosing between checked and unchecked exceptions should be based on
expectations about how the exception will be used. If you expect using code to
avoid calls that raise the exception, the exception should be unchecked. This
is the rationale behind IndexOutOfBoundsException: arrays are supposed to
be used primarily in for loops that control the indices and thus ensure that
all calls on array methods have indices within bounds.
Otherwise, exceptions should be checked. For example, it is likely that
many calls of search will be made without knowledge of whether the
searched-for integer is in the array. In such a case, it would be an error for
the calling code not to handle the exception. Therefore, the exception type
should be checked so that such errors can be detected by the compiler.
The question of whether the exception is “usually” avoided often has to
do with the cost and convenience of avoiding it. For example, it is convenient
and inexpensive to determine the size of a vector (by calling the size method,
which returns in constant time); therefore, using code is likely to use this
method to avoid IndexOutOfBoundsException. But sometimes there is no
convenient way to avoid the exception, or avoiding the exception is costly.
Both situations arise for search. There may be no other procedure to determine
whether the element is in the array, since this is (partly) the purpose of search.
Furthermore, if such a procedure existed, its call would be costly.
The rules for choosing between checked and unchecked exceptions are
summarized in Sidebar 4.2.
72
4.5 — Defensive Programming
You should use an unchecked exception only if you expect that users will usually
write code that ensures the exception will not happen, because
.
There is a convenient and inexpensive way to avoid the exception.
.
The context of use is local.
Otherwise, you should use a checked exception.
73
Chapter 4 Exceptions
catch (NotFoundException e) {
throw new FailureException("C.p" + e.toString( )); }
4.6 Summary
In this chapter, we have extended procedures to include exceptions. Excep-
tions are needed in robust programs because they provide a way to respond to
errors and unusual situations. If an argument is not what is expected, a pro-
cedure can notify the caller of this fact rather than simply failing or encoding
the information in a special result. Since this notification is distinct from the
normal case, the caller cannot confuse the two.
Exceptions are introduced when procedures are designed. Most proce-
dures should be defined over the entire input domain; exceptions are used to
take care of situations in which the “usual” behavior cannot happen. Partial
procedures are suitable only when it is either too expensive or not possible
to check the condition, or when the procedure is used in a limited context in
which it can be proved that all calls have proper arguments.
In implementing a procedure, the programmer must ensure that it termi-
nates as specified in all situations. Only exceptions permitted by the specifi-
cation should be signaled, and each should be signaled only in the situation
74
Exercises
Exercises
4.1 Implement a standalone procedure to read in a file containing words and white
space and produce a compressed version of the file in an output file. The
compressed version should contain all of the words in the input file and none
of the white space, except that it should preserve lines.
4.2 Implement search as specified in Figure 4.1 in two ways: using for loops, and
using while (true) loops that are terminated when accessing the array raises
IndexOutOfBoundsException. Which implementation is better? Discuss.
4.3 A specification for a procedure that computes the sum of the elements in an
array of integers might require a nonempty array, return 0 if the array is empty,
or throw an exception if the array is empty. Discuss which alternative is best
and provide the specification for the procedure.
4.4 Consider a procedure
that multiplies each element of a by the sum of the elements of b; for example,
if a = [1, 2, 3] and b = [4, 5], then on return a = [9, 18, 27]. What should
this procedure do if a or b is null or empty? Give a specification for combine
that answers these questions and explain why your specification is a good one.
75
This page intentionally left blank
Data Abstraction
This chapter discusses the most important abstraction mechanism, data ab-
straction. Data abstraction allows us to abstract from the details of how data
objects are implemented to how the objects behave. This focus on the behavior
of objects forms the basis of object-oriented programming.
Data abstraction allows us to extend the programming language in use
(e.g., Java), with new data types. What new types are needed depends on the
application domain of the program. For example, in implementing a compiler
or interpreter, stacks and symbol tables are useful, while accounts are a natural
abstraction in a banking system. Polynomials arise in a symbolic manipulation
system, and matrices are useful in defining a package of numeric functions. In
each case, the data abstraction consists of a set of objects—for example, stacks
or polynomials—plus a set of operations. For example, matrix operations
include addition, multiplication, and so on, and deposit and withdraw are
operations on accounts.
The new data types should incorporate abstraction both by parameteriza-
tion and by specification. Abstraction by parameterization can be achieved in
the same way as for procedures—by using parameters wherever it is sensible
to do so. We achieve abstraction by specification by making the operations
part of the type. To understand why the operations are needed, consider what
happens if we view a type as just a set of objects. Then all that is needed to
implement the type is to select a storage representation for the objects; all the
77
Chapter 5 Data Abstraction
and we require users to call the operations instead of accessing the represen-
tation directly. Then to implement the type, we implement the operations in
terms of the chosen representation, and we must reimplement the operations
if we change the representation. However, we need not reimplement any using
programs because they did not use the representation. Now we have abstracted
from the representation details; using code depends only on the specified be-
havior of the type with its operations. Therefore, we have achieved abstraction
by specification.
If enough operations are provided, lack of access to the representation will
not cause users any difficulty—anything they need to do to the objects can
be done, and done efficiently, by calls on the operations. In general, there will
be operations to create and modify objects and to obtain information about
their values. Of course, users can augment the set of operations by defining
standalone procedures, but such procedures would not have access to the
representation.
Data abstraction allows us to defer decisions about data structures until
the uses of the data are fully understood. Choosing the right data structures is
crucial to achieving an efficient program. In the absence of data abstraction,
data structures must be defined too early; they must be specified before the
implementations of using modules can be designed. At this point, however,
the uses of the data are typically not well understood. Therefore the chosen
structure may lack needed information or be organized in an inefficient way.
We use data abstraction to avoid defining the structure immediately: we
introduce the abstract type with its objects and operations. Implementations
of using modules can then be designed in terms of the abstract type. Deci-
sions about how to implement the type are made later, when all its uses are
understood.
Data abstraction is also valuable during program modification and mainte-
nance. In this phase, data structures are particularly likely to change, either
to improve performance or to accommodate changing requirements. Data ab-
78
5.1 — Specifications for Data Abstractions
straction limits the changes to just the implementation of the type; none of
the using modules need be changed.
In this chapter, we describe how to specify and implement data abstrac-
tions in Java. We also discuss ways to reason about the correctness of programs
that use and implement types, and we describe some issues that arise in de-
signing new types.
// constructors
// specs for constructors go here
// methods
// specs for methods go here
}
79
Chapter 5 Data Abstraction
all classes have public visibility so that they can be used by code outside of
their containing package.
The specification has three parts. The overview gives a brief description of
the data abstraction, including a way of viewing the abstract objects in terms
of “well-understood” concepts. It usually presents a model for the objects;
that is, it describes the objects in terms of other objects that the reader of the
specification can be expected to understand. For example, stacks might be
defined in terms of mathematical sequences. The overview section also states
whether objects of the type are mutable, so that their state can change over
time, or immutable.
The constructors part of the specification defines the constructors that
initialize new objects, while the methods part defines the methods that allow
access to the objects once they have been created. All the constructors and
methods that appear in the specification will be public.
Constructors and methods are procedures, and they are specified using
the specification notation presented in Chapters 3 and 4, with the following
differences:
80
5.1 — Specifications for Data Abstractions
// constructors
public IntSet ( )
// effects: Initializes this to be empty.
// methods
public void insert (int x)
// modifies: this
// effects: Adds x to the elements of this, i.e., this_post = this + { x }.
that we will model IntSets in terms of mathematical sets. In the rest of the
specification, we specify each operation using this model.
Figure 5.2 uses set notation in the specifications of the methods. In partic-
ular, it uses + for set union, and – for set difference. Figure 5.3 summarizes
the set notation used in this book.
The IntSet type has a single constructor that initializes the new set to be
empty; note that the specification refers to the new set object as this. Since a
constructor always modifies this (to initialize it), we do not bother to indicate
the modification in the modifies clause. In fact, this modification is invisible
81
Chapter 5 Data Abstraction
to users: they do not have access to the constructor’s object until after the
constructor runs, and therefore, they cannot observe the state change.
Once an IntSet object exists, elements can be added to it by calling its
insert method, and elements can be removed by calling remove; again, the
specifications refer to the object as this. These two methods are mutators
since they modify the state of their object; their specifications make it clear
that they are mutators because they contain a modifies clause stating that this
is modified. Note that the specifications of insert and remove use the notation
this_post to indicate the value of this when the operation returns. An input
argument name without the post qualifier always means the value when the
operation is called.
The remaining methods are observers: they return information about the
state of their object but do not change the state. Observers do not have a
modifies clause. (More accurately, an observer does not have a modifies clause
stating that this, or some argument object of its type, is modified; however,
observers typically don’t modify anything.)
The choose method returns an arbitrary element of the IntSet; thus, it is
underdetermined. It throws an exception if the set is empty. This exception
can be unchecked since users can call the size method before calling choose
to cheaply and conveniently ensure that the set is nonempty.
82
5.1 — Specifications for Data Abstractions
Note that insert does not throw an exception if the integer is already
in the set, and similarly, remove does not throw an exception if the integer
is not in the set. These decisions are based on assumptions about how sets
will be used. We expect that users will add and remove set elements with-
out concern for whether they are already there. Therefore, the methods do
not throw exceptions. If we expected a different pattern of usage, we might
change the specifications and headers of these methods (to throw an excep-
tion), or we might provide additional methods that throw an exception (e.g.,
insertNonDup and removeIfIn), so that users can choose the method that best
fits their needs.
In the IntSet specification, we are relying on the reader knowing what
mathematical sets are; otherwise, the specification would not be understand-
able. In general, this reliance on informal description is a weakness of informal
specifications. It is probably reasonable to expect the reader to understand
a number of mathematical concepts, such as sets, sequences, and integers.
However, not all types can be described nicely in terms of such concepts. If
the concepts are inadequate, we must describe the type as best we can, even
by using pictures; but of course, there is always the danger that the reader
will not understand the description or will interpret it differently than we
intended. Techniques for writing understandable specifications will be dis-
cussed in Chapter 9.
Note that the specification takes the form of a preliminary version of the
class. This code could be compiled if the methods and constructors were
given empty bodies (except that methods that return results will need a type-
correct return statement). This will allow you to compile code that uses the
abstraction, so that you’ll be able to get rid of errors that the compiler catches,
such as type errors. You probably won’t be able to run the using code, however,
until after the new type is implemented.
83
Chapter 5 Data Abstraction
// constructors
public Poly ( )
// effects: Initializes this to be the zero polynomial.
// methods
public int degree ( )
// effects: Returns the degree of this, i.e., the largest exponent
// with a non-zero coefficient. Returns 0 if this is the zero Poly.
84
5.2 — Using Data Abstractions
The Poly type has two constructors, one to create the zero polynomial,
and one to create an arbitrary monomial. In general, a type can have a num-
ber of constructors. All constructors have the same name, the type name,
and therefore, if there is more than one constructor, this name is over-
loaded.
Java allows method names to be overloaded as well. Java requires that
overloaded definitions differ from one another in the number of arguments
and/or their types; otherwise, a compile-time error occurs. The two definitions
for the Poly constructor are legal since one has no arguments and the other
has two arguments.
Poly has no mutator methods: no method has a modifies clause. This
is what we expect to see for an immutable data abstraction. Furthermore,
the method specifications do not use the post notation that was used in
the IntSet specification. This notation is not needed for immutable abstrac-
tions: since object state doesn’t change, the pre and post states of objects are
identical.
As part of defining Poly, we need to decide whether NegativeExponent-
Exception is checked or unchecked. Since it seems likely that users will
avoid calls with a negative exponent, it is appropriate to make the exception
unchecked.
85
Chapter 5 Data Abstraction
choose to have each element of the set occur exactly once in the vector or allow
it to occur many times. The latter choice makes the implementation of insert
run faster but slows down remove and isIn. Since isIn is likely to be called
frequently, we will make the former choice, and therefore, there will be no
duplicate elements in the vector.
// constructors
public IntSet ( ) {
// effects: Initializes this to be empty.
els = new Vector( ); }
// methods
public void insert (int x) {
// modifies: this
// effects: Adds x to the elements of this.
Integer y = new Integer(x);
if (getIndex(y) < 0) els.add(y); }
consists of a single instance variable. Since this variable has private visibility,
it can be accessed only by code inside its class.
The constructors and methods belong to a particular object of their type.
The object is passed as an additional, implicit argument to the constructors
and methods, and they can refer to it using the keyword this. For example,
the instance variable els can be accessed using the form this.els. (The code
cannot assign to this.) However, the prefix is not needed: the code can refer
to methods and instance variables of its own object by just using their names.
Thus, in the methods and constructors in the figure, els refers to the els
instance variable of this.
The implementation of IntSet is straightforward. The constructor initial-
izes its object by creating the vector that will hold the elements and assigning
it to els; since the vector is empty, no more work need be done. The insert,
remove, and isIn methods all make use of the private method, getIndex, to
determine whether the element of interest is already in the set. Doing this
check allows insert to preserve the no-duplicates condition. This condition
is relied on in size (since otherwise the size of the vector would not be the
same as the size of the set) and in remove (since otherwise there might be other
occurrences of the element that would need to be removed).
Note that getIndex has private visibility; therefore, it cannot be called
outside the class. The design takes advantage of this fact by having getIndex
return −1 when the element is not in the vector rather that using an exception.
As discussed in Chapter 4, this is a satisfactory approach here, since getIndex
is used only within this class.
Since vectors cannot store ints, the methods use Integer objects instead to
contain the set elements. This approach is somewhat awkward. An alternative
is to use arrays of ints; but this has its own difficulties, since then the
implementation of IntSet would need to switch to bigger arrays as the set
grows. The implementation of Vector takes care of this problem in an efficient
manner.
getIndex uses the equals method to check for membership. This check is
correct because equals for Integer objects returns true only if the two objects
being compared are both Integers and both contain the same integer value.
89
Chapter 5 Data Abstraction
5.3.4 Records
Suppose polynomials are going to be sparse rather than dense. In this case, the
previous implementation would not be a good one, since the array is likely to
be large and full of zeros. Instead, we would like to store information only for
the coefficients that are nonzero.
This could be accomplished by using two vectors:
However, the implementation in this case must ensure that the two arrays
are lined up, so that the ith element of coeffs contains the coefficient that
goes with the exponent stored in the ith element of exps. It would be more
convenient if instead we could use just one vector, each of whose elements
contained both the coefficient and the associated exponent.
90
Figure 5.7 First part of Poly implementation
// constructors
public Poly ( ) {
// effects: Initializes this to be the zero polynomial.
trms = new int[1]; deg = 0; }
// methods
public int degree ( ) {
// effects: Returns the degree of this, i.e., the largest exponent
// with a non-zero coefficient. Returns 0 if this is the zero Poly.
return deg; }
92
5.3 — Implementing Data Abstractions
class Pair {
// overview: A record type
int coeff;
int exp;
Pair(int c, int n) { coeff = c; exp = n; }
}
a constructor for creating a new object of the type; the constructor takes
arguments to define the initial values of the fields. An example is given in
Figure 5.9. Since no visibility is explicitly indicated for the class and its
instance variables, they are package visible.
Note that no specification is given for this class, other than to indicate that
it is a record type. Such a minimal specification is sufficient: knowing that the
class defines a record type indicates that the type simply provides the fields
defined by the instance variables.
We can use Pair in an implementation of sparse polynomials:
Here each element of trms is a Pair. This representation is simpler than the
one using two vectors. An additional benefit is that it allows us to avoid the
use of the intValue method. For example, consider the implementation of the
coeff method. If we are using two vectors, we have:
93
Chapter 5 Data Abstraction
Two objects are equals if they are behaviorally equivalent. Mutable objects are equals
only if they are the same object; such types can inherit equals from Object. Immutable
objects are equals if they have the same state; immutable types must implement equals
themselves.
clone should return an object that has the same state as its object. Immutable
types can inherit clone from Object, but mutable types must implement it them-
selves.
toString should return a string showing the type and current state of its object. All
types must implement toString themselves.
94
5.4 — Additional Methods
At the time the if is executed, both s and t have the same state (the empty
set). However, s and t are nevertheless distinguishable, because of mutations;
for example, if the code now does s.insert(3), s and t will have different
states. Therefore, the call to equals in the if statement must return false.
In other words, for mutable objects s and t, s.equals(t) (or t.equals(s))
should return false if s and t are different objects even when they have the
same state.
On the other hand, if two immutable objects have the same state, they
should be considered equal because there will not be any way to distinguish
among them by calling their methods. For example, consider
Poly p = new Poly(3, 4);
Poly q = new Poly(3, 4);
if (p.equals(q)) ...; else ...
When the if statement is executed, p and q have the same state (the polynomial
3x4). Furthermore, because Polys are immutable, p and q will always have the
same state. Therefore, the call p.equals(q) in the if statement should return
true.
The default implementation of equals provided by Object tests whether
the two objects have the same identity. This is the right test for IntSet: s and
t are not equivalent even though they have the same state. However, it is the
wrong test for Poly, and it will be the wrong test for any immutable type.
Therefore, when you define an immutable type, you need to provide your
own implementation of equals. However, you need not worry about equals
for mutable types; objects of these types will have an equals method—namely,
the one they inherit from Object—that does the right thing.
Object also provides a hashCode method. The specification of hashCode
indicates that if two objects are equivalent according to the equals method,
hashCode should produce the same value for them. Yet the default implemen-
tation for hashCode will not do this for immutable types. hashCode is needed
only for types that are intended to be keys in hash tables. If your immutable
type is one of these, you must implement hashCode in a way that observes this
constraint on its behavior.
There is a weaker equality notion that we will call similarity. Two objects
are similar if it is not possible to distinguish between them using any observers
of their type. Just as it is useful to have a standard name equals for the method
that does equivalence testing, it is also useful to have a standard name for the
method that provides similarity testing. We will call this method similar.
95
Chapter 5 Data Abstraction
There is no requirement to provide this method in a new type, but you can
do so if you wish.
For immutable types, similar and equals are the same. However, for
mutable types, similarity is weaker than equivalence. For example, in
IntSet s = new IntSet( );
IntSet t = new IntSet( );
if (s.similar(t)) ...; else ...
96
5.4 — Additional Methods
loaded) definitions for equals, one overriding the Object method and an extra
one:
The second one is an optimization; it avoids the cast and the call on in-
stanceof, which are expensive, in contexts in which both the object and
the argument are known by the compiler to be Polys. For example, consider
97
Chapter 5 Data Abstraction
Object clone ( );
Unfortunately, this means that calls to the method aren’t very convenient
or efficient. For example, most likely the caller of s.clone( ), where s is an
IntSet, wants to get an IntSet object as a result. And, in fact, IntSet’s
clone method produces an IntSet object. However, clone’s return type
indicates that an Object is being returned. Since Object is not a subtype
of IntSet (in fact the opposite is true), the object returned by clone cannot
be assigned to an IntSet variable; instead the caller must cast the result, for
example,
The toString method produces a string that represents the current state of
its object, together with an indication of its type. For example, for an IntSet,
we might want to see a representation like
IntSet: {1, 7, 3}
Poly: 2 + 3x + 5x**2
98
5.5 — Aids to Understanding Implementations
99
Chapter 5 Data Abstraction
has in mind a relationship between the rep and the abstract objects. For
example, in the implementation in Figure 5.6, IntSets are represented by
vectors, where the elements of the vector are the elements of the set.
This relationship can be defined by a function called the abstraction func-
tion that maps from the instance variables that make up the rep of an object
to the abstract object being represented:
AF: C → A
Specifically, the abstraction function AF maps from a concrete state (i.e., the
state of an object of the class C) to an abstract state (i.e., the state of an abstract
object). For each object c belonging to C, AF(c) is the state of the abstract
object a ∈ A that c represents.
For example, the abstraction function for the IntSet implementation maps
the instance variables of objects of the IntSet class to abstract IntSet states.
Figure 5.12 illustrates this function at some points; it shows how objects with
various els components map to IntSet states. This abstraction function is
many-to-one: many els components map to the same abstract element. For
example, the IntSet {1, 2} is represented by an object whose els vector
contains the Integer with value 1 followed by the Integer with value 2,
and also by an object whose els vector contains the two integers in the
opposite order. Since the process of abstraction involves forgetting irrelevant
information, it is not surprising that abstraction functions are often many-
to-one. In this example, the order in which the elements appear in the els
component is irrelevant.
The abstraction function is a crucial piece of information about an imple-
mentation. It defines the meaning of the representation, the way in which the
100
5.5 — Aids to Understanding Implementations
objects of the class are supposed to implement the abstract objects. It should
always be described in a comment in the implementation.
In writing such a description, however, we are hampered by the fact
that if the specification of the type is informal, the range of the abstraction
function (the set A) is not really defined. We shall overcome this problem by
giving a description of a “typical” abstract object. This allows us to define
the abstraction function in terms of this typical object. The description of the
typical abstract object state is part of the specification; it is provided in the
overview section. For example, the overview for IntSet stated:
(Recall that we are using mathematical sets to denote IntSet states.) Then we
can say
The notation {x | p(x)} describes the set of all x such that the predicate p(x) is
true; this notation was defined in Figure 5.3. For example, here it says that the
elements of the set are exactly the integer values contained in the els vector.
Note that in defining the abstraction function, we use some convenient
abbreviations: we use the notation c.els[i] to stand for use of the get method
of Vector, and we omit the ( ) when we use methods with no arguments (like
intValue and size). We also omit casting and simply assume the elements of
the els vector are Integers.
As a second example, consider the Poly implementation. We chose to
represent a Poly as an array in which the ith element held the ith coefficient
up to the degree. We can describe this representation as follows:
101
Chapter 5 Data Abstraction
so are its abstract objects. Therefore, its abstraction function is always the
identity map.
102
5.5 — Aids to Understanding Implementations
The idea here is that for an integer i in the range 0...99, we record membership
in the set by storing true in els[i]. Integers outside this range will be stored
in otherEls in the same manner as in our previous implementation of IntSet.
Since it would be expensive to compute the size of the IntSet if we had to
examine every part of the els array, we also store the size explicitly in the
rep. This representation is a good one if almost all members of the set are in
the range 0 . . . 99 and if we expect the set to have quite a few members in this
range. Otherwise, the space required for the els array will be wasted.
For this representation we have
// The abstraction function is
// AF(c) = { c.otherEls[i].intValue | 0 <= i < c.otherEls.size }
// +
// { j | 0 <= j < 100 && c.els[j] }
In other words, the set is the union of the elements of otherEls and the indexes
of the true elements of els. Also, we have
// The rep invariant is
// c.els = null && c.otherEls = null && c.els.size = 100 &&
// all elements in c.otherEls are Integers &&
// all elements in c.otherEls are not in the range 0 to 99 &&
103
Chapter 5 Data Abstraction
Note that the sz instance variable of this rep is redundant: It holds in-
formation that can be computed directly from the other instance variables.
Whenever there is redundant information in the rep, the relationship of this
information to the rest of the rep should be explained in the rep invariant (for
example, in the last line of this rep invariant).
It is sometimes convenient to use a helping function in the rep invariant or
abstraction function. For example, the last line of the preceding rep invariant
could be rewritten
// c.sz = c.otherEls.size + cnt(c.els, 0)
// where cnt(a, i) = if i >= a.size then 0
// else if a[i] then 1 + cnt(a, i+1)
// else cnt(a, i+1)
The helping function cnt is defined by a recurrence relation.
The implementation of Poly in Figure 5.7 has an interesting rep invariant.
Recall that we chose to store coefficients only up to the degree, without any
trailing zeros except in the case of the zero polynomial. Therefore, we do not
expect to find a zero in the high element of the trms component unless the
component has just one element. In addition, these arrays always have at least
one element. Furthermore, deg must be one less than the size of trms. Thus
we have
// The rep invariant is
// c.trms = null && c.trms.length >= 1 && c.deg = c.trms.length-1
// && c.deg > 0 ⇒ c.trms[deg] = 0
Recall that the implementation of the coeff operation depended on the length
of the array being one greater than the degree of the Poly; now we see this
requirement spelled out in the rep invariant.
Sometimes all concrete objects are legal representations. Then we have
simply
// The rep invariant is
// true
This is what happens for record types: record objects are used by accessing
their fields directly. This means that using code will be able to modify the
fields, which in turn means that the class implementing the record cannot
104
5.5 — Aids to Understanding Implementations
constrain the rep in any way. Of course, there might be some constraints on
how the record objects are used that define a stronger relationship between
the fields, but these constraints would be ensured by the code that uses
the record objects and would show up in the rep invariant for that code.
For example, the rep invariant for the sparse polynomial implementation
discussed in Section 5.3.4 would include
// for all elements e of c.trms
// e is a Pair and e.exp >= 0 and e.coeff = 0
Rep invariants need not be given for record types because all these classes
have exactly the same rep invariant. They must be given for all other types,
even those for which the invariant is simply true. Giving the invariant may
prevent the implementor from depending on a stronger, unsatisfied invariant.
105
Chapter 5 Data Abstraction
The abstraction function explains the interpretation of the rep. It maps the state of
each legal representation object to the abstract object it is intended to represent. It is
implemented by the toString method.
The representation invariant defines all the common assumptions that underlie the
implementations of a type’s operations. It defines which representations are legal by
mapping each representation object to either true (if its rep is legal) or false (if its rep
is not legal). It is implemented by the repOk method.
// for Poly:
public boolean repOk( ) {
if (trms == null || deg != trms.length - 1 ||
trms.length == 0) return false;
if (deg == 0) return true;
return trms[deg] != 0; }
// for IntSet:
public boolean repOk( ) {
if (els == null) return false;
for (int i = 0; i < els.size( ); i++) {
Object x = els.get(i);
if (!(x instanceof Integer)) return false;
for (int j = i + 1; j < els.size( ); j++)
if (x.equals(els.get(j))) return false;
}
return true; }
106
5.5 — Aids to Understanding Implementations
in Poly, the add, mul, and minus routines would call it, but sub need not
since it doesn’t access the reps of objects directly, and coeff need not since
it doesn’t modify the rep. In IntSet, the mutators insert and remove would
call it. If the calls on repOk are costly, they can be disabled when the program
is in production.
5.5.4 Discussion
A rep invariant is “invariant” because it always holds for the reps of abstract
objects; that is, it holds whenever an object is used outside its implementation.
The rep invariant need not hold all the time, since it can be violated while
executing one of the type’s operations. For example, the Poly mul method
produces a trms component with zero in the high element, but the element is
overwritten with a nonzero value before mul returns. The rep invariant must
hold whenever operations return to their callers.
There is a relationship between the abstraction function and the rep invari-
ant. The abstraction function is of interest only for legal representations, since
only these represent the abstract objects. Therefore, it need not be defined for
illegal representations. For example, both IntSet and Poly have abstraction
functions that are defined only if the els and trms components, respectively,
are non-null, and furthermore, the abstraction function for IntSet makes
sense only if all elements of the Vector are Integers.
There is an issue concerning how much to say in a rep invariant. A rep
invariant should express all constraints on which the operations depend.
A good way to think of this is to imagine that the operations are to be
implemented by different people who cannot talk to one another; the rep
invariant must contain all the constraints that these various implementors
depend on. However, it need not state additional constraints. We will see an
example of unstated constraints in Section 6.6.
When a data abstraction is implemented, the rep invariant is one of the
first things the programmer thinks about. It must be chosen before any opera-
tions are implemented, or the implementations will not work together harmo-
niously. To ensure that it is understood, the rep invariant should be written
down and included as a comment in the code (in addition to the abstraction
function). Writing the rep invariant forces the implementor to articulate what
is known and increases the chances that the operations will be implemented
correctly.
107
Chapter 5 Data Abstraction
All operations must be implemented in a way that preserves the rep invari-
ant. For example, suppose we implemented insert by
public void insert (int x) {
els.addElement(new Integer(x));
}
and the operations would be implemented differently than in Figure 5.6. The
rep invariant tells the reader why the operations are implemented as they are.
108
5.6 — Properties of Data Abstraction Implementations
Given this rep, several choices must be made: what to do with a zero denomi-
nator, how to store negative rationals, and whether or not to keep the rational
in reduced form (that is, with the numerator and denominator reduced so that
there are no common terms). Suppose we choose to rule out zero denomina-
tors, to represent negative rationals by means of negative numerators, and not
to keep the rep in reduced form (to speed up operations like multiplication).
Thus we have
// The rep invariant is
// c.denom > 0
109
Chapter 5 Data Abstraction
110
5.6 — Properties of Data Abstraction Implementations
and suppose this method were implemented (as part of the implementation
given in Figure 5.6) as follows:
This implementation would allow users of IntSet to access the els compon-
ent directly; since this component is mutable, users can modify it. To avoid
this problem, the allEls implementation must return a copy of the els com-
ponent.
Exposing the rep is an implementation error. It can happen either because a
method returns a mutable object in the rep, as discussed previously, or because
a constructor or method makes a mutable argument object part of the rep. For
example, suppose IntSet had the following constructor:
111
Chapter 5 Data Abstraction
Again, we have an implementation error that results in the rep being exposed.
112
5.7 — Reasoning about Data Abstractions
Data type induction is used to reason about whether an implementation preserves the
rep invariant. For each operation, we assume the rep invariant holds for any inputs of
the type, and show it holds at return for any inputs of the type and any new objects
of the type.
To prove the correctness of an operation, we make use of the abstraction function to
relate the abstract objects mentioned in its specification to the concrete objects that
represent them.
Data type induction is also used to reason about abstract invariants. However, in this
case, the reasoning is based on the specification, and observers can be ignored.
The IntSet constructor establishes this invariant because the newly created
vector is empty. The isIn method preserves it because we can assume that the
invariant holds for this when isIn is called and isIn does not modify this;
the same is true for size and choose and private method getIndex. Method
insert preserves the invariant because the following conditions are met:
113
Chapter 5 Data Abstraction
The Poly constructor that produces the zero polynomial preserves the in-
variant because it creates a one-element array; the other Poly constructor pre-
serves the invariant because it explicitly tests for the zero polynomial. The mul
operation preserves the invariant because the following conditions are met:
The invariant holds for this at the time of the call; it also holds for q if q
is not null.
If either this or q is the zero Poly, this is recognized and the proper rep
constructed.
Otherwise, neither this nor q contains a zero in its high term—therefore,
the high term of the trms array in the returned Poly, which contains the
product of the high terms of trms and q.trms, cannot be zero.
This kind of reasoning is called data type induction. The induction is on
the number of procedure invocations used to produce the current value of
the object. The first step of the induction is to establish the property for the
constructor(s); the induction step establishes the property for the methods.
114
5.7 — Reasoning about Data Abstractions
This process would continue until every operation had been considered. Note
that we make use of the rep invariant in these proofs; that is, we are able to
assume it holds on entry.
An important point about these proofs is that we are able to reason about
each operation independently, which is possible because of the rep invariant.
It captures the common assumptions between the operations and, in this way,
stands in place of all the other operations when we consider the proof of any
particular operation. Of course, this reasoning is valid only if all operations
preserve the rep invariant, since that is what allows it to take the place of the
other operations in the reasoning process.
115
Chapter 5 Data Abstraction
abstract invariants for vectors and arrays in our reasoning about the correct-
ness of the IntSet and Poly implementations. For both vectors and arrays, we
assumed that their size was greater than or equal to zero and, furthermore,
that all indexes that were greater than or equal to zero, and less than the size,
were in bounds.
There are similar abstract invariants for sets and polynomials. For example,
the size of an IntSet is always greater than or equal to zero. This property
can be established as follows:
It clearly holds for the constructor since it returns a new, empty IntSet.
It holds for insert since this only increases the size of the IntSet.
It holds for remove since this removes an element from the set only if the
element was in the set at the time of the call.
Note that we completely ignore the observers in this proof. Since they do not
modify their objects (in a way that users can notice), they cannot affect the
property.
Note that we are reasoning at an abstract level, not at an implementation
level. We are not concerned with how IntSets are implemented. Instead, we
work directly with the IntSet specification. Working at the abstract level
greatly simplifies the reasoning.
5.8.1 Mutability
Data abstractions are either mutable, with objects whose values can change,
or immutable. Care should be taken in deciding on this aspect of a type.
In general, a type should be immutable if its objects would naturally have
unchanging values. This might be the case, for example, for such mathematical
objects as integers, Polys, and complex numbers. A type should usually be
mutable if it is modeling something from the real world, where it is natural
for the values of objects to change over time. For example, an automobile in
116
5.8 — Design Issues
A data abstraction is mutable if it has any mutator methods; otherwise, the data
abstraction is immutable.
There are four kinds of operations provided by data abstractions: creators produce
new objects “from scratch”; producers produces new objects given existing objects as
arguments, mutators modify the state of their object; and observers provide information
about the state of their object.
A data type is adequate if it provides enough operations so that whatever users need
to do with its objects can be done conveniently and with reasonable efficiency.
117
Chapter 5 Data Abstraction
The creators usually produce some but not all objects; for example, the Poly
creators (the two constructors) produce only single-term polynomials, while
the IntSet constructor produces only the empty set. The other objects are
produced by producers or mutators. Thus, the producer add can be used to
obtain Polys with more than one term, while the mutator insert can be used
to obtain sets containing many elements.
Mutators play the same role in mutable types that producers play in im-
mutable ones. A mutable type can have producers as well as mutators; for
example, if IntSet had a clone method, this method would be a producer.
Sometimes observers are combined with producers or mutators; for example,
IntSet might have a chooseAndRemove method that returns the chosen ele-
ment and also removes it from the set.
5.8.3 Adequacy
A data type is adequate if it provides enough operations so that everything
users need to do with its objects can be done both conveniently and with
reasonable efficiency. It is not possible to give a precise definition of adequacy,
although there are limits on how few operations a type can have and still be
useful. For example, if we provide only the IntSet constructor and the insert
and remove methods, programs cannot find out anything about the elements
in the set (because there are no observers). On the other hand, if we add just
the size method to these three operations, we can learn about elements in the
118
5.8 — Design Issues
set (for example, we could test for membership by deleting the integer and
seeing if the size changed), but the type would be costly and inconvenient
to use.
A very rudimentary notion of adequacy can be obtained by considering
the operation categories. In general, a data abstraction should have operations
from at least three of the four categories discussed in the preceding section. It
must have creators, observers, and producers (if it is immutable) or mutators
(if it is mutable). In addition, the type must be fully populated. This means that
between its creators, mutators, and producers, it must be possible to obtain
every possible abstract object state.
However, the notion of adequacy additionally must take context of use
into account: a type must have a rich enough set of operations for its intended
uses. If the type is to be used in a limited context, such as a single package,
then just enough operations for that context need be provided. If the type is
intended for general use, a rich set of operations is desirable.
To decide whether a data abstraction has enough operations, identify
everything users might reasonably expect to do. Next, think about how these
things can be done with the given set of operations. If something seems too
expensive or too cumbersome (or both), investigate whether the addition of an
operation would help. Sometimes a substantial improvement in performance
can be obtained simply by having access to the representation. For example,
we could eliminate the isIn operation for IntSets because this operation can
be implemented outside the type by using the other operations. However,
testing for membership in a set is a common use and will be faster if done
inside the implementation. Therefore, IntSet should provide this operation.
There can also be too many operations in a type. When considering the
addition of operations, you need to consider how they fit in with the purpose
of the data abstraction. For example, a storage abstraction like Vector or
IntSet should include operations to access and modify the storage, but not
operations unrelated to this purpose, such as a sort method or a method to
compute the sum of the elements of the vector or set.
Having too many operations makes an abstraction harder to understand.
Also, implementation is more difficult, and so is maintenance, because if
the implementation changes, more code is affected. The desirability of extra
operations must be balanced against these factors. If the type is adequate, its
operations can be augmented by standalone procedures that are outside the
type’s implementation (i.e., static methods of some other class).
119
Chapter 5 Data Abstraction
120
Exercises
5.10 Summary
This chapter has discussed data abstractions: what they are, how to specify
their behavior, and how to implement them, both in general and in Java. We
discussed both mutable abstractions, such as IntSet, and immutable ones,
such as Poly.
We also discussed some important aspects of data type implementations.
In general, we want all objects of the class to be legal representations of the
abstract objects; the rep invariant defines the legal representations. The ab-
straction function defines the meaning of the rep by stating the way in which
the legal class objects represent the abstract objects. Both the rep invariant
and the abstraction function should be included as comments in the imple-
mentation (in the private section of the class declaration). They are helpful
in developing the implementation since they force the implementor to be ex-
plicit about assumptions. They are also helpful to anyone who examines the
implementation later since they explain what must be understood about the
rep. Furthermore, the rep invariant and abstraction function should be im-
plemented (as repOk and toString, respectively) since this makes debugging
and testing easier.
In addition, we explored some issues that must be considered in designing
and implementing data types. Care must be taken in deciding whether or not
a type is mutable; an immutable abstraction can have a mutable rep, however,
and observers can even modify the rep, provided these modifications are
“benevolent” (i.e., not visible to users). Also, care is needed in choosing a
type’s operations so that it serves the needs of its users adequately. We also
discussed data type induction and how it is used to prove properties of objects.
Furthermore, we discussed how having an encapsulated rep is crucial for
obtaining the benefits of locality and modifiability.
Exercises
5.1 Implement a toString method for Polys (as part of the implementation in
Figure 5.7).
5.2 Suppose IntSets were implemented using a Vector as in Figure 5.6, but the
els component was kept sorted in increasing size. Give the rep invariant
121
Chapter 5 Data Abstraction
and abstraction function for this implementation. Also implement repOk and
toString.
5.3 Suppose Polys (Figure 5.4) were implemented with the zero Poly represented
by the empty array. Give the rep invariant and abstraction function for this
implementation, and implement repOk and toString.
5.4 Suppose we wanted a way to create a Poly (Figure 5.4) by reading a string
from a BufferedReader. Specify and implement such an operation. Does the
operation need to be implemented inside the Poly class (e.g., the one in
Figure 5.7), or can it be in a separate class?
5.5 Bounded queues have an upper bound, established when a queue is created, on
the number of integers that can be stored in the queue. Queues are mutable and
provide access to their elements in first-in/first-out order. Queue operations
include
IntQueue(int n);
void enq(int x);
int deq ( );
The constructor creates a new queue with maximum size n, enq adds an
element to the front of the queue, and deq removes the element from the end
of the queue. Provide a specification of IntQueue, including extra operations
as needed for adequacy. Implement your specification. Give the rep invariant
and abstraction function and implement repOk and toString.
5.6 Implement sparse polynomials. Be sure to include the rep invariant and ab-
straction function and to implement repOk and toString.
5.7 Specify and implement a rational number type. Give the rep invariant and
abstraction function and implement repOk and toString.
5.8 Consider a map data abstraction that maps Strings to ints. Maps allow an ex-
isting mapping to be looked up. Maps are also mutable: new pairs can be added
to a map, and an existing mapping can be removed. Give a specification for
maps. Be sure your data type is adequate, and if any operations throw excep-
tions, explain whether they are checked or unchecked. Also implement your
specification. Give the rep invariant and abstraction function and implement
repOk and toString.
5.9 Discuss whether the implementations of bounded queues and maps should
provide their own implementations of equals and clone and implement these
operations if they are needed.
122
Exercises
5.10 Give an informal argument that the implementation of Poly in Figure 5.7
preserves the rep invariant.
5.11 Give an informal argument that the implementation of Poly in Figure 5.7 is
correct.
5.12 Give an informal argument that the following abstract invariant holds for
Polys:
5.13 Provide correctness arguments for your implementations of the types men-
tioned previously (rational numbers, sparse polynomials, bounded queues,
and maps).
5.14 Suppose we wanted to evaluate a Poly (Figure 5.4) at a given point:
123
This page intentionally left blank
Iteration Abstraction
This chapter discusses our final abstraction mechanism, the iteration abstrac-
tion, or iterator for short. Iterators are a generalization of the iteration mecha-
nisms available in most programming languages. They permit users to iterate
over arbitrary types of data in a convenient and efficient way.
For example, an obvious use of a set is to perform some action for each of
its elements:
for all elements of the set
do action
Such a loop might go through the set completely—for example, to sum all
elements of a set. Or it might search for an element that satisfies some criterion,
in which case the loop can stop as soon as the desired element has been found.
IntSets as we have defined them so far provide no convenient way to
perform such loops. For example, suppose we want to compute the sum of the
elements in an IntSet:
public static int setSum (IntSet s) throws NullPointerException
// effects: If s is null throws NullPointerException else
// returns the sum of the elements of s.
125
Chapter 6 Iteration Abstraction
again. Thus, two operations, choose and remove, must be called on each
iteration. This inefficiency could be avoided by having choose remove the
chosen element, but we still have the second problem, which is that iterating
over an IntSet destroys it by removing all its elements. Such destruction may
be acceptable at times but cannot be satisfactory in general. Although we can
collect the removed elements and reinsert them later, as is done in Figure 6.1,
the approach is clumsy and inefficient.
If setSum were an IntSet operation, we could implement it efficiently by
manipulating the rep of IntSet. However, setSum does not make sense as an
IntSet operation; it seems peripheral to the concept of a set. Furthermore,
even if we could justify making it an operation, what about other similar pro-
cedures we might want? There must be a way to implement such procedures
efficiently outside the type.
To support iteration adequately, we need to access all elements in a collec-
tion efficiently and without destroying the collection. How might we do this
for IntSets? One possibility is to provide a members method:
Given this operation, we can implement setSum as shown in Figure 6.2. Since
members does not modify its argument, we no longer need to rebuild the
IntSet after iterating.
126
Introduction
127
Chapter 6 Iteration Abstraction
128
6.1 — Iteration in Java
129
Chapter 6 Iteration Abstraction
controlled by the hasNext method or the loop can be terminated when next
throws an exception.
An iterator is a procedure that returns a generator. A data abstraction can have one or
more iterator methods, and there can also be standalone iterators.
A generator is an object that produces the elements used in the iteration. It has methods
to get the next element and to determine whether there are any more elements. The
generator’s type is a subtype of Iterator.
The specification of an iterator defines the behavior of the generator; a generator has
no specification of its own. The iterator specification often includes a requires clause
at the end constraining the code that uses the generator.
130
6.2 — Specifying Iterators
were modified while the generator is being used. Therefore, we rule out such
modifications in the specification of elements. Almost always a generator over
a mutable object will have such a requirement. We state the requirement in
a requires clause as usual, but since this is a requirement on the use of the
generator, rather than on the call to the iterator, we place the requires clause
at the end of the specification. Normally, a requires clause is the very first
part of a specification. In fact, an iterator might have two requires clauses:
one ruling out certain arguments and the other stating constraints on using
the returned generator.
The second point is that, unlike the choose method, the elements iterator
does not throw any exceptions. It is typical that the use of iterators eliminates
problems associated with certain arguments (like the empty set) that would
arise for related procedures such as choose.
Although both of these data abstractions provide only one iterator, a data
abstraction can have many iterators. Also, neither terms nor elements modi-
fies anything: the iterator doesn’t modify this, and neither does the generator
it returns. Iterators are usually like this, but modifications are occasionally use-
ful. If there is a modification, the iterator specification must explain what it
is, and whether the iterator or the generator does the modification.
131
Chapter 6 Iteration Abstraction
132
6.3 — Using Iterators
133
Chapter 6 Iteration Abstraction
134
6.4 — Implementing Iterators
// inner class
private static class PolyGen implements Iterator {
private Poly p; // the Poly being iterated
private int n; // the next term to consider
135
Chapter 6 Iteration Abstraction
// inner class
private static class PrimesGen implements Iterator {
private Vector ps; // primes yielded
private int p; // next candidate to try
136
6.5 — Rep Invariants and Abstraction Functions for Generators
ceptions listed in the header of the method he or she knows about. If some of
those exceptions do not happen, it is not a problem. We will discuss this issue
further in Chapter 7.
Sidebar 6.3 summarizes the implementation of iterators.
Note how this rep invariant is expressed using instance variables of Poly.
Note also how the requirement that c.p not be null is satisfied because of the
requires clause of the constructor of PolyGen.
137
Chapter 6 Iteration Abstraction
138
6.6 — Ordered Lists
// constructors
public OrderedIntList ( )
// effects: Initializes this to be an empty ordered list.
// methods
public void addEl (int el) throws DuplicateException
// modifies: this
// effects: If el is in this, throws DuplicateException;
// otherwise, adds el to this.
139
Chapter 6 Iteration Abstraction
Furthermore, since users are likely to make calls that throw the exceptions,
the exceptions should be checked.
The implementation of OrderedIntList uses a sorted tree. The idea is that
each node of the tree contains a value and two subnodes, one on the left and
one on the right. The two subnodes are themselves ordered lists, and therefore
the rep is recursive. The tree is sorted so that all the values in the left subnode
are less than the value in the parent node, and all values in the right subnode
are greater than the value in the parent node.
Figure 6.11 gives part of the implementation of ordered lists. Note that
the implementation of addEl implicitly propagates the DuplicateException
raised by its recursive calls; the implementation of remEl is similar.
The smallToBig iterator is implemented in Figure 6.12. The generator
starts by producing the elements of the left subtree. When all these elements
have been produced, it returns the value of the top node of the tree and then
produces the elements of the right subtree. Because it is important for both
generator methods, and especially the hasNext method, to execute efficiently,
the implementation keeps track of how many elements are left to be produced.
It does this by computing how many elements are in the list at the time the
iteration begins.
The abstraction function and rep invariant for OrderedIntList are
140
6.6 — Ordered Lists
141
Chapter 6 Iteration Abstraction
// inner class
private static class OLGen implements Iterator {
private int cnt; // count of number of elements left to generate
private OLGen child; // the current sub-generator
private OrderedIntList me; // my node
Note how the rep invariant depends on the requires clause of the smallToBig
iterator that the ordered list not be modified while the generator is in use.
142
6.7 — Design Issues
143
Chapter 6 Iteration Abstraction
// perform t
// if t generates a new task nt, enqueue it by performing q.enq(nt)
}
When the task being performed generates another task, we simply enqueue it
to be performed later; the generator returned by iterator allTasks will present
it for execution at the appropriate time. However, examples like this are rare;
usually neither the generator nor the loop body will modify the collection.
6.8 Summary
This chapter identified a problem in the adequacy of data types that are
collections of objects. Since a common use of a collection is to perform some
action for its elements, we need a way to access all elements. This method
should be efficient in space and time, convenient to use, and not destructive
of the collection. In addition, it should support abstraction by specification.
Iterators solve this problem. They return a special kind of object, called
a generator, that produces the items in the collection one at a time. Pro-
ducing items incrementally means that extra space to store the items is not
needed, and production can be stopped as soon as the desired object has been
found. Iterators support abstraction by specification for the containing type
by encapsulating the way the items are produced; the approach depends on
knowledge of the rep, but using programs are shielded from this knowledge.
Generators are objects of Iterator types. Such types are subtypes of the
type defined by the Iterator interface. Users of the generator are not aware
of the class that implements the interface; using code is written entirely in
terms of the Iterator interface.
Iterators are useful in their own right, as was illustrated by the allPrimes
example. However, their main use is as operations of data types. We shall see
other examples of such use in the rest of the book.
Exercises
6.1 Specify a procedure, isPrimes, that determines whether an integer is prime,
and then implement it using allPrimes (Figure 6.6).
144
Exercises
6.2 Implement the elements iterator for IntSet (see Figure 6.5). Be sure to give
the rep invariant and abstraction function.
6.3 Complete the implementation of OrderedIntList provided in Figures 6.11 and
6.12 by providing implementations for clone, toString, repOk, and equals
(if necessary).
6.4 Implement the bigToSmall iterator for OrderedIntLists as an extra method
in the implementation provided in Figures 6.11 and 6.12. bigToSmall was
specified in Section 6.6. Also discuss whether this iterator is needed in order
for OrderedIntLists to be adequate.
6.5 Specify and implement an iterator that provides all the nonzero unit polyno-
mials of a Poly in order of increasing degree. For example, for the Poly x +
7x3, it would produce the Polys x and 7x3. You can define this either as a Poly
operation or not as you prefer, but you should justify your choice.
6.6 Implement the following iterator:
Be sure to give the rep invariant and abstraction function for the generator.
145
Chapter 6 Iteration Abstraction
6.8 Discuss the adequacy of Poly without the terms iterator. How would the
adequacy be affected by providing an iterator allCoeffs that produced all
nonzero coefficients up to the degree? How about adding an iterator allTerms
that provided all the exponents up to the degree?
6.9 Consider a Table type that maps Strings to ints; this type was discussed
in the exercises in Chapter 5. Is this type adequate without iterators? Define
any iterators that are needed and implement them as an extension to your
implementation of Table.
6.10 Consider the bounded queue type that was discussed in the exercises in
Chapter 5. Is this type adequate without iterators? Define any iterators that
are needed and implement them as an extension to your implementation of
bounded queues.
6.11 Consider an IntBag type. Bags are like sets except that they can contain the
same integer multiple times. Define an IntBag type by giving a specification
for it and justify the adequacy of your definition. Then provide an implemen-
tation for the type including the rep invariant and abstraction functions for
the type and for any generator types. Also discuss the performance of your
implementation.
146
Type Hierarchy
147
Chapter 7 Type Hierarchy
Type hierarchy is used to define type families consisting of a supertype and its
subtypes. The hierarchy can extend through many levels.
Some type families are used to provide multiple implementations of a type: the subtypes
provide different implementations of their supertype.
More generally, though, subtypes extend the behavior of their supertype, for example,
by providing extra methods.
The substitution principle provides abstraction by specification for type families
by requiring that subtypes behave in accordance with the specification of their
supertype.
the supertype. For example, we could use a type family to provide both sparse
and dense polynomials, so that the most efficient representation could be used
for each polynomial object.
More generally, though, the subtypes in a type family extend the behav-
ior of their supertypes, for example, by providing additional methods. The
hierarchy defining such a type family can be multilevel. Furthermore, at the
bottom of the hierarchy there might be multiple implementations of some
subtype.
Type hierarchy requires the members of the type family to have related
behavior. In particular, the supertype’s behavior must be supported by the
subtypes: subtype objects can be substituted for supertype objects without
affecting the behavior of the using code. This property is referred to as
the substitution principle. It allows using code to be written in terms of the
supertype specification, yet work correctly when using objects of the subtype.
For example, code can be written in terms of the Reader type, yet work
correctly when using a BufferedReader.
The substitution principle provides abstraction by specification for a type
family. It allows us to abstract from the differences among the subtypes to
the commonalities, which are captured in the supertype specification. The
substitution principle is discussed in Section 7.9.
148
7.1 — Assignment and Dispatching
7.1.1 Assignment
A variable declared to belong to one type can actually refer to an object
belonging to some subtype of that type. In particular, if S is a subtype of
T, S objects can be assigned to variables of type T, and they can be passed as
arguments or results where a T is expected.
For example, suppose that DensePoly and SparsePoly are subtypes of
Poly. (The idea is that DensePoly provides a good implementation of Polys
that have relatively few zero coefficients below the degree term, and Sparse-
Poly is good for the Polys that don’t match this criterion.) Then the following
code is permitted:
Poly p1 = new DensePoly( ); // the zero Poly
Poly p2 = new SparsePoly(3, 20); // the Poly 3x20.
Thus, variables of type Poly can refer to DensePoly and SparsePoly objects.
Having assignments like these means that the type of object referred to by
a variable is not necessarily the same as what is declared for the variable. For
example, p1 is declared to have type Poly but in fact refers to a DensePoly
object. To distinguish these two types, we refer to an object’s apparent type
and its actual type. The apparent type is what the compiler can deduce given
the information available to it (from declarations); the actual type is the type
the object really has. For example, the object referred to by p1 has apparent
type Poly but actual type DensePoly. The actual type of an object will always
be a subtype of its apparent type. (As discussed in Chapter 2, recall that we
consider that a type is a subtype of itself.)
The compiler does type checking based on the information available to
it: it uses the apparent types, not the actual types, to do the checking. In
particular, it determines what method calls are legal based on the apparent
type. For example,
int d = p1.degree( );
149
Chapter 7 Type Hierarchy
is considered to be legal since Poly, the apparent type of p1, has a method
named degree that takes no arguments and returns an int.
The goal of the checking is to ensure that when a method call is executed,
the object actually has a method with the appropriate signature. For this to
make sense, it is essential that the object p1 refers to has all the methods
indicated by the supertype with the expected signatures. Thus, DensePoly
and SparsePoly must have all the methods declared for Poly with the expected
signatures. Java ensures that this condition is satisfied.
In the preceding example, suppose that Poly did not have a degree
method. In this case, the call will be rejected by the compiler even if the
object referred to by p1 actually has such a method. An object belonging to
a subtype is created in code that knows it is dealing with the subtype. That
code can use the extra subtype methods. But code written in terms of the
supertype can only use the supertype methods.
7.1.2 Dispatching
The compiler may not be able to determine what code to run when a method
is called. The code to run depends on the actual type of the object, while the
compiler knows only the apparent type. For example, consider the compila-
tion of
When this routine is compiled, the compiler does not know whether the actual
type of the object p refers to is a DensePoly or a SparsePoly, yet it must
call the implementation of terms for DensePoly if p is a DensePoly, and the
implementation of terms for SparsePoly if p is a SparsePoly. (It must call the
code determined by the actual type since the representations are different and
the code works differently in the two cases.)
As discussed in Chapter 2, calling the right method is achieved by a
runtime mechanism called dispatching. The compiler does not generate code
to call the method directly. Instead, it generates code to find the method’s code
and then branch to it.
150
7.1 — Assignment and Dispatching
dv degree
coeff
ivars add
terms
The compiler deduces an apparent type for each object by using the information in
variable and method declarations.
Each object has an actual type that it receives when it is created: this is the type
defined by the class that constructs it.
The compiler ensures the apparent type it deduces for an object is always a supertype
of the actual type of the object.
The compiler determines what calls are legal based on the object’s apparent type.
Dispatching causes method calls to go to the object’s actual code—that is, the code
provided by its class.
151
Chapter 7 Type Hierarchy
152
7.3 — Defining Hierarchies in Java
by a class, then in addition to the specification, the class may provide a full
or partial implementation.
There are two kinds of classes in Java: concrete classes and abstract classes.
Concrete classes provide a full implementation of the type. Abstract classes
provide at most a partial implementation of the type. They have no objects
(since some of their methods are not yet implemented), and using code cannot
call their constructors.
Both kinds of classes can contain normal methods (like we have seen so far)
and final methods. Final methods cannot be reimplemented by subclasses; we
will not use them in this book, but they are occasionally useful to ensure that
the behavior of a method is fixed by the supertype and cannot be changed in
any subtype. Abstract classes may in addition have abstract methods; these
are methods that are not implemented by the superclass and, therefore, must
be implemented by some subclass. However, the distinction between these
categories of methods is of interest only to implementors of subclasses; it is not
of interest to users.
A subclass declares its superclass by stating in its header that it extends
that class. This will automatically cause it to have all the methods of its
superclass with the same names and signatures as defined in the superclass. In
addition, it may provide some extra methods.
A concrete subclass must contain implementations of the subclass con-
structors and the extra methods. In addition, it must implement the abstract
methods of its superclass and may reimplement, or override, the normal meth-
ods. It inherits from its superclass the implementations of the final methods
and any normal methods that it does not override. Any methods it overrides
must have signatures identical to what the superclass defines, except that the
subclass method can throw fewer exception types.
The representation of a subclass object consists of the instance variables
declared for the superclass and those declared for the subclass. When imple-
menting the subclass, it may be necessary to have access to the representation
details of the superclass implementation. This will be possible only if the
superclass made parts of its implementation accessible to the subclass. An im-
portant issue in designing a superclass is to determine the interface it provides
to its subclasses. It’s best if subclasses can interact with superclasses entirely
via the public interface since this preserves full abstraction and allows the
superclass to be reimplemented without affecting the implementations of its
subclasses. But that interface may be inadequate to permit efficient subclasses.
In that case, the superclass can declare protected methods, constructors, and
153
Chapter 7 Type Hierarchy
154
7.4 — A Simple Example
// constructors
public IntSet ( )
// effects: Initializes this to be empty.
// methods
public void insert (int x)
// modifies: this
// effects: Adds x to the elements of this.
there are no protected members. This means that subclasses of IntSet have
no special access to the components of the superclass part of their rep. This lack
of access is acceptable here because the elements iterator provides adequate
power.
155
Chapter 7 Type Hierarchy
Thus, MaxIntSet objects have two instance variables: els (from the superclass)
and biggest (from the subclass). When a new element is inserted, if it is
156
7.4 — A Simple Example
// constructors
public MaxIntSet ( )
// effects: Makes this be the empty MaxIntSet.
// methods
public int max ( ) throws EmptyException
// effects: If this is empty throws EmptyException else returns the
// largest element of this.
}
bigger than the current biggest, the value of biggest is changed. When an
element is removed, if it is the biggest, we need to reset biggest to hold the
new maximum. Computing the new maximum can be accomplished using the
elements iterator.
Figure 7.5 shows the implementation of MaxIntSet. The class implements
the constructor and the max method. In addition, it overrides the implemen-
tations of insert, remove, and repOk. However, the implementations of size,
isIn, elements, subset, and toString are inherited from IntSet.
First, note the implementation of the constructor. The very first thing a
subclass constructor must do is to call a superclass constructor to initialize
the superclass instance variables; here we make the call explicitly (using
the syntax super( )). If the subclass constructor does not contain this call,
Java will automatically insert a call to the superclass constructor with no
arguments. Thus, in this case, the call could have been omitted, for example,
public MaxIntSet ( ) { }
157
Chapter 7 Type Hierarchy
158
7.4 — A Simple Example
Thus, the rep invariant is defined in terms of the abstraction function for
IntSet. Note that it does not include the rep invariant of IntSet for the simple
159
Chapter 7 Type Hierarchy
reason that preserving that rep invariant is the job of IntSet’s implementation,
and there is no way that the implementation of MaxIntSet can interfere since
it has only public access to the IntSet part of its rep. On the other hand, the
implementation of repOk for a subclass should always check the invariant for
the superclass since the subclass rep cannot be correct if the superclass part of
the rep is not correct. This point is illustrated by the implementation of repOk
shown in Figure 7.5.
We might not be happy with the implementation of remove since it some-
times has to go through the els array twice—once to remove x and again to
recompute biggest. To do better, however, MaxIntSet would require access
to the IntSet rep, which could be accomplished by having IntSet declare els
to be protected. In this case, the rep invariant of MaxIntSet must include the
rep invariant of IntSet (since the implementation of MaxIntSet could cause
the rep invariant to be violated), giving:
Sidebar 7.4 summarizes the definitions of the abstraction function and rep
invariant for subclasses of concrete classes.
The abstraction function for a subclass, AF_sub, is typically defined using AF_super,
the abstraction function of the superclass.
The subclass rep invariant, I_sub, needs to include a check on the superclass rep
invariant, I_super, only if the superclass has some protected members. However,
repOk for the subclass should always check repOk for the superclass.
160
7.6 — Abstract Classes
161
Chapter 7 Type Hierarchy
Since no specification is given for the extra subset method, it must have
the same specification as the inherited subset method. The reason a second
subset method is provided is to obtain better performance in the case where
the argument is known to be a SortedIntSet.
To implement SortedIntSet, we might like to use an ordered list. How-
ever, if SortedIntSet is implemented by a subclass of IntSet as defined in
Figure 7.3, we have a problem: every SortedIntSet object will contain within
it instance variables inherited from IntSet. These instance variables are no
longer interesting, since we do not want to keep elements of a SortedIntSet
in the els vector.
We can obtain efficient subtypes whose objects do not contain unused
instance variables by not having these variables in the superclass. However, if
the IntSet class does not have a way to store the set elements, it can’t actually
have any objects. Therefore, it must be abstract.
Figure 7.8 shows part of the implementation of an abstract class for IntSet.
Here insert, remove, elements, and repOk are abstract. isIn, subset, and
toString are implemented by using one of the abstract methods (elements).
Although size could be implemented using elements, this would be in-
efficient. Furthermore, all subclasses will need a way to implement size
efficiently. Therefore, IntSet has an instance variable, sz, to track the size.
This means the definer of IntSet must decide whether to make it accessible to
162
7.6 — Abstract Classes
// constructors:
public SortedIntSet( )
// effects: Makes this be the empty sorted set.
// methods:
public Iterator elements ( )
// effects: Returns a generator that will produce all elements of this,
// each exactly once, in ascending order.
// requires: this not be modified while the generator is in use.
However, this is quite uninteresting: what matters is that sz is the size of the
set and this can only be maintained by the subclasses. Therefore, we will allow
subclasses direct access to sz; this is why it is declared to be protected. Since
no rep invariant is guaranteed by IntSet, its repOk method is abstract. Note
that the class has no abstraction function; this is typical for an abstract class
since the real implementations are provided by the subclasses.
Figure 7.9 shows a partial implementation of SortedIntSet as a subclass
of IntSet as defined in Figure 7.8. This subclass must implement all the
abstract methods but can inherit the nonabstract methods such as size. The
subclass uses the OrderedIntList type defined in Figure 6.10. Note that the
163
Chapter 7 Type Hierarchy
// constructors
public IntSet ( ) { sz = 0; }
// abstract methods
public abstract void insert (int x);
public abstract void remove (int x);
public abstract Iterator elements ( );
public abstract boolean repOk ( );
// methods
public boolean isIn (int x) {
Iterator g = elements ( );
Integer z = new Integer(x);
while (g.hasNext( ))
if (g.next( ).equals(z)) return true;
return false; }
implementation for the extra subset method can be more efficient than that of
the inherited subset method; the extra method will be called when the object
and the argument both have the apparent type SortedIntSet. Note also that
the inherited method is overridden so that the more efficient implementation
can be provided when the argument is a SortedIntSet.
The rep invariant and abstraction function for SortedIntSet are given in
Figure 7.9. The abstraction function maps the els ordered list to a set; it treats
the ordered list as a sequence, as described in the specification of Ordered-
IntList, and uses the [ ] notation to access the elements of the sequence.
The rep invariant constrains both the SortedIntSet instance variable, els,
164
7.6 — Abstract Classes
and the IntSet instance variable, sz. Note that it assumes els is sorted since
this is true of all OrderedIntList objects.
Subclasses can also be abstract. They might continue to list some of the
abstract superclass methods as abstract, or they might introduce new ones of
their own.
Sidebar 7.5 summarizes the use of protected members.
165
Chapter 7 Type Hierarchy
It is desirable to avoid the use of protected members for two reasons: without them,
the superclass can be reimplemented without affecting the implementation of any
subclasses; and protected members are package visible, which means that other code
in the package can interfere with the superclass implementation.
Protected members are introduced to enable efficient implementations of subclasses.
There can be protected instance variables, or the instance variables might be private,
with access given via protected methods. The latter approach is worthwhile if it allows
the superclass to maintain a meaningful invariant.
7.7 Interfaces
A class is used to define a type and also to provide a complete or partial
implementation. By contrast, an interface defines only a type. It contains
only nonstatic, public methods, and all of its methods are abstract. It does
not provide any implementation. Instead, it is implemented by a class that
has an implements clause in its header.
For example, the interface defining the Iterator type is given in Fig-
ure 7.10. Since this is an interface, we do not need to declare that its methods
are public; however, we will continue to declare the methods to be public as
a convention.
In addition to being more convenient when all the methods are abstract,
interfaces also provide a way of defining types that have multiple supertypes.
A class can extend only one class, but it can, in addition, implement one or
more interfaces. For example, a SortedIntSet might implement a Sorted-
Collection interface. This can be expressed by
166
7.8 — Multiple Implementations
167
Chapter 7 Type Hierarchy
7.8.1 Lists
As a first example, consider the IntList abstraction specified in Figure 7.11.
Here we will use one subclass to implement the empty list and another to
implement nonempty lists.
In this case, the type at the top of the hierarchy is defined by an abstract
class. This class is illustrated in Figure 7.12. It has no instance variables,
and there is no constructor since there is no rep. toString and equals are
implemented using the elements iterator. Two definitions are given for equals
// methods
public abstract Object first ( ) throws EmptyException;
// effects: If this is empty throws EmptyException else
// returns first element of this.
168
7.8 — Multiple Implementations
// abstract methods
public abstract Object first ( ) throws EmptyException;
public abstract IntList rest ( ) throws EmptyException;
public abstract Iterator elements ( );
public abstract IntList addEl (Object x);
public abstract int size ( );
public abstract boolean repOk ( );
// methods
public String toString ( ) { ... }
public boolean equals (Object o) {
try { return equals ((IntList) o); }
catch (ClassCastException e) { return false; }
}
public boolean equals (IntList o) {
// compare elements using elements iterator
}
}
169
Chapter 7 Type Hierarchy
public EmptyIntList ( ) { };
170
7.8 — Multiple Implementations
7.8.2 Polynomials
As a second example, consider the Poly type whose specification was given
in Figure 5.4, and suppose we want to provide different implementations for
sparse and dense polynomials. We will use the abstract class shown in Fig-
ure 7.14 to provide a partial implementation of Poly. Although most methods
are abstract, some are not. We have elected to keep the degree as an instance
variable of Poly, since this is useful information for all Poly subclasses. Fur-
thermore, we have made deg protected, so that subclasses can access it directly,
although we have provided a constructor to initialize deg. We provide direct
access to deg because Poly cannot by itself preserve any interesting rep in-
variant on it.
The implementation of DensePoly is similar to what we saw before (in
Figures 5.7 and 5.8), including the use of deg. The main difference is that we
don’t need to implement the methods provided by the superclass. A portion
of the implementation is given in Figure 7.15.
Since the point of the hierarchy in this example is to provide efficient im-
plementations for Poly objects, we need to decide within the implementations
of various Poly methods, such as add, whether the new Poly object should
171
Chapter 7 Type Hierarchy
// methods
public int degree ( ) { return deg; }
public boolean equals (Object o) {
try { return equals((Poly) o); }
catch (ClassCastException e) { return false; }
}
public boolean equals (Poly p) {
if (p == null || deg != p.deg) return false;
Iterator tg = terms( );
Iterator pg = p.terms( );
while (tg.hasNext( )) {
int tx = ((Integer) tg.next( )).intValue( );
int px = ((Integer) pg.next( )).intValue( );
if (tx != px || coeff(tx) != p.coeff(px)) return false); }
return true; }
public sub (Poly p) { return add(p.minus( )); }
public String toString( ) { ... }
}
172
7.8 — Multiple Implementations
public DensePoly ( ) {
super(0); trms = new int[1]; }
public DensePoly (int c, int n) throws NegExpException { ... }
private DensePoly (int n) { super(n); trms = new int[n+1]; }
173
Chapter 7 Type Hierarchy
The first makePoly method returns a DensePoly; the second chooses between
a sparse and dense representation based on the value of n.
Signature Rule. The subtype objects must have all the methods of the
supertype, and the signatures of the subtype methods must be compatible
with the signatures of the corresponding supertype methods.
Methods Rule. Calls of these subtype methods must “behave like” calls to
the corresponding supertype methods.
174
7.9 — The Meaning of Subtypes
Properties Rule. The subtype must preserve all properties that can be
proved about supertype objects.
Foo clone ( )
since then the result could be used without casts, for example,
Foo x = y.clone( );
(assume y is a Foo object). However, Java requires clone to have the signature
Object clone ( )
which leads to using code having to cast the result returned by clone, for
example,
The other two requirements guarantee that subtype objects behave enough
like supertype objects that code written in terms of the supertype won’t no-
tice the difference. These requirements cannot be checked by a compiler since
they require reasoning about the meaning of specifications.
175
Chapter 7 Type Hierarchy
All the examples given so far have obeyed this requirement. In fact, our
subtype methods have all had exactly the same specification as the corre-
sponding supertype method, with one exception, the elements method of
SortedIntSet. Whenever a method is respecified, there is a potential for do-
ing things wrong. In the case of elements, the new behavior is acceptable
because we have taken advantage of the nondeterminism in the specification
of the elements method of IntSet: its specification allows various orders for
producing the elements, and one of these orders is the sorted order produced
by SortedIntSet’s elements. When we give new specifications for supertype
methods in subtypes, we often take advantage of nondeterminism like this.
To understand better how the specification of a subtype method is allowed
to differ from that of the corresponding supertype method, we need to con-
sider the pre- and postconditions. The precondition, which is defined by the
requires clause, is what must be guaranteed to hold by the caller in order
to make the call. The postcondition, which is defined by the effects clause, is
what is guaranteed to hold right after the call (assuming that the precondition
held when the call was made).
A subtype method can weaken the precondition and can strengthen the
postcondition.
Precondition Rule: presuper => presub
Postcondition Rule: (presuper && postsub) => postsuper
Both conditions must be satisfied to achieve compatibility between the sub-
and supertype methods.
176
7.9 — The Meaning of Subtypes
Weakening the precondition means that the subtype method requires less
from its caller than the supertype method does. This rule makes sense because
when code is written in terms of the supertype specification, it must satisfy
the supertype method’s precondition. Since this precondition implies that of
the subtype, we can be sure that the call to the subtype method will be legal
if the call to the supertype method is legal.
For example, suppose we had defined the following IntSet method:
public void addZero ( )
// requires: this is not empty
// effects: Adds 0 to this
The subtype definition satisfies the precondition rule because it has a weaker
precondition.
Just satisfying the precondition rule is not sufficient for the specification of
the subtype method to be correct, since we also need to take the effect of the
call into account. This is captured in the postcondition rule. This rule says
that the subtype method provides more than the supertype method: when
it returns everything that the supertype method would provide is assured,
and maybe some additional effects as well. This rule makes sense because
the calling code depends on the postcondition of the supertype method, but
this follows from the postcondition of the subtype method. However, the
calling code depends on the method’s postcondition only if the call satisfies
the precondition (since otherwise the method can do anything); this is why
the rule is stated as it is.
For example, the subtype definition for addZero given before satisfies the
postcondition rule since its postcondition is identical to that of the supertype
method. However, the following definition of addZero would also be legal:
public void addZero ( )
// effects: If this is not empty, adds 0 to this else
// adds 1 to this.
If the call satisfies the supertype method’s precondition, the effect of the
subtype method is as expected; if the call doesn’t satisfy the precondition,
177
Chapter 7 Type Hierarchy
This method is legal because its postcondition implies that of the supertype
insert method: it does add x to the set, but it does something else as well.
However, suppose that we defined a subtype of IntSet in which we
redefined insert:
public void insert (int x)
// modifies: this
// effects: If x is odd adds it to this else does nothing.
In this case, we have violated the requirement; clearly this postcondition does
not imply that of IntSet’s insert method. Furthermore, a program written
in terms of the IntSet specification would clearly expect even numbers to be
added to the set as well as odd ones!
Another example of an illegal subtype method is the following. Ordered-
IntList (see Figure 6.10) has an addEl method:
178
7.9 — The Meaning of Subtypes
This method satisfies the signature rule because it is allowable for the subtype
method to throw fewer exceptions than the supertype specification. However,
it fails the methods rule because the postcondition rule is not satisfied: the two
methods have different behavior in the case where x is already in the list.
An example of a case where not throwing the exception is acceptable
is the allPrimes generator. The next method of Iterator throws NoSuch-
ElementException if there are no more elements. However, the next method
for the allPrimes generator does not throw the exception; this is allowed
because there is always a larger prime to be produced.
As a final example, consider int versus long. The ints are 32 bits, while
the longs are 64 bits. Furthermore, the two types have different behaviors
in certain cases. For example, if adding two ints results in an overflow, the
overflow will not happen if the same two values are longs. Therefore int is
not a subtype of long, and neither is long a subtype of int.
179
Chapter 7 Type Hierarchy
the invariant. Also, we must consider the subtype constructors and ensure
that they establish the invariant.
In the case of an evolution property, we must show that every method
preserves it. For example, suppose we want to show that the degree of a Poly
doesn’t change. Before we considered subtypes, the way we would show this
is to assume that the degree of some Poly object p is a certain value x, and then
argue that each Poly method does not change this value. With subtypes, we
need to make the same argument for all the subtype methods—for example,
for all DensePoly methods and all SparsePoly methods.
The properties of interest must be defined in the overview section of
the supertype specification. The invariant properties come from the abstract
model. For example, because IntSets are modeled as mathematical sets, they
must have a size greater than or equal to zero, and they also must not contain
duplicate elements. Also, because OrderedIntLists are modeled as sequences
that are sorted in ascending order, we know their elements appear in sorted
order.
As another example of an invariant property, consider a FatSet type
whose objects are never empty. This fact would need to be captured in the
overview section:
Assume that FatSet does not have a remove method but instead has a
removeNonEmpty method:
180
7.9 — The Meaning of Subtypes
ThinSet is not a legal subtype of FatSet because its extra method can cause
its object to become empty; therefore, it does not preserve the supertype’s
invariant.
The only evolution property we have seen so far (and the most common
one) is immutability. Here is a different example. Consider a type SimpleSet
that has only insert and isIn methods so that SimpleSet objects only grow.
This fact must be indicated in the overview:
The signature rule ensures that if a program is type-correct based on the supertype
specification, it is also type-correct with respect to the subtype specification.
The methods rule ensures that reasoning about calls of supertype methods is valid even
though the calls actually go to code that implements a subtype.
The properties rule ensures that reasoning about properties of objects based on the
supertype specification is still valid when objects belong to a subtype. The properties
must be stated in the overview section of the supertype specification.
181
Chapter 7 Type Hierarchy
7.9.3 Equality
In Chapter 5, we discussed the meaning of the equals method: if two objects
are equals, it will never be possible to distinguish them in the future using
methods of their type. As discussed previously, this means that for mutable
types, objects are equals only if they are the very same object, while for
immutable types, they are equals if they have the same state.
When there are subtypes of immutable types, the subtype objects might
have more state, or they might even be mutable. Therefore, subtype objects
might be distinguishable, even though code that uses them via the supertype
interface cannot distinguish them.
For example, consider a type, Point2, which represents points in two-
space; its equals method returns true if the x and y coordinates are equal.
Now suppose type Point3, which represents points in three-space, is defined
to be a subtype of Point2; Point3’s equals will return true only if all three
coordinates are equal. To implement this behavior properly, Point3 must pro-
vide its own extra equals method, and it must also override equals for Point2
and Object, as shown in Figure 7.16. Overriding these methods ensures that
equals works properly on Point3 objects regardless of their apparent type.
182
7.10 — Discussion of Type Hierarchy
Incomplete supertypes establish naming conventions for subtype methods but do not
provide useful specifications for those methods. Therefore, using code is typically not
written in terms of them.
Complete supertypes provide entire data abstractions, with useful specifications for all
the methods.
Snippets provide just a few methods, not enough to qualify as an entire data abstraction.
However, those methods are specified in a way that allows using code to be written in
terms of the supertype.
183
Chapter 7 Type Hierarchy
7.11 Summary
This chapter has discussed inheritance and how it can be used to define type
families and multiple implementations.
The use of type families in program design allows a new kind of abstrac-
tion: the designer abstracts from properties of a related group of types to
identify what all of those types have in common. When used appropriately,
this kind of abstraction can improve the structure of programs in several
ways:
By grouping the related types into a family, the designer makes the re-
lationship among them clear, thus making the program as a whole easier
to understand. For example, a program that treats different kinds of win-
dows as a family is easier to understand than one that just has a bunch
of different window types because the similarities among the set of types
have been carefully delineated.
184
7.11 — Summary
Hierarchy allows the definition of abstractions that work over the entire
family. For example, a procedure that works on windows will be able to
do its job no matter what kind of specialized window is passed it as an
argument.
Hierarchy provides a kind of extensibility. New kinds of subtypes can
be added later, if necessary, to provide extended behavior. Yet all code
defined to work using objects of the existing types in the family will
continue to work when actually using objects belonging to subtypes,
even subtypes that did not exist at the time the using code was written.
This kind of extensibility is similar to that provided by other abstraction
mechanisms. For example, it’s like the ability to replace the implementation
of a data abstraction without affecting the correctness of using code;
but it allows the invention of new abstractions, rather than just new
implementations.
Hierarchy can be used to define the relationship among a group of types, making
it easier to understand the group as a whole.
Hierarchy allows code to be written in terms of a supertype, yet work for many
types—all the subtypes of that supertype.
Hierarchy provides extensibility: code can be written in terms of a supertype,
yet continue to work when subtypes are defined later.
All of these benefits can be obtained only if subtypes obey the substitution
principle.
185
Chapter 7 Type Hierarchy
Exercises
7.1 Define and implement a subtype of IntList (see Figures 7.11 and 7.12) that
provides methods to return the smallest and largest elements of the list. Be
sure to define the rep invariant and abstraction function, and to implement
repOk.
7.2 Define and implement a type MaxMinSet. This type is a subtype of MaxIntSet
(see Figures 7.4 and 7.5); it provides one extra method
Be sure to define the rep invariant and abstraction function and to implement
repOk.
7.3 Define and implement a type ExtendedOrderedIntList, which is a subtype of
OrderedIntList (see Figure 6.10). ExtendedOrderedIntList provides a big-
ToSmall iterator that returns the elements of the list from largest to smallest.
Be sure to define the rep invariant and abstraction function and to implement
repOk.
7.4 Define and implement a type ExtendedSortedIntSet, which is a subtype
of SortedIntSet (see Figures 7.7 and 7.9). ExtendedSortedIntSet provides
a reverseElements iterator that returns the elements of the set in reverse
order, from largest to smallest. Be sure to define the rep invariant and ab-
straction function and to implement repOk. Hint: You will probably need to
reimplement SortedIntSet, and ExtendedOrderedIntList may be useful in
the implementation.
7.5 Give the rep invariants and abstraction functions for EmptyIntList and
FullIntList (see Figure 7.13).
7.6 Complete the implementation of DensePoly and provide the implementation
of SparsePoly. Decide how to choose the representation for the new objects
returned by add and mul and justify your decision.
7.7 Provide multiple implementations for IntSet, including at least one that is
good for small sets (e.g., it might store the elements in a vector) and one that
is good for large sets (e.g., it might store the elements in a hash table). (Hash
tables are provided in java.util.)
186
Exercises
187
This page intentionally left blank
Polymorphic Abstractions
189
Chapter 8 Polymorphic Abstractions
Polymorphism generalizes abstractions so that they work for many types. It allows us
to avoid having to redefine abstractions when we want to use them for more types;
instead, a single abstraction becomes much more widely useful.
A procedure or iterator can be polymorphic with respect to the types of one or more
arguments. A data abstraction can be polymorphic with respect to the types of elements
its objects contain.
190
8.1 — Polymorphic Data Abstractions
// constructors
public Set ( )
// effects: Initializes this to be empty.
// methods
public void insert (Object x) throws NullPointerException
// modifies: this
// effects: If x is null throws NullPointerException else
// adds x to the elements of this.
This abstraction function produces the objects in c.els rather than the ints
contained within those objects. The rep invariant includes the condition that
191
Chapter 8 Polymorphic Abstractions
the set not contain null; it depends on the equals method to determine
equality of elements.
Note that insert stores its argument object in the set rather than a clone
of the object. This behavior is indicated in its specification, which says it
adds x to the set, meaning that very object, and not a clone of it; if a clone
had been required, the specification would have said so explicitly. Note also
that the clone method does not clone the set elements but only clones the els
vector. Therefore, the cloned set shares its elements with the set being cloned.
Neither of these implementations exposes the rep because the state of a set, or,
indeed, of almost any polymorphic collection, consists only of the identities
of its elements and not their states.
192
8.3 — Equality Revisited
There is one important difference between this code and code using an
IntSet. An IntSet stores only ints, and that guarantee is provided by the
compiler: it isn’t possible to call insert on an IntSet object passing something
other than an int as an argument. No such guarantee is provided for Sets.
Even though a typical use is to have a homogeneous Set in which all elements
are of the same type, the compiler will not enforce the constraint. This means
that a class of errors is possible when using polymorphic collections that
cannot happen when using a specific collection like IntSet.
193
Chapter 8 Polymorphic Abstractions
If you are using the set to keep track of distinct vector objects, the imple-
mentation in Figure 8.2 won’t do what you want. For example, consider the
following code:
This code causes s to contain two elements, one for vector x and the other for
vector y. Therefore, even though x is modified, we still find y in the set. Note
that now we must pass containers as arguments to Set methods.
194
8.4 — Additional Methods
// constructor
public Container (Object x) {
// effects: Makes this contain x.
el = x; }
// methods
public Object get ( ) {
// effects: Returns the object in the container.
return el; }
195
Chapter 8 Polymorphic Abstractions
196
8.4 — Additional Methods
// constructors
public OrderedList ( ) {
// effects: Initializes this to be an empty ordered list.
empty = true; }
// methods
public void addEl (Comparable el) throws NullPointerException,
DuplicateException, ClassCastException {
// modifies: this
// effects: If el is in this, throws DuplicateException; if el is null
// throws NullPointerException; if el cannot be compared to other elements
// of this throws ClassCastException; otherwise, adds el to this.
if (val == null) throw new NullPointerException("OrderedList.addEl");
if (empty) {
left = new OrderedList( ); right = new OrderedList( );
val = el; empty = false; return; }
int n = el.compareTo(val);
if (n == 0) throw new DuplicateException("OrderedList.addEl");
if (n < 0) left.addEl(el); else right.addEl(el); }
197
Chapter 8 Polymorphic Abstractions
198
8.5 — More Flexibility
199
Chapter 8 Polymorphic Abstractions
it can be useful to combine the use of the Adder with the use of a type like
Comparable. For example, we could define a type Addable, with the following
methods:
Then element types defined later can be defined as subtypes of Addable. For
example, if Poly were defined after Addable had been defined, we could have
it implement Addable, although it would need to have additional methods to
match the Addable interface.
200
8.5 — More Flexibility
// constructor
public SumSet (Adder p) throws NullPointerException {
// effects: Makes this be the empty set whose elements can be
// added using p, with initial sum p.zero.
els = new Vector( ); a = p; s = p.zero( ); }
The second constructor would be used for types that are subtypes of Addable.
201
Chapter 8 Polymorphic Abstractions
Some of the collection types in java.util are defined like this. They make
use of Comparable and also of a type Comparator:
8.7 Summary
Polymorphic abstractions are desirable because they provide a way to abstract
from the types of parameters. In this way, we can achieve a more powerful
abstraction, one that works for many types rather than just a single type.
Procedures, iterators, and data abstractions can all benefit from this technique.
A polymorphic abstraction usually requires access to certain methods of
its parameters. Sometimes the methods that all objects have, the ones that
are defined by Object, are sufficient. However, sometimes more methods are
needed. In this case, the polymorphic abstraction makes use of an interface to
define the needed methods.
There are two different ways of defining this interface. The first uses an
interface that is intended to be a supertype of the element types. Comparable is
an example of such an interface. We will call this the element subtype approach
202
8.7 — Summary
class Vectors {
// overview: Provides useful procedures for manipulating vectors.
203
Chapter 8 Polymorphic Abstractions
Almost all polymorphic abstractions need to use methods on their parameters, but
sometimes only methods of Object are required.
Polymorphic abstractions that need more than Object methods make use of an
associated interface to define their requirements.
In the element subtype approach, all potential element types must be subtypes of the
associated interface.
In the related subtype approach, a subtype of the interface must be defined for each
potential element type.
Some polymorphic abstractions combine the approaches, allowing the user to select
the one that works best for the parameter type of interest.
Exercises
8.1 Complete the implementation of OrderedList (see Figure 8.5). Be sure to define
the abstraction function and rep invariant and to implement toString and
repOk.
8.2 Implement IntegerAdder, which is a subtype of the Adder interface (see
Figure 8.6).
8.3 Complete the implementation of SumSet (see Figure 8.8). Be sure to define the
rep invariant and abstraction function and to implement toString and repOk.
8.4 Specify and implement a version of SumSet that allows users to supply the
required methods using either Adder or Addable. Be sure to define the rep
invariant and abstraction function and to implement toString and repOk.
8.5 Extend the specification and implementation of Poly (see Figures 5.4, 5.7, and
5.8) to make it a subtype of Addable.
204
Exercises
8.6 Specify and implement a polymorphic list; this type is like IntList (see
Figure 7.11) except that it stores arbitrary objects rather than ints. Be sure to
define the rep invariant and abstraction function and to implement toString
and repOk.
8.7 Specify and implement a Bag type that can hold elements of arbitrary types.
Bags are like sets except that they can contain multiple copies of an element.
Your bags should have insert, remove, elements, and size methods, plus a
method
public int card (Object x)
// effects: Returns a count of the number of occurrences of x in this.
Be sure to define the rep invariant and abstraction function and to implement
toString and repOk.
8.8 Suppose we want to define a procedure to search an arbitrary collection for a
match with an element:
public static int search (Object c, Object x) throws
NullPointerException, NotFoundException,
ClassCastException
// effects: If c is null throws NullPointerException, else if
// c is not searchable, throws ClassCastException, else
// if x is in c returns an index where x can be found,
// else throws NotFoundException.
205
This page intentionally left blank
Specifications
207
Chapter 9 Specifications
9.2.1 Restrictiveness
There is a vast difference between knowing that some members of a speci-
fication’s specificand set are appropriate and knowing that all members are
appropriate. This is similar to the difference between knowing that a program
works on some inputs and knowing that it works on all inputs, a difference we
208
9.2 — Some Criteria for Specifications
209
Chapter 9 Specifications
210
9.2 — Some Criteria for Specifications
9.2.2 Generality
A good specification should be general enough to ensure that few, if any,
acceptable programs are precluded. The importance of the generality criterion
may be less obvious than that of restrictiveness. It is not essential to ensure
that no acceptable implementation is precluded, but the more desirable (that
is, efficient or elegant) implementations should not be ruled out. For example,
the specification
211
Chapter 9 Specifications
9.2.3 Clarity
When we talk about what makes a program “good,” we consider not only
the computations it describes but also properties of the program text itself—
for example, whether it is well modularized and nicely commented. Similarly,
when we evaluate a specification, we must consider not only properties of the
specificand set but also properties of the specification itself—for example,
whether it is easy to read.
A good specification should facilitate communication among people. A
specification may be sufficiently restrictive and sufficiently general—that is,
it may have exactly the right meaning—but this is not enough. If this meaning
is hard for readers to discover, the specification’s utility is severely limited.
People may fail to understand a specification in two distinct ways. They
may study it and come away knowing that they do not understand it. For
example, a reader of the second specification of elems in Figure 9.1 may be
confused about what to do if an element occurs in the bag more than once.
This is troublesome but not as dangerous as when people come away thinking
that they understand a specification when, in fact, they do not. In such a
case, the user and the implementor of an abstraction may each interpret its
specification differently, leading to modules that cannot work together. For
example, the implementor of elems may decide to produce each element the
number of times it occurs in the bag, while the user expects each element to
be produced only once.
212
9.2 — Some Criteria for Specifications
213
Chapter 9 Specifications
The first specification is concise and, for most readers, quite clear. However,
some readers might be left with a nagging doubt: Was the word “subset”
carefully chosen, or might the author have meant proper subset? The second
specification, while a bit harder to read than the first, leaves no doubt on this
point. The question it raises is why, if the specifier intended that p be a subset
test, was this not stated explicitly? The third specification, of course, answers
both of these questions.
By stating the same thing in more than one way, a specification provides
readers with a benchmark against which they can check their understanding.
This helps to prevent misunderstandings and thus allows readers to spend
less time studying a specification. A particularly useful kind of redundancy
in this regard is one or more well-chosen examples, such as those given in the
specification of indexString in Figure 9.2.
A specification that states the same thing in more than one way also
allows for the fact that different readers will find different presentations of
the same information easier to understand. Frequently, some critical part of a
specification is a concept with a name that will be meaningful to some readers
but not to others. For example, consider
static float pv (float inc, float r, int n)
// requires: inc > 0 && r > 0 && n > 0
// effects: Returns the present value of an annual income of inc for
// n years at a risk-free interest rate of r.
// I.e., pv(inc,r,n) = inc + (inc/(1+r)) + ... + (inc/(1+r)n−1).
// E.g., pv(100, .10, 3) = 100 + 100/1.1 + 100/1.21
For readers well versed in financial matters, a specification that did not use the
phrase “present value” would not be as easy to understand as one that did.
For readers lacking that background, the part of the specification following
“I.e.” is invaluable. The part following “E.g.” can be used by either group of
readers to confirm their understanding.
If readers are to benefit from redundancy, it is critical that all redundant
information be clearly marked as such. Otherwise, a reader can waste a lot
of time trying to understand what new information is being presented when,
214
9.3 — Why Specifications?
215
Chapter 9 Specifications
216
9.4 — Summary
mentation phase of the software life cycle, the presence of a good specification
helps both those implementing the specified module and those implementing
modules that use it. As discussed previously, a good specification strikes a
careful balance between restrictiveness and generality. It tells the implemen-
tor what service to provide but does not place any unnecessary constraints on
how that service is provided. In this way, it allows the implementor as much
flexibility as is consistent with the needs of users. Of course, specifications are
crucial for users, who otherwise would have no way to know what they can
rely on in implementing their modules. Without specifications, all that exists is
the code, and it is unclear how much of that code will remain unchanged over
time. During testing, specifications provide information that can be used in
generating test data and in building stubs that simulate the specified module.
(We will discuss this use in Chapter 10.) During the system-integration phase,
the existence of good specifications can reduce the number and severity of
interfacing problems by reducing the number of implicit assumptions about
module interfaces. When an error does appear, specifications can be used to
pinpoint where the fault lies. Moreover, they define the constraints that must
be observed in correcting the error, which helps us avoid introducing new
errors while correcting old ones.
Finally, a specification can be a helpful maintenance tool. The existence of
clear and accurate documentation is a prerequisite for efficient and effective
maintenance. We need to know what each module does and, if it is at all
complex, how it does it. All too often, these two aspects of documentation are
intimately intertwined. The use of specifications as documentation helps to
keep them separate and makes it easier to see the ramifications of proposed
modifications. For example, a proposed modification that requires us only to
reimplement a single abstraction without changing its specification has a much
smaller impact than one that changes the specification as well.
Sidebar 9.3 summarizes the value of specifications.
9.4 Summary
This chapter has discussed specifications and offered criteria to follow in
writing them. We defined the meaning of a specification to be the set of
all program modules that satisfy it. This definition captures the intuitive
purpose of a specification—namely, to state what all legal implementations
217
Chapter 9 Specifications
218
Exercises
specification because it points out a problem with the abstraction that requires
further study.
The second use is as documentation. Specifications are valuable during
every phase of software development, from design to maintenance. Of course,
they are not the only program documentation required. A specification de-
scribes what a module does, but any module whose implementation is clever
should also have documentation that explains how it works. Program modi-
fication and maintenance are eased if these two forms of documentation are
clearly distinguished.
A specification is the only tangible record of an abstraction. Specifications
are a crucial part of our methodology, since without them abstractions would
be too imprecise to be useful. We shall continue to emphasize them in the
chapters that follow.
Exercises
9.1 Provide a concise but readable specification of an IntBag abstraction, with
operations to create an empty bag, insert and remove an element, test an
element for membership, give the size of the bag, give the number of times an
element occurs in a bag, and produce the elements of the bag.
9.2 Take a specification you have given for a problem in an earlier chapter and
discuss its restrictiveness, generality, and clarity.
9.3 Is it meaningful to ask whether a specification is correct? Explain.
9.4 Discuss how specifications can be used during system integration.
9.5 Discuss the relationship between an abstraction, its specification, and its
implementation.
219
This page intentionally left blank
Testing and Debugging
10
So far we have talked a bit about program design and quite a lot about
program specification and implementation. We now turn to the related issues
of ascertaining whether or not a program works as we hope it will and
discovering why not when it does not.
We use the word validation to refer to a process designed to increase our
confidence that a program will function as we intend it to. We do validation
most commonly through a combination of testing and some form of reason-
ing about why we believe the program to be correct. We shall use the term
debugging to refer to the process of ascertaining why a program is not func-
tioning properly and defensive programming to refer to the practice of writing
programs in a way designed specifically to ease the process of validation and
debugging.
Before we can say much about how to validate a program, we need to
discuss what we hope to accomplish by that process. The most desirable
outcome would be an ironclad guarantee that all users of the program will
be happy at all times with all aspects of its behavior. This is not an attainable
goal. Such a guarantee presumes an ability to know exactly what it would
mean to make all users happy. The best result we can hope for is a guarantee
that a program satisfies its specification. Experience indicates that even this
modest goal can be difficult to attain. Most of the time, we settle for doing
things to increase our confidence that a program meets its specification.
221
Chapter 10 Testing and Debugging
There are two ways to go about validation. We can argue that the program
will work on all possible inputs. This activity must involve careful reason-
ing about the text of the program and is generally referred to as verification.
Formal program verification is generally too tedious to do successfully with-
out machine aids, and only relatively primitive aids exist today. Therefore,
most program verification is still rather informal. Even informal verification,
however, can be a difficult process.
The alternative to verification is testing. We can easily be convinced that
a program works on some set of inputs merely by running it on each member
of the set and checking the results. If the set of possible inputs is small,
exhaustive testing (checking every input) is possible. For most programs,
however, the set of possible inputs is so large (indeed, it is often infinite) that
exhaustive testing is impossible. Nevertheless, a carefully chosen set of test
cases can greatly increase our confidence that the program works as specified.
If well done, testing can detect most of the errors in programs.
In this chapter, we focus on testing as a method of validating programs. We
discuss how to select test cases and how to organize the testing process. We
also discuss debugging and defensive programming. Sidebar 10.1 summarizes
the remarks on validation.
10.1 Testing
Testing is the process of executing a program on a set of test cases and
comparing the actual results with the expected results. Its purpose is to
222
10.1 — Testing
reveal the existence of errors. Testing does not pinpoint the location of errors,
however; this is done through debugging. When we test a program, we
examine the relationship between its inputs and outputs. When we debug
a program, we worry about this relationship but also pay close attention to
the intermediate states of the computation.
The key to successful testing is choosing the proper test data. As men-
tioned earlier, exhaustive testing is impossible for almost all programs. For
example, if a program has three integer inputs, each of which ranges over the
values 1 to 1,000, exhaustive testing would require running the program one
billion times. If each run took one second, this would take slightly more than
31 years.
Faced with the impossibility of exhausting the input space, what do
we do? Our goal must be to find a reasonably small set of tests that will
allow us to approximate the information we would have obtained through
exhaustive testing. For example, suppose a program accepts a single integer
as its argument and happens to work in one way on all odd integers and in a
second way on all even ones; in this case, testing it on any even integer, any
odd integer, and zero is a pretty good approximation to exhaustive testing.
223
Chapter 10 Testing and Debugging
1. x ≥ 0
2. .00001 < epsilon < .001
To explore the distinct ways in which the requires clause might be satisfied,
we must explore the pairwise combinations of the ways each conjunct might
be satisfied. Since the first conjunct is a disjunct of two primitive terms (x ≥ 0
is just a shorthand for x = 0 | x > 0), it can be satisfied in one of two ways.
This leaves us with two interesting ways to satisfy the requires clause:
Any set of test data for sqrt should certainly test each of these cases.
It can be difficult to formulate test data that explore many different paths
through the effects clause of the specification. It may be difficult even to know
which paths can be explored. For example, given the preceding specification
of sqrt, we might expect the program sometimes to return an exact result,
sometimes a result a little less than the square root, and sometimes a result a
little greater. However, a program that always returned a result greater than or
equal to the actual square root would be a perfectly acceptable implementa-
tion. We would not be able to find test data that forced this program to return
a result less than the square root, but we could not know this without exam-
ining the code. In fact, without examining the code, we would have no idea
which classes of inputs would lead to results in the three categories.
Nevertheless, we should always examine the effects clause carefully and
try to find test data that exercise different ways to satisfy it. For example,
consider the following procedure
224
10.1 — Testing
225
Chapter 10 Testing and Debugging
sqrt should include cases for epsilon very close to .001 and .00001. For
strings, tests should include the empty string and a one-character string; for
arrays, we should test the empty array and a one-element array.
Aliasing Errors
Another kind of boundary condition occurs when two different formals both
refer to the same mutable object. For example, suppose procedure
were implemented by
Any test data that did not include an input in which v1 and v2 refer to the same
nonempty array would fail to turn up a very serious error in appendVector.
Sidebar 10.2 summarizes black-box testing.
226
10.1 — Testing
Despite the fact that there are n3 inputs, where n is the range of integers allowed
by the programming language, there are only four paths through the program.
Therefore the path-complete property leads us to partition the test data into
four classes. In one class, x is greater than y and z. In another, x is greater than
y but smaller than z, and so forth. Representatives of the four classes are
3, 2, 1 3, 2, 4 1, 2, 1 1, 2, 3
2, 1, 1
227
Chapter 10 Testing and Debugging
j = k;
for (int i = 1; i <= 100; i++)
if (Tests.pred(i*j)) j++;
is path-complete for this program. Using this test might mislead us into
believing that our program was correct, since the test would certainly fail
to uncover any error.
The problem is that a testing strategy based on exercising all paths through
a program is not likely to reveal the existence of missing paths, and omitting a
path is a fairly common programming error. This problem is a specific instance
of the general fact mentioned earlier: no set of test data based solely upon
analysis of the program text is going to be sufficient. One must always take
the specification into account.
Another potential problem with a testing strategy based upon selecting
path-complete test data is that there are often too many different paths through
a program to make that practical. Consider the program fragment in Figure
10.1. There are 2100 different paths through this program, as can be seen from
the following analysis. The if statement causes either the true or the false
branch to be taken, and both of these paths go on to the next iteration of the
loop. Thus, for each path entering the ith iteration, there are two paths entering
the (i + 1)st iteration. Since there is one path entering the first iteration, the
number of paths leaving the ith iteration is 2i. Therefore there are 2100 paths
leaving the 100th iteration.
Testing each of 2100 paths is not likely to be practical. In such cases, we
generally settle for an approximation to path-complete test data. The most
common approximation is based upon considering two or more iterations
through a loop as equivalent and two or more recursive calls to a procedure
as equivalent. To derive a set of test data for the program in Figure 10.1, for
example, we find a path-complete set of test data for the program
j = k;
for (int i = 1; i <= 2; i++)
if (Tests.pred(i*j)) j++;
There are only four paths through this program. A path-complete set of test
data would have representatives in the following categories:
228
10.1 — Testing
To sum up, we always include test cases for each branch of a conditional.
However, we approximate path-complete testing for loops and recursion as
follows:
For loops with a fixed amount of iteration, as in the example just shown,
we use two iterations. We choose to go through the loop twice rather than
once because failing to reinitialize after the first time through a loop is a
common programming error. We also make certain to include among our
tests all possible ways to terminate the loop.
For loops with a variable amount of iteration, we include zero, one, and
two iterations, and in addition, we include test cases for all possible ways
to terminate the loop. For example, consider
while (x > 0) {
// do something
}
With a loop like this, it is possible that no iterations will be performed. This
situation should always be handled by the test cases because not executing
the loop is another situation that is likely to be a source of program error.
For recursive procedures, we include test cases that cause the procedure
to return without any recursive calls and test cases that cause exactly one
recursive call.
This approximation to path-complete testing is, of course, far from fail-
safe. Like engineers’ induction “One, two, three—that’s good enough for me,”
it frequently uncovers errors but offers no guarantees.
Path-complete tests also need to take exceptions into account: for each
statement where an exception could be raised, there must be a test for that
case. For example, consider the statement:
int x = a[0];
229
Chapter 10 Testing and Debugging
characters has just one character. Therefore, we should add a number of odd-
length test strings. Finally, we should arrange the test cases in a sensible
order, with the shortest first. Such an arrangement helps in finding errors
(see Section 10.9).
Test cases here could include calls with n equal to 1, 2, and 3. Whether more
tests are needed can be determined by looking at the iterator’s implementation.
Here we need to consider all paths through the iterator itself, and also through
the generator’s constructor and its two methods.
231
Chapter 10 Testing and Debugging
For each, we would do calls on isIn, size, and elements and check the results.
In the case of isIn, we would do calls in which the element is in the set and
others in which it is not.
We obviously do not yet have enough cases. For example, remove is not
tested at all, and paths in other specifications also have not yet been discussed.
These paths are somewhat hidden in our specifications. For example, the size
of an IntSet remains the same when we insert an element that is already in the
set, and we must therefore look at a case in which we insert the same element
twice. Similarly, the size decreases when we remove an element only if it is in
232
10.4 — Testing Data Abstractions
// constructors
public IntSet ( )
// effects: Initializes this to be empty.
// methods
public void insert (int x)
// modifies: this
// effects: Adds x to the elements of this, i.e., this_post = this + { x }.
the set, so that we must look at one case in which we remove an element after
inserting it and another in which we remove an element that is not in the set.
We might use these additional IntSets:
233
Chapter 10 Testing and Debugging
To find these hidden paths, we must look explicitly for paths in the
mutators. Thus, insert must work properly whether or not the element being
inserted is already in the set, and similarly for remove. This simple approach
will produce the three cases just given.
In addition, of course, we must look for paths in the implementations of
the operations. The cases identified so far provide quite good coverage for
the implementation using the vector with no duplicates (see Figure 10.4). One
possible problem is in isIn, which contains a loop (implicitly via its call to
getIndex). To cover all paths in this loop, we must test the case of a two-
234
10.6 — Testing a Type Hierarchy
element vector with either no match or a match with the first or the second
element. (It is not possible to find such tests cases by considering only the
specification. At the level of the specification, we are concerned only with
whether or not the element is in the set; its position in the vector is not of
interest.) Similarly, in remove, we must be sure to delete both the first and
second elements of the vector.
235
Chapter 10 Testing and Debugging
236
10.7 — Unit and Integration Testing
237
Chapter 10 Testing and Debugging
Subtypes must be tested using both the black-box tests of their supertypes and their
own black-box tests. Their supertype tests must make use of subtype constructors.
The additional subtype black-box tests cover the extra methods and any changed
behavior for the inherited methods.
Glass-box tests for superclasses need not be used when testing subclasses.
Testing an abstract class requires a concrete subclass. The pair is tested using black-
box tests for both sub- and supertype, and also glass-box tests for both sub- and
superclass.
Testing a hierarchy that provides multiple implementations for a supertype may require
testing the subtypes jointly and adding black-box tests to establish that the proper
subtype is chosen for various objects.
in integration testing that do not arise in unit testing; for example, it may take
much longer to run a test. Finally, specifications play rather different roles in
the two kinds of validation.
The acceptance of the specification as a given is a key factor that dis-
tinguishes unit testing from integration testing. During unit testing, when
a module fails to meet its specification, we generally conclude that it is incor-
rect. When validating a whole program, we must accept the fact that the most
serious errors are often errors of specification. In these cases, each unit does
what it is supposed to, but the program as a whole does not. A prime cause of
this kind of problem is ambiguous specifications. When this occurs, a module
may perform as expected by those doing its unit testing while failing to meet
the expectations of those writing some of the modules that call it. This makes
errors detected during integration testing particularly difficult to isolate.
Consider a program implemented by module P, which calls module Q. Dur-
ing unit testing, P and Q are tested individually. (To test either individually,
it is necessary to simulate the behavior of the other, as will be discussed in
Section 10.8.) When each of them has run correctly on its own test cases, we
test them together to see whether they jointly conform to P’s specification.
In doing this joint test, we use P’s test cases. Now suppose that an error is
discovered. The following are the possibilities:
238
10.8 — Tools for Testing
Q is being tested on an input that was not covered in its test cases.
Q does not behave as was assumed in testing P.
It is tempting when dealing with multiple modules like P and Q to test them
jointly rather than to do unit tests for each first. Such joint tests are sometimes
a reasonable approach, but unit testing is usually better. For example, to
test the program shown in Figure 10.1, we care only that each of the four
paths be covered; the various ways in which pred produces its results are
not of concern. However, testing pred thoroughly probably involves many
test cases. Combining all these test cases has many disadvantages: more tests
must be run, tests may take longer to run, and if either of these modules is
reimplemented, we shall have to rethink the whole set of test cases. Testing
each module individually is more efficient.
1. Set up the environment needed to call the unit being tested. In some lan-
guages, this may involve creating and initializing certain global variables.
In most languages, it may involve setting up and perhaps opening some
files.
2. Make a series of calls. The arguments for these calls could be read from a
file or embedded in the code of the driver. If arguments are read from a
file, they should be checked for appropriateness, if possible.
3. Save the results and check their appropriateness.
239
Chapter 10 Testing and Debugging
240
10.8 — Tools for Testing
Sometimes the “right” value can be found only by writing the program the
stub is supposed to replace. In such cases we must settle for a “reasonable”
value.
(If all communication is only via arguments and results, then it is not necessary
to check or modify the environment.)
Drivers are clearly necessary when testing modules before the modules
that invoke them have been written. Stubs are necessary when testing modules
before the modules that they invoke have been written. Both are needed for
unit testing, in which we want to isolate the unit being tested as much as
possible from the other parts of the program.
In practice, it is common to implement drivers and stubs that rely on
interaction with a person. A very simple implementation of a stub might
merely print out the arguments it was called with and ask the person doing
the testing to supply the values that should be returned. Similarly, a simple
driver might rely on the person doing the testing to verify the correctness of
the results returned by the unit being tested. Although drivers and stubs of
this nature are easy to implement, they should be avoided whenever possible.
They are far more prone to error than automated drivers and stubs, and they
make it hard to build up a good database of test data and to reproduce tests.
The reproducibility of tests is particularly important. The following test-
ing scenario is all too typical:
241
Chapter 10 Testing and Debugging
as the program being tested. In this case, the programmer wastes lots of time
testing and debugging the testing environment.
Sidebar 10.5 summarizes the different kinds of tests.
10.9 Debugging
Testing tells us that something is wrong with a program, but knowing the
symptom is a far cry from knowing its cause. Once we know that a prob-
lem exists, the tactics to be used in locating and fixing the problem—in
debugging—are extremely important. The variance in the efficiency with
which people debug is quite high, and we can offer no magic nostrums to
make debugging easy. Most of what we have to say on the subject is simple
common sense.
Debugging is the process of understanding and correcting errors. When
debugging, we try to narrow the scope of the problem by looking for simple
test cases that manifest the bug and by looking at intermediate values to locate
the responsible region in the code. As we collect evidence about the bug, we
formulate hypotheses and attempt to refute them by running further tests.
When we think we understand the cause of the bug, we study the appropriate
region of code to find and correct the error.
The word bug is in many ways misleading. Bugs do not crawl unbidden into
programs. We put them there. Do not think of your program as “having bugs”;
think of yourself as having made a mistake. Bugs do not breed in programs. If
a program contains many bugs, it is because the programmer has made many
mistakes.
242
10.9 — Debugging
Always keep in mind that debugging consumes more time than program-
ming. It is worth trying very hard to get your program right the first time.
Read your code very carefully and understand exactly why you expect it to
work before you begin to test it. No matter how hard you try and no matter
how clever you are, though, the odds against your program working prop-
erly the first time are very long. Consequently, you should design, write, and
document your programs in ways that will make them easier to test and debug.
The key is making sure that you have relatively small modules that can be
tested independently of the rest of your program. To a large extent, this can
be achieved by following the design paradigms outlined earlier in this book.
Introduce data abstractions and associate with each the most restrictive pos-
sible rep invariant. Write careful specifications for each procedure, so that
when it comes time to test it, you know both what input values it should be
prepared to deal with and what it should do in response to each of the possible
inputs.
Just as you need an overall testing strategy, you also need a careful plan
for every debugging session. Before beginning, decide exactly what you want to
accomplish and how you plan to accomplish it. Know what input you are going
to give your program, and exactly what you expect it to do with that input.
If you have not thought carefully about your inputs, you will probably waste
a lot of time doing things that are not likely to help isolate the problem.
The so-called scientific method provides a good paradigm for systematic
debugging. The following is the crux of the scientific method:
243
Chapter 10 Testing and Debugging
Before running the tests, we decide which results would support our
hypothesis and which would refute it:
When we try the experiment, the program returns false and then true. We
immediately reject our first hypothesis and look for another—for example,
that the program will fail on all odd primes.
When debugging, a good starting goal is to find a simple input that causes
the problem to occur. This input may not be the test data that first revealed the
existence of the bug. It is often possible to find simpler input that is sufficient
to provoke a manifestation of the bug. Thus, a good way to start is to pare
down the test data and then run the program on variants of that subset.
For example, suppose we are testing the palindrome procedure of Fig-
ure 10.2; and when we run it on the famous (allegedly Napoleonic) palindrome
“able was I ere I saw elba”, it returns false. This is a rather long palindrome,
so we should try to find a shorter one on which the program fails. We might
begin by taking the middle character of this palindrome and seeing whether
the program succeeds in recognizing that the single character “r” is itself a
palindrome. If it fails on that, we might hypothesize that the program does
not work on palindromes containing an odd number of characters, and we
should examine our other tests to see whether they support this hypothesis.
If it succeeds in recognizing that “r” is a palindrome, we might try “ere” on
the hypothesis that it will fail on odd palindromes containing more than one
character. If “ere” fails to provoke the error, we should probably try “I ere
I”. Suppose the program fails on this input. Two hypotheses come to mind:
perhaps the blanks are the root problem, or perhaps it is the uppercase letters.
We should now test our program on the shortest inputs that might confirm or
refute each hypothesis, for example, “ ” and “I”.
Once we have found a small input that causes the error to occur, we use this
information to locate where in the program the bug is likely to be. Finding the
kind of input necessary to provoke a symptom is often tantamount to locating
a bug. If not, however, the next step is to narrow the scope of the problem by
examining intermediate results.
The goal is to rule out parts of the program that cannot be causing the
problem and then look in more detail at what is left. We do this by tracing the
program—that is, running it and looking at the values of variables at specific
244
10.9 — Debugging
points in its control flow. If the program consists of several modules, our first
goal is to discover which module is the source of the bug. We do this by tracing
all calls and returns of procedures. For each call, we ask whether the arguments
are what they should be; the arguments should satisfy the requires clause of
the called procedure and should also follow from what we have learned in
the trace so far. If the arguments are not right, then the error is in the calling
module. Otherwise, we ask whether the results of the call follow from the
arguments. If not, the error is in the called procedure.
Localizing the problem to a single procedure is often enough, since we can
then discover the error by examining the code of the faulty module. Some-
times, however, it is useful to narrow the bug to a subpart of the faulty module.
To do this, we continue the trace and examine the values of local variables of
the module. The goal is to detect the first manifestation of incorrect behavior.
It is particularly important to check the appropriateness of intermediate results
against values computed prior to beginning the trace. If you wait until you
see the intermediate results before thinking about what they should be, you
run the risk of being unduly influenced by your (erroneous) program.
Consider the following incorrect implementation of palindrome:
245
Chapter 10 Testing and Debugging
only purpose is to help you examine intermediate results. One piece of code that
should be written in either case for each type is a method that displays the
objects in abstract form. In other words, having a toString method is always
desirable. When debugging polynomials, for example, it is much easier to
understand what is happening if a Poly is displayed as the string
"3 + x**5"
[3, 0, 0, 0, 0, 1]
246
10.9 — Debugging
247
Chapter 10 Testing and Debugging
variant of this problem can occur when either the compiler or the operating
system is changed. When your program’s behavior has changed and you are
absolutely sure that you have not changed anything, make sure that you are
not using code that has been compiled to run on a different version of the
operating system.
When you have tried everything you can think of and still have not found
the bug, go away. The goal of any programming project is to complete the
program (including its documentation and testing) expeditiously. The goal
is not to find a particular bug as soon as possible. The obsessive pursuit
of a particular bug is almost always counterproductive. If you spend too
long looking for the same bug, there is a high probability that you will
become stuck in a rut. If you try to debug when you are overly tired, you
will, at best, work inefficiently. At worst, you will make mistakes, such
as making ill-considered changes to the program or accidentally deleting a
crucial file.
When you do find a bug, try to understand why you put it there. Was
it a clerical error, does it reflect a lack of understanding of the program-
ming language, or is it indicative of some logical problem? Knowing why
you inserted a bug may help you understand how to fix your program. It
may also help you to discover other bugs and even to avoid bugs in the
future.
Finally, when you think that you have found a bug and that you know how
it got there, do not be in too much of a rush to fix “the bug.” Make sure that the
bug you found could indeed have caused the symptoms that you observed. If
you have already spent a lot of time observing the behavior of your program, it
may be counterproductive to change that behavior before you have completed
your detective work. Not only is it often easier to repair many bugs at once
than to repair many bugs one at a time, but it almost always leads to a cleaner
and more efficient program.
When you do decide to make a change, think through all of its ramifications.
Convince yourself that the change really will both cure the problem and
not introduce new problems. The hardest bugs to find are often those we
insert while fixing other bugs. This is because we are often not as systematic
in designing these “patches” as in our original designs. We try to make
local changes, when a more global approach might well be called for. It is
often more efficient to reimplement a small procedure than to patch an old
one.
248
10.10 — Defensive Programming
Suppose that a caller of this procedure reverses the order of the second and
third arguments. inRange will probably return false whether or not e is in a.
The first observable symptom of this incorrect call might appear arbitrarily
far from the call. In the worst case, the error would never be detected, and the
249
Chapter 10 Testing and Debugging
250
10.11 — Summary
10.11 Summary
This chapter has discussed the related issues of testing and debugging. Testing
is a method of validating a program’s correctness. We have described a way to
develop test cases methodically by examining both a module’s specification
and its implementation. The test cases should then be run by a driver that
checks the results of each case; the driver either produces the inputs or reads
them from a file, and either checks the results by computations or compares
them to outputs in a file. If the test being run is a unit test, then lower-
level modules are replaced by stubs that simulate their effects. Later, during
integration testing, the stubs are replaced by the implementation.
Testing can exhibit the presence of a bug. Debugging is the process of
understanding and correcting the cause of the bug. In debugging, we try to
narrow the scope of the problem by searching for simple test cases that man-
ifest the bug and by looking at intermediate values to locate the responsible
region in the code. As we collect evidence about the bug, we formulate hy-
potheses and attempt to refute them by running further tests. When we think
we understand the cause of the bug, we study the responsible region of code
to find and correct the error.
Debugging can be made easier if we practice defensive programming,
which consists of inserting checks in the program to detect errors that are
likely to occur. In particular, we should check that the requires clause is
satisfied. It is also a good idea to check the rep invariant. These checks should
be retained in the production code if possible.
The outcome of being methodical about testing, debugging, and defensive
programming is a reduction of programmer effort. This work pays off not only
when the program is written, but also later when it is modified.
Sidebar 10.6 summarizes the preceding discussion about testing and de-
bugging.
251
Chapter 10 Testing and Debugging
Exercises
10.1 Develop a set of test cases for partition using the specification and imple-
mentation given in Figure 3.6. Do the same thing for quickSort and sort.
Write a driver for partition. Run the tests.
10.2 Develop a set of test cases and write a driver for permutations (see Exercise 7
in Chapter 6).
10.3 Implement an iterator that yields all Fibonacci numbers. (A Fibonacci number
is the sum of the preceding two Fibonacci numbers, and the first Fibonacci
number is 0. For example, the first seven Fibonacci numbers are 0, 1, 1, 2, 3,
5, and 8.) Define test cases in advance of debugging. Then debug your program
and report on how successful your tests were.
10.4 Develop a set of test cases for Poly (see Figures 5.4 and 5.7). Write a driver
for Poly and run the tests.
10.5 Develop a set of test cases for OrderedIntList (see Figures 6.10, 6.11, and
6.12). Write a driver and run the tests.
10.6 Suppose IntSets were implemented using OrderedIntLists (see Figures 6.10,
6.11, and 6.12). Discuss what kind of stub you would use for ordered lists in
testing your implementation of IntSet.
10.7 Develop the test cases needed for MaxIntSet (see Figures 7.4 and 7.5), starting
with the test cases for IntSet (see Section 10.4). Write the driver and run the
tests.
10.8 Consider the abstract IntList class shown in Figures 7.11 and 7.12. Develop
the black-box and glass-box tests for this class. Then develop a testing strategy
252
Exercises
including selection of the subclass (or subclasses) that will be used in the tests.
Develop a driver for the pair of classes and run the tests.
10.9 Consider the abstract Poly class shown in Figure 7.14. Develop a strategy for
testing this class including selection of the subclass that will be used in the
tests. Write a driver for Poly and this subclass and run the tests.
10.10 Develop test cases for Adder (see Figure 8.6). Then develop test cases for
PolyAdder (see Figure 8.7). Write a driver for PolyAdder and run the tests.
10.11 Develop test cases for SumSet (see Figure 8.8). Note that this includes deciding
what parameter types to use in the test. Write the driver for SumSet and run
the tests.
10.12 Develop an error profile for yourself. Keep a log in which you record errors in
your programs. For each error, record the reason for it and look for patterns.
253
This page intentionally left blank
Requirements Analysis
11
255
Chapter 11 Requirements Analysis
in a complete and precise manner, but this is quite rare. More often customers
do not fully understand what they want the program to do. Even if the desired
service is well understood, it is probably not described precisely enough to
serve as a basis for constructing a program. The purpose of the requirements
analysis phase is to analyze the needs of the customer and produce a document
describing a program that will meet those needs. This process will require
communication with the customer to make sure the needs are understood.
The document that results from requirements analysis is the input to the
design phase. In this phase, a modular decomposition of a program satisfy-
ing the specification is developed. In the next phase, the individual mod-
ules are implemented and then tested to ensure that they perform as in-
tended. As discussed in Chapter 10, we use two kinds of tests: unit tests,
in which individual modules are tested in isolation, and integration tests, in
which modules are tested in combination.
At best, integration testing shows that the modules together satisfy the
implementor’s interpretation of the specification. The implementor may have
misinterpreted the specification or neglected to test some portion of the pro-
gram’s behavior, though, and the customer therefore needs some other basis
for deciding whether or not the program does what it is supposed to do. This
typically takes the form of acceptance tests. Acceptance tests provide an eval-
uation of the program behavior that is independent of the design, and they
are generally performed by an organization other than the one that worked
on the design and implementation. They should include both trial runs un-
der conditions approximating those the customer will actually encounter
and tests derived directly from the requirements specification.
When the program has passed the acceptance tests, it enters the production
phase and becomes a product that the customer can use. The useful life
of the program occurs during this phase, but the program is unlikely to
remain unchanged even here. First, it almost certainly harbors undetected
errors that must be corrected during production. Correcting such errors is
called program maintenance. Second, the customer’s requirements are likely
to change. Responding to such changes requires program modification.
Figure 11.1a illustrates the waterfall model, an idealized form of the soft-
ware development process previously described, in which each phase is com-
pleted before work starts on the next phase. The waterfall model is neither
realistic nor practical: the software development process is unlikely to proceed
sequentially through the phases. There are two reasons for this. First, some
work can be done in parallel. For example, even before requirements analysis
256
11.1 — The Software Life Cycle
257
Chapter 11 Requirements Analysis
258
11.2 — Requirements Analysis Overview
can lead to a system that isn’t very robust. One technique that can sometimes
work is to produce a very simple prototype that contains only a small subset
of the desired features. Throwing away a small prototype might be acceptable,
and if the subset is well chosen, the prototype can provide substantial insight
into the real product requirements.
Another way to catch errors in early phases is to document all decisions
explicitly and then carefully review the decisions; we will discuss such an
approach in Chapter 14. While better than nothing, however, these methods
are far from adequate. Identification and invention of better methods is an
important area for research in programming methodology.
259
Chapter 11 Requirements Analysis
260
11.2 — Requirements Analysis Overview
261
Chapter 11 Requirements Analysis
262
11.2 — Requirements Analysis Overview
of the functionality of the entire system should be available early would have
an impact on the implementation schedule as well as on the design.
Sidebar 11.2 summarizes the issues that must be considered during re-
quirements analysis.
The result of the requirements phase is a requirements document (see
Sidebar 11.3). This document contains the requirements specification, which
describes the program behavior, including its behavior in the presence of er-
rors. In addition, the document should explain the performance requirements,
the decisions made during analysis, and if it can be done with a reasonable
amount of effort, the alternatives that were rejected (and why they were re-
jected). The latter information is useful when requirements must be rethought
because of errors or changing customer needs.
The requirements document can be the input to two activities in addition
to the design. It can be used to produce acceptance tests and as a basis
for a system user’s manual. The user’s manual is something that must be
produced anyway, but its production can provide an independent check on
the suitability of the specification. If the system is hard to use, this may
be evident when the manual is written. Also, by reading the manual, the
customer may notice deficiencies in the specification that were overlooked
earlier.
263
Chapter 11 Requirements Analysis
264
11.3 — The Stock Tracker
not of concern at this point. What does matter, however, is whether the user
must supply any arguments. In particular, the stock tracker needs access to
information about stocks that has already been created on the user’s behalf
in earlier sessions; how does it find this information? It might receive it as
an argument (e.g., names of one or more files); it might ask the user for the
information via the user interface; or it might “know where to look.” Any of
these choices is a viable option. The third choice seems best, however, since
it avoids errors due to the user mistyping a filename. Therefore, we make the
third choice (after consulting the customer).
Once the program has obtained the information from the previous session,
it is ready to accept user commands. There are two basic kinds of commands:
ones used to examine the stored information, and ones used to add new
information.
Presumably, when a user wishes to examine stored information, he or she
is interested in looking at a single investment or perhaps a group of related
investments. In the latter case, how would a user indicate that a group of
investments is related? It seems unlikely that any decision the system might
make here will match the needs of an arbitrary user and, therefore, a way
for users to group investments should be provided. Grouping could be done
by allowing users to define separate portfolios, each containing a group of
investments that are related as far as the user is concerned. An alternative
might be for users to associate keywords with investments, which would
allow the user to examine all investments marked with a particular keyword.
Either of these choices is plausible, but providing separate portfolios is simpler
(it effectively provides one keyword per investment) and easier for users to
manage, and it seems adequate for this application. Therefore, let’s choose
the separate portfolio approach. A portfolio contains positions, each of which
provides information about a particular stock.
At this point, it is helpful to list the commands and consider them one-
by-one. Here are some of them:
Identify a particular portfolio for further examination.
Browse the information in the identified portfolio.
Create a portfolio or delete one.
Add or remove a position from the open portfolio.
The first command requires a way to name portfolios. These names can be
alphanumeric strings, since such strings are easy for users to enter (or to show
265
Chapter 11 Requirements Analysis
to users in a menu). The user can use a name to identify a portfolio of interest;
we will refer to this portfolio as the open portfolio.
To carry out the second command requires deciding how information
about a portfolio’s contents is presented to the user. One possibility is to
present all of it; a second is to highlight the contents by indicating what
stocks are in the portfolio, and then allow the user to indicate where more
information is desired. The latter approach seems better since it allows the
user to get a general sense of what is in the portfolio. Let’s assume that the
customer chooses this approach, which implies that we need a way to identify
the stocks. An obvious way to identify them is to use the name of the stock:
we can use its “ticker” name, the name used by the stock market. However,
this approach is possible only if a portfolio can contain at most one entry for a
specific stock; let’s assume this is true. Thus, the program informs the user of
the stocks in the open portfolio, and the user can indicate a stock of interest.
Let’s refer to this stock as the current position.
We also need to decide what other information (besides the name of its
stock) is maintained in each position. We need to know the number of shares
being held for that stock. In addition, the user might want to store notes
about the stock (e.g., the date and price when the stock was purchased),
but let’s assume that at present the customer does not want to store such
notes.
The customer does want the system to provide stock price information,
however. This requirement brings up the question of how price information is
obtained and how often it is refreshed. One possibility is to have the user enter
the information, but this is error prone and inconvenient. Instead, it would be
better to obtain price information automatically. Suppose that in investigat-
ing this requirement, we discover that such quotes can be obtained by getting
in touch with a particular Web server. Therefore, we decide to obtain the in-
formation by communicating with this server; each communication will give
us the price of a single stock.
However, remote communication has a cost, and it seems reasonable to
retain price information that isn’t too old. Therefore, we will allow the user
to indicate when he or she wants information to be refreshed. This can be
done for a single stock (the “current” stock in the current portfolio) or for all
stocks in the current portfolio. An implication of this approach is that price
information is not necessarily very current; to convey this information to the
user, it seems appropriate to associate price information with the time at which
that information was obtained.
266
11.3 — The Stock Tracker
267
Chapter 11 Requirements Analysis
268
11.4 — Summary
11.4 Summary
We began this chapter with a description of the software life cycle. While
conceding that it is indeed a cycle, and that it is desirable to start later phases
before earlier ones are complete, we emphasized the importance of doing a
careful requirements analysis of some part of the program before starting
design for that part and a careful design of some portion of the implementation
before starting that implementation work.
The bulk of the chapter was devoted to a discussion of requirements analy-
sis. We usually start requirements analysis with an incomplete understanding
of what the customer really wants. The goal of analysis is to deepen our under-
standing so that we end up with a product that matches the customer’s needs.
Customers should be consulted during analysis because they are the ultimate
judges of what is required.
A number of issues must be considered during requirements analysis:
The program’s behavior must be defined for both correct and incorrect
inputs.
Issues related to hardware and software errors, such as availability and
reliability constraints, must be explored.
Constraints on time and space efficiency must be pinned down. Perfor-
mance is not an add-on feature. It must be designed in from the start.
Scheduling constraints must be addressed. The customer’s desired delivery
schedule for the software or part of the software may well have an impact
on the design and implementation.
It is useful to try to identify those parts of the requirements that are most
likely to change.
It is useful to know whether the system being specified is the first of a
number of similar systems, so that it can be designed in a way that will
allow components to be reused.
We suggested the use of sample sessions or scenarios to drive the analysis.
Scenarios are useful because they provide a way to methodically walk through
how the system behaves, considering first the case of no errors and then
the case of user errors. Finally, we considered system errors; here we don’t
use scenarios, but rather make up a list of possibilities. We illustrated our
approach by a simple example, the stock tracker. In addition to determining
269
Chapter 11 Requirements Analysis
Exercises
11.1 Program xref produces an index for a document: For each word containing
more than one letter, it lists the word followed by the lines in which it
appeared, for example,
compiler 3, 17, 25, ...
Carry out a requirements analysis for this program and describe the result.
11.2 Consider a spelling checker that will compare the words in a document with
a dictionary to identify spelling errors. Carry out a requirements analysis for
this program and describe the result.
11.3 Consider a path finder program that gives directions on the best way to get
from point A to point B. The program has access to a database that identifies
points of interest, how to get from one adjacent location to another, and the
distance involved. Carry out a requirements analysis for this program and
describe the result.
270
Requirements
Specifications
12
271
Chapter 12 Requirements Specifications
the program state by means of a data model. The model is then used in the
requirements specification.
The result is a reasonably precise definition of what is required. Such a
specification is a good basis for program design, since now the designer is
likely to understand what to build. Furthermore, the exercise of defining the
model and then using it to write the specification is a valuable part of the
requirements analysis process since it brings problems to the attention of the
analyst. This leads to a specification that reflects more careful thinking about
the issues and is more likely to meet the customer’s needs as a result.
1. The data model we are using is based on Alloy, a modeling technique defined in more detail in
Jackson, Daniel, Alloy: A Lightweight Object Modeling Language, MIT Laboratory for Computer
Science Technical Report 797, Cambridge, Mass., Feb. 2000.
272
12.1 — Data Models
as the program state changes, items may be added to or removed from sets.
Second, the relationships between the sets can change.
12.1.1 Subsets
Some sets represented by nodes in the graph are subsets of other sets. We will
call sets that have no supersets domains. Each domain is disjoint from all other
domains.
Subset edges are used to indicate that one set is a subset of another. We
represent this information with an arrow with a closed head. The arrow goes
from the subset to the superset. The arrowhead indicates whether the subset
contains all elements of its superset (a filled arrowhead) or just some elements
of the superset (an unfilled arrowhead).
Subsets can share an arrow; in this case, they are mutually disjoint, and
the arrowhead indicates whether or not their union exhausts the superset.
Subsets that don’t share an arrow are not necessarily disjoint.
Three constraints are useful to define for subsets. First, subsets can some-
times be fixed. This means that the subset’s membership is fixed for all time;
the subset never gains or loses elements. A fixed subset is indicated by double
lines on both sides of its node.
Second, a subset can be static. This means its membership is determined
statically: an element never switches between belonging to the subset and not
belonging to the subset. We will indicate a static subset by double lines on
the left side of its node. Note that a subset that is fixed is also static.
Third, it is sometimes useful to state explicitly how many elements a
subset contains. This can be done by writing the size of the subset within the
box for its node; for example, a “1” means that the subset contains exactly
273
Chapter 12 Requirements Specifications
one element, while “<=1” means the subset is either empty or contains one
element.
Figure 12.1 shows some of the subset relations and constraints for a file
system. An FSObject (file system object) can be either a file or a directory.
These two subsets are disjoint and exhaust their superset. Furthermore, they
are both static: a file system object cannot switch from being a directory to
being a file or vice versa.
In addition, there are two interesting subsets of Dir. Root represents the
root directory; this directory is fixed for all time (and thus its node is marked
as fixed), and there is exactly one root directory. Cur represents the current
directory. There is at most one current directory, and different directories can
be chosen as current while the file system is in use. Note that since Root and
Cur do not share an arrow, they need not be disjoint; and therefore the root
directory could be the current directory.
12.1.2 Relations
Relations are used to indicate how items of a set are related to items in other
sets. They are represented by arrows with open heads. Each relation arrow
is labeled with a name. Each relation has a source (the node it comes from)
and a target (the node it points at). For example, there might be a contents
arrow from the node DirEntry (representing entries in directories) to the node
FSObject, indicating that a directory entry names a file system object. Here
DirEntry is the source, and FSObject is the target.
274
12.1 — Data Models
Relation names needs not be unique within a diagram, except that if two
relations have the same source, they should have distinct names.
Sometimes it is useful to define the inverse of a relation. For example, we
might have a parent relation that maps a directory to its parent directory (each
directory except for the root directory has a parent directory). But it is also
useful to define a children relation that is the inverse of parent: if directory
d1 is the parent of d2, then d2 is a child of d1. A single arrow can represent both
a relation and its inverse. The label on the arrow names the primary relation
and the inverse relation. We use the notation r1(~r2) to mean that r1 is the
primary relation and r2 is its inverse. Thus, for a file system, we would have
parent(~children).
The graph also defines the multiplicity and mutability of the relations.
Multiplicity defines the number of items a relation maps to or from. A relation
actually maps to a set: it maps an item in the source to a set of items in the
target. The relation’s multiplicity defines how many items are in the set. For
example, the parent relation maps to at most one directory (since root has no
parent, and all other directories have a single parent). Multiplicity is indicated
by annotating the relation arrow with one of the following symbols:
275
Chapter 12 Requirements Specifications
276
12.1 — Data Models
and FSObject cannot be changed. This last point reflects a decision: users
can’t change the binding of a name within a directory entry. However, they
can replace an entry with a new one that contains a different binding (since
entries is mutable).
Annotating the relations forces us to think about issues that might other-
wise be overlooked, but that are important for the requirements. For example,
in deciding what the associated symbols are for the parent relation, we needed
to decide whether the parent of a directory can change. We have decided that
it can change (since we did not mark the relation as immutable).
Relations map source elements to target elements without any additional
arguments. When arguments are needed, there are two ways to handle them.
First, we can introduce an extra node that effectively associates the argument
with the result. This is what we did when we introduced DirEntry; we are
using it to model a map from a name in a particular directory to a file system
object. The other approach is to use recursion. For example, we might like
to think of a pathname as a sequence of names, but modeling this would
require a relation that takes an integer as an argument. In this case, we instead
277
Chapter 12 Requirements Specifications
use recursion, effectively treating the pathname as a linked list rather than a
sequence.
Expressing constraints as part of defining the diagram is particularly
useful because the process is so methodical: we simply consider every subset
node and every relation. For each relation, we decide for each end what its
multiplicity is and whether it is mutable. Similarly for each subset, we decide
about its mutability (whether it is fixed, static, or unconstrained) and its size.
However, the graph does not usually capture all constraints that a model must
satisfy. These extra constraints will be defined textually, as explained in the
next section.
Figure 12.3 Descriptions of sets and relations for the file system
Domains
FSObject: all the files and directories in a file system
File: all files in a file system
Dir: all directories in a file system
Root: the root directory
Cur: the current directory
DirEntry: entries in directories
Name: string names within directory entries
PathName: pathnames for directories and files
Relations
parent(∼children): gives the parent directory of a directory
entries: gives the entries (name/file system object pairs) of a directory
name: gives the name of a directory or file within a directory entry
contents: gives the file system object associated with a name in a directory
first: gives the name at the start of a pathname
rest: gives the rest of the pathname (all but the first name)
pn: gives the pathnames for all paths starting from a directory
278
12.1 — Data Models
information for the file system. Note that the explanations omit information
in the graph, such as the subset relationships among the sets.
The second part defines additional constraints. There are two forms of
constraints. First, some relations are derived. A derived relation is one that
can be defined in terms of other relations. It’s important to identify derived
relations because this reduces the size of the model, making it easier for people
to understand. Also, identifying derived relations reduces the number of
additional constraints. Derived relations will automatically be constrained by
constraints on the relations that define them and vice versa.
The way to recognize derived relations is to consider each relation in turn
and ask whether it can be defined in terms of the other relations. For the file
system model, such an analysis leads to identifying two derived relations. The
first is parent(~children): a directory’s parent is the directory that contains
an entry for it:
This definition makes use of a notational shorthand: when a set has just one
member, we use the set name to also name that member. Thus, e.contents is
used to stand for the directory that is the single element of that set.
Note that d.parent is defined to be a set; this, of course, is necessary
since every relation maps to a set. Furthermore, this set must have at most
one element, since the graph constrains the parent relation to map to a set
containing at most one element. Once we have defined how parent is derived
from the other relations, this constraint applies to them as well, so that now
we know that a directory can be contained in at most one other directory.
The second derived relation is pn:
279
Chapter 12 Requirements Specifications
root directory is an ancestor of every directory (except itself), and there are
no cycles in an ancestor chain. To capture this constraint precisely, it is useful
to define a helping function:
A directory’s ancestors are its parent and its parent’s ancestors
ancestors(d) = if d = Root then { }
else d.parent + ancestors(d.parent)
(Recall that “+” denotes set union.) Here again we use the notational short-
hand: Root is used to denote its single element, the root directory.
Given this definition, we can state the following:
A directory is not its own ancestor and every directory but
the root has the root as an ancestor
for all d: Dir [ !(d in ancestors(d)) &&
( d = Root || Root in ancestors(d) ) ]
This constraint is interesting because it tells us that the file system contains
only the root directory and other directories accessible from the root. A similar
constraint limits the files contained by the system:
Every file has an entry in some directory
for all f: File [ there exists d: Dir, e: DirEntry
( e in d.entries && f = e.contents ) ]
Together these constraints say that the only file system objects that exist are
those accessible by a path from the root directory. For a user, this means that
files and directories that become unreachable from the root cannot be used;
for an implementor, this means that it is not necessary to provide storage for
unreachable objects.
We also want to constrain the contents of directories.
A directory contains at most one entry with a given name
for all d: Dir, e1,e2: DirEntry [
e1, e2 in d.entries && e1.name = e2.name => e1 = e2 ]
Note that we have written the constraints both informally, in English, and
by using mathematical notation. As was the case with rep invariants, it is
280
12.1 — Data Models
Constraints
A directory is not its own ancestor and every directory but
the root has the root as an ancestor
for all d: Dir [ !(d in ancestors(d)) && ( d = Root || Root in ancestors(d) ) ]
Every file has an entry in some directory
for all f: File [ there exists d: Dir, e: DirEntry
( e in d.entries && f = e.contents ) ]
A directory contains at most one entry with a given name
for all d: Dir, e1,e2: DirEntry [ e1, e2 in d.entries && e1.name = e2.name
=> e1 = e2 ]
A directory can contain at most one entry for a subdirectory
for all d: Dir, e1,e2: DirEntry [ e1, e2 in d.entries && e1.contents in Dir &&
e1.contents = e2.contents => e1 = e2 ]
acceptable to give only the informal definition, providing your definitions are
precise and understandable.
Figure 12.4 gives the constraints for the file system. The constraints do
not include everything that is true about the file system. For example, it is
true that the root directory does not have a parent, and that every other
directory has exactly one parent. However, this fact can be proved from
the constraints already stated, namely, the constraint on ancestors and the
multiplicity constraint on parents.
It’s easier to forget about a textual constraint than those expressed directly
in the graph because there is no way to be as methodical about them. Instead,
you need to think about all groups of relations and whether any other con-
straints are needed for them.
281
Chapter 12 Requirements Specifications
282
12.2 — Requirements Specifications
283
Chapter 12 Requirements Specifications
// Format restrictions:
// NAME: A nonempty string of printable characters not containing the character /
// PATHNAME: A nonempty sequence of NAMEs separated by / and beginning
// either with / (meaning the name starts from the root) or with
// a NAME, meaning the pathname starts from the current directory.
// Static Operations
start( )
// effects: Creates a new file system consisting of just the root
// directory, and this directory is empty.
// Dynamic Operation
makeDirInCur(String n)
// checks: NAME(n) and there is a directory c in Cur and n is not defined in c
// effects: Creates a new directory and enters it with name n in c.
makeCurFromRoot(String p)
// checks: p is a pathname leading from the root to a directory d
// effects: makes d be the current directory.
deleteDir( )
// checks: There is a directory c in Cur and c is empty and is not the root
// effects: Removes entry for c from its parent and sets Cur = { }.
format constraints for Name and PathName, using the notation NAME(n) or
PATHNAME(p); these predicates return true if their argument has the proper
format.
All operations must preserve the constraints of the data model, including
both constraints defined in the graph and ones defined textually. Each op-
eration definition should be examined and its impact on the state validated
against the constraints to ensure that all of them are preserved; as usual in
doing such an analysis, the constraints can be assumed to hold on the state
when the operation starts running. In doing the analysis, start with the checks
clause; it must contain enough checks to rule out conditions that if not checked
would cause the operation to violate some constraint or otherwise not make
sense. Then consider whether the modifications described in the effects clause
will lead to a state that satisfies the constraints. The effects clause must de-
284
12.2 — Requirements Specifications
285
Chapter 12 Requirements Specifications
abstract types except that they can be written in terms of the data model.
For example, the specification of the method that creates a subdirectory of
directory d might state that d is the parent of the new directory.
286
12.3 — Requirements Specification for Stock Tracker
Relations
name: the name of a portfolio
folio: the portfolio associated with a name in Folios
contents: the positions in a portfolio
ticker: the ticker name of a position
quote: the quote for a position
price: the price of a position
time: the date and time of a quote for a position
amount: the number of shares of a position
value: the dollar value of a position
folioValue: the dollar value of a portfolio
and that all positions have non-negative amounts of stock and non-negative
price. Both of these constraints are interesting. The first one implies that when
the tracker obtains a price for a stock, this information must be reflected in
positions in other portfolios that are for the same stock. The second constraint
allows a position that contains zero shares; this makes it possible for a user
to track the quote for a stock without owning any of that stock. The second
constraint also implies that the tracker must cope somehow with a negative
price (should it ever receive one from the Web server); it could either set the
price to zero or leave the old price in place. Let’s assume the former since it
will work even for a newly purchased stock.
287
Chapter 12 Requirements Specifications
288
12.3 — Requirements Specification for Stock Tracker
Derived Relations
The value of a position reflects its amount and price
for all p: Position [ p.value = p.amount * fToD(p.quote.price) ]
The value of a portfolio reflects the values of its positions
for all f: Folio [ f.folioValue = sum(p.value for all p in f.contents) ]
Constraints
A portfolio contains at most one position for a particular stock
for all f: Folio, p1,p2: Position [ p1, p2 in f && p1.ticker = p2.ticker
=> p1 = p2 ]
The current position must belong to the open portfolio
Cur != { } => ( Open != { } && Cur in Open.contents )
All positions for the same stock have the same quote
for all p1,p2: Position [ p1.ticker = p2.ticker => p1.quote = p2.quote ]
All positions have a non-negative amount and a non-negative price
for all p: Position [ p.amount >= 0 && p.quote.price >= 0 ]
289
Figure 12.9 First part of specification of tracker operations
// Modifications are written to disk as they occur. When the stock tracker
// runs, it starts in the end state from the last time it ran.
// Static Operations
startTracker( )
// effects: Starts the tracker running with the end state from its last run.
// Dynamic Operations
createFolio(String f)
// checks: NAME(f) and f not in use in Folios
// effects: Creates an empty portfolio named f and makes it be the
// open portfolio.
deleteFolio
// checks: There exists a portfolio f in Open and f is empty
// effects: Deletes f, removes its entry from Folios, and sets Open = { }.
openFolio(String f)
// checks: f names a portfolio
// effects: Makes f be the open portfolio and sets Cur = { }.
selectStock(String t)
// checks: There exists a portfolio f in Open and t names a stock in f
// effects: Makes t’s position in f be the current position.
buyStock(String n)
// checks: NUM(n) and there exists a position p in Cur
// effects: Increases shares for p by n.
sellStock(String n)
// checks: NUM(n) and there exists position p in Cur and n <= p.amount
// effects: Decreases shares of p by n.
addStock(String t, String n)
// checks: NUM(n) and there exits a portfolio f in Open and t names
// a stock that is not in f
// effects: Adds a position for t with n shares to f and gets a quote for
// t from the web server if no quote for that stock is currently known.
deleteStock
// checks: There exists position p in Cur
// effects: Removes p from Open and sets Cur = { }.
12.4 — Requirements Specification for a Search Engine
moveStock(String f, String t)
// checks: f names a portfolio and there exists portfolio f1 in Open and f != f1
// and t names a position in f
// effects: Removes t’s position from f. If t has a position in f1, adds
// n shares to that position, where n is the number of shares in the
// deleted position, else adds a position for t to f1 with n shares.
getPrice
// checks: There exists a position p in Cur
// effects: Uses the Web server to update the price of p and
// all other positions for that stock.
getPrices
// checks: There exists portfolio f in Open
// effects: Uses the Web server to update the prices of all positions in f.
// Also updates prices of all other positions for those stocks.
The customer indicates that a user should be able to search the collection
for a document with a particular title. However, the main purpose of the
engine is to run queries against the collection, which means we have to decide
what a query is. In consultation with the customer, we determine that a
query begins by the user presenting a single word, which we will refer to
as a keyword. The customer indicates that many words are uninteresting (e.g.,
“and” and “the”) and will not be used as keywords. The customer expects
the search engine to know what the uninteresting words are without any user
intervention; thus, it must have access to some storage, such as a file, that lists
the uninteresting words.
The system responds to a query by presenting information about what
documents contain the keyword. This information is ordered by how many
times the keyword occurs in the documents. The system does not present
the actual documents, but rather provides information so that the user can
examine the matching documents further if desired.
However, the ability to query using a single keyword is quite limited,
and the customer also requests the ability to “refine” a query by providing
another keyword; the matching documents must contain all the keywords.
The customer rules out more sophisticated queries, such as queries that match
documents containing any one of their keywords or queries that require the
keywords to be adjacent in the document in order for there to be a match.
However, such queries are likely in a later release of the product.
Now we need to consider user and system errors, and also performance.
The main performance issue is how to carry out the queries; the customer
wants it to be done expeditiously. This requirement has two implications.
First, the program must contain data structures that speed up the process of
running a query. Second (and more important) is the question of whether
querying requires visiting the Web sites containing the documents. The cus-
tomer indicates that this should not happen; instead, the query should be
based on information already known to the search engine. One implication of
this decision is that the collection might not be up to date. A site might have
been modified since the search engine was told about it, and queries will not
reflect the modifications: they will miss newly added documents or find doc-
uments that no longer exist. The customer indicates that this is acceptable but
that tracking modifications might be desired in a future release. The customer
also indicates that all information about documents should be stored at the
search engine, so that if a query matches a document, the user will be able
to view the document even if it no longer exists at the site from which it was
292
12.4 — Requirements Specification for a Search Engine
fetched. One point to note about these decisions is that a trade-off is being
made between speed of processing queries versus the space taken for storing
documents at the search engine.
Now let’s consider errors. There aren’t any interesting system errors: the
system has some persistent storage containing information about uninterest-
ing words, but this storage is not modified and the customer is not concerned
about media failures. Furthermore, the customer indicates that it is acceptable
for the search engine to simply fail if something goes wrong.
There are interesting user errors, however. The user could enter an un-
interesting word as a keyword or could enter a word not in any document;
the customer indicates that the user should be told about the error in the first
case, but that in the second case, the response will simply be an empty set of
matches. The user might also present a URL for a site that doesn’t exist, that
doesn’t contain documents, or that has already been added to the collection;
all of these actions should result in the user being notified of the error. The
customer indicates that it is acceptable if a document is found at multiple sites
and that, in this case, the document will end up in the collection just once.
Two documents are considered to be the same if they have the same title; again,
a later release might handle things differently.
Now that we have a rough idea of what the search engine is supposed to
do, we are ready to write the requirements specification. As we do so, we will
uncover a number of issues that were overlooked in the analysis but must be
resolved to arrive at a precise specification. Thus, the process of writing the
requirements specification, including the definition of the data model, is an
intrinsic and important part of the requirements analysis process.
The sets and relations for the search engine are defined in Figure 12.11
and the graph is given in Figure 12.12. A document has a title, some URLs
(of the sites from which it was obtained), and a body; a body is a sequence
of words. The NK node represents the uninteresting words; this set is fixed
(its membership never changes). Match represents the set of documents that
match the current query; Key is the set of keywords used in this query. Key
and NK are disjoint (a keyword is never an uninteresting word), but they do
not exhaust Word (since at any moment, many words in documents are neither
keywords nor uninteresting words). Cur is a document that was identified by
title as being of current interest; CurMatch is a Match that is currently being
examined.
The constraints for the search engine are given in Figure 12.13. We can
see that sum is a derived relation: it is the sum of the number of occurrences
293
Chapter 12 Requirements Specifications
Domains
Doc: the set of documents
URL: the URLs of sites where documents were found
Title: the title of a document
Entry: the entries (word/index pairs) in a document
Num: positive integers
Word: words in documents
NK: uninteresting words
Key: keywords used in current query
Match: documents matching keywords of current query
Cur: document currently being examined
CurMatch: match currently being examined
Relations
site: the URLs of sites containing a document
title: the title of a document
body: the entries that make up the contents of a document
index: the index of an entry in Entry
wd: the word of an entry in Entry
doc: the document of an entry in Match
ind: the index of an entry in Match
sum: the count of the occurrences of keywords in a match.
of each keyword in the document. The constraints indicate that the indexes
in Match and Entry are unique, that documents are in Match only if a query
is occurring, that the documents in Match are exactly those that contain all
the words in Key, and that the ordering of documents in Match reflects the
number of occurrences of keywords in the documents.
The requirements specification for the search engine is given in Fig-
ure 12.14. The specification does not give information about formats; we are
assuming a standard format for URLs and a simple format for documents. For
example, words in documents are whatever appears between white space or
HTML control characters, and the sections of a document (title, authors, body)
are separated in a simple way. The specification indicates that the search en-
gine knows about uninteresting words via some private file and that it has no
other persistent state.
294
12.4 — Requirements Specification for a Search Engine
295
Chapter 12 Requirements Specifications
Figure 12.13 Derived relations and constraints for the search engine
Constraints
Every entry in a document has a distinct index
for all d: Doc, e1,e2: Entry [ e1, e2 in d.body &&
e1.index = e2.index ⇒ e1 = e2 ]
Every match has a distinct index
for all m1, m2: Match [ m1.ind = m2.ind => m1 = m2 ]
If there are no keywords, there are no entries in Match
Key = { } => Match = { }
Match contains exactly the documents that match
for all m: Match [ matches(m.doc) ] &&
for all d: Doc [ matches(d) => there exists m: Match (d = m.doc) ]
Match is ordered by keyword count
for all m1, m2: Match [ m1.ind < m2.ind => m1.sum ≥ m2.sum ]
We might provide a way for users to see a list of all the interesting words
occurring in documents in the collection.
We might allow the set of uninteresting words to be changed.
We might have the engine periodically revisit the sites where its documents
came from to obtain any new documents that have been added to the sites.
We might want to have the engine record the URLs of sites persistently
so that it can refetch the documents when it starts up or even store the
documents persistently as well.
We might want to have the engine not store the documents but rather
simply record information about them and then refetch ones that the user
wants to examine.
296
12.4 — Requirements Specification for a Search Engine
// The engine has a private file that contains the list of uninteresting
// words.
// Static Operations
startEngine( )
// effects: Starts the engine running with NK containing the words
// in the private file. All other sets are empty.
// Dynamic Operations
query(String w)
// checks: w not in NK
// effects: Sets Key = { w } and makes Match contain the documents
// that match w, ordered as required. Clears CurMatch.
queryMore(String w)
// checks: Key != { } and w not in NK and w not in Key
// effects: Adds w to Key and makes Match be the documents already
// in Match that additionally match w. Orders Match properly.
// Clears CurMatch.
makeCurrent(String t)
// checks: t in Title
// effects: Makes Cur contain the document with title t.
makeCurMatch(String i)
// checks: NUM(i) and i is an index in Match
// effects: Makes CurMatch contain the ith entry in Match.
addDocuments(String u)
// checks: u does not name a site in URL and u names a site that
// provides documents
// effects: Adds u to URL and the documents at site u with new
// titles to Doc. If Key is non-empty adds any documents
// that match the keywords to Match and clears CurMatch.
297
Chapter 12 Requirements Specifications
12.5 Summary
The main result of requirements analysis is a requirements specification. This
specification needs to be complete and precise: complete so that it captures
all decisions about the requirements, and precise so that the designers can
understand what product to build. This chapter has discussed a way to write
requirements specifications that makes it more likely that we will meet these
goals.
The approach is to base the specification on a data model. The model
defines the program state and how it changes over time. It does so using a no-
tation that allows constraints to be specified. Constraints are defined at several
points: when defining the graph, when defining the textual constraints, and
when specifying the operations. Defining constraints is useful both during
analysis, where they help the analyst to think of details that might otherwise
be overlooked, and in writing the requirements specification, where they pro-
vide a double-check on whether the operations are specified correctly.
The need to define constraints often points out a place where the require-
ments need more thought. This can lead the analyst to further dialog with the
customer to work out the details. Thus, the need to define constraints leads
directly to a more complete specification. The constraints can also lead to im-
proved understanding on the part of designers because the use of the data
model allows a more precise specification than would otherwise be possible.
As a result, we can move into the design phase with more confidence that the
product we will construct is the one the customer wants.
Exercises
12.1 Define more operations for the file system. In particular, add operations to add
and remove entries from directories and to read and write files.
12.2 Extend the data model for the file system to take account of soft links. These
are pathnames that are stored in files; they can be used to access file system
objects but do not guarantee that the objects exist.
12.3 Look at the operations provided by a file system you are familiar with and
compare them to definitions developed in the preceding two exercises. Then
298
Exercises
modify the data model to match your system and define some of its operations
in terms of the new model.
12.4 Extend the stock tracker so that it provides a log of user updates and the ability
to make use of information in the log to undo changes. Modify the data model
and requirements specification to support this upgrade.
12.5 Extend the stock tracker so that it provides “alerts”: warnings when a stock
price exceeds a stated upper bound or falls below a stated lower bound.
Modify the data model and the requirements specification to support this
change.
12.6 Extend the stock tracker so that it keeps its information about stock prices
reasonably up to date (e.g., its quotes are all obtained within the last hour).
Modify the data model and the requirements specification to support this
change.
12.7 Extend the search engine so that it allows distinct documents to have the
same title and so that it revisits document sites periodically to obtain recent
information. Modify the data model and requirements specification to support
these changes.
12.8 Produce a data model and requirements specification for the xref program
discussed in the exercises in Chapter 11.
12.9 Produce a data model and requirements specification for the spelling checker
discussed in the exercises in Chapter 11.
12.10 Produce a data model and requirements specification for the path finder
program discussed in the exercises in Chapter 11.
299
This page intentionally left blank
Design
13
In preceding chapters, we have discussed the specification and implemen-
tation of individual abstractions. We have emphasized abstractions because
they are the building blocks out of which programs are constructed. We now
discuss how to invent abstractions and how to put them together to build
good programs. Our approach will rely heavily on material presented earlier,
especially our discussions of good abstractions and good specifications (for
example, see Chapter 9).
301
Chapter 13 Design
The first step consists of inventing a number of helping abstractions that are
useful in the problem domain of the target. The helpers can be thought of as
constituting an abstract machine that provides objects and operations tailored
to implementing the target. The idea is that if the machine were available,
implementing the target to run on it would be straightforward.
Next, we define the helpers precisely by providing a specification for each
one. When an abstraction is first identified, its meaning is usually a bit hazy.
The second step involves pinning down the details and then documenting the
decisions in a specification that is as complete and unambiguous as is feasible.
Once the behavior of each helper is defined precisely, we can use them
to write programs. In principle, the target can now be implemented, but we
302
13.1 — An Overview of the Design Process
1. Select a target abstraction whose implementation has not yet been studied.
2. Identify helper abstractions that would be useful in implementing the target and that
facilitate decomposition of the problem.
3. Sketch how the helpers will be used in implementing the target.
4. Iterate until the implementations of all abstractions have been studied.
303
Chapter 13 Design
When errors are discovered, we must correct them by changing the design.
We often must discard all later work that depends on the error. That is why
we are reluctant to start implementation until after we have a complete design,
or at least a complete design of the part of the program being implemented.
Of course, no matter how careful we are about our design, we are likely to
uncover problems with it during implementation. When this happens, we
must rethink the part of the design related to the problem.
Our discussion so far has outlined how design occurs but has neglected a
number of questions, such as:
How is decomposition accomplished? That is, how do we identify sub-
sidiary abstractions that will help to decompose the problem?
How do we select the next target?
How do we know whether we are making progress? For example, are
the helpers easier to implement than the target that caused them to be
introduced?
How are performance and modification requirements factored into a de-
sign?
How much decomposition should be done?
These and other, similar questions will be addressed as we carry out an
example. First, however, we discuss how to document a design.
304
13.2 — The Design Notebook
the design starts or introduced as helpers) and shows their relationships. The
module dependency diagram shows the code modules (e.g., the classes and
interfaces) that will exist in the program when it is implemented. It also shows
their dependencies, where module M1 depends on module M2 if a change to
M2’s specification might cause a change in M1. The module dependency diagram
will be especially useful for tracking the impact of a change in a specification,
since it will allow us to identify all modules that must be reconsidered because
of the change.
A module dependency diagram consists of nodes, which represent abstrac-
tions, and arcs (see Sidebar 13.4). There are three kinds of nodes, one for each
of the three kinds of abstractions we use. Each node names its abstraction.
Nodes like the one labeled C1.P in Figure 13.1 represent procedures, ones like
C2.I represent iterators, and the remainder represent data abstractions. A data
abstraction will be implemented by either a class or interface. Procedures and
iterators will be implemented by static methods in some class; the node name
will indicate the class as well as the method name (e.g., C.P indicates that
procedure P will be implemented in class C).
There are two kinds of arcs. The first kind, an arc with an open head, is
a using arc; it shows which modules are to be used in implementing which
other modules. The arc goes from a source abstraction to a helper abstraction;
it means that the implementation of the source abstraction will use the helper.
We say that the source abstraction uses or depends on the helpers. In the case
305
Chapter 13 Design
The nodes of the diagram represent abstractions; each node names its abstraction, and
this name identifies a program entity that will implement the abstraction.
The arcs represent dependency, where abstraction A depends on abstraction B if a
change to B’s specification means that A’s implementation or specification must be
reconsidered. aboveskip=17pt
.
An arc with an open head indicates that abstraction A’s implementation uses or
weakly uses B.
.
The arcs with closed heads indicate extensions: a subtype extends its supertype.
306
13.2 — The Design Notebook
example, Figure 13.1 indicates that data abstraction G has two subtypes, G1
and G2. There cannot be any cycles involving just extension arcs.
The module dependency diagram is useful when errors are detected. A
design error shows up as a flaw in an abstraction—for example, an efficient
implementation becomes impossible or needed arguments are missing. The
result is that the abstraction’s interface, and therefore, its specification, must
change. The potential impact of the change can be determined from the
diagram. All abstractions that use the erroneous one must be reconsidered in
light of the new interface and its specification. (That reconsideration may find
some of those abstractions erroneous, and so on.) An abstraction that weakly
uses an erroneous data abstraction is affected if the abstraction disappears
entirely but not if its specification changes or it is replaced by another type.
For example, if we found a problem with G in Figure 13.1, we would have
to rethink the implementations of E and P, but D would not be affected. Of
course, if rethinking E prompted us to change its specification, we would be
forced to reexamine the implementation of D.
307
Chapter 13 Design
The extension arrows are used in a similar way. If the specification of a data
abstraction with subtypes changes, all its subtypes must be examined; either
their specifications will also change, or they can no longer be its subtypes.
Thus, a change to the specification of G means that we must examine G1 and
G2. Also, if a data abstraction with supertypes changes, we must examine the
supertypes. It is possible that the change will have no impact on a supertype;
this would happen if the change affects only the subtype’s new methods, and
the subtype still satisfies the substitution principle (as defined in Chapter 7).
If, however, the substitution principle is no longer satisfied, the supertype
must be redefined, or the changed abstraction can no longer be its subtype,
and the diagram must be changed to reflect this. Thus, if the specification of
G1 changes, we must examine G; either the substitution principle still holds,
or we must redefine G, or G1 can no longer be a subtype of G.
Although a module dependency diagram looks a bit like a data model
graph, the two are very different. A module dependency diagram describes
the structure of a program; its nodes are program modules, and its arcs define
relationships among these modules. A data model, on the other hand, is
abstract; its nodes define sets and do not correspond to program modules.
We will discuss this issue further in Section 13.11.
308
13.2 — The Design Notebook
309
Chapter 13 Design
In closing this section, we should note that if the design is very large, it may
be useful to structure the notebook by introducing subsidiary notebooks. In a
module dependency diagram, any subgraph can be viewed as an independent
subsystem. However, the most convenient choice is a subgraph in which
only one node is used from outside. In this case, the entire substructure
simply corresponds to a single abstraction as far as the rest of the program
is concerned.
310
13.3 — The Structure of Interactive Programs
The second reason for having the separation is that it allows us to change
the way the user interface appears to the user (the “look and feel”) without
changing the functional part. For example, this makes it relatively easy to
replace a simple UI with a more sophisticated one. In fact, we can have several
different UIs; all of them can make use of the same FP.
The third reason for the separation is that it allows us to develop thorough
regression tests (see Chapter 10) for the functional part. The regression test
code will be programmed to interact with the FP just like a UI does, but rather
than interacting with a user, it will act as a driver of the FP.
There are two ways to connect the UI and FP, as illustrated by the module
dependency diagrams in Figure 13.2. In the first structure, the FP provides
methods that the UI calls when user inputs happen; the FP methods carry
out the user request and return some information to the UI that informs it
of the result. The second structure extends the first: the UI still calls the FP
to inform it of user inputs, but the FP can either return a result or call a UI
method, whichever is more convenient. In doing a design, we always start
with the first structure, since it will be adequate for many applications. We
will switch to the second structure if we discover the need for it as the design
progresses.
We begin our design by considering the UI since it drives the application
by interacting with the user. We do not concern ourselves with the form of
the user interaction, and in fact UI design is beyond the scope of the text. Our
concern instead is to come up with a design of the FP that is independent of
311
Chapter 13 Design
any particular UI. We focus on specifying the FP methods that are used by
a UI to carry out user requests. These methods will be independent of the
particular UI, so that we can change the UI as desired.
To determine the FP interface, we consider each operation in the require-
ments specification. In most cases, we will invent an FP method that will be
called by a UI to perform the operation. The FP will do all the actual work
of the application; the UI is responsible only for the interaction between the
user and the FP.
Figure 13.3 gives a specification for our FP, Engine. This specification
is derived from the requirements specification for the search engine shown
in Figure 12.14. A first point to note is that the methods are similar to the
associated operations but with two differences. First, they do not have checks
clauses (nor requires clauses); instead, they throw NotPossibleException
when a check would be violated. The value of this exception will be a string
explaining the problem; the idea is that the UI will simply present the string
to the user to explain what the problem is. The second difference from the
operations is that methods return results that can be used by the UI to display
information to the user.
A second point is that the specification of Engine makes use of the data
model. We will continue to use the data model as we develop the design.
A third point is that the methods return objects of two data abstractions,
Doc and Query, and we need to define these data types. Doc is the way the
UI gets hold of a document; to display a document, it needs access to its
title and text. Query is the way the UI gets hold of a query result; here it
needs access to the keywords of the query and the documents that match
the query, but it does not need to know the sum for each match since this
information is not displayed to users. Figure 13.4 gives specifications for these
two types. The specifications are preliminary: they are missing constructors
312
13.3 — The Structure of Interactive Programs
class Engine {
// overview: An engine has a state as described in the search engine
// data model. The methods throw the NotPossibleException
// when there is a problem; the exception contains a string explaining
// the problem. All instance methods modify the state of this.
// constructors
Engine( ) throws NotPossibleException
// effects: If the uninteresting words cannot be retrieved from the
// persistent state throws NotPossibleException else creates NK and
// initializes the application state appropriately.
// methods
Query queryFirst (String w) throws NotPossibleException
// effects: If ¬WORD(w) or w in NK throws NotPossibleException else
// sets Key = { w }, performs the new query, and returns the result.
313
Chapter 13 Design
class Doc {
// overview: A document contains a title and a text body.
// methods
String title ( )
// effects: Returns the title of this.
String body ( )
// effects: Returns the body of this.
}
class Query {
// overview: Provides information about the keywords of a query and
// the documents that match those keywords. size returns the number
// of matches. Documents can be accessed using indexes between 0 and
// size. Documents are ordered by the number of matches they
// contain, with document 0 containing the most matches.
// methods
String[ ] keys ( )
// effects: Returns the keywords of this.
int size ( )
// effects: Returns a count of the documents that match the query.
314
13.4 — Starting the Design
Engine
Query
Doc
315
Chapter 13 Design
A good way to study the problem structure is to make a list of the tasks
that must be accomplished. In the case of a data abstraction like Engine, each
method is a task, so we begin by considering each of them in turn. We can
choose to consider them in any order, but some of them are more interesting
than others. For example, considering queryFirst will force us to think about
how we do query processing, and considering findDoc will force us to think
about how we find a document given its title.
When a method has lots of work to do, we list the tasks it must accomplish.
Here is a list for queryFirst:
1. Check the input string w to be sure it’s a word.
2. Make sure it’s an interesting word.
3. Start a new query with w as the only keyword.
4. For each document, determine whether it is a match (contains w).
5. For each match, determine the number of occurrences of w.
6. Sort the matches by number of occurrences of w.
7. Return the information about the matches and query.
We do not assume that the final program will have subparts that correspond
to the listed tasks. Listing the tasks is just a first step toward a design. Also,
although we have listed the tasks in approximately the order in which they
might be carried out, we do not assume that this order will exist in the final
program. As we continue the design, we might reorganize the way the tasks
are carried out.
Instead, the next step is to use the list as a guide in inventing the abstrac-
tions that will determine the program structure. In looking for abstractions,
we seek to hide details of processing that are not of interest at the current level
of the design. Although we can use procedures and iterators to hide details,
data types (and sometimes families of types) are most useful for this.
Let’s start by considering task 4 since it is critical to the performance of
the queryFirst method. One way to proceed is to examine all the words of all
the documents, or at least all the words of a single document, until we find
a match. However, this is going to be very time consuming if the collection
is large. Furthermore, as we look at a document, we will look at many words
that are not the current keyword but that might be keywords in later queries.
If we can record the information we learn about them, we can avoid work in
future queries. To record the information, we need a data abstraction, ITable,
316
13.4 — Starting the Design
that keeps track of what interesting words are in documents. Information can
be added to ITable either when documents are fetched (by addDoc) or when a
query is run after documents have been fetched; the former decision is better
because it avoids a test in queryFirst. This decision means that queryFirst
doesn’t need to iterate through the documents at all! It just uses the prestored
information by calling a lookup method of ITable.
Now consider task 5. Although there are presumably many fewer docu-
ments that match a query than there are documents in the collection, process-
ing the matching documents to count occurrences of the keyword is still lots of
work. Furthermore, it is redundant work: when information about a document
was added to the ITable, we had to look at every word of every document, so
we might as well count occurrences at the same time and store this informa-
tion in the ITable too. The lookup method can then return information about
counts as well as matches.
Next let’s consider task 6. We need to sort the matches by count, but
this can be accomplished in many ways. Rather than deciding how to do
this now, let’s instead introduce a data abstraction, MatchSet, to take care
of the details. In fact, it is probably a good idea for MatchSet to perform the
query: this provides flexibility for its implementation (e.g., the chance to sort
as documents are added to the set), and it allows us to defer deciding about
the kind of information the ITable lookup method returns until we consider
what form will be most useful for implementing MatchSet. Having MatchSet
perform the query means that all of tasks 3–6 will be handled by a MatchSet
method; this method can return the Query object that contains the result for
the query.
Finally, let’s consider the first two tasks. For the second task, we need
to preprocess the information about uninteresting words when the program
starts up so that we can quickly look up whether a proposed word is unin-
teresting. This information could be stored in a separate data abstraction, or
we might merge the information with that about interesting words. The latter
choice gives us a table that keeps track of all words, both interesting and un-
interesting; its lookup method can return all this information. An advantage
of this choice is that it allows each word in a new document to be added to the
table with just one call, rather than having to check first in another table to
see whether the word is uninteresting. Therefore, let’s make this choice and
rename ITable to be WordTable to better match its function. Another point
is that WordTable methods can also detect nonwords; a nonword is just a
special kind of uninteresting word.
317
Chapter 13 Design
318
13.4 — Starting the Design
class Comm {
static Iterator getDocs (String u) throws NotPossibleException
// effects: If u isn’t a legitimate URL or the site it names does not
// respond as expected throws NotPossibleException else returns a
// generator that will produce the documents from site u (as strings).
}
319
Figure 13.7 Specifications of WordTable and TitleTable
class WordTable {
// overview: Keeps track of both interesting and uninteresting words.
// The uninteresting words are obtained from a private file. Records
// the number of times each interesting word occurs in each document.
// constructors
WordTable ( ) throws NotPossibleException
// effects: If the file cannot be read throws NotPossibleException
// else initializes the table to contain all the words in the file
// as uninteresting words.
// methods
boolean isInteresting (String w)
// effects: If w is null or a nonword or an uninteresting word
// returns false else returns true.
class TitleTable {
// overview: Keeps track of documents with their titles.
// constructors
TitleTable ( )
// effects: Initializes this to be an empty table.
// methods
void addDoc (Doc d) throws DuplicateException
// requires: d is not null
// modifies: this
// effects: If a document with d’s title is already in this throws
// DuplicateException else adds d with its title to this.
320
13.4 — Starting the Design
Thus, its rep just stores the state of the engine and the tables that will be used
to process future user requests. The actual rep may be different from what is
shown here but will contain the information shown in the sketch.
321
Chapter 13 Design
class Query {
// overview: as before plus
// constructors
Query ( )
// effects: Returns the empty query.
// methods
void addKey (String w) throws NotPossibleException
// requires: w is not null
// modifies: this
// effects: If this is empty or w is already a keyword in the query
// throws NotPossibleException else modifies this to contain the
// query for w and all keywords already in this.
class Doc {
// overview: As before plus
// constructors
Doc (String d) throws NotPossibleException
// effects: If d cannot be processed as a document throws
// NotPossibleException else makes this be the Doc
// Corresponding to d.
}
322
13.5 — Discussion of the Method
Engine
WordTable
Doc
323
Chapter 13 Design
Our basic approach is to let the problem structure determine the program structure:
List the tasks to be accomplished.
Use the list to help in inventing abstractions, especially data abstractions, to accomplish
the tasks.
Introduce abstractions to hide detail: to abstract from how to what.
Each abstraction should be focused on a single purpose, and its implementation should
actually do something.
of a module should be at roughly the same level of detail. Finally, each abstrac-
tion should be focused on a single purpose; we discuss this issue further in
Chapter 14. See Sidebar 13.6 for a summary of our design method.
The design of Engine is typical of the design of a top-level abstraction in
a system. The implementations of such abstractions are concerned primarily
with organizing the computation, while the details of carrying out the steps
are handled by helpers. Introducing partially specified data types like Doc and
Query is also typical. In the early stages of design, we frequently know that
two modules must communicate with each other, but we do not know exactly
how this communication is to take place. In particular, we know that modules
are to communicate through objects of some type, but we do not know what
methods on those objects will be useful. Therefore, the specification of the
shared type is necessarily incomplete. It will be completed as we continue
with the design.
324
13.6 — Continuing the Design
Identify all candidates; these are abstractions whose implementation has not yet been
studied but whose specification is complete.
Choose the target T from among the candidates. Reasons for choosing a particular
target include exploring an uncertainty, increasing insight into the program structure,
or finishing up a part of the design.
are not candidates since we have not yet studied how to implement modules
that use them; and therefore we cannot be certain that their specifications are
complete. (Actually, we shall occasionally select an incomplete abstraction as
a candidate, as discussed a bit later in this section.)
Therefore, we have three candidates: getDocs, TitleTable, and Query.
How do we choose between them? There are no hard and fast rules here;
either candidate could be studied. However, there are several reasons why we
might prefer one of several candidates:
325
Chapter 13 Design
Furthermore, doing this might allow us to start implementing that part of the
program.
In our example, all candidates are central to the design, although getDocs
raises issues about how to communicate with other sites, whereas the other
two candidates will expose details about data structures. To shorten the
presentation, we will not go into the design of getDocs; instead, we will
assume that it is provided by some library (e.g., a class Comm). Therefore, we
need to choose between TitleTable and Query. Both will provide insight; we
will start with TitleTable since it seems a bit simpler, and also it will shed
some light on Doc.
There are two main issues in TitleTable: how to get a title from a doc-
ument (in the addDoc method), and how to organize the table so that lookup
will be fast. The title can be obtained by calling the title method of Doc. A
fast lookup can be achieved by using a hash table, either one we implement
directly within TitleTable or the one provided by java.util. However, we
need to decide what types to use for the keys and values. In this case, the
values are Docs. Either we can use a string for the key or we can introduce a
Title data abstraction. The former decision seems acceptable, assuming that
titles are random enough that the hash method for strings will produce suf-
ficiently random results; we will make this assumption since it seems to be
reasonable.
We do not need to extend the module dependency diagram at this point
because we have not introduced any new abstractions. Even if we had decided
to use the hash table in java.util, it isn’t necessary to add it to the diagram
since it is in a standard library. However, we do need to explain in the
documentation about the implementation of TitleTable that we intend to
use a hash table mapping from strings to Docs. And if we decided to use the
hash table in java.util, we should also state this. In this case, using the hash
table in java.util should be adequate.
326
13.7 — The Query Abstraction
In addition, Query must create an empty query, but that task is trivial.
Probably a good place to start is by considering how to make a query given
a keyword. To do so, Query must
1. Find all the documents that contain the keyword with its count.
2. Keep track of the keyword.
3. Sort the documents based on number of occurrences of keywords.
Task 2 can be done using the WordTable lookup method. To do a good job
of task 3, we need a quick way to check whether a document that matches
327
Chapter 13 Design
the new key is already in the query. This cannot be done efficiently using the
sorted tree, since that is sorted on number of matches with the previously
known keywords, not on something that identifies the document. Instead, we
need to store the previous matches (or the new matches) in a hash table so
that we can look them up efficiently. Using a hash table will give us a constant
rather than a linear lookup.
Next let’s consider the addDoc method. This method must check whether
each of its keywords is in the new document; if they are, it must add the
document to the matches in the proper sorted order (i.e., it just adds the
document with its sum to the sorted tree). However, there is a problem with
this scheme: how does addDoc find out whether the keywords are in the new
document? We could look up each keyword, and then determine whether
the new document is in the list; but since these lists can be long, the test
will be expensive. As an alternative, we could provide another WordTable
method to look up a document and return its interesting words, but either
that method requires a more complex implementation for WordTable, or it will
be time consuming. Also, we don’t really need this method; the only time we
are interested in looking up the words of a document is when it is first added
to the database. Therefore, instead let’s have the addDoc method of WordTable
return a hash table that tracks the keywords of the new document. This table
can be constructed in time linear in the document size, and testing whether the
document matches the query will be linear in the number of keywords. The
table can actually map each word in the document to its number of occurrences
so that in case the document matches the query, the sum of matches can be
computed easily. The table will need to be an argument to the addDoc method
of Query.
Thus, we have identified a need to change the specification of Query and
also of WordTable, which means we need to re-examine Engine to determine
the impact of these changes. But there is no problem: Engine simply passes the
table returned by the call of the addDoc method of WordTable to the addDoc
method of Query.
Finally, let’s consider the observers. Keeping the documents in a sorted
tree or a hash table will not allow an efficient implementation for fetch.
Although we could imagine replacing the fetch method with an elements
iterator (over the sorted tree), fetch is really what is needed for the UI.
Therefore, after we sort, we should move the documents into an array so that
fetch can be implemented efficiently.
However, if we are going to move the documents into an array, it doesn’t
make sense to use the sorted tree to sort them! Instead. we should store them
328
13.7 — The Query Abstraction
in an array and sort them in place, using a good sorting algorithm such as
quickSort. Actually, we’ll use a vector so that in the addDoc method, we can
simply insert the new document into the proper location in the vector (using
the Vector method insertElementAt). The implementation of the addKey
method will move the documents into a hash table, store the documents
containing all keys in a vector, and then, as in the constructor, sort the vector.
Thus, we end up with approximately the following rep for a nonempty
query:
WordTable k;
Vector matches; // elements are DocCnt objects
String[ ] keys;
DocCnt is a record-like type with two fields, the document and the count of
the occurrences of keywords in the document. The rep invariant will state that
matches is sorted by count, that the sums are correct, and that the matches
contain all the keywords—in other words, it will state some of the constraints
from the data model!
Since the implementation of Query is not obvious, it’s useful to document
our decisions by making sketches of some of the methods. Figure 13.10 shows
these sketches. They can be used to help in developing the specifications for
329
Chapter 13 Design
class Query {
// As before except the addDoc method specification has changed.
class WordTable {
// As before plus
newly identified methods and abstractions, and they can also be entered in
the design notebook (in the section for the Query abstraction).
The extended specifications for Query and WordTable are given in Fig-
ure 13.11. Figure 13.12 gives the specification for quickSort and related
abstractions. quickSort is a generic abstraction that requires that elements
of the vector belong to a type that extends the Comparable interface (this in-
terface was defined in Figure 8.4). Since the actual vector being used in Query
contains DocCnt objects, this means DocCnt must extend this interface. Note
that the specification of DocCnt does not contain details about the methods
for accessing the components since these are standard for record-like types,
as explained in Chapter 5.
The extended module dependency diagram is shown in Figure 13.13. The
most interesting point here is the use of hierarchy to explain how DocCnt
330
Figure 13.12 Specifications of quickSort and DocCnt
class Sorting {
// overview: Provides a number of procedures for sorting vectors.
// methods
int compareTo (Object x) throws ClassCastException, NullPointerException
// effects: If x is null throws NullPointerException; if x isn’t a DocCnt
// object, throws ClassCastException. Otherwise, if this.cnt < x.cnt
// returns -1; if this.cnt = x.cnt returns 0; else returns 1.
}
331
Chapter 13 Design
332
13.9 — Finishing Up
class Doc {
// As before plus we now know that Doc is immutable and that
// it provides an iterator.
Iterator words ( )
// effects: Returns a generator that will yield all the words in the
// document as strings in the order they appear in the text.
}
class Helpers {
// Provides various helping procedures; at present only canon is defined.
we can change it easily if necessary. Thus, this design is better than one in
which, for example, both Engine and WordTable needed to produce canonical
forms.
Another point is that the words iterator of Doc simply returns words. It
does not determine whether those words are keywords. This design is more
desirable than one in which Doc knows about keywords since it represents a
clean separation of concerns.
The specification of canon and the extended specifications for Doc are
given in Figure 13.14. Figure 13.15 shows the extended module dependency
diagram.
13.9 Finishing Up
Now we have only the canon and Doc abstractions left. canon is trivial to
implement, and we need not consider it further. Doc parses its input string as
needed. When a Doc is constructed, the constructor finds the title by parsing
the first part of the string; if it can’t find a title, it throws the NotPossible-
Exception. The rest of the parsing is done by the words iterator; at each
333
Chapter 13 Design
iteration it finds the next word and returns it. It terminates when it reaches
the end of the document.
334
13.10 — Interaction between FP and UI
335
Chapter 13 Design
Actually, we might also want to have a hierarchy for the FP, so that the
UI can be used with variants of the FP. We will discuss this issue further in
Chapter 15.
The extension of the search engine to inform the user about progress in
fetching documents does not address another user need: when an operation
takes a long time to carry out, the user may want to terminate it. When this
happens, the user generally requires a speedy response to the termination
command. Our current design allows the termination to happen each time the
engine calls the docProgress method, but this may not be fast enough, for
example, if communication is very slow or the site containing the documents
is not responding well.
To provide faster response, however, requires the use of concurrency. An
FP method that runs for a long time would fork a thread to do the lengthy
work and then return to the UI. The UI can then call some other FP method
if the user indicates that it is time to abandon that work. For example, in
the search engine, the body of the addDocs method would fork a thread; the
thread would carry out the fetching and processing of documents, while the
addDocs method would return immediately. Engine would need to provide
another method that the UI can call to terminate fetching of documents:
void stopFetch ( )
// effects: Terminates the current fetching of documents.
This structure allows the search engine to go on to the next user command
immediately, even though document fetching is in progress. (To find out how
to use threads in Java, consult a Java text.)
336
13.11 — Module Dependency Diagrams versus Data Models
keep the design focused on what is wanted because it plays an important role
in clarifying the requirements specification. The end result of the design is
a structure that is quite different from the model. This can be seen for the
search engine by comparing Figures 12.12 and 13.15.
However, the data model can be used to check on the correctness of the
design. First, every set in a data model must show up somewhere in the
program, or the design cannot be correct. For example, here is an explanation
of what happened to the sets for the search engine:
337
Chapter 13 Design
A module dependency diagram identifies program modules that will be used in the
implementation. A data model defines sets that do not correspond to program modules.
Every set in a data model must show up somewhere in the design. One check on the
completeness of the design is to explain how it handles each set.
Furthermore, the design must enforce all the constraints of the model. A good check
on the correctness of the design is to explain how this is accomplished.
338
13.12 — Review and Discussion
339
Chapter 13 Design
340
13.12 — Review and Discussion
341
Chapter 13 Design
are guidelines for making this choice, there are no rules. For example, there is
no requirement that all abstractions at one level in the design must be studied
before those at lower levels. Instead, common sense should be used, with the
goal of finishing the entire design as quickly as possible. This is why we look
at questionable abstractions as soon as possible and even study them before
they are complete.
If, while studying the implementation of some target, we discover an error
in the abstraction itself (i.e., a change in its specification), we must correct
the error before proceeding further with the current target. We use the arcs
in the module dependency diagram to discover all implementations affected
by the error. Then we correct the design for those implementations. In the
process, we may discover more errors that must also be corrected. We saw
several examples of such errors in our design, but all of them were easy to
handle. In a real design, we are likely to find more significant problems that
may lead us to back up through several abstractions before the problem can
be fixed. In fact, some design errors may not be found until the implementation
is underway. The data model can be used to check for design errors as dis-
cussed in Section 13.11; other techniques for finding errors early will be
discussed in Chapter 14.
When there are no more candidates, then ordinarily the design is finished.
There is one special case, however. When two or more abstractions are mu-
tually recursive, then none can be a candidate, since each is used by another
abstraction that has not yet been studied. When dealing with mutually recur-
sive abstractions, we must proceed with caution. One must be selected as a
candidate and studied first. Since this candidate is used by another abstrac-
tion that has not been studied, we may discover later that its behavior is not
what is needed. This problem is another indication of why mutual recursion
must be viewed with suspicion.
342
13.13 — Top-Down Design
lems are being avoided that would have been introduced by other structures
studied during the design.
Because such documentation is difficult and time consuming to produce, it
is often neglected. It is important in the later stages of program development,
however, whenever a situation arises in which a design decision must be
reconsidered or changed. The new decision can best be made by someone
who fully understands the design. The documentation makes the needed
information available, both to people other than the original designers and
even to the original designers, who will forget it as time goes by.
343
Chapter 13 Design
and that we avoid actually implementing any abstractions until their design
and that of their helpers is complete; doing implementation before then is
truly a waste of effort, since the chances of all the details being right are
small.
13.14 Summary
This chapter has discussed program design. Design progresses by modular
decomposition based on the recognition of useful abstractions. We discussed
how this decomposition happens and illustrated the design process with an
example. We also discussed a method of documenting a design in a notebook.
The example used was a simple search engine, and the resulting program
was small. In addition, the presentation of the design process was unrealistic:
we made very few errors as we went along, and these errors had little impact on
the overall design. In the real world, any design, even of a simple program,
requires a great deal of iteration, and many errors will be introduced and
corrected as it progresses. Nevertheless, the basic methods we presented still
apply, even to much larger programs. We have used them in such programs
ourselves.
We do not claim that the search engine design is the best possible. In
fact, the goal of the design process is never a “best” design. Instead, it is an
“adequate” design, one that satisfies the requirements and design goals and
has a reasonably good structure. We discuss this issue in the next chapter.
This chapter carried out the design in its entirety, without any implemen-
tation occurring until the design was finished. It is often desirable to start the
implementation earlier. If some portion of the design is complete, those mod-
ules can be implemented while the remainder of the design is carried out. For
example, we might have implemented the TitleTable while we were working
on the design of query processing. Starting the implementation early is desir-
able because it can lead to earlier completion of the program or to the early
release of a product that provides some features. However, before doing any
implementation, it is important to evaluate the design to determine whether
it is “correct”—that is, will lead to a program that meets the requirements.
Techniques for determining this are discussed in the next chapter.
344
Exercises
Exercises
13.1 In many places in the design of Engine, we chose to require that arguments
were not null rather than having the called method throw an exception if the
argument was null. What do you think of this approach versus having the
called methods throw exceptions?
13.2 In the design of Query, sorting matches using a sorted tree was rejected in
favor of sorting a vector. Compare the performance differences between these
two alternatives.
13.3 Suppose we change the design so that the addDoc method of WordTable is
passed an extra argument, an Iterator that it uses to get the words of the
document:
Provide a specification for this method. Also discuss its ramifications on the
design and how it affects the module dependency diagram. Discuss whether
this change is an improvement over the design presented in the chapter.
13.4 Suppose the search engine is no longer going to store documents once they
have been entered in the word and title table; instead, if a user wants to view
a document (selected through a match on a title or a query), the engine will
refetch the document. Modify the requirements specification and the design
to accommodate this change.
13.5 Modify the search engine to support disjunctive queries—that is, queries that
match all documents that contain at least one keyword in a list of keywords.
First, change the requirements specification, including changes to the data
model if any are needed. Then change the design.
13.6 Modify the search engine requirement specification and design to allow more
than one document to have the same title.
13.7 Design and implement the stock tracker program specified in Chapter 12.
13.8 Design and implement the xref program specified in the exercises of Chap-
ter 12.
13.9 Design and implement the spelling checker specified in the exercises of Chap-
ter 12.
345
Chapter 13 Design
13.10 Design and implement the path finder program specified in the exercises of
Chapter 12.
13.11 Form a team of three or four people and design and implement a moderately
large program.
346
Between Design
and Implementation
14
In this chapter, we discuss briefly the two considerations that arise between
the completion of a design and the start of implementation—namely, evalua-
tion of the design and choice of a program development strategy.
347
Chapter 14 Between Design and Implementation
the review is only to find errors, not to correct them. Errors should be recorded
in an error log, and then the review should continue (unless so many errors
have been found that continuing is no longer productive).
It is useful for the designers to present not only the design but also the
alternatives that were considered and rejected. This will give the outside
reviewers a context for evaluating the chosen design. It may also help the
reviewers to find flaws in the design. A common problem is failure to apply
design criteria uniformly. Explaining that an alternative was rejected because
it failed to meet some criterion may well prompt the reviewers to notice that
some other part of the design fails to meet that same criterion.
There are three critical issues to address in evaluating a design:
1. Will all implementations of the design exhibit the desired functionality?
That is, will the program be “correct”?
2. Are there implementations of the design that will be acceptably efficient?
3. Does the design describe a program structure that will make implemen-
tations reasonably easy to build, test, and maintain? Also, how difficult
will it be to enhance the design to accommodate future modifications, es-
pecially those identified during the requirements phase?
348
14.1 — Evaluating a Design
If the design does not specify any performance constraints for sort, relatively
little can be said about the performance of its implementations or about the
performance of abstractions to be implemented using sort. The problem is
that implementations of sort span a wide range with respect to performance.
Considerably more can be said about the performance of implementations if
the design includes the following criterion:
349
Chapter 14 Between Design and Implementation
possible to explain how the implementation handles every set and preserves
each constraint.
The next step is to trace paths through the design that correspond to the
various operations. We select some test data and then trace how both control
and data would flow through an implementation based on the design. This
tracing process is sometimes called a walk-through. The test data are chosen
in much the same way as described in Chapter 10. However, since “testing” a
design is labor intensive, we must be very selective in choosing our test data.
Since the point of tracing the design is to convince ourselves that all im-
plementations of the design will have the desired functionality, the success
of this method is related to the completeness of the test cases. We are using
the test cases to carry out an informal verification process. During the design
review, it is also important to discuss the completeness of the process—that
is, to argue that all cases have been considered. Both normal and exceptional
cases should be considered.
Picking test cases for a design review is simplified by the fact that the data
can be symbolic. We need only identify properties that the test data should
have; we do not need to invent data with those properties.
As an example, consider the search engine program designed in Chap-
ter 13. We start with the call of the constructor of Engine. If an error occurs
in creating the “WordTable” (because the file of uninteresting words is ill
formed), the program will terminate. Otherwise, the table will be initialized,
and we can proceed with user commands. At this point, no documents are
in the collection; and therefore an attempt to look up a document using its
title will fail, and queries will either fail (if the input is uninteresting or not a
word) or give no matches.
Now suppose the user requests the fetching of some documents. Here we
can define some symbolic data: the fetch produces three documents:
All of these words are interesting words; in addition, the documents contain
some uninteresting words.
We use these data to walk through the design of Engine. The walk-through
is, in effect, a hand simulation of the design. The main thing we want to
350
14.1 — Evaluating a Design
examine is the flow of information through the program. Here is how we might
start a walk-through based upon the previous data:
1. Processing the fetch causes three Docs to be created. The Docs are then
added to the title and word tables. When the Doc is passed to the WordTable
addDoc method, its words are added to the table if they are interesting.
This work is done efficiently since the title table and the word table are
hash tables, and the documents are processed incrementally.
2. Suppose the user performs a query for w1. This word will be canonicalized,
found to be interesting, and passed to Query. Query looks up the word in
the WordTable and finds that it is contained in d1 and d2. It will sort in
the order d2, d1. Now the user can examine the documents via the UI. The
lookups in the word table are fast since it is a hash table, and the sort is
also efficient.
3. Next the user adds w2 to the query. This is canonicalized, found to be
interesting, and passed to the addKey method of query. The method looks
up the word in the WordTable and finds that it matches d1 and d3. It
combines this information with its previous query results to determine
that only d1 has both words.
4. Next the user looks up a title t. This is looked up in the title table, which
returns the appropriate match or throws an exception if t is not a title of
an existing document.
The walk-through continues in this fashion until the behavior of the entire
program has been explored. In the process, we estimate the performance of
each module, so that we can construct estimates of worst-case and average
efficiencies for the whole program.
Walk-throughs are a laborious and imprecise process. Experience indicates
that designers are seldom able to examine their own designs adequately. The
process works best when it is performed by a team of people including, but
not dominated by, the designers.
Tracing through the entire design with a small set of inputs helps us to
uncover gross errors in the way the abstractions that make up the design fit
together. A good next step is to work bottom-up through the module de-
pendency diagram, isolating subsystems that can be meaningfully evaluated
independently of the context in which they will be used. Since these subsys-
tems are likely to be considerably smaller than the system as a whole, we can
trace more sets of test data through them. For example, if canonicalization had
351
Chapter 14 Between Design and Implementation
been left out of the design, the error might be noticed during a review of the
WordTable because, at that point, we could look in more detail at the words
in documents.
The modifiability of the design should be addressed explicitly during the
review. A discussion of how the design must be changed to accommodate
each expected modification should take place. A plausible measure of how
well the design accommodates modifications is how many abstractions must
be reimplemented or respecified in each case. The best situation is one in which
only a single abstraction needs to be reimplemented.
For example, suppose the search engine were modified to not store the
text of documents; instead, when a user wanted to examine a document, the
document would need to be refetched from its site. This requires our design
to be changed in a number of ways. First, we would probably like to refetch
the document using its URL, but this means that we need to obtain that URL
and to relate it to its document (e.g., by storing it in the Doc). One way to
do so is to replace the getDocs iterator with an iterator that produces the
URLs of the documents and then use a procedure to fetch a document given
its URL; this procedure can also be used to refetch documents. We also need
to decouple the production of words in the body of the document from the
objects stored in the title and word tables so that the storage for the body
can be deleted once the document’s words have been added to the word table.
One way to handle this change is to introduce a FullDoc abstraction, with a
words iterator, and also a method that will return a Doc. The Engine creates a
FullDoc and passes it to the addDoc method of the title and word tables, but
both tables map to Docs and not to FullDocs. Therefore, the FullDoc can be
garbage collected once information about it has been added to the two tables.
The Doc stores only the URL of the document and perhaps other identifying
information (e.g., the title), but not the full contents. Thus, this modification
requires quite a few changes, but they are reasonably straightforward.
A walk-through forces us to look at the design from a different perspective
than the one that characterized the design process. During design, we focused
on identifying abstractions and specifying their interfaces. These abstractions
arose from considering what steps were to be carried out, but our attention
was focused on parts of the program separately. Now we go back over the steps
carried out by the whole program as it uses the abstractions, and this exercise
forces us to address the question of whether the abstractions can be composed
to solve the original problem.
Sidebar 14.2 summarizes this part of the design review.
352
14.1 — Evaluating a Design
Explain how the design captures the sets and constraints of the data model.
Do a walk-through of the program on symbolic test data to show that the design will
be able to perform correctly and with the required performance.
Do the same process on individual modules or groups of related modules, to show that
their arguments are sufficient and that their performance can be adequate.
Discuss how the design will accommodate potential modifications.
14.1.2 Structure
The most important structural issue to address in evaluating a design is the
appropriateness of the module boundaries. There are two key questions to
ask:
Coherence of Procedures
Each procedure in a design should represent a single coherent abstraction. The
coherence of an abstraction can be examined by looking at its specification. A
procedure should perform a single abstract operation on its arguments. (Our
discussion applies to iterators too. An iterator maps its inputs to a sequence
of items; this mapping should be a single abstract operation.)
Some procedures have no apparent coherence. They are held together
by nothing more than some arbitrarily placed bracketing mechanism. In the
353
Chapter 14 Between Design and Implementation
Note that such a structure can make it more difficult to identify data abstrac-
tions since part of the job of each type is taken over by the procedure.
The isInteresting method of WordTable could be viewed as exhibiting
conjunctive coherence because it checks for both nonwords and uninteresting
words. However, it seems reasonable to view a nonword as uninteresting, and
therefore combining these checks within a single method seems acceptable.
However, we might have gone further and had isInteresting canonicalize
354
14.1 — Evaluating a Design
its input and return the canonical form if the word is interesting. Making
this change means that the query and queryMore methods of Engine need to
make just one call where now they make two calls (first to canon and then
to isInteresting). Nevertheless, this grouping seems undesirable because
canonicalizing a word and checking whether a word is interesting do not
seem closely related.
In an environment in which procedure calls are unduly expensive, con-
junctive coherence may be useful since it can eliminate some calls. However,
unless the actions have a strong logical connection, it is generally better not
to combine procedures. The more we put into a procedure, the harder it will
be to debug and maintain it. Furthermore, as we maintain a program, we are
likely to discover occasions when it would be useful to perform some subset
of the conjuncts. If this happens, the appropriate thing to do is probably to
break up the original procedure. What people often do instead, however, is
to add another procedural abstraction. This leads to more code to debug and
maintain, and to a program that occupies more space at runtime. Alternatively,
the programmer might modify the original abstraction to take a flag that con-
trols what subset of the work is to be performed. This is also a bad idea since
it leads to disjunctive coherence, which is discussed next.
Disjunctive coherence is indicated by a specification with an effects clause
of the form:
A || B || ...
355
Chapter 14 Between Design and Implementation
be encoded into the second argument of the call, and getEnd must test this
argument to figure out what to do. This extra work requires both time and
space. Also, we may implement getEnd using subsidiary abstractions such as
getFirst and getLast, thus increasing the number of procedure calls that get
executed.
Disjunctive coherence often arises from a misguided attempt to generalize
abstractions. When a program design contains two or more similar abstrac-
tions, it is always worthwhile to consider whether a single more general
abstraction might replace all or some of the similar ones. If successful, gener-
alization saves space and programmer effort with little cost in execution speed
or complexity in the implementation of the generalized abstraction. However,
356
14.1 — Evaluating a Design
Coherence of Types
Each method of a type should be a coherent procedure or iterator. In addition,
a type should provide an abstraction that its users can conveniently think
of as a set of values and a set of methods intimately associated with those
values. One way of judging the coherence of a type is to examine each method
to see whether it really belongs in the type. As discussed in Chapter 5, a
type should be adequate—that is, should provide enough methods so that
common uses are efficient. In badly designed types, one frequently finds
additional methods that do not seem particularly relevant to the abstraction
and whose implementation can take little or no advantage of direct access to
the representation. It is generally better to move such methods out of the type.
If fewer operations have access to the representation, it is easier to modify the
representation if it becomes desirable to do so.
Consider, for example, a stack type containing sqrtTop method:
357
Chapter 14 Between Design and Implementation
sqrtTop has little to do with stacks, and its implementation can run just fine
without access to a stack’s representation. Therefore, this method should be
moved out of the stack abstraction.
358
14.1 — Evaluating a Design
Reducing Dependencies
A design with fewer dependencies is generally better than one with more.
Having fewer dependencies can result from narrower interfaces; for example,
passing an element instead of a set can mean that the called abstraction no
longer depends on Set. It can also result from changing strong dependencies
into weak ones. Such a change can be an improvement because the module
with the dependency is not affected by changes to the specification of the data
abstraction it depends on.
For example, in the design of Engine, the WordTable depends on Doc be-
cause its addDoc method calls the Doc words method; a similar situation exists
for TitleTable. We can reduce these dependencies to weak dependencies by
changing the design slightly. First, rather than having the addDoc method
of WordTable call the words iterator, we could instead pass it the generator
returned by the words iterator:
Hash table addDoc (Iterator e, Doc d)
// requires: e produces strings
// modifies: this
// effects: Adds each interesting word w produced by e
// to this, mapped to d and the number of occurrences of w in e;
// also returns a table mapping each interesting word produced by e
// to a count of its occurrences in e.
Second, we can change the specification for the addDoc method of TitleTable
to:
void addDoc (String t, Doc d) throws DuplicateException
// requires: d and t are not null
// modifies: this
// effects: If a document with title t is already in this throws
// DuplicateException else adds d to this with title t.
The module dependency diagram that results from these changes is shown
in Figure 14.3. One advantage of this structure over the one shown in Fig-
ure 13.15 is that TitleTable no longer needs to canonicalize the title since
359
Chapter 14 Between Design and Implementation
this can be done in Engine, and therefore there are fewer dependencies in the
design. Another advantage is that the structure more easily accommodates the
modification discussed in Section 14.1.1 in which document bodies are dis-
carded and refetched when needed. With the revised structure, changes to
the title and word tables are not needed to accommodate the modification.
Sidebar 14.3 summarizes the desiderata for program structure.
360
14.2 — Ordering the Program Development Process
361
Chapter 14 Between Design and Implementation
difficult to write stubs for some abstractions, and one advantage of bottom-up
implementation is that we can avoid writing stubs.
In top-down development, we implement and test all modules that use a
module M before implementing and testing M. Possible top-down orders for
the previous example include A, B, C, D, E and A, C, E, B, D. Just as bottom-
up development reduces our dependence on stubs, top-down development
reduces our dependence on drivers. It is important, however, that top-down
development be accompanied by careful unit testing of all modules. If we
tested B only as it is used by A, we might see only part of B’s specified behavior.
Were we to change A later, new bugs might be revealed in B. Therefore, if we
choose to use A as a driver for B, we must make sure that A tests B thoroughly.
Otherwise, we must use a separate driver to test B.
Neither development strategy strictly dominates the other. Most of the
time, it seems best to work top-down. However, there can be compelling
reasons to pursue a bottom-up approach. We advocate a mixed strategy in
which one works top-down on some parts of the system and bottom-up on
others.
Top-down development has the advantage of helping us to catch serious
design errors early on. When we test a module, we are testing not only the
implementation of that module, but also the specifications of the modules
that it uses. If we follow a bottom-up strategy, we might easily spend a great
deal of effort implementing and testing modules that are not useful because
there is a problem with the design of one of their ancestors in the module
dependency diagram. A similar problem can occur in top-down development
362
14.2 — Ordering the Program Development Process
363
Chapter 14 Between Design and Implementation
364
14.2 — Ordering the Program Development Process
365
Chapter 14 Between Design and Implementation
3. Next we implement and unit test the canon procedure. Then we implement
TitleTable and unit test it using Doc and canon. This allows us to test
searches based on titles.
4. Next we implement and unit test the WordTable using Doc and canon.
5. Next we extend the stub for Query so that size returns 0, and the various
mutators record their arguments. This will allow us to complete testing
the logic of Engine.
6. Finally we implement DocCnt and Query and unit test them using Doc
and WordTable. Then we can run the entire Engine.
This strategy allows us to get an early version of the engine: we can do searches
on titles after step 3.
The strategy is typical of testing strategies in that either we implement the
entire data abstraction, or we use very simple stubs: some methods record their
input, others return canned responses, and still others are not implemented
because they are not called at this stage of the development. For example, in
step 2, the addDoc method of WordTable records its argument, isInterest-
ing has a canned response, and the lookup method isn’t called. Sometimes,
however, we implement a data abstraction in stages. For example, we might
provide a complete implementation of the empty query (as a subtype of Query)
in step 2; nonempty queries are implemented later.
The testing of Engine should be done using the regression test code (i.e.,
the TestUI of Figure 13.16). In addition, of course, the actual UI must be
implemented and run against the Engine, but testing the UI will be simpler if
we can separate it from tests that determine that Engine is functionally cor-
rect. The UI tests will focus on how the display looks, and whether user inputs
lead to the right calls on Engine methods; these tests could even be done with
a simple stub for Engine (e.g., using a predefined set of documents).
14.3 Summary
In this chapter, we discussed some things that should be done between the
completion of a design and the start of implementation. The key points to take
away are the importance of conducting a systematic evaluation of the design
and developing a plan specifying the order of implementation and testing of
the modules comprising the design.
366
Exercises
Exercises
14.1 A Map abstraction might provide an insert method to add a string with its
associated element to the Map and a change method to change the element
associated with the string. Suppose these two operations were replaced by a
single method that adds the association if it does not already exist and changes
it if it does. Discuss the coherence of this modified abstraction. How does the
modified abstraction compare with the original?
14.2 Consider the effect of various potential modifications for the search engine
on its design. For example, suppose we want to support more sophisticated
queries, or we want to allow several documents to have the same title, or we
367
Chapter 14 Between Design and Implementation
want the engine to record persistently the sites where documents are located
so that they can be refetched the next time it runs.
14.3 One could argue that having a single word table that keeps track of both
interesting and uninteresting words is less coherent than a design with two
separate tables. Discuss this point and compare the two alternatives.
14.4 Consider an alternative design for Query in which a query object is initially
empty and keywords are added to it. Discuss the advantages and disadvant-
ages of this design relative to the one that was presented in Chapter 13.
14.5 Perform a design review for some program that you have designed. Be sure to
include a discussion of the structure and modifiability of the program as well
as a discussion of its correctness.
14.6 Define an implementation strategy for a program that you have designed.
368
Design Patterns
15
When designing a program, it is useful to understand the ways that people
have organized programs in the past, since these approaches might speed
up the design process or lead to a better program in the end. This chapter
discusses a number of such design patterns.1 Each pattern provides a benefit:
some patterns improve performance, while others make it easier to change the
program in certain ways.
In this book we have already used several design patterns. One is the
iterator pattern. As explained in Chapter 6, we use iterators as a basic part
of our methodology since it allows us to provide efficient access to elements
of collection objects without either violating encapsulation or complicating
the abstraction. Another is the template pattern. This pattern captures the
idea of implementing concrete methods in a superclass in terms of abstract
methods that will be implemented in subclasses; the concrete method defines
a template for how execution proceeds, but the details are filled in later,
when the subclasses are implemented. Examples of the use of this pattern
can be found in the implementations of IntSet (see Figure 7.8) and Poly (see
Figure 7.14).
1. More information about design patterns can be found in Gamma, Erich, Richard Helm, Ralph
Johnson, and John Vlissides, Design Patterns, Addison-Wesley, Reading, Mass., 1995.
369
Chapter 15 Design Patterns
370
15.1 — Hiding Object Creation
371
Chapter 15 Design Patterns
and not on the particular type of generator (SetGen in this case). This structure
is flexible because we can change the type of generator by just reimplementing
the elements method; the using code does not need to change at all.
Factory methods are sometimes gathered together in their own factory
class. A factory class might provide static methods, or it might have objects
of its own. Such objects are called factory objects.
The methods of a factory class might create objects of a single type or of
several types. For example, Figure 15.2 shows a factory class containing static
factory methods that create polynomials: one method creates the zero poly-
nomial, and the other creates an arbitrary monomial. This class creates objects
of only one type (Poly). In a symbolic manipulation program, you might have
a factory class with methods to create polynomials, matrices, vectors, and
so on. Such a factory has an additional benefit; it provides an easy way to
ensure that the objects it creates all work together properly. For example,
suppose there are several types whose implementations differ depending on
the environment in which the system is supposed to run. The factory can
ensure that all these types’ objects are created for the same environment. Thus,
the use of the factory not only hides complexity so that using code is simpler;
372
15.1 — Hiding Object Creation
it also ensures that certain errors aren’t possible since using code cannot create
incompatible objects based on different environments.
Sidebar 15.2 summarizes the preceding discussion.
Factory objects are useful when many places in a program need to use the
factory methods. In such a case, a single module creates the factory object,
which is passed to other modules. The advantage of this structure is that the
dependency on the particular implementation choice is limited to that one
module. The rest of the code will depend only on the interface of the factory
object and not its class!
Figure 15.3 shows the module dependency diagram for a program that
uses factory objects. The figure shows a factory interface with two imple-
mentations, Factory1 and Factory2. Objects of two types, S and T, are created
by the factory; Factory1 creates one “flavor” for these types (S1 and T1),
373
Chapter 15 Design Patterns
while Factory2 creates another. Module M creates the factory objects by using
Factory1 and/or Factory2. It passes a factory object to module P, which stands
for the rest of the program; P is shown as a single module but will actually
consist of many modules. P then uses the factory object passed to it by M to
create S and T objects of the “flavor” selected by M. Note that because there
are factory objects, they need to be passed to code in the rest of the program.
Thus, this code (e.g., P) takes a factory object as an extra argument.
The figure illustrates the way that factories reduce dependencies. P does
not depend on the specific implementation of the factory (i.e., Factory1
and Factory2) since this information is limited to M. M weakly depends on
Factory since it uses none of its methods; instead, it only uses constructors
of Factory1 and Factory2. Neither M nor P depends on the implementation
choices for S and T since that information is limited to the factory implemen-
tation. Yet we can be certain that P will use compatible implementations of S
and T.
Factories aren’t needed when all you want to do is to reimplement some
types, replacing old implementations with new ones. In this case, you can just
relink your code to use the new classes rather than the old ones. However, fac-
374
15.2 — Neat Hacks
A factory is not needed when all you want to do is reimplement some type. In this
case, you can just relink your code with the new class.
A factory is useful when you use multiple implementations of a type within a program.
A factory is also useful when you want several different versions of a program, each
using a different implementation for one or more types.
Using a factory object can limit dependency on the factory class to a single module.
tories are useful when a single problem uses several implementations for a type
or when each of several versions of a program uses a different implementation
choice for some set of types.
Two other patterns are closely related to factories. A builder is a method
that not only hides implementation choices for one or more types but also
constructs a collection of objects of these types. A prototype is an object that
can be used to create other objects of its type. The prototype is created by
one module; the rest of the code calls a method of the prototype to obtain
other objects of the prototype’s type. The new objects will be in an initial
state, rather than a clone of the prototype (in case the prototype has been
modified). Sidebar 15.3 summarizes the uses of factories.
15.2.1 Flyweights
Sometimes you will encounter a situation where you have many instances
of identical objects. When this happens you can greatly reduce your storage
requirements if you can manage to use just one object per set of identical ones.
375
Chapter 15 Design Patterns
The flyweight pattern allows one object to be used to represent many identical instances.
Flyweights must be immutable.
Flyweights always depend on an associated table, which maps identical instances to
the single object that represents all of them. This table can either be hidden within
the flyweight class or can be visible to users.
Flyweights should be used when there is a sufficiently large amount of sharing to
justify the extra complexity of maintaining the related table.
The technique for accomplishing this sharing is called the flyweight pattern;
the shared objects are called flyweights (see Sidebar 15.4). The name comes
from the fact that the pattern makes even very small objects, such as individual
characters, practical. The overhead for such an object is high relative to the
information it stores, but the cost is insignificant if there is enough sharing.
However, the use of the pattern is not restricted to small objects; for example,
it might be used for font objects in a document processing system.
You can take advantage of the savings provided by using flyweights only
if the objects are immutable. This is clearly necessary since the objects are
going to be shared. If the flyweights were mutable, they could not be shared
since modifications made because of the needs of one using context would be
visible to the others.
For example, flyweights might be useful in the search engine. Each docu-
ment is potentially very large, yet a document contains many occurrences of
the same word, and the same word may occur in many different documents.
Rather than storing a document as a (very long) string, or as a collection of
strings with one string per word, why not have just one object for each unique
occurrence of a word? Having one object is acceptable since strings are im-
mutable.
Thus, there would be just one object for the word “the”, even though “the”
occurs many times in the documents. A document might be represented as an
array (or vector) of Words; the objects representing the unique words would
be shared both within the array representing a single document and between
arrays representing different documents.
376
15.2 — Neat Hacks
To use this pattern, there must be a way to avoid creating duplicate objects.
This implies that we cannot create the objects using a constructor since a new
object is created each time a constructor is called. Therefore, we need another
way to create new objects. But we know how to do this—by using a factory
method. The factory method needs access to a table that keeps track of already
existing objects. When a new object is requested, the method checks whether
the desired object already exists in the table; it returns the preexisting object
if one exists and otherwise, creates a new object.
There are two structures for providing flyweights. In the first, the table is
inaccessible to users. In this case, the flyweight class provides a static factory
method that is used to create flyweights, and the table is maintained within the
class, in a static variable. Figure 15.4 illustrates this structure. When make-
Word encounters a string that is not in the table, it calls the constructor to
create a new word, but users can’t create words directly since the constructor
is private.
The mapWord method shown in the figure provides different results de-
pending on a context. For example, a context might indicate that the resulting
private Word(String s)
// effects: Makes this be the word corresponding to s.
377
Chapter 15 Design Patterns
string should have all alphabetic characters capitalized. Having the context
information be an argument enhances sharing: a word can be shared even in
different contexts. Another point is that the context might be mutable—for
example, the using code might have a context object that it changes to re-
flect the current constraints. Obviously, a mutable context could not be part
of the flyweight, since flyweights must be immutable. This type of mutable
information is referred to as the extrinsic state; the idea is that it is related to
the flyweight object but not inside it since flyweights cannot contain mutable
information. The information inside the flyweight is referred to as its intrinsic
state.
The second structure for flyweights makes the table accessible to users:
there is a table object, and the factory method is a method of this object.
This structure is useful when there are other things to do with the table (e.g.,
iterate over its elements) or if more than one table is needed. This structure is
illustrated in Figure 15.5, which shows a type Ident and an associated table
type IdentTable. The Ident type might be used in implementing a compiler.
An Ident is a string, but there are two kinds of identifiers: reserved words
(e.g., “class”, “for”), which have a predefined meaning, and all the other
words, which are used for naming variables, methods, and so on. The main
point to notice is that tables have a number of methods. A second point is
that Ident’s constructor is package visible, which means it is not accessible to
users but is accessible to IdentTable provided they are defined in the same
package.
A final point about flyweights is that it may be necessary to remove entries
from the associated table when they are no longer in use. For example, if
documents were removed from the search engine, some words might no longer
appear in any document, in which case we might want to be sure they no
longer consumed storage. This can be accomplished in Java by having the
table refer to its elements using weak pointers; details can be found in a Java
text.
15.2.2 Singletons
Sometimes a type needs just a single object (or a few objects). In this case, we
may want to ensure that additional objects aren’t created. This can be impor-
tant for performance: for example, if there were more than one IdentTable,
we would not be able to guarantee just one object for a unique identifier. The
378
15.2 — Neat Hacks
// constructors:
public IdentTable( )
// effects: Makes this be the empty IdentTable.
// methods:
public Ident makeReserved (String s) throws WrongKindException
// modifies: this
// effects: If s is already in this as a reserved word returns
// the prestored object else if it is in this as a nonreserved word
// throws WrongKindException else adds s to this as a reserved word.
// various methods
}
379
Chapter 15 Design Patterns
When a type has just one object, that object is called a singleton.
Using a singleton can improve performance or eliminate errors.
To enforce singleton-ness, the constructor must be made private and access to the
object provided through a static method.
Although the static method makes it possible to access the object without having it
be a parameter, this structure is undesirable since it increases dependencies. Making
the entire class static is even less desirable.
constraint can also be important for correctness. For example, we might im-
plement equals for Ident by simply checking whether the two objects are
the same object:
380
15.2 — Neat Hacks
This means that M’s specification must explain its use of the singleton, and
the module dependency diagram must show the dependency.
For example, exactly one WordTable and exactly one TitleTable are
used in the search engine. We could make these tables singletons and thus
eliminate a potential source of errors. However, the search engine will still
have the module dependency diagram shown in Figure 13.15 even if the word
table object is a singleton and is not passed explicitly to Query. Also, the
specification of Query would still need to describe its use of the word table.
Since the dependencies and specifications need to reflect the use of the
singleton object, it is better to pass it as an explicit parameter. Not only does
this make the code cleaner, but it also provides better modifiability: if we
should later change the way to access the singleton, all code that receives it as
an argument will be unaffected by the change. This really is just an argument
in favor of abstraction by parameterization: code is more general if it uses
parameters rather than depending on specific objects.
One final point: it is possible to use a class as the singleton object. In this
case, there are only static methods; for example, makeReserved, makeNon-
Reserved, and elements would all be static methods of the IdentTable class
shown in Figure 15.6. However, doing things this way is less desirable than
having an actual object because classes cannot easily be treated as objects
in Java.
381
Chapter 15 Design Patterns
The object would be a vector for a small set and a hash table for a large one.
However, this approach has the disadvantage that the code of each method
needs to determine what the current state is and then cast els to either a
vector or a hash table—for example, each method would have roughly the
following form:
382
15.2 — Neat Hacks
This is both inconvenient and expensive (since casts are relatively costly).
The state pattern provides a better approach. It separates the type being
implemented from the type used to implement it. The type being implemented
is called the context; the type used to implement it is called the state type.
The structure is illustrated in Figure 15.7, which shows a portion of the set
383
Chapter 15 Design Patterns
The state pattern allows the representation of an object to change as the object’s state
changes. It uses a state type to implement objects of a context type.
The pattern applies only to mutable types.
The pattern is worthwhile if there is a significant benefit to using different implemen-
tations as state changes (e.g., because different reps are suitable for large and small
objects).
Having the context type’s implementation control when objects change their
implementation provides better modularity than having this be controlled by subtypes
of the state type.
384
15.3 — The Bridge Pattern
385
Chapter 15 Design Patterns
The bridge pattern separates the implementation hierarchy from the subtype hierarchy.
The pattern occurs naturally when the state pattern is used, but it can be used more
generally.
The bridge pattern adds complexity, and therefore it should be used only in
conjunction with the state pattern, or when there is a need for both multiple
implementations and subtype hierarchy.
The bridge pattern occurs naturally for mutable types whose objects
change their implementation over time, since we needed to introduce this
structure for them anyway. The pattern can be used whenever there are mul-
tiple implementations; for example, we could use it for Poly. However, the
pattern does add complexity: we now have two hierarchies where before we
had just one. Therefore, the pattern should be used only when there is a need
for the state pattern, or when there is a need for both multiple implementations
and extension subtypes.
386
15.4 — Procedures Should Be Objects Too
The second subtype, LTFilterer, accepts all integers less than a predefined
value. The point to notice here is that LTFilterer’s check method actually
requires a second argument, the integer bound that it is checking against.
However, doWork expects a check method that takes just one argument, the
object being checked. We resolve this incompatibility by having the bound
be part of the rep of the LTFilterer object. An example of a use is:
Here the filter will accepts all elements of c that are less than 100.
387
Chapter 15 Design Patterns
388
15.4 — Procedures Should Be Objects Too
filtering (with the Filterer interface). Note also that in all cases, the call-
ing context expects there to be no side effects (since the specifications of the
interfaces indicate modifies nothing).
The command pattern is used when there is no expectation about the behav-
ior of the procedure. All the calling context expects is a particular signature;
the effects clause does not constrain what the procedure does, and the mod-
ifies clause allows arbitrary modifications. Typically, the modifications will
be to objects that are accessible from the rep of the command object, and at
least some of these objects are likely to have been provided when the com-
mand object was created (i.e., the command object is highly likely to be a
closure).
An example of such an interface is the Runnable interface shown in Fig-
ure 15.10. This interface is used in Java to start up new threads. (See a Java
text for information about how to use threads in Java.) When a new thread is
created, the creating thread provides a procedure for the new thread to run by
supplying a Runnable object. The new thread runs the procedure by calling
the run method on this object. The procedure is almost certainly a closure; for
example, it may know of certain objects that it uses to communicate with its
creating thread.
Both the strategy pattern and the command pattern have the structure
shown in Figure 15.11. Here S specifies the interfaces for the needed proce-
dures; its subtypes S1 and S2 implement the procedures. The Creator module
creates an object of subtype S1 and passes it to the using module U. Note that
U depends only on interface S and not on its subtypes.
Sidebar 15.8 summarizes the discussion about these two patterns.
389
Chapter 15 Design Patterns
The strategy pattern and the command pattern allow the use of procedures as objects.
With the strategy pattern, the using context expects a certain behavior from the
procedure; with the command pattern, it expects only a certain interface.
In either pattern, the procedure may be a closure. A closure makes use of some
prebound arguments, which are provided when the procedure’s object is created.
Both patterns are defined via an interface that specifies the procedures as methods.
Subtypes of the interface implement the procedures.
15.5 Composites
Certain applications use a tree of objects to store their information, where all
the objects in the tree belong to types in a type hierarchy. The top object in the
tree belongs to such a type; it has descendants (its children) that also belong
390
15.5 — Composites
to such types; they in turn have such descendants, and this continues until
you reach the leaves of the tree. This sort of structure arises in user interfaces;
for example, the entire display is a window, which has other windows as
subcomponents, and so on, and all these windows, such as plain windows
and bordered windows, belong to the same type family.
Another place where the structure arises is in compilers and interpreters.
In these programs, the text to be compiled or interpreted is processed to arrive
at a parse tree. Then the tree is manipulated in various ways. For example, an
interpreter might first walk over the tree to determine whether the program it
represents is type correct, and then each time the interpreter is asked to run
the program, it walks over the tree to carry out the execution. A compiler
would also do a tree walk to carry out type checking, but then it would
do another tree walk for each later processing phase. There typically will be
many optimization phases in which the compiler gathers information about
the program that can be used to produce efficient code in the later, code gene-
ration phases.
The nodes of a parse tree are entities that represent portions of the pro-
gram. For example, in the tree corresponding to a Java program, there would
be a node for an if statement, and this node would contain references to its
components—namely, a node for the expression being tested, a node for the
statement to execute if the test is true, and possibly a node for the statement
to execute if the test is false (if the statement has an else part). Similarly, the
expression node would point to other nodes for its components, which might
be subexpressions, variables, or literals. An example of such a tree is shown
in Figure 15.12; it corresponds to the program fragment:
The figure shows that the node for the if statement has three descendants:
the node for the if-expression (x > 6), the node for the then-part (the return
statement), and the node for the else-part (the assignment statement). The if-
expression is a binary expression consisting of the variable x, the operator
>, and the literal 6. The assignment statement consists of the variable being
assigned to (z) and the variable being assigned (x).
All the types appearing in a parse tree are similar in the sense that as the
compiler or interpreter interacts with them, it does so in similar ways; that is, it
calls the same methods. Therefore, all these types are members of a hierarchy.
A part of the hierarchy for our fragment of Java is shown in Figure 15.13.
391
Chapter 15 Design Patterns
Higher levels in the hierarchy are abstract—that is, the types have no objects.
This might be true, for example, for the types Node, Expr, and Stmt.
Such a hierarchy is an example of the composite pattern (see Sidebar 15.9).
All types in the hierarchy have certain methods. However, the types may also
differ; for example, internal nodes, which are called components, might have
methods to access their descendants, while the leaf nodes do not need such
methods.
392
15.5 — Composites
The composite pattern composes objects in trees containing nodes, all of which belong
to the same type family. Component nodes have descendants in the tree, while leaf
nodes have no descendants.
This pattern occurs naturally in certain applications, ranging from user interfaces to
compilers and interpreters.
There are three ways of traversing the trees that arise when using the composite pattern:
The interpreter pattern uses a method in each node for each phase. With this pattern,
it is easy to add a new node type but more difficult to add a new phase.
The procedural approach has a class per phase, with each class containing a static
method per node type. This pattern makes it easy to add a new phase but requires lots
of casts.
The visitor pattern also has a class per phase, with each class containing a (nonstatic)
method for each concrete node type. This pattern makes it easy to add a new phase,
and it does not require casts. But it is more complex than the interpreter or procedural
approaches.
393
Chapter 15 Design Patterns
provide a method for each phase. This is called the interpreter pattern. This
structure is illustrated in Figure 15.14. The figure sketches the implementation
of IfStmt, the node type for the if statement. IfStmt provides a method for
each phase carried out by the compiler; the implementations of other node
types are similar.
The figure shows how methods on node objects are implemented—by
calling the corresponding method on the node’s descendants. Thus, the code
for the typeCheck method of the IfStmt object first checks that each of its
child nodes is type correct, and then it checks that the expression returns a
boolean result.
The interpreter pattern allows new types of nodes to be defined with only
localized effort: just define the class for the new node type. However, it doesn’t
work so well when a new phase is added to the compiler because, in this case,
every class that implements a node type must be modified. And, unfortunately,
the latter kind of modification is more likely than the former because compilers
are often improved by adding additional phases.
The second way of traversing the tree is to implement a class per phase: a
class to do type checking, a class for each optimization phase, and a class for
394
15.5 — Composites
each code generation phase. Each class contains a static method for each node
type, and these methods call one another recursively. Figure 15.15 illustrates
the structure for the type-checking phase. Each static method is passed a node
of the tree as an argument. It uses methods of that node object to obtain
the node’s descendants; it then passes each descendant as an argument to
the static method for the descendant’s type. Thus, TypeCheck.ifStmt calls
TypeCheck.expr, passing it the node corresponding to the if-expression as an
argument.
Using this approach, we have effectively moved the knowledge about how
to perform a phase such as type checking out of the nodes and into the class
implementing that phase. This means that when we add another phase, we
merely need to implement another class; the classes implementing the node
types need not change. However, if we add another node type, we need to
add procedures to each class, so this kind of change is more difficult than it
was with the interpreter pattern.
A problem with the procedural approach is that many methods must do
casts, which is both inconvenient and expensive. For example, to type check
the statement in the then part, TypeCheck.ifStmt must call TypeCheck.stmt,
395
Chapter 15 Design Patterns
since it does not know what kind of statement it is dealing with. Type-
Check.stmt must then figure this out by using casts, as shown in Figure 15.15.
These casts are avoided with the interpreter approach since the call on the
s1.typeCheck method shown in Figure 15.14 goes to s1’s object, which knows
what kind of statement it is. (For example, for the structure in Figure 15.12,
it is a return statement.)
The third way of traversing the tree is intermediate between these forms.
This technique is called the visitor pattern. With this pattern, the knowledge
about what the tree structure is like below a node is localized to the node,
as in the interpreter pattern, but the knowledge about what phase is being
executed is localized in a separate class, as in the procedural approach. The
classes that implement the phases are the visitor classes.
The visitor pattern is illustrated in Figure 15.16. As in the procedural
approach, there is a class for each phase, and within that class is a method
for each node type. However, these methods are not static; there is an actual
visitor object instead. Furthermore, we need methods only for concrete node
types—that is, those that actually have objects. With the procedural approach,
methods are needed for some abstract types as well since the code must handle
such nodes; for example, Stmt is such a type.
Figure 15.16 also shows part of the IfStmt class. Every node class provides
an accept method that takes a visitor object as an argument. This method
traverses the tree beneath its node by calling accept methods on its child
nodes, each of which will call its own method on the visitor as part of its
processing. When all child nodes have been traversed, the parent calls the
method associated with its node’s type on the visitor. Thus, the IfStmt accept
method calls accept on the if-expression node, the then-statement node, and
the else-statement node if there is one. Then it calls the ifStmt method on its
visitor.
The visitor object keeps track of what has been learned in the processing
so far. In the case of type checking, this is information about type correctness
up to this point. The information is shown as being kept on a stack within the
visitor object. For statements, the only possibilities are “correct”, meaning the
statement type checked correctly, or “error”, meaning it did not type check
correctly. For expressions, the information is the actual type of the expression
or “error” if there is a mismatch in the types of subexpressions.
The module dependency diagram for the visitor pattern is shown in Fig-
ure 15.17. The figure shows that in this pattern there is a mutual dependency:
the nodes use the visitors, and the visitors use the nodes. Note that the node
396
15.5 — Composites
types depend only on the Visitor interface, but the visitor types depend on
various node subtypes: each visitor method depends on the related node type
(e.g., the ifStmt visitor method depends on the IfStmt node type). (It’s pos-
sible to make the visitor types depend only on Node, but this complicates the
397
Chapter 15 Design Patterns
code of the visitor methods and makes them more costly since they need to
do casts.)
The visitor pattern is similar to the procedural approach in the way it
accommodates change: a new phase requires writing a new visitor class, but a
new node type requires implementing a new method in every existing visitor
class. The pattern avoids the need for the casts used in the procedural ap-
proach because a node object calls the visitor method for its type, which it
knows. The pattern implements a form of what is called double dispatch: it gets
to the right code based on both the type of the node being traversed and the
phase being run.
However, the visitor pattern is more complicated than either the inter-
preter pattern or the procedural approach, and it has a problem that arises
from the fact that all phases do not have identical structure. For example,
for type checking, we want to return a boolean; but for optimization, we
might modify the parse tree, and for code generation, we might modify the
object storing the code being generated. In the interpreter and procedural ap-
proaches, these differences showed up as different types of results in different
phases. The visitor pattern doesn’t allow different kinds of results; instead,
information about what has happened so far must be stored within the visitor
(e.g., the els stack in Figure 15.16), and the code can be more complex as a
result.
An additional problem is that sometimes more interaction is required
between a node and the visitor. For example, the node might need to call
a visitor method both before it starts calling accept on its child nodes and
398
15.6 — The Power of Indirection
after. To allow this interaction, we would need to have two visitor methods
associated with each node type (the “before” method and the “after” method).
Furthermore, we probably would want a different accept method for each
way of interacting with a visitor, since if there were just one accept method,
it would sometimes make unnecessary calls (e.g., to the “before” method in a
phase that doesn’t need it).
The visitor classes are more closely coupled to the node classes than was
the case in the procedural approach because of the callbacks from the node
to the visitor methods. Furthermore, if a new phase is added that needs to
have nodes interact with the visitor in a new way, all node types must be
modified to accommodate the change. Such changes are not necessary with
the procedural approach.
399
Chapter 15 Design Patterns
The adaptor, proxy, and decorator patterns all interpose an object between using code
and the original object.
In the adaptor pattern, the interposed object has different behavior than the original
object, and therefore it can be used only by new (or modified) code.
In the proxy pattern, the new object has identical behavior to the original object, while
in the decorator pattern the new object has extended behavior. In both patterns, the
new object can be used by either new or preexisting code.
be some extra methods. All these patterns have the object structure shown in
Figure 15.18.
To illustrate these patterns, consider a Registry type that provides a
mapping from strings to objects and assume at least one registry object is
already in use. A registry object has a lookup method:
If you decide that you need to also do the reverse lookup (from an object
to the string that maps to it), you can accomplish it by using a decorator. This
decorator has all the registry methods plus the new reverseLookup method; it
forwards calls on all the old methods to the original object, and it implements
the new method itself, using the original object as needed. (The reverse lookup
400
15.6 — The Power of Indirection
won’t be very efficient, but that may not matter, for example, if the registries
are small.)
Alternatively, if you decide that you want the lookup method to retrieve
only the first matching object, you can use an adaptor. In this case, the
specification of the lookup method changes to:
and, therefore, the adaptor type cannot be a subtype of the type of the original
object.
Finally, suppose the new code is going to run at a different computer than
the rest, yet it needs to access one of the preexisting registries. In this case,
you can use a proxy. The proxy resides on the same machine as the new code,
which makes calls on it as if it were making calls directly on the real registry
object at the other computer. The proxy forms a packet that represents the
call and sends it across the network to the registry object using some remote
procedure call mechanism. When the reply packet arrives, the proxy extracts
the result and returns to the caller.
Since the adaptor changes the observable behavior of the original object,
its type is unrelated to that of the original object. The proxy and decorator do
not change the observable behavior of the original object, and therefore their
types can be subtypes of the type of the original object. This allows them to
be used in another situation: where a system is being extended with a new
kind of object that needs to fit in with existing code.
For example, adding a new kind of window to a window system can be
done with the decorator pattern. Since the new window type (e.g., “bordered
window”) is a subtype of the some preexisting window type, it can be placed
in an existing tree of windows (e.g., a composite structure), and it can be
manipulated with the preexisting code that already interacts with existing
types of windows.
On the other hand, suppose you need to redistribute objects, so that an
object that at present runs at the same machine as the code that uses it is moved
to some other machine. In this case, you can use the proxy pattern: a proxy is
left behind on the original machine, and it forwards all the calls made by the
preexisting code to the machine where the original object now resides.
401
Chapter 15 Design Patterns
15.7 Publish/Subscribe
A change in one object is sometimes of interest to a number of other objects.
We will refer to the object of interest as the subject and to the other objects as
observers. For example, when a document changes, one observer might print
a new copy, while another might send e-mail to an interested user. Or, when e-
mail arrives in your mailbox, one observer might add its header to a list, while
another might cause your terminal to alert you by making a noise. Another
example arises in a distributed file system; when a file is modified, all remote
sites that have cached the file need to be notified.
In this kind of situation, it is desirable to decouple the subject from the
observers, since it allows the observers to change without having to modify the
subject. The number of observers might change over time, and the observers
need not all belong to the same type.
The observer pattern captures this structure. The subject maintains a list of
interested parties; it provides methods that allow observers to add and remove
themselves from the list. When the subject’s state changes, it notifies every
observer in the list by calling its update method.
The structure is illustrated in Figure 15.19 where S is a particular subtype
of Subject and O is a subtype of Observer. Some object of type S would act as
a subject in a program, and it might be observed by objects of type O (and of
other types not shown in the diagram). In addition to the nodes representing
the subject and observer types, the figure also shows a User type; an object
of this type causes the state of the subject to change.
Note that very loose coupling exists between the subject and its observers:
the observer depends on the subject only to support the Subject interface,
and the subject depends on the observers only to support the Observer
interface. Other details about the actual subject and observer objects—that
is, their other methods—are hidden.
Either the update method takes no arguments, in which case the observer
must call other methods on the subject to find out about its current state, or
the observer can be passed information about the subject’s state directly as
extra arguments of the update method. The former structure is called a pull
structure because the observers explicitly ask for the information they need
(this asking is the “pull”); the latter structure is called a push structure, since
information is given to observers directly (it is “pushed” to them). Each form
402
15.7 — Publish/Subscribe
has advantages and disadvantages. The pull approach requires extra calls to
methods of the subject, but the push approach might provide more arguments
than a particular observer needs. These issues are especially significant if
subject and observers are on different machines: the pull method requires
extra remote communicate, while the push method can consume bandwidth
unnecessarily.
The observer pattern is sometimes referred to as publish/subscribe because
the subject publishes information to the subscribers (which are the observers).
Sidebar 15.12 summarizes this discussion.
403
Chapter 15 Design Patterns
The observer pattern captures a situation in which changes in the state of some subject
object are of interest to other observer objects. It abstracts from the number of observers
and defines a standard way for subjects and observers to interact.
With the pull structure, the observer is notified of a change and then it communicates
further with the subject to determine the details of what happened.
With the push structure, all information about the change is sent to the observer as
part of the notification.
The pull structure causes more communication, since the observer must make calls on
methods of the subject to find out the details, but the push structure can cause more
information to be sent to an observer than it needs.
and the mediator forwards the information to the observers. The structure is
illustrated in Figure 15.20.
In this structure, the subject and observers know nothing about one
another; they are related only through their use of the mediator. Of course,
complete decoupling works only with a push model. If we used the pull
approach instead, the observers would depend on a Subject interface that
provided the methods used to get the additional information.
Although we have described the mediator pattern as a way for the subject
to communicate with the observers, the communication need not be asym-
metric. Instead, the mediator can be used by a group of “colleagues”; each
communication goes from one of them to all the others.
In addition to decoupling the subject and observers, the mediator pat-
tern also centralizes control over the details of communication. For example,
the mediator might prioritize the observers and communicate with them in
priority order. Or, it might use a “first acceptor” approach: rather than com-
municate with all observers, it communicates with them one-by-one and stops
as soon as one “accepts” the information. Since the communication details are
localized to the mediator, they can be changed just by reimplementing the
mediator; the code of subjects and observers that use the mediator need not
change.
404
15.7 — Publish/Subscribe
405
Chapter 15 Design Patterns
15.8 Summary
This chapter has discussed a number of design patterns. Patterns typically
impose a level of indirection since it allows something to be changed. For
example, a factory object is interposed between the code that uses some types
and the code that implements them; the indirection allows us to easily switch
to different implementations for the types. Similarly, the state pattern uses
a level of indirection between the object being implemented to the one that
represents it, so that we can change to a different implementation. And, a
mediator is interposed between a subject and its observers so that the subject
does not depend on the observers nor on the order in which they receive the
information.
Patterns can be used to improve the flexibility or performance of a pro-
gram. However, they can also make a program more complex and more difficult
to understand. For example, the down side of using a mediator is that it is not
clear from reading the code of the subject what communication paradigm (e.g.,
broadcast or first acceptor) is in use. Therefore, it is important to have a sound
motivation for using a pattern. In general, a pattern should be introduced into
a program only when a substantial benefit can be gained by using it.
Even though restraint is necessary when using patterns, they are a useful
design tool. They provide a vocabulary for design. They are useful when you
design yourself, since they give you more options; and they are useful for
explaining designs and for understanding the designs of others.
406
Exercises
Exercises
15.1 Analyze the design of the stock tracker (see Chapter 13) to identify places
where patterns might be used and decide whether the use of the pattern is
justified in each case.
15.2 Analyze some program you have designed to identify places where patterns
would be useful and decide whether the use of the pattern is justified in each
case.
407
This page intentionally left blank
Glossary
409
Glossary
410
Glossary
411
Glossary
Header The first part of a method declaration, which defines the method
name, the types of its arguments and result, and the types of any excep-
tions it throws. The header defines the method’s signature.
Heap The storage area in which objects reside.
Helper abstractions Abstractions invented while investigating the design
of a target abstraction; helpers are abstractions that would be useful in
implementing the target and that facilitate decomposition of the problem.
High availability A program is highly available if it is very likely to be up
and running all the time.
High reliability A program is highly reliable if it is unlikely to lose infor-
mation even in the presence of hardware failures.
Immutability An object is immutable if its state never changes. A data type
is immutable if its objects are immutable.
Implementation and test phase The phase of the software life cycle in
which the abstractions identified during design are implemented and
tested.
Incomplete supertype A data type whose specification is so weak that
using code is highly unlikely to be written in terms of it. Such a supertype
is used to establish naming conventions for methods of its subtypes.
Induction step of data type induction The step that shows for each
method of the type that a predicate holds when the method returns, as-
suming the predicate holds when the method is called.
Informal specification A specification written in an informal specification
language (e.g., English). The specifications in this book are informal.
Inheritance A way of obtaining code without writing it. In particular, a
subclass can inherit the implementations of its superclass’s methods.
Instance An object.
Instance method A method belonging to an object.
Instance variable A variable that is part of the rep of an object.
Integration testing Testing a group of modules together.
Interface The interface of an abstraction is what it makes visible to other
program modules. Also, in Java, an interface is an entity used to define a
data type by declaring its methods.
416
Glossary
417
Glossary
418
Glossary
419
Glossary
420
Glossary
Property A predicate.
Prototype pattern A design pattern in which an object provides a factory
method that produces a new object of its own class; the new object is in
an initial state, similar to what would normally be obtained by calling a
constructor.
Proxy pattern A design pattern in which an object is interposed between
using code and the original object in order to control access to the original
object. The interposed and original objects are both members of the same
type.
Publish/subscribe A way of communicating in which an object publishes
information, and other objects (the subscribers) are informed about the
new information.
Pull structure in the observer pattern With this structure, the observer
is notified of a change and then communicates further with the subject to
determine the details of what happened.
Push structure in the observer pattern With this structure, the observer
is informed about the state of the subject object as part of the notification.
Range of a procedure The type of a procedure’s result.
Record type A data type consisting of a set of visible fields. Its abstraction
function is the trivial map, and its rep invariant is “true”.
Reflecting an exception Responding to an exception thrown by a call by
throwing another exception; this typically will be a different exception
than the one thrown by the call.
Regression testing The process of methodically rerunning all tests after
each error is corrected.
Related subtype approach to polymorphism A way of using hierarchy
to define polymorphism. The polymorphic abstraction is defined in terms
of an interface, and a subtype of this interface must be defined for every
element type. The interface is an example of the strategy pattern.
Relation in a data model A mapping between sets in a data model, indicat-
ing how items in one set are related to items in another set.
Rep The representation of a data abstraction.
421
Glossary
422
Glossary
423
Glossary
Static inner class A class nested inside another class. We use static inner
classes to implement iterators.
Static method A method that belongs to a class rather than to an object.
Static subset A subset in a data model whose potential membership is
determined statically.
Strategy pattern A design pattern that allows the use of procedures as
objects, where the using context expects a certain behavior from the
procedure. This pattern is related to the command pattern.
Stronger predicate Predicate A is stronger than predicate B if we can prove
that B holds assuming A holds.
Strong type checking Type checking done at compile time that catches all
type errors.
Stub A program that simulates the behavior of some module.
Subclass A class that inherits the rep and methods of its superclass. The
subclass implements a subtype of the type implemented by its superclass.
Subject object in the observer pattern The object whose state changes
are being observed by other objects.
Substitution principle A principle that governs the behavior of types
in a hierarchy. It requires that subtypes behave in accordance with the
specification of their supertype.
Subscriber The object that receives published information is the one in the
publish/subscribe communication pattern.
Subtype A type that extends another type, which is called its supertype.
Sufficiently general specification A specification is sufficiently general if
it does not preclude acceptable implementations.
Sufficiently restrictive specification A specification is sufficiently restric-
tive if it rules out all implementations that are unacceptable to an abstrac-
tion’s users.
Superclass A Java class that can have subclasses—that is, classes that can
inherit its rep and methods. The superclass implements a supertype of the
types implemented by its subclasses.
Supertype A type that has subtypes.
424
Glossary
425
Glossary
426
Index
Abstract classes, 153, 154, 161–166, 238 Abstraction sections, design notebook,
Abstract invariant, 116 308–310
Abstract methods, 153, 161 Acceptance tests/testing, 255, 256, 258,
Abstract state, 100 263
Abstract subclasses, 165 Activation record, 22, 23
Abstraction by parameterization, 6, Actual parameter values, 22
7–8, 39, 40, 77, 381 Actual type, 26, 27, 149, 151
Abstraction by specification, 6, 7, 8–10, Adaptor pattern, 399, 400, 401
39, 41, 77, 78 Addable type, 200
Abstraction function, 99–102, 114, addDocs method, 318, 326, 328, 332, 334
121 Adder interface, 198, 199
for generators, 137–138 addKey method, 327, 329
implementing, 105–107 add method, 90
for OrderedIntList, 140 add Zero, 177
and rep invariant, 106, 107 Adequacy, 117, 118–119, 357
for subclass, 159 allPrimes iterator, 132, 136, 231
for subclasses of concrete super- Ambiguous specifications, 238
classes, 160 Apparent type, 24, 26, 27, 149, 150, 151
Abstractions, 4–6, 215, 218, 219, 301, Arcs, kinds of, 305–307
323, 341 Arrays, 19, 20, 116
benefits of, 40–42 bounds checking for, 24, 25, 249
coherence of, 353 class, 48, 54
and decomposition, 2–3 constructor, 20
within design notebook, 304, 305 mutability of, 21
for hiding details, 339 array type, 25
hierarchy, 5 Assertions, 9
kinds of, 10–12 Assignments, 20, 149–150, 151
mechanisms, 7 Automatic propagation, reflecting
See also Data abstractions; Procedural exception by, 67
abstractions; Specifications Automatic storage management, 24, 25
427
Index
428
Index
429
Index
430
Index
431
Index
432
Index
433
Index
434
Index
435
Index
436
Index
437
Index
438
Index
439
Index
440
Index
441
Index
442
Index
443