The
twist
The
difficulty with
Music.java
can be seen by running the program. The output is
Wind.play( ).
This is clearly the desired output, but it doesn’t seem to make sense
that it would work that way. Look at the
tune( )
method:
public static void tune(Instrument i) {
// ...
i.play(Note.middleC);
}
It
receives an
Instrument
handle. So how can the compiler possibly know that this
Instrument
handle points to a
Wind
in this case and not a
Brass
or
Stringed?
The compiler can’t. To get a deeper understanding of the issue,
it’s useful to examine the subject of
binding.
Method
call binding
Connecting
a method call to a method body is called
binding.
When binding is performed before the program is run (by the compiler and
linker, if there is one), it’s called
early
binding.
You might not have heard the term before because it has never been an option
with procedural languages. C compilers have only one kind of method call, and
that’s early binding.
The
confusing part of the above program revolves around early binding because the
compiler cannot know the correct method to call when it has only an
Instrument
handle.
The
solution is called
late
binding,
which means that the binding occurs at run-time based on the type of object.
Late binding is also called
dynamic
binding
or
run-time
binding.
When a language implements late binding, there must be some mechanism to
determine the type of the object at run-time and to call the appropriate
method. That is, the compiler still doesn’t know the object type, but the
method-call mechanism finds out and calls the correct method body. The
late-binding mechanism varies from language to language, but you can imagine
that some sort of type information must be installed in the objects.
All
method binding in Java uses late binding unless a method has been declared final.
This means that you ordinarily don’t need to make any decisions about
whether late binding will occur – it happens automatically.
Why
would you declare a method
final?
As noted in the last chapter, it prevents anyone from overriding that method.
Perhaps more importantly, it effectively “turns off” dynamic
binding, or rather it tells the compiler that dynamic binding isn’t
necessary. This allows the compiler to generate more efficient code for
final
method calls.
Producing
the right behavior
Once
you know that all method binding in Java happens polymorphically via late
binding, you can write your code to talk to the base-class and know that all
the derived-class cases will work correctly using the same code. Or to put it
another way, you “send a message to an object and let the object figure
out the right thing to do.”
The
classic example in OOP is the “shape”
example. This is commonly used because it is easy to visualize, but
unfortunately it can confuse novice programmers into thinking that OOP is just
for graphics programming, which is of course not the case.
The
shape example has a base class called
Shape
and
various derived types:
Circle,
Square,
Triangle,
etc. The reason the example works so well is that it’s easy to say
“a circle is a type of shape” and be understood.
The
inheritance diagram shows the relationships:
The
upcast could occur in a statement as simple as:
Here,
a
Circle
object is created and the resulting handle is immediately assigned to a
Shape,
which would seem to be an error (assigning one type to another) and yet
it’s fine because a
Circle
is
a
Shape
by inheritance. So the compiler agrees with the statement and doesn’t
issue an error message.
When
you call one of the base class methods (that have been overridden in the
derived classes):
Again,
you might expect that
Shape’s
draw( )
is called because this is, after all, a
Shape
handle, so how could the compiler know to do anything else? And yet the proper
Circle.draw( )
is called because of late binding (polymorphism).
The
following example puts it a slightly different way:
//: Shapes.java
// Polymorphism in Java
class Shape {
void draw() {}
void erase() {}
}
class Circle extends Shape {
void draw() {
System.out.println("Circle.draw()");
}
void erase() {
System.out.println("Circle.erase()");
}
}
class Square extends Shape {
void draw() {
System.out.println("Square.draw()");
}
void erase() {
System.out.println("Square.erase()");
}
}
class Triangle extends Shape {
void draw() {
System.out.println("Triangle.draw()");
}
void erase() {
System.out.println("Triangle.erase()");
}
}
public class Shapes {
public static Shape randShape() {
switch((int)(Math.random() * 3)) {
default: // To quiet the compiler
case 0: return new Circle();
case 1: return new Square();
case 2: return new Triangle();
}
}
public static void main(String[] args) {
Shape[] s = new Shape[9];
// Fill up the array with shapes:
for(int i = 0; i < s.length; i++)
s[i] = randShape();
// Make polymorphic method calls:
for(int i = 0; i < s.length; i++)
s[i].draw();
}
} ///:~
The
base class
Shape
establishes the common interface to anything inherited from
Shape
– that is, all shapes can be drawn and erased. The derived classes
override these definitions to provide unique behavior for each specific type of
shape.
The
main class
Shapes
contains a
static
method
randShape( )
that produces a handle to a randomly-selected
Shape
object each time you call it. Note that the upcasting happens in each of the
return
statements, which take a handle to a
Circle,
Square,
or
Triangle
and send it out of the method as the return type,
Shape.
So whenever you call this method you never get a chance to see what specific
type it is, since you always get back a plain
Shape
handle.
main( )
contains an array of
Shape
handles filled through calls to
randShape( ).
At this point you know you have
Shapes,
but you don’t know anything more specific than that (and neither does the
compiler). However, when you step through this array and call
draw( )
for each one, the correct type-specific behavior magically occurs, as you can
see from one output example:
Circle.draw()
Triangle.draw()
Circle.draw()
Circle.draw()
Circle.draw()
Square.draw()
Triangle.draw()
Square.draw()
Square.draw()
Of
course, since the shapes are all chosen randomly each time, your runs will have
different results. The point of choosing the shapes randomly is to drive home
the understanding that the compiler can have no special knowledge that allows
it to make the correct calls at compile time. All the calls to
draw( )
are made through dynamic binding.
Extensibility
Now
let’s return to the musical instrument example. Because of polymorphism,
you can add as many new types as you want to the system without changing the
tune( )
method. In a well-designed OOP program, most or all of your methods will follow
the model of
tune( )
and communicate only with the base-class interface.
Such a program is
extensible
because you can add new functionality by inheriting new data types from the
common base class. The methods that manipulate the base-class interface will
not need to be changed at all to accommodate the new classes.
Consider
what happens if you take the instrument example and add more methods in the
base class and a number of new classes. Here’s the diagram:
All
these new classes work correctly with the old, unchanged
tune( )
method. Even if
tune( )
is in a separate file and new methods are added to the interface of
Instrument,
tune( )
works correctly without recompilation. Here is the implementation of the above
diagram:
//: Music3.java
// An extensible program
import java.util.*;
class Instrument3 {
public void play() {
System.out.println("Instrument3.play()");
}
public String what() {
return "Instrument3";
}
public void adjust() {}
}
class Wind3 extends Instrument3 {
public void play() {
System.out.println("Wind3.play()");
}
public String what() { return "Wind3"; }
public void adjust() {}
}
class Percussion3 extends Instrument3 {
public void play() {
System.out.println("Percussion3.play()");
}
public String what() { return "Percussion3"; }
public void adjust() {}
}
class Stringed3 extends Instrument3 {
public void play() {
System.out.println("Stringed3.play()");
}
public String what() { return "Stringed3"; }
public void adjust() {}
}
class Brass3 extends Wind3 {
public void play() {
System.out.println("Brass3.play()");
}
public void adjust() {
System.out.println("Brass3.adjust()");
}
}
class Woodwind3 extends Wind3 {
public void play() {
System.out.println("Woodwind3.play()");
}
public String what() { return "Woodwind3"; }
}
public class Music3 {
// Doesn't care about type, so new types
// added to the system still work right:
static void tune(Instrument3 i) {
// ...
i.play();
}
static void tuneAll(Instrument3[] e) {
for(int i = 0; i < e.length; i++)
tune(e[i]);
}
public static void main(String[] args) {
Instrument3[] orchestra = new Instrument3[5];
int i = 0;
// Upcasting during addition to the array:
orchestra[i++] = new Wind3();
orchestra[i++] = new Percussion3();
orchestra[i++] = new Stringed3();
orchestra[i++] = new Brass3();
orchestra[i++] = new Woodwind3();
tuneAll(orchestra);
}
} ///:~
The
new methods are
what( ),
which returns a
String
handle with a description of the class, and
adjust( ),
which provides some way to adjust each instrument.
In
main( ),
when you place something inside the
Instrument3
array
you automatically upcast to
Instrument3. You
can see that the
tune( )
method is blissfully ignorant of all the code changes that have happened around
it, and yet it works correctly. This is exactly what polymorphism is supposed
to provide. Your code changes don’t cause damage to parts of the program
that should not be affected. Put another way, polymorphism is one of the most
important techniques that allow the programmer to “separate the things
that change from the things that stay the same.”