Thinking in C++ Vol 2 - Practical Programming |
Prev |
Home |
Next |
What we usually want in such cases is true diamond
inheritance, where a single Top object is shared by both Left and
Right subobjects within a complete Bottom object, which is what
the first class diagram depicts. This is achieved by making Top a virtual
base class of Left and Right:
//: C09:VirtualBase.cpp
// Shows a shared subobject via a virtual base.
#include <iostream>
using namespace std;
class Top {
protected:
int x;
public:
Top(int n) { x = n; }
virtual ~Top() {}
friend ostream&
operator<<(ostream& os, const Top& t) {
return os << t.x;
}
};
class Left : virtual public Top {
protected:
int y;
public:
Left(int m, int n) : Top(m) { y = n; }
};
class Right : virtual public Top {
protected:
int z;
public:
Right(int m, int n) : Top(m) { z = n; }
};
class Bottom : public Left, public Right {
int w;
public:
Bottom(int i, int j, int k, int m)
: Top(i), Left(0, j), Right(0, k) { w = m; }
friend ostream&
operator<<(ostream& os, const Bottom&
b) {
return os << b.x << ',' << b.y
<< ',' << b.z
<< ',' << b.w;
}
};
int main() {
Bottom b(1, 2, 3, 4);
cout << sizeof b << endl;
cout << b << endl;
cout << static_cast<void*>(&b)
<< endl;
Top* p = static_cast<Top*>(&b);
cout << *p << endl;
cout << static_cast<void*>(p) <<
endl;
cout << dynamic_cast<void*>(p) <<
endl;
} ///:~
Each virtual base of a given type refers to the same object,
no matter where it appears in the hierarchy. This
means that when a Bottom object is instantiated, the object layout may
look something like this:
The Left and Right subobjects each have a
pointer (or some conceptual equivalent) to the shared Top subobject, and
all references to that subobject in Left and Right member
functions will go through those these pointers. Here, there
is no ambiguity when upcasting from a Bottom to a Top object,
since there is only one Top object to convert to.
The output of the previous program is as follows:
36
1,2,3,4
1245032
1
1245060
1245032
The addresses printed suggest that this particular
implementation does indeed store the Top subobject at the end of the
complete object (although it s not really important where it goes). The result
of a dynamic_cast to void* always resolves to the address of the
complete object.
Although it is technically illegal to do so, if you
remove the virtual destructor (and the dynamic_cast statement, so the
program will compile), the size of Bottom decreases to 24 bytes. That
seems to be a decrease equivalent to the size of three pointers. Why?
It s important not to take these numbers too literally.
Other compilers we use manage only to increase the size by four bytes when the
virtual constructor is added. Not being compiler writers, we can t tell you
their secrets. We can tell you, however, that with multiple inheritance, a
derived object must behave as if it has multiple VPTRs, one for each of its
direct base classes that also have virtual functions. It s as simple as that.
Compilers make whatever optimizations their authors invent, but the behavior
must be the same.
The strangest thing in the previous code is the initializer
for Top in the Bottom constructor. Normally one doesn t worry
about initializing subobjects beyond direct base classes, since all classes
take care of initializing their own bases. There are, however, multiple paths
from Bottom to Top, so relying on the intermediate classes Left
and Right to pass along the necessary initialization data results in an
ambiguity who is responsible for performing the initialization? For this
reason, the most derived class must initialize a virtual base. But what
about the expressions in the Left and Right constructors that
also initialize Top? They are certainly necessary when creating
standalone Left or Right objects, but must be ignored when
a Bottom object is created (hence the zeros in their initializers in the
Bottom constructor any values in those slots are ignored when the Left
and Right constructors execute in the context of a Bottom
object). The compiler takes care of all this for you, but it s important to
understand where the responsibility lies. Always make sure that all concrete
(nonabstract) classes in a multiple inheritance hierarchy are aware of any
virtual bases and initialize them appropriately.
These rules of responsibility apply not only to
initialization, but to all operations that span the class hierarchy. Consider
the stream inserter in the previous code. We made the data protected so we
could cheat and access inherited data in operator<<(ostream&,
const Bottom&). It usually makes more sense to assign the work of
printing each subobject to its corresponding class and have the derived class
call its base class functions as needed. What would happen if we tried that
with operator<<( ), as the following code illustrates?
//: C09:VirtualBase2.cpp
// How NOT to implement operator<<.
#include <iostream>
using namespace std;
class Top {
int x;
public:
Top(int n) { x = n; }
virtual ~Top() {}
friend ostream& operator<<(ostream& os,
const Top& t) {
return os << t.x;
}
};
class Left : virtual public Top {
int y;
public:
Left(int m, int n) : Top(m) { y = n; }
friend ostream& operator<<(ostream& os,
const Left& l) {
return os << static_cast<const
Top&>(l) << ',' << l.y;
}
};
class Right : virtual public Top {
int z;
public:
Right(int m, int n) : Top(m) { z = n; }
friend ostream& operator<<(ostream& os,
const Right& r) {
return os << static_cast<const
Top&>(r) << ',' << r.z;
}
};
class Bottom : public Left, public Right {
int w;
public:
Bottom(int i, int j, int k, int m)
: Top(i), Left(0, j), Right(0, k) { w = m; }
friend ostream& operator<<(ostream& os,
const Bottom& b){
return os << static_cast<const
Left&>(b)
<< ',' << static_cast<const
Right&>(b)
<< ',' << b.w;
}
};
int main() {
Bottom b(1, 2, 3, 4);
cout << b << endl; // 1,2,1,3,4
} ///:~
You can t just blindly share the responsibility upward in
the usual fashion, because the Left and Right stream inserters
each call the Top inserter, and again there will be duplication of data.
Instead you need to mimic what the compiler does with initialization. One
solution is to provide special functions in the classes that know about the virtual
base class, which ignore the virtual base when printing (leaving the job to the
most derived class):
//: C09:VirtualBase3.cpp
// A correct stream inserter.
#include <iostream>
using namespace std;
class Top {
int x;
public:
Top(int n) { x = n; }
virtual ~Top() {}
friend ostream& operator<<(ostream& os,
const Top& t) {
return os << t.x;
}
};
class Left : virtual public Top {
int y;
protected:
void specialPrint(ostream& os) const {
// Only print Left's part
os << ','<< y;
}
public:
Left(int m, int n) : Top(m) { y = n; }
friend ostream& operator<<(ostream& os,
const Left& l) {
return os << static_cast<const
Top&>(l) << ',' << l.y;
}
};
class Right : virtual public Top {
int z;
protected:
void specialPrint(ostream& os) const {
// Only print Right's part
os << ','<< z;
}
public:
Right(int m, int n) : Top(m) { z = n; }
friend ostream& operator<<(ostream& os,
const Right& r) {
return os << static_cast<const
Top&>(r) << ',' << r.z;
}
};
class Bottom : public Left, public Right {
int w;
public:
Bottom(int i, int j, int k, int m)
: Top(i), Left(0, j), Right(0, k) { w = m; }
friend ostream& operator<<(ostream& os,
const Bottom& b){
os << static_cast<const Top&>(b);
b.Left::specialPrint(os);
b.Right::specialPrint(os);
return os << ','
<< b.w;
}
};
int main() {
Bottom b(1, 2, 3, 4);
cout << b << endl; // 1,2,3,4
} ///:~
The specialPrint( ) functions are protected
since they will be called only by Bottom. They print only their own data
and ignore their Top subobject because the Bottom inserter is in
control when these functions are called. The Bottom inserter must know
about the virtual base, just as a Bottom constructor needs to. This same
reasoning applies to assignment operators in a hierarchy with a virtual base,
as well as to any function, member or not, that wants to share the work
throughout all classes in the hierarchy.
Having discussed virtual base classes, we can now illustrate
the full story of object initialization. Since virtual bases give rise to shared subobjects, it makes sense that they should be available before the sharing
takes place. So the order of initialization of subobjects follows these rules,
recursively:
1. All virtual base class subobjects are initialized, in top-down,
left-to-right order according to where they appear in class definitions.
2. Non-virtual base classes are then initialized in the usual order.
3. All member objects are initialized in declaration order.
4. The complete object s constructor executes.
The following program illustrates this behavior:
//: C09:VirtInit.cpp
// Illustrates initialization order with virtual bases.
#include <iostream>
#include <string>
using namespace std;
class M {
public:
M(const string& s) { cout << "M "
<< s << endl; }
};
class A {
M m;
public:
A(const string& s) : m("in A") {
cout << "A " << s <<
endl;
}
virtual ~A() {}
};
class B {
M m;
public:
B(const string& s) : m("in B") {
cout << "B " << s <<
endl;
}
virtual ~B() {}
};
class C {
M m;
public:
C(const string& s) : m("in C") {
cout << "C " << s <<
endl;
}
virtual ~C() {}
};
class D {
M m;
public:
D(const string& s) : m("in D") {
cout << "D " << s <<
endl;
}
virtual ~D() {}
};
class E : public A, virtual public B, virtual public C
{
M m;
public:
E(const string& s) : A("from E"),
B("from E"),
C("from E"), m("in
E") {
cout << "E "
<< s << endl;
}
};
class F : virtual public B, virtual public C, public D
{
M m;
public:
F(const string& s) : B("from F"),
C("from F"),
D("from F"), m("in F") {
cout << "F " << s <<
endl;
}
};
class G : public E, public F {
M m;
public:
G(const string& s) : B("from G"),
C("from G"),
E("from G"), F("from G"),
m("in G") {
cout << "G " << s <<
endl;
}
};
int main() {
G g("from main");
} ///:~
The classes in this code can be represented by the following
diagram:
Each
class has an embedded member of type M. Note that only four derivations
are virtual: E from B and C, and F from B
and C. The output of this program is:
M in B
B from G
M in C
C from G
M in A
A from E
M in E
E from G
M in D
D from F
M in F
F from G
M in G
G from main
The initialization of g requires its E and F
part to first be initialized, but the B and C subobjects are
initialized first because they are virtual bases and are initialized from G s
initializer, G being the most-derived class. The class B has no
base classes, so according to rule 3, its member object m is initialized,
then its constructor prints B from G , and similarly for the C
subject of E. The E subobject requires A, B, and C
subobjects. Since B and C have already been initialized, the A
subobject of the E subobject is initialized next, and then the E
subobject itself. The same scenario repeats for g s F subobject,
but without duplicating the initialization of the virtual bases.
Thinking in C++ Vol 2 - Practical Programming |
Prev |
Home |
Next |