|
Picturing virtual functions
To understand exactly what’s going
on when you use a virtual function, it’s helpful to visualize the
activities going on behind the curtain. Here’s a drawing of the array of
pointers A[ ] in Instrument4.cpp:
The array of Instrument pointers
has no specific type information; they each point to an object of type
Instrument. Wind, Percussion, Stringed, and
Brass all fit into this category because they are derived from
Instrument (and thus have the same interface as Instrument, and
can respond to the same messages), so their addresses can also be placed into
the array. However, the compiler doesn’t know that they are anything more
than Instrument objects, so left to its own devices it would normally
call the base-class versions of all the functions. But in this case, all those
functions have been declared with the virtual keyword, so something
different happens.
Each time you create a class that
contains virtual functions, or you derive from a class that contains virtual
functions, the compiler creates a unique VTABLE for that
class, seen on the right of the diagram. In that table it places the addresses
of all the functions that are declared virtual in this class or in the base
class. If you don’t override a function that was declared virtual in the
base class, the compiler uses the address of the base-class version in the
derived class. (You can see this in the adjust entry in the Brass
VTABLE.) Then it places the VPTR (discovered in
Sizes.cpp) into the class. There is only one VPTR for each object when
using simple inheritance like this. The VPTR must be initialized to point to the
starting address of the appropriate VTABLE. (This happens in the constructor,
which you’ll see later in more detail.)
Once the VPTR is initialized to the
proper VTABLE, the object in effect “knows” what type it is. But
this self-knowledge is worthless unless it is used at the point a virtual
function is called.
When you call a virtual function through
a base class address (the situation when the compiler doesn’t have all the
information necessary to perform early binding), something special happens.
Instead of performing a typical function call, which is simply an
assembly-language CALL to a particular address, the compiler generates
different code to perform the function call. Here’s what a call to
adjust( ) for a Brass object looks like, if made through an
Instrument pointer (An Instrument reference produces the same
result):
The compiler begins with the
Instrument pointer, which points to the starting address of the object.
All Instrument objects or objects derived from Instrument have
their VPTR in the same place (often at the beginning of the object), so the
compiler can pick the VPTR out of the object. The VPTR points to the starting
address of the VTABLE. All the VTABLE function addresses are laid out in the
same order, regardless of the specific type of the object. play( )
is first, what( ) is second, and adjust( ) is third. The
compiler knows that regardless of the specific object type, the
adjust( ) function is at the location VPTR+2. Thus, instead of
saying, “Call the function at the absolute location
Instrument::adjust” (early
binding;
the wrong action), it generates code that says, in effect, “Call the
function at VPTR+2.” Because the fetching of the VPTR and the
determination of the actual function address occur at runtime, you get the
desired late binding. You send a message to the object, and the object figures
out what to do with
it.
|
|