Interchangeable
objects
with
polymorphism
When
dealing with type hierarchies, you often want to treat an object not as the
specific type that it is but instead as its base type. This allows you to write
code that doesn’t depend on specific types. In the shape example,
functions manipulate generic shapes without respect to whether they’re
circles, squares, triangles, and so on. All shapes can be drawn, erased, and
moved, so these functions simply send a message to a shape object; they
don’t worry about how the object copes with the message.
Such
code is unaffected by the addition of new types, and adding new types is the
most common way to extend an object-oriented program to handle new situations.
For example, you can derive a new subtype of shape called pentagon
without
modifying the functions that deal only with generic shapes. This ability to
extend a program easily by deriving new subtypes is important because it
greatly improves designs while reducing the cost of software maintenance.
There’s
a problem, however, with attempting to treat derived-type objects as their
generic base types (circles as shapes, bicycles as vehicles, cormorants as
birds, etc.). If a function is going to tell a generic shape to draw itself, or
a generic vehicle to steer, or a generic bird to fly, the compiler cannot know
at compile-time precisely what piece of code will be executed. That’s the
whole point – when the message is sent, the programmer doesn’t w
ant
to know what piece of code will be executed; the draw function can be applied
equally to a circle, square, or triangle, and the object will execute the
proper code depending on its specific type. If you don’t have to know
what piece of code will be executed, then when you add a new subtype, the code
it executes can be different without changes to the function call. Therefore,
the compiler cannot know precisely what piece of code is executed, so what does
it do? For example, in the following diagram the
BirdController
object just works with generic
Bird
objects, and does not know what exact type they are. This is convenient from
BirdController’s
perspective, because it doesn’t have to write special code to determine
the exact type of
Bird
it’s working with, or that
Bird’s
behavior. So how does it happen that, when
fly( )
is called while ignoring the specific type of
Bird,
the right behavior will occur?
The
answer is the primary twist in object-oriented programming: The compiler cannot
make a function call in the traditional sense. The function call generated by a
non-OOP compiler causes what is called
early
binding
,
a term you may not have heard before because you’ve never thought about
it any other way. It means the compiler generates a call to a specific function
name, and the linker resolves this call to the absolute address of the code to
be executed. In OOP, the program cannot determine the address of the code until
run-time, so some other scheme is necessary when a message is sent to a generic
object.
To
solve the problem, object-oriented languages use the concept of
late
binding
.
When you send a message to an object, the code being called isn’t
determined until run-time. The compiler does ensure that the function exists
and it performs type checking on the arguments and return value (a language
where this isn’t true is called
weakly
typed
),
but it doesn’t know the exact code to execute.
To
perform late binding, the compiler inserts a special bit of code in lieu of the
absolute call. This code calculates the address of the function body, using
information stored in the object itself (this process is covered in great
detail in Chapter XX). Thus, each object can behave differently according to
the contents of that special bit of code. When you send a message to an object,
the object actually does figure out what to do with that message.
You
state that you want a function to have the flexibility of late-binding
properties using the keyword
virtual.
You don’t need to understand the mechanics of
virtual
to use it, but without it you can’t do object-oriented programming in
C++. In C++, you must remember to add the
virtual
keyword because by default member functions are
not
dynamically bound. Virtual functions allow you to express the differences in
behavior of classes in the same family. Those differences are what cause
polymorphic behavior.
Consider
the shape example. The family of classes (all based on the same uniform
interface) was diagrammed earlier in the chapter.
To
demonstrate polymorphism, we want to write a single piece of code that ignores
the specific details of type and talks only to the base class. That code is
decoupled
from type-specific information, and thus is simpler to write and easier to
understand. And, if a new type – a
Hexagon,
for example –
is
added through inheritance, the code you write will work just as well for the
new type of
Shape
as it did on the existing types. Thus the program is
extensible. If
you write a function in C++ (as you will soon learn how to do):
void doStuff(Shape& s) {
s.erase();
// ...
s.draw();
}
This
function speaks to any
Shape,
so it is independent of the specific type of object it’s drawing and
erasing (the ‘
&’
means “take the address of the object that’s passed to
doStuff( ),
but it’s not important that you understand the details of that right
now). If in some other part of the program we use the
doStuff( )
function:
Circle c;
Triangle t;
Line l;
doStuff(c);
doStuff(t);
doStuff(l);
The
calls to
doStuff( )
automatically
work right, regardless of the exact type of the object.
This
is actually a pretty amazing trick. Consider the line:
What’s
happening here is that a
Circle
is being passed into a function that’s expecting a
Shape.
Since a
Circle
is
a
Shape
it can be treated as one by
doStuff( ).
That is, any message that
doStuff( )
can send to a
Shape,
a
Circle
can accept. So it is a completely safe and logical thing to do.
We
call this process of treating a derived type as though it were its base type
upcasting.
The name
cast
is
used in the sense of casting into a mold and the
up
comes from the way the inheritance diagram is typically arranged, with the base
type at the top and the derived classes fanning out downward. Thus, casting to
a base type is moving up the inheritance diagram: “upcasting.”
An
object-oriented program contains some upcasting somewhere, because that’s
how you decouple yourself from knowing about the exact type you’re
working with. Look at the code in
doStuff( ):
s.erase();
// ...
s.draw();
Notice
that it doesn’t say “If you’re a
Circle,
do this, if you’re a
Square,
do that, etc.” If you write that kind of code, which checks for all the
possible types that a
Shape
can actually be, it’s messy and you need to change it every time you add
a new kind of
Shape.
Here, you just say “You’re a shape, I know you can
erase( )
yourself,
do it and take care of the details correctly.”
What’s
amazing about the code in
doStuff( )
is that somehow the right thing happens. Calling
draw( )
for
Circle
causes different code to be executed than when calling
draw( )
for
a
Square
or a
Line,
but when the
draw( )
message is sent to an anonymous
Shape,
the correct behavior occurs based on the actual type that the
Shape
is. This is amazing because, as mentioned earlier, when the C++ compiler is
compiling the code for
doStuff( ),
it cannot know exactly what types it is dealing with. So ordinarily,
you’d expect it to end up calling the version of
erase( )
and
draw( )
for
Shape,
and not for the specific
Circle,
Square,
or
Line.
And yet the right thing happens, because of polymorphism. The compiler and
run-time system handle the details; all you need to know is that it happens and
more importantly how to design with it. If a member function is
virtual,
then
when
you send a message to an object, the object will do the right thing, even when
upcasting is involved.
Go to CodeGuru.com
Contact: webmaster@codeguru.com
© Copyright 1997-1999 CodeGuru