图说C++对象模型:对象内存布局详解(下)

释放双眼,带上耳机,听听看~!

5.2.2 菱形继承


菱形继承也称为钻石型继承或重复继承,它指的是基类被某个派生类简单重复继承了多次。这样,派生类对象中拥有多份基类实例(这会带来一些问题)。为了方便叙述,我们不使用上面的代码了,而重新写一个重复继承的继承层次:****

图说C++对象模型:对象内存布局详解(下)

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类子对象的内存布局如下图:

图说C++对象模型:对象内存布局详解(下)

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)。我们通过一张图来更好地理解。

图说C++对象模型:对象内存布局详解(下)

图说C++对象模型:对象内存布局详解(下)

虚基类表的第二、第三…个条目依次为该类的最左虚继承父类、次左虚继承父类…的内存地址相对于虚基类表指针的偏移值,这点我们在下面会验证。

6.2.简单虚继承


如果我们的B1类虚继承于B类:****

//类的内容与前面相同

class
B{…}

class
B1 :
virtual

public
B

图说C++对象模型:对象内存布局详解(下)

根据我们前面对虚继承的派生类的内存布局的分析,B1类的对象模型应该是这样的:

图说C++对象模型:对象内存布局详解(下)

我们通过指针访问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++对象模型:对象内存布局详解(下)

这个结果与我们的C++对象模型图完全符合。这时我们可以来分析一下虚表指针的第二个条目值12的具体来源了,回忆上文讲到的:

第二、第三…个条目依次为该类的最左虚继承父类、次左虚继承父类…的内存地址相对于虚基类表指针的偏移值。

在我们的例子中,也就是B类实例内存地址相对于vbptr的偏移值,也即是:[4]-[1]的偏移值,结果即为12,从地址上也可以计算出来:007CFDFC-007CFDF4结果的十进制数正是12。现在,我们对虚基类表的构成应该有了一个更好的理解。

6.3.虚拟菱形继承


如果我们有如下继承层次:****

class
B{…}

class
B1:
virtual

public
 B{…}

class
B2:
virtual

public
 B{…}

class
D :
public
B1,
public
B2{…}

类图如下所示:

图说C++对象模型:对象内存布局详解(下)

菱形虚拟继承下,最派生类D类的对象模型又有不同的构成了。在D类对象的内存构成上,有以下几点:

在D类对象内存中,基类出现的顺序是:先是B1(最左父类),然后是B2(次左父类),最后是B(虚祖父类) 

D类对象的数据成员id放在B类前面,两部分数据依旧以0来分隔。 

编译器没有为D类生成一个它自己的vptr,而是覆盖并扩展了最左父类的虚基类表,与简单继承的对象模型相同。 

超类B的内容放到了D类对象内存布局的最后。

菱形虚拟继承下的C++对象模型为:

图说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(); }

查看运行结果:

图说C++对象模型:对象内存布局详解(下)

7.一些问题解答


7.1.C++封装带来的布局成本是多大?


在C语言中,“数据”和“处理数据的操作(函数)”是分开来声明的,也就是说,语言本身并没有支持“数据和函数”之间的关联性。 在C++中,我们通过类来将属性与操作绑定在一起,称为ADT,抽象数据结构。

C语言中使用struct(结构体)来封装数据,使用函数来处理数据。举个例子,如果我们定义了一个struct Point3如下:

t
ypedef

struct
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);

运行结果:

图说C++对象模型:对象内存布局详解(下)

从结果可以看到,_x,_y,_z三个数据成员在内存中紧挨着。

总结一下:

不考虑虚函数与虚继承,当数据都在同一个访问标识符下,C++的类与C语言的结构体在对象大小和内存布局上是一致的,C++的封装并没有带来空间时间上的影响。

7.2.下面这个空类构成的继承层次中,每个类的大小是多少?


今有类如下:****

class
B{};

class
B1 :
public

virtual
 B{};

class
B2 :
public

virtual
 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(); }

输出结果是:

图说C++对象模型:对象内存布局详解(下)

解析:

编译器为空类安插1字节的char,以使该类对象在内存得以配置一个地址。 

b1虚继承于b,编译器为其安插一个4字节的虚基类表指针(32为机器),此时b1已不为空,编译器不再为其安插1字节的char(优化)。 

b2同理。 

d含有来自b1与b2两个父类的两个虚基类表指针。大小为8字节。

给TA打赏
共{{data.count}}人
人已打赏
安全技术

C++中引用和指针的区别

2022-1-11 12:36:11

安全网络

kafka解决了什么问题?

2021-8-18 16:36:11

个人中心
购物车
优惠劵
今日签到
有新私信 私信列表
搜索