The basic concept of multiple
inheritance (MI) sounds simple enough.
[[[Notes:
]]]
You
create a new type by inheriting from more than one base class. The syntax is
exactly what you’d expect, and as long as the inheritance diagrams are
simple, MI is simple as well.
However, MI can introduce a number of
ambiguities and strange situations, which are covered in this chapter. But
first, it helps to get a perspective on the
subject.
Before C++, the most successful
object-oriented language was Smalltalk. Smalltalk was
created from the ground up as an OO language. It is often referred to as
pure, whereas C++, because it was built on top of C, is called
hybrid. One of the design decisions made with Smalltalk was that all
classes would be derived in a single hierarchy, rooted in a single base class
(called Object – this is the model for the object-based
hierarchy). You cannot create
a new class in Smalltalk without inheriting it from an existing class, which is
why it takes a certain amount of time to become productive in Smalltalk –
you must learn the class library before you can start making new classes. So the
Smalltalk class hierarchy is always a single monolithic
tree.
Classes in Smalltalk usually have a
number of things in common, and always have some things in common (the
characteristics and behaviors of Object), so you almost never run into a
situation where you need to inherit from more than one base class. However, with
C++ you can create as many hierarchy trees as you want. Therefore, for logical
completeness the language must be able to combine more than one class at a time
– thus the need for multiple inheritance.
However, this was not a crystal-clear
case of a feature that no one could live without, and there was (and still is) a
lot of disagreement about whether MI is really essential in C++. MI was added in
AT&T cfront release 2.0 and was the first significant change to the
language. Since then, a number of other features have been added (notably
templates) that change the way we think about programming and place MI in a much
less important role. You can think of MI as a “minor” language
feature that shouldn’t be involved in your daily design
decisions.
One of the most pressing issues that
drove MI involved containers. Suppose you want to create a container that
everyone can easily use. One approach is to use void* as the type inside
the container, as with PStash and Stack. The Smalltalk approach,
however, is to make a container that holds Objects. (Remember that
Object is the base type of the entire Smalltalk hierarchy.) Because
everything in Smalltalk is ultimately derived from Object, any container
that holds Objects can hold anything, so this approach works
nicely.
Now consider the situation in C++.
Suppose vendor A creates an object-based hierarchy that includes a useful
set of containers including one you want to use called Holder. Now you
come across vendor B’s class hierarchy that contains some other
class that is important to you, a BitImage class, for example, which
holds graphic images. The only way to make a Holder of BitImages
is to inherit a new class from both Object, so it can be held in the
Holder, and BitImage:
This was seen as an important reason for
MI, and a number of class libraries were built on this model. However, as you
saw in Chapter XX, the addition of templates has changed the way containers are
created, so this situation isn’t a driving issue for MI.
The other reason you may need MI is
logical, related to design. Unlike the above situation, where you don’t
have control of the base classes, in this one you do, and you intentionally use
MI to make the design more flexible or useful. (At least, you may believe this
to be the case.) An example of this is in the original iostream library
design:
Both istream and ostream
are useful classes by themselves, but they can also be inherited into a class
that combines both their characteristics and behaviors.
Regardless of what motivates you to use
MI, a number of problems arise in the process, and you need to understand them
to use
it.
When you inherit from a base class, you
get a copy of all the data members of that base class in your derived class.
This copy is referred to as a subobject. If you multiply inherit from
class d1 and class d2 into class mi, class mi
contains one subobject of d1 and one of d2. So your mi
object looks like this:
Now consider what happens if d1
and d2 both inherit from the same base class, called
Base:
In the above diagram, both d1 and
d2 contain a subobject of Base, so mi contains two
subobjects of Base. Because of the path produced in the diagram, this is
sometimes called a “diamond” in the inheritance hierarchy. Without
diamonds, multiple inheritance is
quite straightforward, but as soon as a diamond appears, trouble starts because
you have duplicate subobjects in your new class. This takes up extra space,
which may or may not be a problem depending on your design. But it also
introduces an
ambiguity.
What happens, in the above diagram, if
you want to cast a pointer to an mi to a pointer to a Base? There
are two subobjects of type Base, so which address does the cast produce?
Here’s the diagram in code:
//: C10:MultipleInheritance1.cpp // MI & ambiguity //{L} ../TestSuite/Test #include "../purge.h" #include <iostream> #include <vector> using namespace std; class MBase { public: virtual char* vf() const = 0; virtual ~MBase() {} }; class D1 : public MBase { public: char* vf() const { return "D1"; } }; class D2 : public MBase { public: char* vf() const { return "D2"; } }; // Causes error: ambiguous override of vf(): //! class MI : public D1, public D2 {}; int main() { vector<MBase*> b; b.push_back(new D1); b.push_back(new D2); // Cannot upcast: which subobject?: //! b.push_back(new mi); for(int i = 0; i < b.size(); i++) cout << b[i]->vf() << endl; purge(b); } ///:~
Two problems occur here. First, you
cannot even create the class mi because doing so would cause a clash
between the two definitions of vf( ) in D1 and
D2.
Second, in the array definition for b[
] this code attempts to create a new mi and upcast the address to a
MBase*. The compiler won’t accept this because it has no way of
knowing whether you want to use D1’s subobject MBase or
D2’s subobject MBase for the resulting
address.
To solve the first problem, you must
explicitly disambiguate the function vf( ) by writing a redefinition
in the class mi.
The solution to the second problem is a
language extension: The meaning of the virtual keyword is overloaded. If
you inherit a base class as virtual, only one subobject of that class
will ever appear as a base class. Virtual base classes are implemented by the
compiler with pointer magic in a way suggesting the implementation of ordinary
virtual functions.
Because only one subobject of a virtual
base class will ever appear during multiple inheritance, there is no ambiguity
during upcasting. Here’s an example:
//: C10:MultipleInheritance2.cpp // Virtual base classes //{L} ../TestSuite/Test #include "../purge.h" #include <iostream> #include <vector> using namespace std; class MBase { public: virtual char* vf() const = 0; virtual ~MBase() {} }; class D1 : virtual public MBase { public: char* vf() const { return "D1"; } }; class D2 : virtual public MBase { public: char* vf() const { return "D2"; } }; // MUST explicitly disambiguate vf(): class MI : public D1, public D2 { public: char* vf() const { return D1::vf();} }; int main() { vector<MBase*> b; b.push_back(new D1); b.push_back(new D2); b.push_back(new MI); // OK for(int i = 0; i < b.size(); i++) cout << b[i]->vf() << endl; purge(b); } ///:~
The compiler now accepts the upcast, but
notice that you must still explicitly disambiguate the function
vf( ) in MI; otherwise the compiler wouldn’t know which
version to
use.
The use of virtual base classes
isn’t quite as simple as that. The above example uses the
(compiler-synthesized) default constructor. If the virtual base has a
constructor, things become a bit strange. To understand this, you need a new
term: most-derived
class.
The most-derived class is the one
you’re currently in, and is particularly important when you’re
thinking about constructors. In the previous example, MBase is the
most-derived class inside the MBase constructor. Inside the D1
constructor, D1 is the most-derived class, and inside the MI
constructor, MI is the most-derived class.
When you are using a virtual base class,
the most-derived constructor is responsible for initializing that virtual base
class. That means any class, no matter how far away it is from the virtual base,
is responsible for initializing it. Here’s an example:
//: C10:MultipleInheritance3.cpp // Virtual base initialization. // Virtual base classes must always be // Initialized by the "most-derived" class. //{L} ../TestSuite/Test #include "../purge.h" #include <iostream> #include <vector> using namespace std; class MBase { public: MBase(int) {} virtual char* vf() const = 0; virtual ~MBase() {} }; class D1 : virtual public MBase { public: D1() : MBase(1) {} char* vf() const { return "D1"; } }; class D2 : virtual public MBase { public: D2() : MBase(2) {} char* vf() const { return "D2"; } }; class MI : public D1, public D2 { public: MI() : MBase(3) {} char* vf() const { return D1::vf(); // MUST disambiguate } }; class X : public MI { public: // You must ALWAYS init the virtual base: X() : MBase(4) {} }; int main() { vector<MBase*> b; b.push_back(new D1); b.push_back(new D2); b.push_back(new MI); // OK b.push_back(new X); for(int i = 0; i < b.size(); i++) cout << b[i]->vf() << endl; purge(b); } ///:~
As you would expect, both D1 and
D2 must initialize MBase in their constructor. But so must MI
and X, even though they are more than one layer away! That’s
because each one in turn becomes the most-derived class. The compiler
can’t know whether to use D1’s initialization of MBase
or to use D2’s version. Thus you are always forced to do it in the
most-derived class. Note that only the single selected virtual base constructor
is
called.
Forcing the most-derived class to
initialize a virtual base that may be buried deep in the class hierarchy can
seem like a tedious and confusing task to put upon the user of your class.
It’s better to make this invisible, which is done by creating a default
constructor for the virtual base class, like this:
//: C10:MultipleInheritance4.cpp // "Tying off" virtual bases so you don't have // to worry about them in derived classes. //{L} ../TestSuite/Test #include "../purge.h" #include <iostream> #include <vector> using namespace std; class MBase { public: // Default constructor removes responsibility: MBase(int = 0) {} virtual char* vf() const = 0; virtual ~MBase() {} }; class D1 : virtual public MBase { public: D1() : MBase(1) {} char* vf() const { return "D1"; } }; class D2 : virtual public MBase { public: D2() : MBase(2) {} char* vf() const { return "D2"; } }; class MI : public D1, public D2 { public: MI() {} // Calls default constructor for MBase char* vf() const { return D1::vf(); // MUST disambiguate } }; class X : public MI { public: X() {} // Calls default constructor for MBase }; int main() { vector<MBase*> b; b.push_back(new D1); b.push_back(new D2); b.push_back(new MI); // OK b.push_back(new X); for(int i = 0; i < b.size(); i++) cout << b[i]->vf() << endl; purge(b); } ///:~
If you can always arrange for a virtual
base class to have a default constructor, you’ll make things much easier
for anyone who inherits from that
class.
The term “pointer magic” has
been used to describe the way virtual inheritance is implemented. You can see
the physical overhead of virtual inheritance with the following
program:
//: C10:Overhead.cpp // Virtual base class overhead //{L} ../TestSuite/Test #include <fstream> using namespace std; ofstream out("overhead.out"); class MBase { public: virtual void f() const {}; virtual ~MBase() {} }; class NonVirtualInheritance : public MBase {}; class VirtualInheritance : virtual public MBase {}; class VirtualInheritance2 : virtual public MBase {}; class MI : public VirtualInheritance, public VirtualInheritance2 {}; #define WRITE(ARG) \ out << #ARG << " = " << ARG << endl; int main() { MBase b; WRITE(sizeof(b)); NonVirtualInheritance nonv_inheritance; WRITE(sizeof(nonv_inheritance)); VirtualInheritance v_inheritance; WRITE(sizeof(v_inheritance)); MI mi; WRITE(sizeof(mi)); } ///:~
Each of these classes only contains a
single byte, and the “core size” is that byte. Because all these
classes contain virtual functions, you expect the object size to be bigger than
the core size by a pointer (at least – your compiler may also pad extra
bytes into an object for alignment). The results are a bit surprising (these are
from one particular compiler; yours may do it differently):
sizeof(b) = 2 sizeof(nonv_inheritance) = 2 sizeof(v_inheritance) = 6 sizeof(MI) = 12
Both b and
nonv_inheritance contain the extra pointer, as expected. But when virtual
inheritance is added, it would appear that the VPTR plus two extra
pointers are added! By the time the multiple inheritance is performed, the
object appears to contain five extra pointers (however, one of these is probably
a second VPTR for the second multiply inherited subobject).
The curious can certainly probe into your
particular implementation and look at the assembly language for member selection
to determine exactly what these extra bytes are for, and the cost of member
selection with multiple
inheritance[24].
The rest of you have probably seen enough to guess that quite a bit more goes on
with virtual multiple inheritance, so it should be used sparingly (or avoided)
when efficiency is an
issue.
When you embed subobjects of a class
inside a new class, whether you do it by creating member objects or through
inheritance, each subobject is placed within the new object by the compiler. Of
course, each subobject has its own this pointer, and as long as
you’re dealing with member objects, everything is quite straightforward.
But as soon as multiple inheritance is introduced, a funny thing occurs: An
object can have more than one this pointer because the object represents
more than one type during upcasting. The following example demonstrates this
point:
//: C10:Mithis.cpp // MI and the "this" pointer //{L} ../TestSuite/Test #include <fstream> using namespace std; ofstream out("mithis.out"); class Base1 { char c[0x10]; public: void printthis1() { out << "Base1 this = " << this << endl; } }; class Base2 { char c[0x10]; public: void printthis2() { out << "Base2 this = " << this << endl; } }; class Member1 { char c[0x10]; public: void printthism1() { out << "Member1 this = " << this << endl; } }; class Member2 { char c[0x10]; public: void printthism2() { out << "Member2 this = " << this << endl; } }; class MI : public Base1, public Base2 { Member1 m1; Member2 m2; public: void printthis() { out << "MI this = " << this << endl; printthis1(); printthis2(); m1.printthism1(); m2.printthism2(); } }; int main() { MI mi; out << "sizeof(mi) = " << hex << sizeof(mi) << " hex" << endl; mi.printthis(); // A second demonstration: Base1* b1 = &mi; // Upcast Base2* b2 = &mi; // Upcast out << "Base 1 pointer = " << b1 << endl; out << "Base 2 pointer = " << b2 << endl; } ///:~
The arrays of bytes inside each class are
created with hexadecimal sizes, so the output addresses (which are printed in
hex) are easy to read. Each class has a function that prints its this
pointer, and these classes are assembled with both multiple inheritance and
composition into the class MI, which prints its own address and the
addresses of all the other subobjects. This function is called in
main( ). You can clearly see that you get two different this
pointers for the same object. The address of the MI object is taken and
upcast to the two different types. Here’s the
output:[25]
sizeof(mi) = 40 hex mi this = 0x223e Base1 this = 0x223e Base2 this = 0x224e Member1 this = 0x225e Member2 this = 0x226e Base 1 pointer = 0x223e Base 2 pointer = 0x224e
Although object layouts vary from
compiler to compiler and are not specified in Standard C++, this one is fairly
typical. The starting address of the object corresponds to the address of the
first class in the base-class list. Then the second inherited class is placed,
followed by the member objects in order of declaration.
When the upcast to the Base1 and
Base2 pointers occur, you can see that, even though they’re
ostensibly pointing to the same object, they must actually have different
this pointers, so the proper starting address can be passed to the member
functions of each subobject. The only way things can work correctly is if this
implicit upcasting takes place when you call a member function for a multiply
inherited
subobject.
Normally this isn’t a problem,
because you want to call member functions that are concerned with that subobject
of the multiply inherited object. However, if your member function needs to know
the true starting address of the object, multiple inheritance causes problems.
Ironically, this happens in one of the situations where multiple inheritance
seems to be useful: persistence.
The lifetime of a local object is the
scope in which it is defined. The lifetime of a global object is the lifetime of
the program. A persistent object lives between
invocations of a program: You can normally think of it as existing on disk
instead of in memory. One definition of an object-oriented
database is “a collection of
persistent objects.”
To implement persistence, you must move a
persistent object from disk into memory in order to call functions for it, and
later store it to disk before the program expires. Four issues arise when
storing an object on disk:
Because the object
must be converted back and forth between a layout in memory and a serial
representation on disk, the process is called serialization
(to write an object to disk) and deserialization
(to restore an object from disk). Although it would be
very convenient, these processes require too much overhead to support directly
in the language. Class libraries will often build in support for serialization
and deserialization by adding special member functions and placing requirements
on new classes. (Usually some sort of serialize( ) function must be
written for each new class.) Also, persistence is generally not automatic; you
must usually explicitly write and read the objects.
Consider sidestepping the pointer issues
for now and creating a class that installs persistence into simple objects using
multiple inheritance. By inheriting the persistence class along with your
new class, you automatically create classes that can be read from and written to
disk. Although this sounds great, the use of multiple inheritance introduces a
pitfall, as seen in the following example.
//: C10:Persist1.cpp // Simple persistence with MI //{L} ../TestSuite/Test //{-g++3} dumps core #include "../require.h" #include <iostream> #include <fstream> using namespace std; class Persistent { int objSize; // Size of stored object public: Persistent(int sz) : objSize(sz) {} void write(ostream& out) const { out.write((char*)this, objSize); } void read(istream& in) { in.read((char*)this, objSize); } }; class Data { float f[3]; public: Data(float f0 = 0.0, float f1 = 0.0, float f2 = 0.0) { f[0] = f0; f[1] = f1; f[2] = f2; } void print(const char* msg = "") const { if(*msg) cout << msg << " "; for(int i = 0; i < 3; i++) cout << "f[" << i << "] = " << f[i] << endl; } }; class WData1 : public Persistent, public Data { public: WData1(float f0 = 0.0, float f1 = 0.0, float f2 = 0.0) : Data(f0, f1, f2), Persistent(sizeof(WData1)) {} }; class WData2 : public Data, public Persistent { public: WData2(float f0 = 0.0, float f1 = 0.0, float f2 = 0.0) : Data(f0, f1, f2), Persistent(sizeof(WData2)) {} }; int main() { { ofstream f1("f1.dat"), f2("f2.dat"); assure(f1, "f1.dat"); assure(f2, "f2.dat"); WData1 d1(1.1, 2.2, 3.3); WData2 d2(4.4, 5.5, 6.6); d1.print("d1 before storage"); d2.print("d2 before storage"); d1.write(f1); d2.write(f2); } // Closes files ifstream f1("f1.dat"), f2("f2.dat"); assure(f1, "f1.dat"); assure(f2, "f2.dat"); WData1 d1; WData2 d2; d1.read(f1); d2.read(f2); d1.print("d1 after storage"); d2.print("d2 after storage"); } ///:~
In this very simple version, the
Persistent::read( ) and Persistent::write( ) functions
take the this pointer and call iostream read( ) and
write( )
functions.
(Note that any type of iostream can be used). A more sophisticated
Persistent class would call a virtual write( )
function for each subobject.
With the language features covered so far
in the book, the number of bytes in the object cannot be known by the
Persistent class so it is inserted as a constructor argument. (In Chapter
XX, run-time type identification shows how you can find the exact type of
an object given only a base pointer; once you have the exact type you can find
out the correct size with the sizeof
operator.)
The Data class contains no
pointers or VPTR, so there is no danger in simply writing
it to disk and reading it back again. And it works fine in class WData1
when, in main( ), it’s written to file F1.DAT and later read
back again. However, when Persistent is second in the inheritance list of
WData2, the this pointer for Persistent is offset to the
end of the object, so it reads and writes past the end of the object. This not
only produces garbage when reading the object from the file, it’s
dangerous because it walks over any storage that occurs after the
object.
This problem occurs in multiple
inheritance any time a class must
produce the this pointer for the actual object from a subobject’s
this pointer. Of course, if you know your compiler always lays out
objects in order of declaration in the inheritance list, you can ensure that you
always put the critical class at the beginning of the list (assuming
there’s only one critical class). However, such a class may exist in the
inheritance hierarchy of another class and you may unwittingly put it in the
wrong place during multiple inheritance. Fortunately, using run-time type
identification (the subject of Chapter XX) will produce
the proper pointer to the actual object, even if multiple inheritance is
used.
A more practical approach to persistence,
and one you will see employed more often, is to create virtual functions in the
base class for reading and writing and then require the creator of any new class
that must be streamed to redefine these functions. The argument to the function
is the stream object to write to or read
from.[26] Then the
creator of the class, who knows best how the new parts should be read or
written, is responsible for making the correct function calls. This
doesn’t have the “magical” quality of the previous example,
and it requires more coding and knowledge on the part of the user, but it works
and doesn’t break when pointers are present:
//: C10:Persist2.cpp // Improved MI persistence //{L} ../TestSuite/Test #include "../require.h" #include <iostream> #include <fstream> #include <cstring> using namespace std; class Persistent { public: virtual void write(ostream& out) const = 0; virtual void read(istream& in) = 0; virtual ~Persistent() {} }; class Data { protected: float f[3]; public: Data(float f0 = 0.0, float f1 = 0.0, float f2 = 0.0) { f[0] = f0; f[1] = f1; f[2] = f2; } void print(const char* msg = "") const { if(*msg) cout << msg << endl; for(int i = 0; i < 3; i++) cout << "f[" << i << "] = " << f[i] << endl; } }; class WData1 : public Persistent, public Data { public: WData1(float f0 = 0.0, float f1 = 0.0, float f2 = 0.0) : Data(f0, f1, f2) {} void write(ostream& out) const { out << f[0] << " " << f[1] << " " << f[2] << " "; } void read(istream& in) { in >> f[0] >> f[1] >> f[2]; } }; class WData2 : public Data, public Persistent { public: WData2(float f0 = 0.0, float f1 = 0.0, float f2 = 0.0) : Data(f0, f1, f2) {} void write(ostream& out) const { out << f[0] << " " << f[1] << " " << f[2] << " "; } void read(istream& in) { in >> f[0] >> f[1] >> f[2]; } }; class Conglomerate : public Data, public Persistent { char* name; // Contains a pointer WData1 d1; WData2 d2; public: Conglomerate(const char* nm = "", float f0 = 0.0, float f1 = 0.0, float f2 = 0.0, float f3 = 0.0, float f4 = 0.0, float f5 = 0.0, float f6 = 0.0, float f7 = 0.0, float f8= 0.0) : Data(f0, f1, f2), d1(f3, f4, f5), d2(f6, f7, f8) { name = new char[strlen(nm) + 1]; strcpy(name, nm); } void write(ostream& out) const { int i = strlen(name) + 1; out << i << " "; // Store size of string out << name << endl; d1.write(out); d2.write(out); out << f[0] << " " << f[1] << " " << f[2]; } // Must read in same order as write: void read(istream& in) { delete []name; // Remove old storage int i; in >> i >> ws; // Get int, strip whitespace name = new char[i]; in.getline(name, i); d1.read(in); d2.read(in); in >> f[0] >> f[1] >> f[2]; } void print() const { Data::print(name); d1.print(); d2.print(); } }; int main() { { ofstream data("data.dat"); assure(data, "data.dat"); Conglomerate C("This is Conglomerate C", 1.1, 2.2, 3.3, 4.4, 5.5, 6.6, 7.7, 8.8, 9.9); cout << "C before storage" << endl; C.print(); C.write(data); } // Closes file ifstream data("data.dat"); assure(data, "data.dat"); Conglomerate C; C.read(data); cout << "after storage: " << endl; C.print(); } ///:~
The pure virtual functions in
Persistent must be redefined in the derived classes to perform the proper
reading and writing. If you already knew that Data would be persistent,
you could inherit directly from Persistent and redefine the functions
there, thus eliminating the need for multiple inheritance. This example is based
on the idea that you don’t own the code for Data, that it was
created elsewhere and may be part of another class hierarchy so you don’t
have control over its inheritance. However, for this scheme to work correctly
you must have access to the underlying implementation so it can be stored; thus
the use of protected.
The classes WData1 and
WData2 use familiar iostream inserters and extractors to store and
retrieve the protected data in Data to and from the iostream
object. In write( ), you can see that spaces are added after each
floating point number is written; these are necessary to allow parsing of the
data on input.
The class Conglomerate not only
inherits from Data, it also has member objects of type WData1 and
WData2, as well as a pointer to a character string. In addition, all the
classes that inherit from Persistent also contain a VPTR, so this example
shows the kind of problem you’ll actually encounter when using
persistence.
When you create write( ) and
read( ) function pairs, the read( ) must exactly mirror
what happens during the write( ), so read( ) pulls the
bits off the disk the same way they were placed there by write( ).
Here, the first problem that’s tackled is the char*, which points
to a string of any length. The size of the string is calculated and stored on
disk as an int (followed by a space to enable parsing) to allow the
read( ) function to allocate the correct amount of
storage.
When you have subobjects that have
read( ) and write( ) member functions, all you need to
do is call those functions in the new read( ) and
write( ) functions. This is followed by direct storage of the
members in the base class.
People have gone to great lengths to
automate persistence, for example, by creating modified preprocessors to support
a “persistent” keyword to be applied when defining a class. One can
imagine a more elegant approach than the one shown here for implementing
persistence, but it has the advantage that it works under all implementations of
C++, doesn’t require special language extensions, and is relatively
bulletproof.
The need for multiple
inheritance in Persist2.cpp is contrived, based on
the concept that you don’t have control of some of the code in the
project. Upon examination of the example, you can see that MI can be easily
avoided by using member objects of type Data, and putting the virtual
read( )and write( ) members inside Data or
WData1 and WData2 rather than in a separate class. There are many
situations like this one where multiple inheritance may be avoided; the language
feature is included for unusual, special-case situations that would otherwise be
difficult or impossible to handle. But when the question of whether to use
multiple inheritance comes up, you should ask two questions:
If
you can’t answer “no” to both questions, you can avoid using
MI and should probably do so.
One situation to watch for is when one
class only needs to be upcast as a function argument. In that case, the class
can be embedded and an automatic type conversion operator provided in your new
class to produce a reference to the embedded object. Any time you use an object
of your new class as an argument to a function that expects the embedded object,
the type conversion operator is used. However, type conversion can’t be
used for normal member selection; that requires
inheritance.
Rodents & pets(play)
One of the best arguments for multiple
inheritance involves code that’s out of your control. Suppose you’ve
acquired a library that consists of a header file and compiled member functions,
but no source code for member functions. This library is a class hierarchy with
virtual functions, and it contains some global functions that take pointers to
the base class of the library; that is, it uses the library objects
polymorphically. Now suppose you build an application around this library, and
write your own code that uses the base class polymorphically.
Later in the development of the project
or sometime during its maintenance, you discover that the base-class interface
provided by the vendor is incomplete: A function may be nonvirtual and you need
it to be virtual, or a virtual function is completely missing in the interface,
but essential to the solution of your problem. If you had the source code, you
could go back and put it in. But you don’t, and you have a lot of existing
code that depends on the original interface. Here, multiple inheritance is the
perfect solution.
For example, here’s the header file
for a library you acquire:
//: C10:Vendor.h // Vendor-supplied class header // You only get this & the compiled Vendor.obj #ifndef VENDOR_H #define VENDOR_H class Vendor { public: virtual void v() const; void f() const; ~Vendor(); }; class Vendor1 : public Vendor { public: void v() const; void f() const; ~Vendor1(); }; void A(const Vendor&); void B(const Vendor&); // Etc. #endif // VENDOR_H ///:~
Assume the library is much bigger, with
more derived classes and a larger interface. Notice that it also includes the
functions A( ) and B( ), which take a base pointer and
treat it polymorphically. Here’s the implementation file for the
library:
//: C10:Vendor.cpp {O} // Implementation of VENDOR.H // This is compiled and unavailable to you #include "Vendor.h" #include <fstream> using namespace std; extern ofstream out; // For trace info void Vendor::v() const { out << "Vendor::v()\n"; } void Vendor::f() const { out << "Vendor::f()\n"; } Vendor::~Vendor() { out << "~Vendor()\n"; } void Vendor1::v() const { out << "Vendor1::v()\n"; } void Vendor1::f() const { out << "Vendor1::f()\n"; } Vendor1::~Vendor1() { out << "~Vendor1()\n"; } void A(const Vendor& V) { // ... V.v(); V.f(); //.. } void B(const Vendor& V) { // ... V.v(); V.f(); //.. } ///:~
In your project, this source code is
unavailable to you. Instead, you get a compiled file as Vendor.obj or
Vendor.lib (or the equivalent for your system).
The problem occurs in the use of this
library. First, the destructor isn’t virtual. This is actually a design
error on the part of the library creator. In addition, f( ) was not
made virtual; assume the library creator decided it wouldn’t need to be.
And you discover that the interface to the base class is missing a function
essential to the solution of your problem. Also suppose you’ve already
written a fair amount of code using the existing interface (not to mention the
functions A( ) and B( ), which are out of your control),
and you don’t want to change it.
To repair the problem, create your own
class interface and multiply inherit a new set of derived classes from your
interface and from the existing classes:
//: C10:Paste.cpp // Fixing a mess with MI //{L} Vendor ../TestSuite/Test #include "Vendor.h" #include <fstream> using namespace std; ofstream out("paste.out"); class MyBase { // Repair Vendor interface public: virtual void v() const = 0; virtual void f() const = 0; // New interface function: virtual void g() const = 0; virtual ~MyBase() { out << "~MyBase()\n"; } }; class Paste1 : public MyBase, public Vendor1 { public: void v() const { out << "Paste1::v()\n"; Vendor1::v(); } void f() const { out << "Paste1::f()\n"; Vendor1::f(); } void g() const { out << "Paste1::g()\n"; } ~Paste1() { out << "~Paste1()\n"; } }; int main() { Paste1& p1p = *new Paste1; MyBase& mp = p1p; // Upcast out << "calling f()\n"; mp.f(); // Right behavior out << "calling g()\n"; mp.g(); // New behavior out << "calling A(p1p)\n"; A(p1p); // Same old behavior out << "calling B(p1p)\n"; B(p1p); // Same old behavior out << "delete mp\n"; // Deleting a reference to a heap object: delete ∓ // Right behavior } ///:~
In MyBase (which does not
use MI), both f( ) and the destructor are now virtual, and a new
virtual function g( ) has been added to the interface. Now each of
the derived classes in the original library must be recreated, mixing in the new
interface with MI. The functions Paste1::v( ) and
Paste1::f( )need to call only the original base-class versions of
their functions. But now, if you upcast to MyBase as in
main( )
MyBase* mp = p1p; // Upcast
any
function calls made through mp will be polymorphic, including
delete. Also, the new interface function g( ) can be called
through mp. Here’s the output of the program:
calling f() Paste1::f() Vendor1::f() calling g() Paste1::g() calling A(p1p) Paste1::v() Vendor1::v() Vendor::f() calling B(p1p) Paste1::v() Vendor1::v() Vendor::f() delete mp ~Paste1() ~Vendor1() ~Vendor() ~MyBase()
The original library functions
A( ) and B( ) still work the same (assuming the new
v( ) calls its base-class version). The destructor is now virtual
and exhibits the correct behavior.
Although this is a messy example, it does
occur in practice and it’s a good demonstration of where multiple
inheritance is clearly necessary: You must be able to upcast to both base
classes.
The reason MI exists in C++ and not in
other OOP languages is that C++ is a hybrid language and couldn’t enforce
a single monolithic class hierarchy the way Smalltalk does. Instead, C++ allows
many inheritance trees to be formed, so sometimes you may need to combine the
interfaces from two or more trees into a new class.
If no “diamonds” appear in
your class hierarchy, MI is fairly simple (although identical function
signatures in base classes must be resolved). If a diamond appears, then you
must deal with the problems of duplicate subobjects by introducing virtual base
classes. This not only adds confusion, but the underlying representation becomes
more complex and less efficient.
Multiple inheritance has been called the
“goto of the
90’s”.[27]
This seems appropriate because, like a goto, MI is best avoided in normal
programming, but can occasionally be very useful. It’s a
“minor” but more advanced feature of C++, designed to solve problems
that arise in special situations. If you find yourself using it often, you may
want to take a look at your reasoning. A good Occam’s Razor is to ask,
“Must I upcast to all of the base classes?” If not, your life will
be easier if you embed instances of all the classes you don’t need
to upcast
to.
[24]
See also Jan Gray, “C++ Under the Hood”, a chapter in
Black Belt C++ (edited by Bruce Eckel, M&T Press,
1995).
[25]
For easy readability the code was generated for a small-model Intel
processor.
[26]
Sometimes there’s only a single function for streaming, and the argument
contains information about whether you’re reading or
writing.
[27]
A phrase coined by Zack Urlocker.