The twist

Bruce Eckel’s Thinking in Java Contents | Prev | Next

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

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.

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
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:

Shape
s = new Circle();

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):

s.draw();

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

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.”

More by Author

Must Read