|
Articles Index
By Terence Parr,
MageLang Institute
December 1996
Consider that the reusability and, indeed, the lifetime of a piece of code
depends heavily on the number of type restrictions placed upon the data
elements by that code. The fewer restrictions on the type of data elements
operated on, the more widely applicable the code will be. For example, a list
manager that was restricted to operate only on integers could not be reused to
manage a list of real numbers despite the fact that the majority of the code
would be identical. This article describes how Java promotes
considerable type flexibility and reusability when referring to data elements
as well as when creating data elements. Java goes as far as to allow type
information to be a variable. This mechanism can be used to create and
examine the functionality of objects whose type is computed at runtime.
Introduction
In nonobject-oriented programming languages, the type of each element
must be known at compile time, which results in very inflexible code.
Object-oriented languages relax this constraint--only the kind of
element needs to be known. Java takes this a step further. Interfaces
allow Java code to refer to behavior of an object regardless of the
kind of object.
Certainly flexibility in how a program refers to data elements is crucial,
but what about the creation of data elements? Consider a method,
tmake, that constructs a generic tree data structure. To create the
various nodes, tmake would do a lot of new TreeNode()
operations. Now imagine that you must subclass the generic tree nodes,
defining MyTreeNode, to add specific behavior. The method tmake
could not be reused to create trees consisting of MyTreeNode
elements--it is restricted to creating elements of a specific type. Java
provides a solution to this inflexibility by allowing programs to specify
variable type information, thus, deferring type information to runtime.
Class definitions in many object-oriented languages, such as C++, are
compile-time entities that mainly specify how the compiler is to lay out
memory. Because Java binds method names to code on-the-fly at runtime, Java
class definitions must exist at runtime. Consequently, each Java class in
your program results in a corresponding class definition object of type
Class.
Every regular object that you create has a reference to its class definition object; that is, Java objects are conscious--they know their identity. Method getClass defined in class Object can be used to obtain an object's class definition:
Button b = new Button("OK");
Class bClassDef = b.getClass();
|
Here, bClassDef refers to the class definition for Button.
To obtain the class definition object for a particular class, call the static method forName in Class:
try {
Class bdef=Class.forName("Button");
} catch (ClassNotFoundException e) {
System.err.println("class Button not found");
}
|
This runtime type information makes Java more robust than C++, for example,
because you cannot operate on an object of incorrect type. In C++, you can
cast an object to whatever type you want and then operate on it in an unsafe
manner. In Java, the operation is safe.
Object Factories
So, what can you do with a class definition object (of type Class)? One of the things you can do is make new instances of that class. While Java provides a new operator for this purpose, new is a compile-time operator in that it cannot take a variable argument:
String cname = "Button";
Button b = new(cname); // INVALID!
|
An object factory, the runtime equivalent of the new operator, would be a useful thing to have. Consider how you would write a program that allowed users to specify a set of objects that had to be created while the program was running? More specifically, how could you create instances of classes that were unknown when you wrote your program? This is precisely the requirement for GUI builders. Also, one can imagine a presentation designer that allowed users to drag-n-drop a variety of Java Component objects onto the pages.
The following method represents a factory that takes a class name in a String and returns an instance of that class.
Object factory(String p) {
Class c;
Object o=null;
try {
c = Class.forName(p);// get class def
o = c.newInstance(); // make a new one
} catch (Exception e) {
// either class not found,
// class is interface/abstract, or
// class or initializer is not accessible.
System.err.println("Can't make a " + p);
}
return o;
}
|
The constructor (with no arguments) for the specified class is called, but there is no way to call a constructor with arguments.
You can place factory in a class called ObjectFoundry as a public static method so that it can be used by all parts of your program. You can allow exceptions to flow through to the caller of the factory so that an appropriate error message can be generated:
public class ObjectFoundry {
public static Object factory(String p)
throws ClassNotFoundException,
InstantiationException,
IllegalAccessException {
Class c = Class.forName(p);
Object o = c.newInstance();
return o;
}
}
|
Your factory can then be used as follows:
public class T {
public static void main(String[] args) {
Button b;
try {
b = (Button)
ObjectFoundry.factory("Button");
} catch (Exception e) {
System.err.println("Can't make Button");
}
}
}
|
Returning to the tmake method from the introduction, you can see
that tmake could easily be extended to create trees consisting of
arbitrary tree nodes.
Checking an Object's Identity
In the last section, we introduced the notion that you sometimes have to
create objects of types that can only be determined at runtime. We defined a
runtime equivalent of the new operator. It turns out that a
runtime equivalent of the instanceof operator is also useful; that
is, you may want to compare the identity of an object to a type known only at
runtime. For example, you may want to create a list manager that only
contains objects of a specific type that is specified during list manager
construction.
The operation obj instanceof type checks more than just whether or
not obj is an instance of type. Operator
instanceof checks:
-
Whether or not
obj is an instance of class
type or any class on the path to the root of the class hierarchy (that is, the superclasses).
-
Whether or not
obj is an instance of a class that implements
type if type is an interface instead of a class.
So, in effect, instanceof checks whether obj is a particular kind of object or whether obj can behave according to a particular interface. You distinguish between these operations because you may wish to examine identity rather than behavior. For example, you may want to know that some object is a simple Timer rather than a TimeBomb that also implements some interface Ticking. The following method embodies operation 1:
public static boolean
isKindOf(Object obj, String type)
throws ClassNotFoundException {
// get the class def for obj and type
Class c = obj.getClass();
Class tClass = Class.forName(type);
// check against type and superclasses
while ( c!=null ) {
// have we found the given classname?
if ( c==tClass ) return true;
c = c.getSuperclass();
}
return false;
}
|
To get a runtime equivalent of operator instanceof, you check both isKindOf and, if the type is an interface, check to see if obj implements that interface using a method, behavesLike, which is defined in the next section:
public static boolean
instanceOf(Object obj, String type)
throws ClassNotFoundException {
Class tClass = Class.forName(type);
if ( tClass.isInterface() ) {
return behavesLike(obj, type);
}
else {
return isKindOf(obj, type);
}
}
|
Checking an Object's Behavior
While you might want to check the identity or ancestry of an
object, most of the time you care only how an object behaves (because you want to send it a message). For example, consider a simple remote-method invocation mechanism. Objects could be created on one system, R, and receive messages (method calls) from objects on another system, S. In principle, the object on S would only care that the object on R answered a set of messages--the actual class name of the R object may be unknown to S. If the R object were a database manager, then the S object would only care that it answered add and delete, for example. In order to "hook up" the objects on R and S, R could send S the name of an interface it implemented; in other words, R could send S the name of a role it was going to play.
Testing interface compliance is a matter of asking an object for the list of
interfaces it implements and then checking the desired interface name for
membership in this list. We have implemented a method called
behavesLike as an analog to isKindOf defined in the
previous section:
public static boolean
behavesLike(Object obj, String behavior)
throws ClassNotFoundException {
// get interface def for behavior
Class bClass = Class.forName(behavior);
Class c = obj.getClass();
// walk back up hierarchy to check interfaces
while ( c!=null ) {
// get interfaces for this class
Class[] ia = c.getInterfaces();
// is behavior among them?
for (int i=0; i<ia.length; i++) {
if ( ia[i] == bClass ) return true;
}
c = c.getSuperclass();
}
return false;
}
|
The method behavesLike reports true when obj is a subclass
of a class that implements behavior, because a class
that implements an interface forces all subclasses to satisfy that interface
as well. You have to walk back up the class hierarchy, because the method
getInterfaces returns only the list of interfaces for the associated
object.
Examples Using ObjectFoundry
To test the ObjectFoundry class, this example defines an interface and a few classes:
interface Mortal {
public void kill();
}
class Animal implements Mortal {
String species;
public Animal() {
species="unknown";
}
public Animal(String sp) {
species=sp;
}
public void kill() {
//play("BestOfNewAgeCD.au");
}
}
class Dog extends Animal {
public Dog() {
species = "Dogus Chewus Maximus";
}
}
|
Given those classes and these definitions:
Animal animal =
(Animal)ObjectFoundry.factory("Animal");
Dog dog =
(Dog)ObjectFoundry.factory("Dog");
|
you can ask the following questions:
-
Is a
Dog an instanceOf Animal?
ObjectFoundry.instanceOf(dog,"Animal")==true.
-
Does an
Animal behaveLike an Animal?
ObjectFoundry.behavesLike(animal, "Animal")==false.
-
Does a
Dog behaveLike an Animal?
ObjectFoundry.behavesLike(dog, "Animal")==false.
-
Does a
Dog behaveLike a Mortal?
ObjectFoundry.behavesLike(dog,"Mortal")==true.
-
Is a
Animal an instanceOf Mortal?
ObjectFoundry.instanceOf(animal,"Mortal")==true.
The uses of ObjectFoundry methods would, of course, have to be enclosed in try-catch blocks, because ObjectFoundry methods throw exceptions.
Conclusion
Code reuse is of primary importance and object-oriented languages in general
represent a tremendous step toward the goal of widely applicable code. By
allowing code to refer to data elements by the kind of element rather than a
specific type, object-oriented programs are very flexible. Java provides two
significant advantages over many other object-oriented languages in that
(i) Java interfaces allow programs to refer to elements by their
behavior rather than their identity (class name) and (ii) Java allows
type information to be a variable, thus, allowing the behavior or kind of
element to be computed at runtime. The latter capability increases
flexibility in one of the last situations in which object-oriented programs
require specific, compile-time, type information--object creation.
Source Code
The following Java source files provide the series of tests and class
definitions described in this article.
|