The most effective weapon that computing scientists have in their fight against complexity is abstraction. What is abstraction?
Sometimes abstraction is described as "remembering the 'what' and ignoring the 'how'".
Large complex systems can only be made understandable by decomposing them into modules. When viewed from the outside, each module should be simple, with the complexity hidden inside. We strive for modules that have simple interfaces that can be used without knowing the implementations. Here we use interface to mean any information about the module that other modules must assume to be able to do their work correctly.
Two kinds of abstraction are of interest to computing scientists: procedural abstraction and data abstraction.
When we develop an algorithm following the top-down approach, we are practicing procedural abstraction. At a high level, we break the problem up into several tasks. We give each task a name and state its requirements, but we do not worry about how the task is to be accomplished until we expand it at a lower level of our design.
When we code a task in a programming language, we will typically make each task a procedure (or function). Any other program component that calls the procedure needs to know its interface (name, parameters, assumptions, etc.) but does not need to know the procedure's internal implementation details. The internal implementation can be changed without affecting the caller.
In data abstraction, the focus is on the problem's data rather than the tasks to be carried out. We examine the concepts of data abstraction in the sections that follow.
In a language like C or Pascal, all data structures are visible. A programmer can define custom data types, yet their structure and values are known to other parts of the program. These are concrete data structures.
As an example, consider a collection of records about the employees of a company. Suppose we store these records in a global C or Pascal array. The array and all its elements are visible to all parts of the program. Any statement in the program can directly access and modify the elements of the array.
An abstract data structure is a module consisting of data and operations. The data are hidden within the module and can only be accessed by means of the operations. The data structure is called abstract because its name and its interface are known, but not its implementation. The operations are explicitly given; the values are only defined implicitly by means of the operations.
An abstract data structure supports information hiding. Its implementation is hidden behind an interface that remains unchanged, even if the implementation changes. The implementation detail of the module is a design decision that is kept as a secret from the other modules.
The concept of encapsulation is related to the concept of information hiding. The data and the operations that manipulate the data are all combined in one place. That is, they are encapsulated within a module.
An abstract data structure has a state that can be manipulated by the operations. The state is a value, or collection of information, held by the abstract data structure.
As an example, again consider the collection of records about the employees of a company. Suppose we impose a discipline on our C or Pascal program, only allowing the collection of records to be accessed through a small group of procedures. Inside this group of procedures, the array of records can be manipulated directly. However, all other parts of the program must use one of the procedures in the group to manipulate the records in the collection. The fact that the collection is implemented with an array is (according to the discipline we imposed) hidden behind the interface provided by the group of procedures. It is a secret of the module providing the procedures.
Now suppose we wish to modify our program and change the implementation from an array to a linked list or maybe to move the collection to a disk file. By approaching the design of the collection as an abstract data structure, we have limited the parts of the program that must be changed to the small group of procedures that used the array directly; other parts of the program are not affected.
As another example of an abstract data structure, consider a stack.
We provide operations like push
, pop
, and
empty
to allow a user of the stack to access and
manipulate it. Except for the code implementing these operations, we
disallow direct access to the concrete data structure that implements
the stack. The implementation might use an array, a linked list, or
some other concrete data structure; the actual implementation is
"hidden" from the user of the stack.
There is only one instance of an abstract data structure. Often we need to create multiple instances of an abstract data structure. For example, we might need to have a collection of employee records for each different department within a large company.
We need to go a step beyond the abstract data structure and define an abstract data type (ADT).
What do we mean by type?
Consider the built-in type int
in C (or
INTEGER
in Pascal). By declaring a C variable to be of
type int
, we are specifying that the variable has the
characteristics of that type:
int
, a subset of the
mathematical set of integers,
int
, addition, multiplication, comparison for
equality, etc.
Suppose we declare a C variable to have type int
. By
that declaration, we are creating a container in the program's memory
that, at any point in time, holds a single value drawn from the
int
domain. The contents of this container can be
operated upon by the int
operations. In a program, we
can declare several int
variables: each variable may have
a different value, yet all of them have the same set of operations.
In the definition of a concrete data type, the values are the most prominent features. The values and their representations are explicitly prescribed; the operations on the values are often left implicit.
The opposite is the case in the definition of an abstract data type. The operations are explicitly prescribed; the values are defined implicitly in terms of the operations. A number of representations of the values may be possible.
Conceptually, an abstract data type is a set of entities whose logical behavior is defined by a domain of values and a set of operations on that domain. In the terminology we used above, an ADT is set of abstract data structures all of whom have the same domain of possible states and have the same set of operations.
We will refer to a particular abstract data structure from an ADT as an instance of the ADT.
The implementation of an ADT in a language like C or Pascal is similar to that discussed above for abstract data structures. In addition to providing operations to access and manipulate the data, we need to provide operations to create and destroy instances of the ADT. All operations (except create) must have as a parameter an identifier (e.g., a pointer) for the particular instance to be operated upon.
While C and Pascal do not directly support ADTs, the class
construct provides a direct way to define ADTs in languages like C++
and Java.
The behavior of an ADT is defined by a set of operations that can be applied to an instance of the ADT.
Each operation of an ADT can have inputs (i.e., parameters) and outputs (i.e., results). The collection of information about the names of the operations and their inputs and outputs is the interface of the ADT.
To specify an ADT, we need to give:
There are two primary approaches for specifying the semantics of the operations:
In some ways, the axiomatic approach is the more elegant of the two approaches. It is based in the well-established mathematical fields of abstract algebra and category theory. Furthermore, it defines the new ADT independently of other ADTs. To understand the definition of the new ADT it is only necessary to understand its axioms, not the semantics of a model.
However, in practice, the axiomatic approach to specification becomes very difficult to apply in complex situations. The constructive approach, which builds a new ADT from existing ADTs, is the more useful methodology for most practical software development situations.
To illustrate both approaches, let us look at a well-known ADT that we studied in the introductory data structures course, the stack.
In this section we give an axiomatic specification of an unbounded stack ADT. By unbounded, we mean that there is no maximum capacity for the number of items that can be pushed onto an instance of a stack.
Remember that an ADT specification consists of the name, sets, signatures, and semantics.
Stack
(of Item
)
In this specification, we are defining an ADT named
Stack
. The parameter Item
represents the
arbitrary unspecified type for the entities stored in the stack.
Item
is a formal generic parameter of the ADT
specification. Stack
is itself a generic ADT; a
different ADT is specified for each possible generic argument
that can be substituted for Item
.
The sets (domains) involved in the Stack
ADT are
the following:
Stack
:
Item
:
boolean
:
{ False, True }
To specify the signatures for the operations, we use the notation for
mathematical functions. By a tuple like (Stack, Item)
,
we mean the Cartesian product of sets Stack
and
Item
, that is, the set of ordered pairs where the first
component is from Stack
and the second is from
Item
. The set to the right of the ->
is the return type of the function.
We categorize the operations into one of four groups depending upon their functionality:
We will normally list the operations in that order.
For now, we assume that a mutator returns a distinct new instance of the ADT with a state that is a modified version of the original instance's state. That is, we are taking an applicative (or functional or referentially transparent) approach to ADT specifications.
Technically speaking, a destructor is not an operation of the ADT. We can represent the other types of operations as functions on the sets in the specification. However, we cannot define a destructor in that way. But destructors are of pragmatic importance in the implementation of ADTs, particularly in languages that do not have automatic storage reclamation (i.e., garbage collection).
The signatures of the Stack
ADT operations are as
follows.
Constructors:
create: -> Stack
Mutators:
push: (Stack, Item) -> Stack
pop: Stack -> Stack
Accessors:
top: Stack -> Item
empty: Stack -> boolean
Destructors:
destroy: Stack ->
The operation pop
may not be the same as the "pop"
operation you learned in a data structures class. The traditional
"pop" both removes the top element from the stack and returns it. In
this ADT, we have separated out the "return top" functionality into
accessor operation top
and left operation
pop
as a pure mutator operation that returns the modified
stack.
The separation of the traditional "pop" into two functions has two advantages:
Also note that operation destroy
does not return a value.
As we pointed out above, the destroy
operation is not
really a part of the formal ADT specification.
We can specify the semantics of the Stack
ADT with the
following axioms. Each axiom must hold for all instances
s
of type Stack
and all entities
x
of type Item
.
top(push(s,x)) = x
pop(push(s,x)) = s
empty(create()) = True
empty(push(s,x)) = False
The axioms are logical assertions that must always be true. Thus we can write Axioms 3 and 4 more simply as:
empty(create())
not empty(push(s,x))
The first two axioms express the last-in-first-out (LIFO) property of stacks. Axiom 1 tells us that the top element of the stack is the last element pushed. Axiom 2 tells us that removal of the top element returns the stack to the state it had before the last push.
Moreover, axioms 1 and 2 specify the LIFO property of stacks in purely mathematical terms; there was no need to use the properties of any representation or use any time-based (i.e., imperative) reasoning.
The last two axioms define when a stack is empty and when it not. Axiom 3 tells us that a newly created stack is empty. Axiom 4 tells us that pushing an entity on a stack results in a nonempty stack.
But what about the sequences of operations top(create())
and pop(create())
?
Clearly we do not want to allow either top
or
pop
to be applied to an empty stack. That is,
top
and pop
are undefined when their
arguments are empty stacks.
Functions may be either total or partial.
A -> B
is defined for all
elements of A
.
For example, the multiplication operation on the set of real numbers
R
is a total function (R,R) -> R
.
A -> B
is undefined for
one or more elements of A
.
For example, the division operation on the set of real numbers
R
is a partial function because it is undefined when the
divisor is 0
.
In software development (and, hence, in specification of ADTs), partial functions are common. To avoid errors in execution of such functions, we need to specify the actual domain of the partial functions precisely.
In an axiomatic specification of an ADT, we restrict operations to their domains by using preconditions. The precondition of an operation is a logical assertion that specifies the assumptions about and the restrictions upon the values of the arguments of the operation.
If the precondition of an operation is false, then the operation cannot be safely applied. If any operation is called with its precondition false, then the program is incorrect.
In the axiomatic specification of the stack, we introduce two preconditions as follows.
pop(Stack S)
:
not empty(S)
top(Stack S)
not empty(S)
Note that we have not given the semantics of the destructor operation
destroy
. This operation cannot be handled in the simple
framework we have established.
Operation destroy
is really an operation on the
"environment" that contains the stack. By introducing, the
"environment" explicitly into our specification, we could specify its
behavior more precisely. Of course, the semantics of
create
would also need to be extended to modify the
environment and the other operations would likely require
preconditions to ensure that the stack has been created in the
environment.
Another simplification that we have made in this ADT specification is that we did not impose a bound on the capacity of the stack instance. We could specify this, but it would also complicate the axioms the specification.
In this section, we give a constructive specification of a bounded stack ADT. By bounded, we mean that there is a maximum capacity for the number of items that can be pushed onto an instance of a stack.
StackB
(of Item
)
In this specification of bounded stacks, we have one additional set involved, the set of integers.
StackB
:
Item
:
boolean
:
int
:
{ ..., -2, -1, 0, 1, 2, ... }
In this specification of unbounded stacks, we define the
create
operation to take the maximum capacity as its
parameter.
Constructors:
create: int -> StackB
Mutators:
push: (StackB, Item) -> StackB
pop: StackB -> StackB
In this specification, we add operation full
to detect
whether or not the stack instance has reached its maximum capacity.
Accessors:
top: StackB -> Item
empty: StackB -> boolean
full: StackB -> boolean
Destructors:
destroy: StackB ->
In the constructive approach, we give the semantics of each operation by associating both a precondition and a postcondition with the operation.
As before, the precondition is a logical assertion that specifies the required characteristics of the values of the arguments.
A postcondition is a logical assertion that specifies the characteristics of the result computed by the operation with respect to the values of the arguments.
In this specification, we are a bit informal about the nature of the underlying model. Although the presentation here is informal, we try to be precise in the statement of the pre- and postconditions.
Note: We can formalize the model using an ordered pair of type
(integer max, sequence stk)
, in which max
is
the upper bound on the stack size and stk
is a sequence
that represents the current sequence elements of elements in the
stack.
create(int size) -> StackB S'
size >= 0
S'
is a valid new instance of
StackB
&&
S'
has the capacity to store size
items &&
empty(S')
push(StackB S, Item I) -> StackB S'
S
is a valid StackB
instance &&
not full(S)
S'
is a valid StackB
instance &&
S' = S
with I
added as the new top.
pop(StackB S) -> StackB S'
S
is a valid StackB
instance &&
not empty(S)
S'
is a valid StackB
instance &&
S' = S
with the top item deleted
top(StackB S) -> Item I
S
is a valid StackB
instance &&
not empty(S)
I =
the top item on S
S
is not modified by this operation.)
empty(StackB S) -> boolean e
S
is a valid StackB
instance
e
is true
if and only if S
contains no elements (i.e., is empty)
S
is not modified by this operation.)
full(StackB S) -> boolean f
S
is a valid StackB
instance
f
is true
if and only if S
contains no space for additional items (i.e., is full)
S
is not modified by this operation.)
destroy(StackB S) ->
S
is a valid StackB
instance
StackB S
no longer exists
Note that each operation except the constructor (create
)
has a StackB
instance as an input; the constructor and
each of the mutators also has a StackB
instance as an
output. This parameter identifies the particular instance that the
operation is manipulating.
Also note that all of these StackB
instances are required
to be "valid" in all preconditions and postconditions, except the
precondition of the constructor and the postcondition of the
destructor. By valid we mean that the state of the instance is within
the acceptable domain of values; it has not become corrupted or
inconsistent. What is specifically mean by "valid" will differ from
one implementation of a stack to another.
Suppose we implement the mutator operations as imperative commands
rather then applicative functions. That is, we implement mutators so
that they directly modify the state of an instance instead of
returning a modified copy. (S
and S'
are
implemented as different states of the same physical instance.)
Then, in some sense, the above "validity" property is invariant for an instance of the ADT; the constructor makes the property true, all mutators and accessors preserve its truth, and the destructor makes it false.
An invariant property must hold between operations on the instance; it might not hold during the execution of an operation. (For this discussion, we assume that only one thread has access to the ADT implementation.)
Aside: An invariant on an ADT instance is similar in concept to an invariant for a while-loop. A loop invariant holds before and after each execution of the loop.
As a convenience in specification we will sometimes state the invariants of the ADT separately from the pre- and postconditions of the methods. We sometimes will divide the invariants into two groups.
The interface invariants are part of the public interface of the ADT. They only deal with the state of an instance in terms of the abstract model for the ADT.
The implementation invariants are part of the hidden state of an instance; in some cases, they define the meaning of the abstract properties stated in the interface invariants in terms of hidden values in the implementation.
In a later section we will examine how to implement an ADT using the Java class construct, but first let us review a few features of Java.
A Java class
is similar to a user-defined
struct
type in C or user-defined record type in
Pascal. A class
is a template for constructing data
items that have the same structure but differing values (states). We
say that an item constructed by a class is a class instance
(or, as we see later, an object).
Like the C structure type or Pascal record type, a Java class can consist of several components. In Pascal, all the components are data fields. However, in Java, functions and procedures may be included as components of a class. These procedures and functions are called methods.
A method declared in a class may be either a class method or instance method.
We declare a method as a class method by giving the keyword
static
in the header of its definition.
For example, a main
method of a program is a class method
of the class in which it is defined.
public static void main(String[] args) { // beginning code for the program }
If we do not include the keyword static
in the
header of a method definition, the method is an instance
method. For example, consider methods to implement the
push
and top
operations of a class that
implements a stack.
public void pop() { // code for pop operation } public Object top() { // code for top operation }
Note that pop()
is a procedure (i.e., it has
return type void
) method and top
is a
function method.
In a similar fashion, the variables (data fields) declared in a class may be either class variables or instance variables.
static
is used to declare a
class variable.
static
denotes an
instance variable.
An instance method has direct access to the instance variables of the
class instance (object) to which it is applied. The instance's
variables are implicit arguments of the method calls. (If needed to
distinguish among names, the builtin variable this
can be
used to refer to the instance to which the method is applied.) The
instance methods also have access to the class variables (if any).
Class methods only have access to the class variables. The methods do not have any implicit arguments. In fact, class methods can be called without any instances of the class being in existence.
The components of a class can be designated as public
or
private
.
public
components of the class are accessible
from anywhere in the program (i.e., from any package).
private
components are only accessible from
inside the class.
As a general rule, the data fields of a class should be private instance variables, meaning that they are associated with a specific instance and are only accessible by the instance methods. This hides, or encapsulates, the data fields within the class instance.
Note: Actually, the instance methods of a class can access the instance variables of any instance of that class, not just the current instance.
In general, avoid public instance variables. They break the principle of information hiding, leading to potential entanglements among modules.
A public method of a class is a service provided by that instance to other parts of a program. The private methods of a class can be used in implementing the public methods.
Class methods and variables should be used sparingly. These are more or less the types of subprograms and global variables found in languages like Pascal and C. Their excessive use can greatly reduce the potential benefits that can be realized from object-oriented techniques.
Note: There are two other types of accessibility, "friendly" and
protected
, but public
and
private
are sufficient for our purposes in discussion of
ADT implementations.
A Java variable is a strongly typed "container" in memory that is declared to hold either:
int
), floating point numbers
(double
), booleans (boolean
), and single
characters (char
).
Note: Although arrays are not class instances, array variables hold a reference to an instance of the array.
The class instances themselves are stored in the dynamically managed heap memory area. Java allocates memory from the heap to hold newly constructed instances of a class. Java's garbage collector reclaims the memory for instances that are no longer needed by the program.
If only one implementation of an ADT is needed, the following techniques can be used to implement an ADT using Java.
class
construct to represent
the entire ADT. If we want to allow access to the class from
anywhere in the program, we will make the class public
.
For the StackB
ADT, we can use the following structure for
the class:
public class StackB { // implementation of instance methods and data here }
For example, to declare a variable that can hold a reference to a
StackB
instance, we can use the following declaration:
StackB stk;
The class and its methods should be documented with the invariants, preconditions, and postconditions.
A Java constructor is a method with the same name as the class. It does not have a return type specified. Upon creation of an instance of the class, the constructor initializes the instance's state so that the class invariants are established.
A constructor is normally invoked by the Java operator
new
. The operator new
allocates memory on
the heap for the instance, calls the constructor to initialize the new
instance, and then returns a reference to the new instance.
For example, we can represent the ADT operation
create
by the constructor method StackB
.
public class StackB { public StackB(int size) { // initialization code } // rest of StackB methods and data ... }
A user of the StackB
class can then declare a variable
and initialize it to hold a reference to a new stack with a capacity
of 100 items as follows:
StackB stk = new StackB(100);
The expression new StackB(100)
allocates a
StackB
instance in the heap storage and calls the
constructor above to initialize the data fields encapsulated within
the instance.
We can apply a method to a class instance by using the selector (i.e.,
"dot") notation. This notation is similar to the notation for
accessing record
components in Pascal.
For example, in the case of the StackB
ADT we can
represent the operations as instance methods of class
StackB
. The explicit StackB
parameters and
return values of the operations thus become implicit.
Suppose we want to push an item x
onto the stk
created above. We can do that with the following code:
if (!stk.full()) stk.push(x);
We can then examine the top item and remove it:
if (!stk.empty()) { it = stk.top(); stk.pop(); }
public
methods of the class. That
is, precede the method's definition by the keyword
public
.
void
) methods, except those mutator operations that
explicitly require new instances to be generated (e.g., a copy or
clone
operation).
For example, the pop
method of StackB
would
have the following structure:
public void pop() { // code to implement operation }
A mutator method modifies the encapsulated state of the class instance (which is the implicit argument of the method). In any circumstance in which its precondition and the class invariants hold on entry, the method must establish its postcondition and reestablish the invariants upon exit. (The invariant might not hold in the middle of the method's execution.)
Comment: Implementing mutator operations as procedure calls that modify the stored state is really an optimization. All mutators can be implemented in the applicative style, returning a modified copy of the instance. This implementation might, however, be quite inefficient in use of processor time and memory.
clone
), implement the corresponding Java methods to
return new instances of the class rather than to modify the current
instance (i.e., their implicit arguments).
Any mutator method must, of course, establish its postcondition and reestablish the invariants for the current instance. In addition, these applicative mutators must also establish the invariants for the new instance returned.
For example, the empty
method of StackB
would
have the following structure:
public boolean empty() { // code to implement operation }
An accessor method accesses the encapsulated state of the class instance (which is the implicit argument) and computes a value to be returned. In any circumstance in which its precondition and the class invariants hold on entry, the method must establish its postcondition and reestablish the invariants upon exit. (The invariant might not hold in the middle of the method's execution.)
For example, in the StackB
class, we might include an
explicit destroy operation that releases the storage resources and
disables further use of the instance.
public void destroy() { // code to free resources }
Note: The Java framework allows a finalize()
method to be
included in each class. This method is called implicitly whenever the
garbage collector detects that the instance is no longer in use.
However, since it is difficult to predict when (if ever) this method
will be executed, it is safer to include explicit destructors when
resources are in short supply and must be explicitly managed.
private
data fields of the Java class to
represent the encapsulated state of the instance needed for a
particular implementation. By making the data fields
private
they are still available to the instance's
methods, but are not visible outside the class.
For example, the StackB
class might have the following
data fields:
public class StackB { // public operations of class instance // encapsulated data fields of class instance private int topItem; // Pointer to next index for insertion private int capacity; // Maximum number of items in stack private Object[] stk; // the stack }
public
data fields in the class.
These violate the principle of information hiding. Instead
introduce appropriate accessor and mutator methods to allow
manipulation of the hidden state.
private
methods to aid in
implementation.
Functionality common to several methods can be placed in separate
functions and procedures as needed. However, since these are
private
, they can only be accessed from within the class
and thus can be changed without affecting the public interface of the
class.
For example, it is frequently useful to add public
toString
and clone
methods. The
toString
method returns a Java String
reflecting the "value" of the instance in a format suitable for
printing. The clone
method creates a new instance that
has the same value as the current instance.
static
) variables. Since a class variable is
shared among all instances of the class, it may be difficult to
preserve the invariants for individual instances as the value of the
class variable changes.
However, it is a good programming practice to use class
constants where appropriate. These are data fields
declared with both the static
and final
modifiers. Their values may be initialized but cannot be changed
thereafter.
These constants may be declared private
if usage is to be
restricted to the class or public
if the users of the
class also need access.
By convention, the names of constants are normally written with all
uppercase letters. For example, the following defines a symbolic name
for the integer code used for Sunday as a day of the week in the
Day
class defined later.
public static final int SUNDAY = 1;
Java does not currently have a parameterized class facility (like the
C++ template
or Ada generic
mechanisms). We
must handle the type parameters of the ADT in other ways.
For example, in the implementation below we represent the set
Item
of the StackB
ADT by the class
Object
. As we will see when we discuss inheritance, the
Object
type will allow us to store an instance of any
class on the StackB
. With this definition, any data of a
reference type can appear in the stack, but values of the primitive
types cannot.
The next section gives a Java implementation of the
StackB
ADT. A similar constructive definition and two
implementations of a Queue ADT are available
in a separate document.
In this section, we give an implementation of the StackB
ADT that uses an array of objects and an integer "pointer" to
represent the stack.
This implementation is not robust; each operation assumes that its precondition holds. A more robust implementation might check whether the precondition holds and throw an exception if it does not.
Remember that the invariants are implicitly pre- and postconditions of all mutator and accessor methods, postconditions of the constructor, and preconditions of the destructor.
// A Bounded Stack ADT public class StackB { // Interface Invariant: Once created and until destroyed, this // stack instance has a valid and consistent internal state public StackB(int size) // Pre: size >= 0 // Post: initialized new instance with capacity size && empty() { stk = new Object[size]; capacity = size; topItem = 0; } public void push(Object item) // Pre: not full() // Post: item added as the new top of this instance's stack { stk[topItem] = item; topItem++; } public void pop() // Pre: not empty() // Post: item at top of stack removed from this instance { topItem--; stk[topItem] = null; } public Object top() // Pre: not empty() // Post: return item at top of this instance's stack { return stk[topItem-1]; } public boolean empty() // Pre: true // Post: return true iff this instance's stack has no elements { return (topItem <= 0); } public boolean full() // Pre: true // Post: return true iff this instance's stack is at full capacity { return (topItem >= capacity); } public void destroy() // Pre: true // Post: internal resources released; stack effectively deleted { stk = null; capacity = 0; topItem = 0; } // Implementation Invariants: 0 <= topItem <= capacity // stack is in array section stk[0..topItem-1] // with the top at stk[topItem-1], etc. private int topItem; // Pointer to next index for insertion private int capacity; // Maximum number of items in stack private Object[] stk; // the stack }
If several different implementations of an ADT are needed, then the Java specification of an ADT's interface should be separated from the class implementation. The interface specification can be reused among several classes and various implementations of the interface can be used interchangeably.
This can be done as follows:
interface
that specifies the
type signatures for the ADT's mutator and accessor (and, if needed,
destructor) operations. These method signatures should have
the same characteristics as described above in the discussion of
class-based specification.
interface
by
the interface invariants, preconditions, and postconditions that must
be supported by any implementation of the ADT. There are no
implementation invariants for an interface, but individual classes
that implement the interface
will have them.
For example, a bounded stack interface
might be specified
as follows:
public interface StackADT { // Interface Invariant: Once created and until destroyed, this // stack instance has a valid and consistent internal state public void push(Object item); // Pre: not full() // Post: item added as the new top of this instance's stack ... public Object top(); // Pre: not empty() // Post: return item at top of this instance's stack ... }
interface
.
For example, an array-based StackADT
could be implemented
similarly to the StackB
definition given in the previous
section.
public class StackInArray implements StackADT { // Interface Invariant: Once created and until destroyed, this // stack instance has a valid and consistent internal state public StackInArray(int size) // Pre: size >= 0 // Post: initialized new instance with capacity size && empty() { stk = new Object[size]; capacity = size; topItem = 0; } public void push(Object item) // Pre: not full() // Post: item added as the new top of this instance's stack { stk[topItem] = item; topItem++; } ... public Object top() // Pre: not empty() // Post: return item at top of this instance's stack { return stk[topItem-1]; } ... // Implementation Invariants: 0 <= topItem <= capacity // stack is in array section stk[0..topItem-1] // with the top at stk[topItem-1], etc. private int topItem; // Pointer to next index for insertion private int capacity; // Maximum number of items in stack private Object[] stk; // the stack }
interface
type to hold instances of any concrete class that implements the
interface
. Any of the operations defined in the
interface
can be applied to the instance to which this
variable refers.
For example, a variable of type StackADT
can hold
instances of any concrete class that implements the interface
StackADT
.
StackADT theStack = new StackInArray(100); theStack.push("Hello World");
For an ADT specification and implementations that follow this approach, see the description of the Ranked Sequence ADT case study given in a separate document. In addition to Java interfaces, the Ranked Sequence case study uses other Java features such as exceptions, enumerations, packages, and Javadoc annotations.
Consider an ADT for storing and manipulating calendar dates. We will
call the ADT Day
to avoid confusion with the
Date
class in the Java API. This ADT is based on the
Day
class defined in Chapter 4 of the book
Core_Java 1.2: Volume I - Fundamentals (Fourth Edition)
by Cay S. Horstmann and Gary Cornell (Sun Microsystems Press/Prentice
Hall, 1999).
Logically, a calendar date consists of three pieces of information: a
year designator, a month designator, and a
day of the month designator. A secondary piece of
information is the day of the week. In this ADT interface definition,
we use integers (i.e., Java int
) to designate these
pieces of information.
create(int y, int m, int d) -> Day D'
y != 0 && 1 <= m <= 12 && 1 <= d <= #days in month m
&&
(y,m,d)
does not fall in the gap formed by the
change to the modern (Gregorian) calendar
D'
is a valid new instance of
Day
with year y
, month m
, and
day d
setDay(Day D, int y, int m, int d) -> Day D'
D
is a valid instance of Day
&&
y != 0 && 1 <= m <= 12 && 1 <= d <= #days in month
&&
(y,m,d)
does not fall in the gap formed by the
change to the modern (Gregorian) calendar
D'
is a valid instance of Day
&&
D'= D
except with
year y
, month m
, and day d
Question: Should we include setDay
,
setMonth
, and setYear
operations?
What problems might arise?
advance(Day D, int n) -> Day D'
D
is a valid instance of Day
D'
is a valid instance of Day
&&
D' = D
with the date moved
n
days later (Negative n
moves to an
earlier date.)
getDay(Day D) -> int d
D
is a valid instance of Day
d
is day of the month from D
, where
1 <= d <= #days in month getMonth(D)
D
is unchanged.)
getMonth(Day D) -> int m
D
is a valid instance of Day
m
is the month from D
, where
1 <= m <= 12
D
is unchanged.)
getYear(Day D) -> int y
D
is a valid instance of Day
y
is the year from D
, where
y != 0
D
is unchanged.)
getWeekday(Day D) -> int wd
D
is a valid instance of Day
wd
is the day of the week upon which
D
falls: 0 = Sunday, 1 = Monday, ..., 6 = Saturday
D
is unchanged.)
equals(Day D, Day D1) -> boolean eq
D
and D'
are valid instances of
Day
eq
is true if and only if D
and
D'
denote the same calendar date
D
and D'
are unchanged.)
daysBetween(Day D, Day D1) -> int d
D
and D'
are valid instances of
Day
d
is the number of calendar days from
D1
to D
, i.e.,
equals(D,advance(D1,d))
would be true
D
is unchanged.)
toString(Day D) -> String s
D
is a valid instance of Day
s
is the date D
expressed in the format
"Day[getYear(D)
,getMonth(D)
,getDay(D)
]".
D
is unchanged.)
destroy(Day D) ->
D
is a valid instance of Day
D
no longer exists
The following implementation of the Day
ADT is adapted
from the like-named class in Chapter 4 of the book Core Java
1.2: Volume I - Fundamentals (Fourth Edition) by Cay
S. Horstmann and Gary Cornell (Sun Microsystems Press/Prentice Hall,
1999).
This implementation represents the calendar as three integers. It converts the dates to and from Julian dates to do some of the operations.
// This class implementation is adapted from the Day class in // Horstmann and Cornell, Core Java 1.2: Volume I - Fundamentals // (Fourth Edition), Prentice Hall, 1999. import java.util.*; import java.io.*; public class Day { // Interface Invariant: Once created and until destroyed, this // instance contains a valid date. getYear() != 0 && // 1 <= getMonth() <= 12 && 1 <= getDay() <= #days in getMonth(). // Also calendar date getMonth()/getDay()/getYear() does not // fall in the gap formed by the change to the modern // (Gregorian) calendar. // Constants for days of the week public static final int SUNDAY = 1; public static final int MONDAY = 2; public static final int TUESDAY = 3; public static final int WEDNESDAY = 4; public static final int THURSDAY = 5; public static final int FRIDAY = 6; public static final int SATURDAY = 7; // Constructors public Day() // Pre: true // Post: the new instance's day, month, and year set to today's // date (i.e., the date of creation of the instance) // // Implementation uses GregorianCalendar class from the Java API // to get today's date. // { GregorianCalendar todaysDate = new GregorianCalendar(); year = todaysDate.get(Calendar.YEAR); month = todaysDate.get(Calendar.MONTH) + 1; day = todaysDate.get(Calendar.DAY_OF_MONTH); } public Day(int y, int m, int d) throws IllegalArgumentException // Pre: y != 0 && 1 <= m <= 12 && 1 <= d <= #days in month m // (y,m,d) does not fall in the gap formed by the // change to the modern (Gregorian) calendar. // Post: the new instance's day, month, and year set to y, m, // and d, respectively // Exception: IllegalArgumentException if y m d not a valid date { year = y; month = m; day = d; if (!isValid()) throw new IllegalArgumentException(); } // Mutators public void setDay(int y, int m, int d) throws IllegalArgumentException // Pre: y != 0 && 1 <= m <= 12 && 1 <= d <= #days in month m // (y,m,d) does not fall in the gap formed by the // change to the modern (Gregorian) calendar. // Post: this instance's day, month, and year set to y, m, // and d, respectively // Exception: IllegalArgumentException if y m d not a valid date { year = y; month = m; day = d; if (!isValid()) throw new IllegalArgumentException(); } public void advance(int n) // Pre: true // Post: this instance's date moved n days later. (Negative n // moves to an earlier date.) { fromJulian(toJulian() + n); } // Accessors public int getDay() // Pre: true // Post: returns the day from this instance, where // 1 <= getDay() <= #days in this instance's month { return day; } public int getMonth() // Pre: true // Post: returns the month from this instance's date, where // 1 <= getMonth() <= 12 { return month; } public int getYear() // Pre: true // Post: returns the year from this instance's date, where // getYear() != 0 { return year; } public int getWeekday() // Pre: true // Post: returns the day of the week upon which this instance // falls, where 1 <= getWeekday() <= 7; // 1 == Sunday, 2 == Monday, ..., 7 == Saturday { // calculate day of week return (toJulian() + 1) % 7 + 1; } public boolean equals(Day dd) // Pre: dd is a valid instance of Day // Post: returns true if and only if this instance and instance // dd denote the same calendar date { return (year == dd.getYear() && month == dd.getMonth() && day == dd.getDay()); } public int daysBetween(Day dd) // Pre: dd is a valid instance of Day // Post: returns the number of calendar days from the dd // instance's date to this instance's date, where // equals(dd.advance(n)) would hold { // implementation code return toJulian() - dd.toJulian(); } public String toString() // Pre: true // Post: returns this instance's date expressed in the format // "Day[year,month,day]" { return "Day[" + year + "," + month + "," + day + "]"; } // Destructors -- None needed // Private Methods private boolean isValid() // Pre: true // Post: returns true iff this is a valid date { Day t = new Day(); t.fromJulian(this.toJulian()); return t.day == day && t.month == month && t.year == year; } private int toJulian() // Pre: true // Post: returns Julian day number that begins at noon of this day // // A positive year signifies A.D., negative year B.C. // Remember that the year after 1 B.C. was 1 A.D. (i.e., no year 0). // // A convenient reference point is that May 23, 1968, at noon // is Julian day 2440000. // // Julian day 0 is a Monday. // // This algorithm is from Press et al., Numerical Recipes // in C, 2nd ed., Cambridge University Press 1992. // { int jy = year; if (year < 0) jy++; int jm = month; if (month > 2) jm++; else { jy--; jm += 13; } int jul = (int) (java.lang.Math.floor(365.25 * jy) + java.lang.Math.floor(30.6001*jm) + day + 1720995.0); int IGREG = 15 + 31*(10+12*1582); // Gregorian Calendar adopted Oct. 15, 1582 if (day + 31 * (month + 12 * year) >= IGREG) // change over to Gregorian calendar { int ja = (int)(0.01 * jy); jul += 2 - ja + (int)(0.25 * ja); } return jul; } private void fromJulian(int j) // Pre: true // Post: this calendar Day is set to Julian date j // // This algorithm is from Press et al., Numerical Recipes // in C, 2nd ed., Cambridge University Press 1992 // { int ja = j; int JGREG = 2299161; /* the Julian date of the adoption of the Gregorian calendar */ if (j >= JGREG) /* correct for crossover to Gregorian Calendar */ { int jalpha = (int)(((float)(j - 1867216) - 0.25) / 36524.25); ja += 1 + jalpha - (int)(0.25 * jalpha); } int jb = ja + 1524; int jc = (int)(6680.0 + ((float)(jb-2439870) - 122.1) /365.25); int jd = (int)(365 * jc + (0.25 * jc)); int je = (int)((jb - jd)/30.6001); day = jb - jd - (int)(30.6001 * je); month = je - 1; if (month > 12) month -= 12; year = jc - 4715; if (month > 2) --year; if (year <= 0) --year; } // Implementation Invariants: // year != 0 && 1 <= month <= 12 && 1 <= day <= #days in month // (year,month,day) not in gap formed by the change to the // modern (Gregorian) calendar private int year; private int month; private int day; }
The design and implementation of ADTs (i.e., classes) must be approached from two points of view simultaneously:
The client-supplier relationship is as represented in the following diagram:
________________ ________________ | | | | | Client |===USES===>| Supplier | |________________| |________________| (ADT user) (ADT)
The supplier's concerns include:
The clients' concerns include:
As we have noted previously, the interface of an ADT is the set of features (i.e., public operations) provided by a supplier to clients.
A precise description of a supplier's interface forms a contract between clients and supplier.
The client-supplier contract:
The contract
If we are both the clients and suppliers in a design situation, we should consciously attempt to separate the two different areas of concern, switching back and forth between our supplier and client "hats".
We can use the following design criteria for evaluating ADT (class) interfaces. Of course, some of these criteria conflict with one another; a designer must carefully balance the criteria to achieve a good interface design.
Some of the material here is based on the presentation in the following books:
The first version of these lecture notes were written for use in the first Java-based version of CSCI 211 (File Systems) during the 1996 fall semester. They were subsequently modified for use in the CSCI 581 (Object-Oriented Design and Programming) classes in the 1997 fall and 1999 spring semesters, in the CSCI 405 (Computer Simulation) class in the 2000 spring semester, and in ENGR 691 (Software Architecture) in the 2000 fall, 2002 spring, and 2004 spring semesters.
UP to ENGR 691 Lecture Notes root document?