Collections
To
summarize what we’ve seen so far, your first, most efficient choice to
hold a group of objects should be an array, and you’re forced into this
choice if you want to hold a group of primitives. In the remainder of the
chapter we’ll look at the more general case, when you don’t know at
the time you’re writing the program how many objects you’re going
to need, or if you need a more sophisticated way to store your objects. Java
provides four types of collection
classes
to solve this problem: Vector,
BitSet,
Stack,
and Hashtable.
Although compared to other languages that provide collections this is a fairly
meager supply, you can nonetheless solve a surprising number of problems using
these tools.
Among
their other characteristics –
Stack,
for example, implements a LIFO (last-in, first-out) sequence, and
Hashtable
is an associative
array
that lets you associate any object with any other object – the Java
collection classes will automatically resize themselves. Thus, you can put in
any number of objects and you don’t need to worry about how big to make
the collection while you’re writing the program.
Disadvantage:
unknown type
The
“disadvantage” to using the Java collections is that you lose type
information when you put an object into a collection. This happens because,
when the collection was written, the programmer of that collection had no idea
what specific type you wanted to put in the collection, and making the
collection hold only your type would prevent it from being a general-purpose
tool. So instead, the collection holds handles to objects of type
Object,
which is of course every object in Java, since it’s the root of all the
classes. (Of course, this doesn’t include primitive types, since they
aren’t inherited from anything.) This is a great solution, except for
these reasons:
- Since
the type information is thrown away when you put an object handle into a
collection,
any
type of object can be put into your collection, even if you mean it to hold
only, say, cats. Someone could just as easily put a dog into the collection.
- Since
the type information is lost, the only thing the collection knows it holds is a
handle to an
Object.
You must perform a cast
to the correct type before you use it.
On
the up side, Java won’t let you
misuse
the objects that you put into a collection. If you throw a dog into a
collection of cats, then go through and try to treat everything in the
collection as a cat, you’ll get an exception when you get to the dog. In
the same vein, if you try to cast the dog handle that you pull out of the cat
collection into a cat, you’ll get an exception at run-time.
//: CatsAndDogs.java
// Simple collection example (Vector)
import java.util.*;
class Cat {
private int catNumber;
Cat(int i) {
catNumber = i;
}
void print() {
System.out.println("Cat #" + catNumber);
}
}
class Dog {
private int dogNumber;
Dog(int i) {
dogNumber = i;
}
void print() {
System.out.println("Dog #" + dogNumber);
}
}
public class CatsAndDogs {
public static void main(String[] args) {
Vector cats = new Vector();
for(int i = 0; i < 7; i++)
cats.addElement(new Cat(i));
// Not a problem to add a dog to cats:
cats.addElement(new Dog(7));
for(int i = 0; i < cats.size(); i++)
((Cat)cats.elementAt(i)).print();
// Dog is detected only at run-time
}
} ///:~
You
can see that using a
Vector
is straightforward: create one, put objects in using addElement( ),
and later get them out with elementAt( ).
(Note that
Vector
has a method size( )
to let you know how many elements have been added so you don’t
inadvertently run off the end and cause an exception.)
The
classes
Cat
and
Dog
are distinct – they have nothing in common except that they are
Objects.
(If you don’t explicitly say what class you’re inheriting from, you
automatically inherit from
Object.)
The
Vector
class, which comes from
java.util,
holds
Objects,
so not only can you put
Cat
objects into this collection using the
Vector
method
addElement( ),
but you can also add
Dog
objects without complaint at either compile-time or run-time. When you go to
fetch out what you think are
Cat
objects using the
Vector
method
elementAt( ),
you get back a handle to an
Object
that you must cast to a
Cat.
Then you need to surround the entire expression with parentheses to force the
evaluation of the cast before calling the
print( )
method for
Cat,
otherwise you’ll get a syntax error. Then, at run-time, when you try to
cast the
Dog
object to a
Cat,
you’ll get an exception.
This
is more than just an annoyance. It’s something that can create some
difficult-to-find bugs. If one part (or several parts) of a program inserts
objects into a collection, and you discover only in a separate part of the
program through an exception that a bad object was placed in the collection,
then you must find out where the bad insert occurred. You do this by code
inspection, which is about the worst debugging tool you have. On the upside,
it’s convenient to start with some standardized collection classes for
programming, despite the scarcity and awkwardness.
Sometimes
it works right anyway
It
turns out that in some cases things seem to work correctly without casting back
to your original type. The first case is quite special: the
String
class has some extra help from the compiler to make it work smoothly. Whenever
the compiler expects a
String
object and it hasn’t got one, it will automatically call the toString( )
method that’s defined in
Object
and can be overridden by any Java class. This method produces the desired
String
object, which is then used wherever it was wanted.
Thus,
all you need to do to make objects of your class print out is to override the
toString( )
method, as shown in the following example:
//: WorksAnyway.java
// In special cases, things just seem
// to work correctly.
import java.util.*;
class Mouse {
private int mouseNumber;
Mouse(int i) {
mouseNumber = i;
}
// Magic method:
public String toString() {
return "This is Mouse #" + mouseNumber;
}
void print(String msg) {
if(msg != null) System.out.println(msg);
System.out.println(
"Mouse number " + mouseNumber);
}
}
class MouseTrap {
static void caughtYa(Object m) {
Mouse mouse = (Mouse)m; // Cast from Object
mouse.print("Caught one!");
}
}
public class WorksAnyway {
public static void main(String[] args) {
Vector mice = new Vector();
for(int i = 0; i < 3; i++)
mice.addElement(new Mouse(i));
for(int i = 0; i < mice.size(); i++) {
// No cast necessary, automatic call
// to Object.toString():
System.out.println(
"Free mouse: " + mice.elementAt(i));
MouseTrap.caughtYa(mice.elementAt(i));
}
}
} ///:~
You
can see the redefinition of
toString( )
in
Mouse.
In the second
for
loop in
main( )
you find the statement:
System.out.println("Free
mouse: " + mice.elementAt(i));
After
the ‘
+’
sign the compiler expects to see a String
object.
elementAt( )
produces an
Object,
so to get the desired
String
the compiler implicitly calls
toString( ).
Unfortunately, you can work this kind of magic only with
String;
it isn’t available for any other type.
A
second approach to hiding the cast has been placed inside
Mousetrap.
The
caughtYa( )
method accepts not a
Mouse,
but an
Object,
which it then casts to a
Mouse.
This is quite presumptuous, of course, since by accepting an
Object
anything could be passed to the method. However, if the cast is incorrect
– if you passed the wrong type – you’ll get an exception at
run-time. This is not as good as compile-time checking but it’s still
robust. Note that in the use of this method:
MouseTrap.caughtYa(mice.elementAt(i));
Making
a type-conscious Vector
You
might not want to give up on this issue just yet. A more ironclad solution is
to create a new class using the
Vector,
such that it will accept only your type and produce only your type:
//: GopherVector.java
// A type-conscious Vector
import java.util.*;
class Gopher {
private int gopherNumber;
Gopher(int i) {
gopherNumber = i;
}
void print(String msg) {
if(msg != null) System.out.println(msg);
System.out.println(
"Gopher number " + gopherNumber);
}
}
class GopherTrap {
static void caughtYa(Gopher g) {
g.print("Caught one!");
}
}
class GopherVector {
private Vector v = new Vector();
public void addElement(Gopher m) {
v.addElement(m);
}
public Gopher elementAt(int index) {
return (Gopher)v.elementAt(index);
}
public int size() { return v.size(); }
public static void main(String[] args) {
GopherVector gophers = new GopherVector();
for(int i = 0; i < 3; i++)
gophers.addElement(new Gopher(i));
for(int i = 0; i < gophers.size(); i++)
GopherTrap.caughtYa(gophers.elementAt(i));
}
} ///:~
This
is similar to the previous example, except that the new
GopherVector
class has a
private
member
of type
Vector
(inheriting from
Vector
tends to be frustrating, for reasons you’ll see later), and methods just
like
Vector.
However, it doesn’t accept and produce generic
Objects,
only
Gopher
objects.
Because
a
GopherVector
will accept only a
Gopher,
if you were to say:
gophers.addElement(new
Pigeon());
you
would get an error message
at
compile time
.
This approach, while more tedious from a coding standpoint, will tell you
immediately if you’re using a type improperly.
Note
that no cast is necessary when using
elementAt( )
– it’s always a
Gopher.
Parameterized
types
This
kind of problem isn’t isolated – there are numerous cases in which
you need to create new types based on other types, and in which it is useful to
have specific type information at compile-time. This is the concept of a parameterized
type
.
In C++, this is directly supported by the language in templates.
At one point, Java had reserved the keyword generic
to someday support parameterized types, but it’s uncertain if this will
ever occur.