5.2.2 菱形继承
菱形继承也称为钻石型继承或重复继承,它指的是基类被某个派生类简单重复继承了多次。这样,派生类对象中拥有多份基类实例(这会带来一些问题)。为了方便叙述,我们不使用上面的代码了,而重新写一个重复继承的继承层次:****
class
B {
public
:
int
ib;
public
: B(
int
i=
1
) :ib(i){}
virtual
void
f()
{
cout
<<
"B::f()"
<<
endl
; }
virtual
void
Bf()
{
cout
<<
"B::Bf()"
<<
endl
; } };
class
B1 :
public
B {
public
:
int
ib1;
public
: B1(
int
i =
100
) :ib1(i) {}
virtual
void
f()
{
cout
<<
"B1::f()"
<<
endl
; }
virtual
void
f1()
{
cout
<<
"B1::f1()"
<<
endl
; }
virtual
void
Bf1()
{
cout
<<
"B1::Bf1()"
<<
endl
; } };
class
B2 :
public
B {
public
:
int
ib2;
public
: B2(
int
i =
1000
) :ib2(i) {}
virtual
void
f()
{
cout
<<
"B2::f()"
<<
endl
; }
virtual
void
f2()
{
cout
<<
"B2::f2()"
<<
endl
; }
virtual
void
Bf2()
{
cout
<<
"B2::Bf2()"
<<
endl
; } };
class
D :
public
B1,
public
B2 {
public
:
int
id;
public
: D(
int
i=
10000
) :id(i){}
virtual
void
f()
{
cout
<<
"D::f()"
<<
endl
; }
virtual
void
f1()
{
cout
<<
"D::f1()"
<<
endl
; }
virtual
void
f2()
{
cout
<<
"D::f2()"
<<
endl
; }
virtual
void
Df()
{
cout
<<
"D::Df()"
<<
endl
; } };
这时,根据单继承,我们可以分析出B1,B2类继承于B类时的内存布局。又根据一般多继承,我们可以分析出D类的内存布局。我们可以得出D类子对象的内存布局如下图:
D类对象内存布局中,图中青色表示b1类子对象实例,黄色表示b2类子对象实例,灰色表示D类子对象实例。从图中可以看到,由于D类间接继承了B类两次,导致D类对象中含有两个B类的数据成员ib,一个属于来源B1类,一个来源B2类。这样不仅增大了空间,更重要的是引起了程序歧义:
D d; d.ib =
1
;
//二义性错误,调用的是B1的ib还是B2的ib?
d.B1::ib =
1
;
//正确
d.B2::ib =
1
;
//正确
尽管我们可以通过明确指明调用路径以消除二义性,但二义性的潜在性还没有消除,我们可以通过虚继承来使D类只拥有一个ib实体。
6.虚继承
虚继承解决了菱形继承中最派生类拥有多个间接父类实例的情况。虚继承的派生类的内存布局与普通继承很多不同,主要体现在:
虚继承的子类,如果本身定义了新的虚函数,则编译器为其生成一个虚函数指针(vptr)以及一张虚函数表。该vptr位于对象内存最前面。
vs非虚继承:直接扩展父类虚函数表。
虚继承的子类也单独保留了父类的vprt与虚函数表。这部分内容接与子类内容以一个四字节的0来分界。
虚继承的子类对象中,含有四字节的虚表指针偏移值。
为了分析最后的菱形继承,我们还是先从单虚继承继承开始。
6.1.虚基类表解析
在C++对象模型中,虚继承而来的子类会生成一个隐藏的虚基类指针(vbptr),在Microsoft Visual C++中,虚基类表指针总是在虚函数表指针之后,因而,对某个类实例来说,如果它有虚基类指针,那么虚基类指针可能在实例的0字节偏移处(该类没有vptr时,vbptr就处于类实例内存布局的最前面,否则vptr处于类实例内存布局的最前面),也可能在类实例的4字节偏移处。****
一个类的虚基类指针指向的虚基类表,与虚函数表一样,虚基类表也由多个条目组成,条目中存放的是偏移值。第一个条目存放虚基类表指针(vbptr)所在地址到该类内存首地址的偏移值,由第一段的分析我们知道,这个偏移值为0(类没有vptr)或者-4(类有虚函数,此时有vptr)。我们通过一张图来更好地理解。
虚基类表的第二、第三…个条目依次为该类的最左虚继承父类、次左虚继承父类…的内存地址相对于虚基类表指针的偏移值,这点我们在下面会验证。
6.2.简单虚继承
如果我们的B1类虚继承于B类:****
//类的内容与前面相同
class
B{…}class
B1 :
virtualpublic
B
根据我们前面对虚继承的派生类的内存布局的分析,B1类的对象模型应该是这样的:
我们通过指针访问B1类对象的内存,以验证上面的C++对象模型:
int
main(){ B1 a;
cout
<<
"B1对象内存大小为:"
<<
sizeof
(a) <<
endl
;
//取得B1的虚函数表
cout
<<
"[0]B1::vptr"
;cout
<<
"\t地址:"
<< (
int
*)(&a)<<
endl
;
//输出虚表B1::vptr中的函数
for
(
int
i =
0
; i<
2
;++ i) {cout
<<
" ["
<< i <<
"]"
; Fun fun1 = (Fun)*((
int
*)*(
int
*)(&a) + i); fun1();cout
<<
"\t地址:\t"
<< *((
int
*)*(
int
*)(&a) + i) <<
endl
; }
//[1]
cout
<<
"[1]vbptr "
;cout
<<
"\t地址:"
<< (
int
*)(&a) +
1
<<
endl
;
//虚表指针的地址
//输出虚基类指针条目所指的内容
for
(
int
i =
0
; i <
2
; i++) {cout
<<
" ["
<< i <<
"]"
;
cout
<< *(
int
*)((
int
*)*((
int
*)(&a) +
1
) + i);
cout
<<
endl
; }
//[2]
cout
<<
"[2]B1::ib1="
<< *(
int
*)((
int
*)(&a) +
2
);cout
<<
"\t地址:"
<< (
int
*)(&a) +
2
;cout
<<
endl
;
//[3]
cout
<<
"[3]值="
<< *(
int
*)((
int
*)(&a) +
3
);cout
<<
"\t\t地址:"
<< (
int
*)(&a) +
3
;cout
<<
endl
;
//[4]
cout
<<
"[4]B::vptr"
;cout
<<
"\t地址:"
<< (
int
*)(&a)
+3
<<
endl
;
//输出B::vptr中的虚函数
for
(
int
i =
0
; i<
2
; ++i) {cout
<<
" ["
<< i <<
"]"
; Fun fun1 = (Fun)*((
int
*)*((
int
*)(&a) +
4
) + i); fun1();cout
<<
"\t地址:\t"
<< *((
int
*)*((
int
*)(&a) +
4
) + i) <<
endl
; }
//[5]
cout
<<
"[5]B::ib="
<< *(
int
*)((
int
*)(&a) +
5
);cout
<<
"\t地址: "
<< (
int
*)(&a) +
5
;cout
<<
endl
;
运行结果:
这个结果与我们的C++对象模型图完全符合。这时我们可以来分析一下虚表指针的第二个条目值12的具体来源了,回忆上文讲到的:
第二、第三…个条目依次为该类的最左虚继承父类、次左虚继承父类…的内存地址相对于虚基类表指针的偏移值。
在我们的例子中,也就是B类实例内存地址相对于vbptr的偏移值,也即是:[4]-[1]的偏移值,结果即为12,从地址上也可以计算出来:007CFDFC-007CFDF4结果的十进制数正是12。现在,我们对虚基类表的构成应该有了一个更好的理解。
6.3.虚拟菱形继承
如果我们有如下继承层次:****
class
B{…}class
B1:
virtualpublic
B{…}class
B2:
virtualpublic
B{…}class
D :
public
B1,
public
B2{…}
类图如下所示:
菱形虚拟继承下,最派生类D类的对象模型又有不同的构成了。在D类对象的内存构成上,有以下几点:
在D类对象内存中,基类出现的顺序是:先是B1(最左父类),然后是B2(次左父类),最后是B(虚祖父类)
D类对象的数据成员id放在B类前面,两部分数据依旧以0来分隔。
编译器没有为D类生成一个它自己的vptr,而是覆盖并扩展了最左父类的虚基类表,与简单继承的对象模型相同。
超类B的内容放到了D类对象内存布局的最后。
菱形虚拟继承下的C++对象模型为:
下面使用代码加以验证:
int
main(){ D d;
cout
<<
"D对象内存大小为:"
<<
sizeof
(d) <<
endl
;
//取得B1的虚函数表
cout
<<
"[0]B1::vptr"
;cout
<<
"\t地址:"
<< (
int
*)(&d) <<
endl
;
//输出虚表B1::vptr中的函数
for
(
int
i =
0
; i<
3
; ++i) {cout
<<
" ["
<< i <<
"]"
; Fun fun1 = (Fun)*((
int
*)*(
int
*)(&d) + i); fun1();cout
<<
"\t地址:\t"
<< *((
int
*)*(
int
*)(&d) + i) <<
endl
; }
//[1]
cout
<<
"[1]B1::vbptr "
;cout
<<
"\t地址:"
<< (
int
*)(&d) +
1
<<
endl
;//虚表指针的地址
//输出虚基类指针条目所指的内容
for
(
int
i =
0
; i <
2
; i++) {cout
<<
" ["
<< i <<
"]"
;
cout
<< *(
int
*)((
int
*)*((
int
*)(&d) +
1
) + i);
cout
<<
endl
; }
//[2]
cout
<<
"[2]B1::ib1="
<< *(
int
*)((
int
*)(&d) +
2
);cout
<<
"\t地址:"
<< (
int
*)(&d) +
2
;cout
<<
endl
;
//[3]
cout
<<
"[3]B2::vptr"
;cout
<<
"\t地址:"
<< (
int
*)(&d) +
3
<<
endl
;
//输出B2::vptr中的虚函数
for
(
int
i =
0
; i<
2
; ++i) {cout
<<
" ["
<< i <<
"]"
; Fun fun1 = (Fun)*((
int
*)*((
int
*)(&d) +
3
) + i); fun1();cout
<<
"\t地址:\t"
<< *((
int
*)*((
int
*)(&d) +
3
) + i) <<
endl
; }
//[4]
cout
<<
"[4]B2::vbptr "
;cout
<<
"\t地址:"
<< (
int
*)(&d) +
4
<<
endl
;//虚表指针的地址
//输出虚基类指针条目所指的内容
for
(
int
i =
0
; i <
2
; i++) {cout
<<
" ["
<< i <<
"]"
;
cout
<< *(
int
*)((
int
*)*((
int
*)(&d) +
4
) + i);
cout
<<
endl
; }
//[5]
cout
<<
"[5]B2::ib2="
<< *(
int
*)((
int
*)(&d) +
5
);cout
<<
"\t地址: "
<< (
int
*)(&d) +
5
;cout
<<
endl
;
//[6]
cout
<<
"[6]D::id="
<< *(
int
*)((
int
*)(&d) +
6
);cout
<<
"\t地址: "
<< (
int
*)(&d) +
6
;cout
<<
endl
;
//[7]
cout
<<
"[7]值="
<< *(
int
*)((
int
*)(&d) +
7
);cout
<<
"\t\t地址:"
<< (
int
*)(&d) +
7
;cout
<<
endl
;
//间接父类
//[8]
cout
<<
"[8]B::vptr"
;cout
<<
"\t地址:"
<< (
int
*)(&d) +
8
<<
endl
;
//输出B::vptr中的虚函数
for
(
int
i =
0
; i<
2
; ++i) {cout
<<
" ["
<< i <<
"]"
; Fun fun1 = (Fun)*((
int
*)*((
int
*)(&d) +
8
) + i); fun1();
cout
<<
"\t地址:\t"
<< *((
int
*)*((
int
*)(&d) +
8
) + i) <<
endl
; }
//[9]
cout
<<
"[9]B::id="
<< *(
int
*)((
int
*)(&d) +
9
);cout
<<
"\t地址: "
<< (
int
*)(&d)
+9
;cout
<<
endl
; getchar(); }
查看运行结果:
7.一些问题解答
7.1.C++封装带来的布局成本是多大?
在C语言中,“数据”和“处理数据的操作(函数)”是分开来声明的,也就是说,语言本身并没有支持“数据和函数”之间的关联性。 在C++中,我们通过类来将属性与操作绑定在一起,称为ADT,抽象数据结构。
C语言中使用struct(结构体)来封装数据,使用函数来处理数据。举个例子,如果我们定义了一个struct Point3如下:
t
ypedefstruct
Point3 {float
x;float
y;float
z; } Point3;
为了打印这个Point3d,我们可以定义一个函数:
void
Point3d_print(
const Point3d *pd){
printf
(
"(%f,%f,%f)"
,pd->x,pd->y,pd_z); }
而在C++中,我们更倾向于定义一个Point3d类,以ADT来实现上面的操作:
class
Point3d {public
: point3d (
float
x =
0.0
,
float
y =
0.0
,
float
z =
0.0
) : _x(x), _y(y), _z(z){}float
x()
const
{
return
_x;}float
y()
const
{
return
_y;}float
z()
const
{
return
_z;}
private
:float
_x;float
_y;float
_z; };inline
ostream&operator
<<(ostream &os,
const
Point3d &pt) { os<<
"("
<<pr.x()<<
","
<<pt.y()<<
","
<<pt.z()<<
")"
; }
看到这段代码,很多人第一个疑问可能是:加上了封装,布局成本增加了多少?答案是class Point3d并没有增加成本。学过了C++对象模型,我们知道,Point3d类对象的内存中,只有三个数据成员。
上面的类声明中,三个数据成员直接内含在每一个Point3d对象中,而成员函数虽然在类中声明,却不出现在类对象(object)之中,这些函数(non-inline)属于类而不属于类对象,只会为类产生唯一的函数实例。
所以,Point3d的封装并没有带来任何空间或执行期的效率影响。而在下面这种情况下,C++的封装额外成本才会显示出来:
虚函数机制(virtual function) , 用以支持执行期绑定,实现多态。
虚基类 (virtual base class) ,虚继承关系产生虚基类,用于在多重继承下保证基类在子类中拥有唯一实例。
不仅如此,Point3d类数据成员的内存布局与c语言的结构体Point3d成员内存布局是相同的。C++中处在同一个访问标识符(指public、private、protected)下的声明的数据成员,在内存中必定保证以其声明顺序出现。而处于不同访问标识符声明下的成员则无此规定。对于Point3类来说,它的三个数据成员都处于private下,在内存中一起声明顺序出现。我们可以做下实验:
void
TestPoint3Member(
const Point3d& p){
cout
<<
"推测_x的地址是:"
<< (
float
*) (&p) <<
endl
;cout
<<
"推测_y的地址是:"
<< (
float
*) (&p) +
1
<<
endl
;cout
<<
"推测_z的地址是:"
<< (
float
*) (&p) +
2
<<
endl
;
cout
<<
"根据推测出的地址输出_x的值:"
<< *((
float
*)(&p)) <<
endl
;cout
<<
"根据推测出的地址输出_y的值:"
<< *((
float
*)(&p)
+1
) <<
endl
;cout
<<
"根据推测出的地址输出_z的值:"
<< *((
float
*)(&p)
+2
) <<
endl
; }
//测试代码
Point3d
a(1,2,3)
; TestPoint3Member(a);
运行结果:
从结果可以看到,_x,_y,_z三个数据成员在内存中紧挨着。
总结一下:
不考虑虚函数与虚继承,当数据都在同一个访问标识符下,C++的类与C语言的结构体在对象大小和内存布局上是一致的,C++的封装并没有带来空间时间上的影响。
7.2.下面这个空类构成的继承层次中,每个类的大小是多少?
今有类如下:****
class
B{};class
B1 :
publicvirtual
B{};class
B2 :
publicvirtual
B{};class
D :
public
B1,
public
B2{};int
main(){ B b; B1 b1; B2 b2; D d;
cout
<<
"sizeof(b)="
<<
sizeof
(b)<<
endl
;cout
<<
"sizeof(b1)="
<<
sizeof
(b1) <<
endl
;cout
<<
"sizeof(b2)="
<<
sizeof
(b2) <<
endl
;cout
<<
"sizeof(d)="
<<
sizeof
(d) <<
endl
; getchar(); }
输出结果是:
解析:
编译器为空类安插1字节的char,以使该类对象在内存得以配置一个地址。
b1虚继承于b,编译器为其安插一个4字节的虚基类表指针(32为机器),此时b1已不为空,编译器不再为其安插1字节的char(优化)。
b2同理。
d含有来自b1与b2两个父类的两个虚基类表指针。大小为8字节。