[ Back to Index of Web Works ]
part of George's Web Site
Related Web Works |
Container support for objects in Java 1.0.2Organizing objects is easy when you put them into containers
Herbert Spencer wrote, "Science is organized knowledge." The corollary might be that applications are organized objects. Let's take a moment to turn to some aspects of Java that are critical for developing applications rather than applets. Of those who have heard of Java, the majority has learned about the language through the popular press. The statement that crops up a lot is that Java is for "programming small applications, or applets, that can be embedded on a Web page." While correct, this definition conveys only one aspect of the new language; it doesn't describe the whole picture. Perhaps Java can be better described as a language designed to build systems -- large systems -- of well-understood portable pieces of executable code that can be combined, in whole or in part, to produce a desirable whole. In this column I will begin to look at the various tools you can use to build in Java. I will demonstrate how these tools can be combined to make a larger application, and how, once you have an application, you can further aggregate the application into still larger systems -- all possible because in Java there is no distinction between a complete application and a simple subroutine. To provide source code fodder for this and past columns, I chose to build a BASIC interpreter. "Why BASIC?" you might ask, thinking no one uses BASIC anymore. This is not entirely true. BASIC lives on in Visual BASIC and in other scripting languages. But more importantly, many people have been exposed to it and can make the following conceptual leap: If "applications" are programmed in BASIC, and BASIC can be written in Java, then applications can be written in Java. BASIC is just another interpreted language; the tools that we will be building can be modified to use any language syntax, thus the core concepts are the focus of these articles. Therefore, what starts as an application becomes a component of other applications -- even applets perhaps.
Generic classes and containers Since we're starting to look a bit more seriously at application development, we will assume we have already determined that generic classes are a valid solution.
Java, like many general-purpose languages, provides several tools for
creating generic classes. Different requirements will necessitate using
different tools. In this column I will use the development of a
Containers: a definition Containers have both an organizing principle and an interface. Stacks, for example, may be organized as "first in, last out" (FILO), and their interface may be defined to have two methods -- push() and pop(). Simple containers can be thought of as having the standard methods add and remove. Further, they will have a means to enumerate the entire container, to check to see if a candidate object is already in the container and to test the number of elements being held by the container.
The Java container classes demonstrate some of the problems with
containers, especially keyed containers (those containers that use a
key to locate an object). The non-keyed containers like
Stack and Vector simply stuff objects
in and pull objects out. The keyed container
Hashtable uses a key object to locate a data object.
For the keying function to work,
the key object must support a method
HashCode that returns a unique hash code for every object.
This keying ability works because the
But what about sorted containers? The only sorting interface provided
in the if (someStringObject == "this") then { ... do something ... } The above code compares the object references, notes that there are two different objects here, and returns false. You have to write the code as follows: if (someStringObject.compareTo("this") == 0) then { ... do something ...} This latter test uses knowledge encapsulated in the compareTo method of String to compare two string objects and return an indication of equality.
Using the tools in the box
To use implementation inheritance, you extend (subclass) an
existing class. By extension, all subclasses of the base class have the
same capabilities as the root class. This is the basis for the
In addition to implementation inheritance, there is behavioral inheritance (implementing), which is achieved by specifying that an object implements a particular Java interface. An object that implements an interface can be cast to an object reference of that interface type. Then that reference can be used to invoke the methods specified by that interface. Typically, interfaces are used when a class may need to process several objects of different types in a common way. For example, Java defines the Runnable interface that is used by the thread classes to work with classes in their own thread.
Building a container As I mentioned earlier, in the development of general-purpose applications, in many cases a good container would be useful. In my example application I needed a container that was both keyed, meaning that I wanted to retrieve contained objects by using a simple key, and sorted so that I could retrieve the contained objects in a specific order based on the key values. When designing systems, it is important to keep in mind what parts of the system use a particular interface. In the case of containers, there are two critical interfaces -- the container itself and the keys that index the container. User programs use the container to store and organize objects; the containers themselves use the key interfaces to help them organize themselves. When designing containers, we strive to make them easy to use and to store a wide variety of objects (thus increasing their utility). We design the keys to be flexible so that a wide variety of container implementation can use the same key structures. To solve my behavioral requirements, keying and sorting, I turn to a useful tree data structure called a binary search tree (BST). Binary trees have the useful property of being sorted, so they can be efficiently searched and can be dumped out in sorted order. The actual BST code is an implementation of the algorithms published in the book Introduction to Algorithms, by Thomas Cormen, Charles Leiserson, and Ron Rivest.
java.util.Dictionary
The
By subclassing
Basically,
If you look at the
First cut at a generic keyed container The first is BSTNode, which is equivalent to a HashtableEntry. It is defined as shown in the code outline below. You can also look at the source. class BSTNode { protected BSTNode parent; protected BSTNode left; protected BSTNode right; protected String key; protected Object payload; public BSTNode(String k, Object p) { key = k; payload = p; } protected BSTNode() { super(); } BSTNode successor() { return successor(this); } BSTNode precessor() { return predecessor(this); } BSTNode min() { return min(this); } BSTNode max() { return max(this); } void print(PrintStream p) { print(this, p); } private static BSTNode successor(BSTNode n) { ... } private static BSTNode predecessor(BSTNode n) { ... } private static BSTNode min(BSTNode n) { ... } private static BSTNode max(BSTNode n) { ... } private static void print(BSTNode n, PrintStream p) { ... } } Let's take a look at this code in order to clarify two things. First, there's the null-protected constructor, which is present so that subclasses of this class do not have to declare a constructor that overrides one of this class' constructors. Second, the methods successor, predecessor, min, max, and print are very short and merely call the same private equivalent so as to conserve memory space. The next special-purpose class is the class BSTEnumerator, which provides an implementation of a java.util.Enumeration that can be used to get each key or each object out of the tree in order. You can follow along with the code below. class BSTEnumerator extends BSTNode implements Enumeration { private BSTNode currentNode; private boolean keys; BSTEnumerator(BSTNode start, boolean doKeys) { super(); currentNode = start; keys = doKeys; } public boolean hasMoreElements() { return (currentNode != null); } public Object nextElement() { BSTNode n; if (currentNode == null) { throw new NoSuchElementException(); } n = currentNode; currentNode = currentNode.successor(); return (keys ? n.key : n.payload); } } As you can see, we extend BSTNode, which gives us access to the methods for moving through a tree. Further, we can look into the protected parts of the object -- the key and the payload instance variables. To implement the Enumeration interface we need only a hasMoreElements method and a nextElement method.
The hasMoreElements method returns
true if the node we are holding is non-null. This
design allows us to create an enumerator without having to check if
there are any objects in our container; the enumerator will always
provide the correct response. The second method, nextElement,
simply returns the node we are holding and replaces that node with the
next node in the tree (known as the successor node). What is
actually returned is either the key value or the payload, depending on
the state of the key's boolean. By implementing the system in
this way, only these two classes --
Finally, after the node and enumerator are designed, we put together our
subclass of public class BinarySearchTree extends Dictionary { BSTNode rootNode; private int elementCount; private BSTNode search(String searchKey) { ... } private void insert(BSTNode n) { ... ; elementCount++ } private BSTNode delete(BSTNode z) { ...; elementCount--; }
This first section of the code above shows the two state
variables that our public Enumeration elements() { if (rootNode == null) return null; return new BSTEnumerator(rootNode.min(), false); } public Enumeration keys() { if (rootNode == null) return null; return new BSTEnumerator(rootNode.min(), true); } public boolean isEmpty() { return (elementCount == 0); } public int size() { return elementCount; } The key and elements methods simply create a new BSTEnumerator with the keys flag appropriately set, and pass it the logical first element in the tree. Later, when nextElement is called, this node will be used to find successive nodes until all nodes have been visited. The isEmpty and size methods simply return the state of elementCount to the user. This variable is maintained in the insert and delete methods of the tree.
The next method from public Object get(Object key) { if (rootNode == null) return null; BSTNode n = search((String) key); return (n != null) ? n.payload : null; } Next we have the dictionary administration methods remove and put. The implementation of these methods is shown below. The only subtle implementation detail is that, implicitly, keyed containers cannot contain multiple objects for the same key. This is simply an implementation decision that Sun made in the specification of the language. It is straightforward to implement multiple objects per key but get and remove get a bit harder to write. The remove method checks to see if the node exists. If it does, it gets back its payload, then removes it while returning the payload to the caller. The put method removes an object associated with key if it exists and then adds a new node with the new value into the tree. public Object remove(Object key) { BSTNode n = search((String) key); if (n == null) return null; Object r = n.payload; delete(n); // call the BST delete method return r; } public Object put(Object key, Object value) { BSTNode n; Object r = remove(key); n = new BSTNode((String) key, value); insert(n); // Call the BST insert method return r; }
And with that a complete container is created to go along with
Hashtable. To test it I wrote a simple test program that fills
the container with some strings and then enumerates them out. Next it
removes one of the objects and prints out the complete list again. At
about line 32 of the test program there is the statement Dictionary
d = new XXX(); where the XXX is replaced with
either BinarySearchTree or Hashtable.
This test program inserts the following colors into the dictionary: red, green,
orange, purple, chartreuse, gray, magenta, cyan, yellow, tangerine,
turquoise, brown, black, white, silver, gold, pink, bronze, beige,
blue, and aquamarine. And it sets the stored object value to be a string
consisting of the number of the color in the list, as well as the color's value. This
gives us an idea of how the colors get rearranged when they are in the
System.out.println("XXX contains:"); for (Enumeration e = d.elements(); e.hasMoreElements(); ) { System.out.println(e.nextElement()); } The results are shown in this table:
As you can see from the above table, the objects from the BinarySearchTree-based dictionary come back in sorted order, whereas the Hashtable entries come back in an unpredictable order (unless you read the source to Hashtable, of course).
The use of String in the binary search tree container For any container to work, the key must identify an object. In our BinarySearchTree container we use a String object to identify the contained object. The object identified by the key is located using a binary search algorithm, which uses the relative value of the key you are holding versus the key you are currently looking at, to determine which key to look at next. This is implemented in the search method in BinarySearchTree shown below. private search BSTNode search(String searchKey) { return search(rootNode, searchKey); } private static BSTNode search(BSTNode n, String searchKey) { if ((n == null) || searchKey.equals(n.key)) { return n; } if (searchKey.compareTo(n.key) < 0) { return search(n.left, searchKey); } else { return search(n.right, searchKey); } } If you recall the way that a binary tree works, each node optionally has two child nodes (hence the term "binary search tree"). The node to the left points to a node that is "less than" the current node, and the node to the right points to a node that is "greater than" the current node. The search method uses the equals method to see if the key of this node is equal to the passed key. If the keys are not equal, then search uses the compareTo method to decide if the left branch or the right branch of the tree should be followed. Once the branch determination is made, the method is recursively called on that node. If the node is null, as it will be when the key is not present in the tree, search simply returns null, indicating that the key is not present.
As you can see, the algorithm is simple and efficient, however it
depends on the key object having a method compareTo to
determine if the left or right branch of the tree should be followed.
Unfortunately, the base class of
Wrapping up
About the author Resources
If you have problems with this magazine, contact
webmaster@javaworld.com
|
[ Back to Index of Web Works | Top ] |