C++对象模型是什么
《深度探索C++对象模型》这本书中对对象模型的描述如下:
有两个概念可以解释C++对象模型: 语言中直接支持面向对象程序设计的部分。 对于各种支持的底层实现机制。
语言中直接支持面向对象程序设计的部分,包括了构造函数、析构函数、多态、虚函数等等。 对象模型的底层实现机制并未标准化,不同的编译器有一定的自由来设计对象模型的实现细节。在我看来,对象模型研究的是对象在存储上的空间与时间上的更优,并对C++面向对象技术加以支持,如以虚指针、虚表机制支持多态特性。
多态与虚函数表指针
C++中虚函数的作用主要是为了实现多态机制。多态,简单来说,是指在继承层次中,父类的指针可以具有多种形态——当它指向某个子类对象时,通过它能够调用到子类的函数,而非父类的函数。
class Base { virtual void print(void); }
class Drive1 :public Base{ virtual void print(void); }
class Drive2 :public Base{ virtual void print(void); }
Base * ptr1 = new Base;
Base * ptr2 = new Drive1;
Base * ptr3 = new Drive2;
ptr1->print(); //调用Base::print()
prt2->print(); //调用Drive1::print()
prt3->print(); //调用Drive2::print()
这是一种运行期多态,即父类指针唯有在程序运行时才能知道所指的真正类型是什么。这种运行期决议,是通过虚函数表来实现的。
虚函数表
当一个类本身定义了虚函数,或其父类有虚函数时,为了支持多态机制,编译器将为该类添加一个虚函数表指针(vptr)。虚函数表指针一般都放在对象内存布局的第一个位置上,这是为了保证在多层继承或多重继承的情况下能以最高效率取到虚函数表。 当vprt位于对象内存最前面时,对象的地址即为虚函数表指针地址。以下面的代码为例,在Windows平台下测试下虚函数指针(vptr)的地址。
class Base
{
public:
Base(int i) :baseI(i){};
virtual ~Base(){}
virtual void print(void){ cout << "调用了虚函数Base::print()"; }
virtual void setI(){cout<<"调用了虚函数Base::setI()";}
private:
int baseI;
};
我们可以取得虚函数指针的地址:
Base b(1000);
int * vptrAdree = (int *)(&b);
cout << "虚函数指针(vprt)的地址是:\t"<<vptrAdree << endl;
运行代码出结果:
我们强行把类对象的地址转换为 int*
类型,取得了虚函数表指针的地址。虚函数表指针指向虚函数表,虚函数表中存储的是一系列虚函数的地址,虚函数地址出现的顺序与类中虚函数声明的顺序一致。虚函数表指针地址值,可以得到虚函数表的地址,也即是虚函数表第一个虚函数的地址:
typedef void(*Fun)(void);
Fun vfunc = (Fun)*( (int *)*(int*)(&b));
cout << "第一个虚函数的地址是:" << (int *)*(int*)(&b) << endl;
cout << "通过地址,调用虚函数Base::print():";
vfunc();
- 我们把虚表指针的值取出来:
*(int*)(&b)
,它是一个地址,虚函数表的地址 - 把虚函数表的地址强制转换成
int*
:(int *)*(int*)(&b)
- 再把它转化成我们
Fun
指针类型 :(Fun)*( (int *)*(int*)(&b))
这样,我们就取得了类中的第一个虚函数,我们可以通过函数指针访问它。 运行结果:
同理,第二个虚函数setI()
的地址为:
(int * )(*(int*)(&b)+1)
普通继承下的对象模型
我们接下来来研究下普通继承下的C++对象模型是怎样的(普通继承是相对于虚继承来说的)。 首先定义一个基类Base
,如下:
class Base
{
public:
Base(int i) : baseI(i){};
virtual ~Base(){}
int getI(){ return baseI; }
static void countI(){};
virtual void print(void){ cout << "Base::print()"; }
private:
int baseI;
static int baseS;
};
单继承
单继承的情景下,我们定义派生类Derive
,继承自Base
:
class Derive : public Base
{
public:
Derive(int d) : Base(1000), DeriveI(d){};
// overwrite父类虚函数
virtual void print(void){ cout << "Drive::Drive_print()" ; }
// Derive声明的新的虚函数
virtual void Drive_print(){ cout << "Drive::Drive_print()" ; }
virtual ~Derive(){}
private:
int DeriveI;
};
继承类图为:
在C++对象模型中,对于一般继承(这个一般是相对于虚拟继承而言):
- 若子类重写(overwrite)了父类的虚函数,则子类虚函数将覆盖虚表中对应的父类虚函数(注意子类与父类拥有各自的一个虚函数表)
- 若子类并无overwrite父类虚函数,而是声明了自己新的虚函数,则该虚函数地址将扩充到虚函数表的最后。
一般单继承下的对象模型如下:
多继承
一般多继承
一般多继承的继承类图关系如下:
单继承中(一般继承),子类会扩展父类的虚函数表。在多继承中,子类含有多个父类的子对象,该往哪个父类的虚函数表扩展呢?当子类overwrite了父类的函数,需要覆盖多个父类的虚函数表吗? 在多继承中,有如下规则:
- 子类的虚函数被放在声明的第一个基类的虚函数表中。
- overwrite时,所有基类的print()函数都被子类的print()函数覆盖。
- 内存布局中,父类按照其继承时的声明顺序排列。
其中第二点保证了父类指针指向子类对象时,总是能够调用到真正的函数。 多继承的继承关系和对象模型图解如下:
菱形继承
菱形继承也称为钻石型继承或重复继承,它指的是基类被某个派生类简单重复继承了多次。这样,派生类对象中拥有多份基类实例(这会带来一些问题)。为了方便叙述,我们不使用上面的代码了,而重新写一个重复继承的继承层次:
class B
{
public:
B(int i = 1) : ib(i){}
virtual void f() { cout << "B::f()" << endl; }
virtual void Bf() { cout << "B::Bf()" << endl; }
int ib;
};
class B1 : public B
{
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; }
int ib1;
};
class B2 : public B
{
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; }
int ib2;
};
class D : public B1, public B2
{
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; }
int id;
};
继承类图关系如下:
根据单继承,我们可以分析出B1,B2类继承于B类时的内存布局。又根据一般多继承,我们可以分析出D类的内存布局。我们可以得出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实体。
虚继承下的对象模型
虚继承解决了菱形继承中最派生类拥有多个间接父类实例的情况。虚继承的派生类的内存布局与普通继承很多不同,主要体现在:
- 虚继承的子类,如果本身定义了新的虚函数,则编译器为其新增一个虚函数表指针(vptr)以及一张虚函数表。该vptr位于对象内存最前面。而非虚继承的情况是,直接扩展父类虚函数表。虚继承是新增虚函数表,普通继承是扩展虚函数表。
- 子类会有一个虚基类表指针,指向一个虚基类表。虚基类表中存放的是子类的所有虚基类(按继承关系从左至右)基地址相对于这个虚基类表指针的偏移。
- 虚继承的子类单独保留了父类的vptr与虚函数表。这部分内容接与子类内容以一个四字节的0来分界。
- 虚继承的子类对象中,含有四字节的虚表指针偏移值。
虚基类表
在C++对象模型中,虚继承而来的子类会生成一个隐藏的虚基类表指针(vbptr),在Microsoft Visual C++中,虚基类表指针总是在虚函数表指针之后,因而,对某个类实例来说,如果它有虚基类表指针,那么虚基类表指针可能在实例的0字节偏移处,也可能在类实例的4字节偏移处(该类没有vptr时,vbptr就处于类实例内存布局的最前面,否则vptr处于类实例内存布局的最前面)。 一个类的虚基类表指针指向的虚基类表,与虚函数表一样,虚基类表也由多个条目组成,条目中存放的是偏移值。第一个条目存放虚基类表指针(vbptr)所在地址到该类内存首地址的偏移值,由第一段的分析我们知道,这个偏移值为0(类没有vptr)或者-4(类有虚函数,此时有vptr)。我们通过一张图来更好地理解。
虚基类表的第二、第三...个条目依次为该类的最左虚继承父类、次左虚继承父类...的内存地址相对于虚基类表指针的偏移值。
简单虚继承
如果我们的B1类虚继承于B类:
class B
{
public:
B(int i = 1) : ib(i){}
virtual void f() { cout << "B::f()" << endl; }
virtual void Bf() { cout << "B::Bf()" << endl; }
int ib;
};
class B1 : virtual public B
{
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; }
int ib1;
};
继承类图关系如下:
单虚继承的对象模型如下图所示:
如果在虚继承中子类未添加新的虚函数,只是覆盖基类的虚函数,那么子类对象的首地址就不是存放虚函数表的指针,而是虚基类表指针,如下图所示:
多虚继承
如果我们的D类虚继承于B1和B2类,代码如下(具体代码省略):
class D : virtual public B1, virtual public B2 {
...
}
多虚继承的对象模型如下图所示:
菱形虚继承
如果我们有如下的菱形虚继承层次:
class B{...}
class B1: virtual public B{...}
class B2: virtual public B{...}
class D : public B1,public B2{...}
类图如下所示:
菱形虚拟继承下,派生类D类的对象模型又有不同的构成。在D类对象的内存构成上,有以下几点:
- 在D类对象内存中,基类出现的顺序是:先是B1(最左父类),然后是B2(次左父类),最后是B(虚祖父类)
- D类对象的数据成员id放在B类前面,两部分数据依旧以0来分隔。
- 编译器没有为D类生成一个它自己的vbptr,而是覆盖并扩展了最左父类的虚基类表,与简单继承的对象模型相同。
- 超类B的内容放到了D类对象内存布局的最后。
菱形虚拟继承下的C++对象模型为:
问:菱形虚继承的子类D有几个虚函数表? 答:1、2、3个。
- 拥有3个虚函数表:B1、B2各自虚继承了类B,且B1、B2各自新增了自己的虚函数表
- 拥有2个虚函数表:B1、B2各自虚继承了类B,且B1、B2只有一个新增了自己的虚函数表
- 拥有1个虚函数表:B1、B2各自虚继承了类B,但B1、B2没有新增虚函数表
相关问题解答
C++封装带来的布局成本有多大?
在C语言中,“数据”和“处理数据的操作(函数)”是分开来声明的,也就是说,语言本身并没有支持“数据和函数”之间的关联性。在C++中,我们通过类来将属性与操作绑定在一起,称为ADT,抽象数据结构。 C语言中使用struct(结构体)来封装数据,使用函数来处理数据。举个例子,如果我们定义了一个struct Point3如下:
typedef struct Point3
{
float x;
float y;
float z;
} Point3;
而在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;
};
看到这段代码,很多人第一个疑问可能是:加上了封装,布局成本增加了多少? 答案是class Point3d并没有增加成本。学过了C++对象模型,我们知道,Point3d类对象的内存中,只有三个数据成员。 上面的类声明中,三个数据成员直接内含在每一个Point3d对象中,而成员函数虽然在类中声明,却不出现在类对象(object)之中,这些函数(non-inline)属于类而不属于类对象,只会为类产生唯一的函数实例。 所以,Point3d的封装并没有带来任何空间或执行期的效率影响。 而在下面这种情况下,C++的封装额外成本才会显示出来:
- 虚函数机制(virtual function) , 用以支持执行期绑定,实现多态。
- 虚基类 (virtual base class) ,虚继承关系产生虚基类,用于在多重继承下保证基类在子类中拥有唯一实例。
不仅如此,Point3d类数据成员的内存布局与c语言的结构体Point3d成员内存布局是相同的。C++中处在同一个访问标识符(指public、private、protected)下的声明的数据成员,在内存中必定保证以其声明顺序出现。而处于不同访问标识符声明下的成员则无此规定。对于Point3类来说,它的三个数据成员都处于private下,在内存中一起声明顺序出现。
总结一下: 不考虑虚函数与虚继承,当数据都在同一个访问标识符下,C++的类与C语言的结构体在对象大小和内存布局上是一致的,C++的封装并没有带来空间时间上的影响。
空类构成的继承层次中,每个类的大小是多少?
今有类如下,这个空类构成的继承层次中,每个类的大小是多少?
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();
}
输出结果是(在64位操作系统上):
sizeof(b)=1
sizeof(b1)=8
sizeof(b2)=8
sizeof(d)=16
解析:
- 编译器为空类安插1字节的char,以使该类对象在内存得以配置一个地址。
- b1虚继承于b,编译器为其安插一个4字节(32为机器),8字节(64位机器)的虚基类表指针,此时b1已不为空,编译器不再为其安插1字节的char(优化)。
- b2同理。
- d含有来自b1与b2两个父类的两个虚基类表指针。大小为8字节(32为机器),16字节(64位机器)。
虚函数表放置在内存布局中的那个区域?
- 虚函数表的内容是在编译时确定的。编译期间编译器就为每个类确定好了对应的虚函数表里的内容。 在程序运行时,编译器会把虚函数表的首地址赋值给虚函数表指针。
- 每个类拥有一个虚函数表,每个类的对象中的虚函数表指针所指向的都是同一个虚函数表。
- 虚函数表的内容是固定不变的,因此它会存放在常量区。
虚函数表指针在什么时候创建?
对象构造时。类的构造函数中会首先执行vptr的初始化然后再执行其他的。