目录
本文主要内容如下:
最后还有一些问题:
一、理解虚函数表
二、对象模型概述
三、继承下的C++对象模型
单继承:
多继承:
一般的多继承(非菱形继承):
菱形继承:
五、虚继承
5.1虚基类表解析:
5.2简单虚继承
5.3虚拟多继承
5.4虚拟菱形继承
六、C++封装带来的布局成本是多大?
七、下面这个空类构成的继承层次中,每个类的大小是多少?
由于本人在查找答案时,发现绝大多数博文都是相同的,并且不全,所以我将各站中写的比较好的博文综合起来,补全了答案,并对一些不必要的内容进行了简化,以下是我参考的一些博文链接:
图说C++对象模型:对象内存布局详解 - melonstreet - 博客园 (cnblogs.com)
图解C++对象模型,看这一篇就够了 - 知乎 (zhihu.com)
C++中类所占内存,父类与子类所占内存大小的关系(详细记忆)_c++中虚函数子类和父类的大小为什么会一样-CSDN博客
C++类的大小计算汇总 - 冯耀耀 - 博客园 (cnblogs.com)
C++中涉及到虚函数成员、静态成员、虚继承、多继承、空类等。
类作为一种类型定义,是没有大小可言的。
类的大小指的是类实例化出的对象的大小。因此,用sizeof对一个类型名操作,得到的是具有该类型实体的大小。
规律综合:
- 类大小的计算,遵循结构体的对齐规则。
- 类的大小与数据成员有关,与成员函数和静态成员无关。即普通成员函数,静态成员函数,静态数据成员,静态常量数据成员,均对类的大小没有关系。
- 虚函数对类的大小有影响,是因为虚函数表指针带来的影响。
- 虚继承对类的大小有影响,是因为虚基表指针带来的影响。
- 静态数据成员之所以不计算在类的对象大小内,是因为类的静态数据成员被该类的对象所共享,并不属于具体的哪一个对象,静态数据成员定义在内存的全局区。
- 空类的大小为1,含有虚函数,虚继承,多继承是特殊情况。
本文主要内容如下:
- 虚函数表解析。含有虚函数或其父类含有虚函数的类,编译器都会为其添加一个虚函数表、vptr,先了解虚函数表的构成,有助于对C++对象模型的理解。
- 虚基类表解析。虚继承产生虚基类表(vbptr),虚基类表的内容与虚函数表完全不同。
- 对象模型的概述:介绍简单对象模型,表格驱动对象模型,以及非继承情况下的C++对象模型。
- 继承下的C++对象模型。分析C++类对象在以下情况的内存布局:
- 单继承:子类单一继承自父类,分析了子类重写父类虚函数、子类定义了新的虚函数情况下子类对象内存布局
- 多继承:子类继承于多个父类,分析了子类重写父类虚函数、子类定义了新的虚函数情况下子类对象内存布局,同时分析了非虚继承下的菱形继承。
- 虚继承:分析了单一继承下的虚继承、多重基层下的虚继承、重复继承下的虚继承。
最后还有一些问题:
C++封装带来的布局成本多大?
由空类组成的继承层次中,每个类对象的大小是多大?
一、理解虚函数表
C++中虚函数的作用主要是为了实现多态机制。多态,简单来说是指在继承层次中,父类的指针可以具有多种形态,当它指向某个子类对象时,通过它能够调用到子类的函数,而非父类的函数。这是一种运行期多态,即父类指针唯有在程序运行时才能知道所指的真正类型是什么。而这种决议是通过虚函数表来实现的。
当一个类本身定义了虚函数,或其父类有虚函数时,为了支持多态机制,编译器将为该类添加一个虚函数指针(vptr)。虚函数指针一般都放在内存布局的第一个位置上,这是为了保证在多层继承或多重继承的情况下能以最高效率取到虚函数表。
虚函数表指针指向虚函数表,虚函数表中存储的是一系列虚函数的地址,虚函数地址出现的顺序与类中虚函数声明的顺序一致。取到的虚函数表的地址也即是虚函数表第一个虚函数的地址。
二、对象模型概述
在C++中有两种数据成员(class data members):static 和 nonstatic,以及三种类成员函数(class member function):static、nonstatic 和 virtual。现在我们有一个类Base,包含了上面五种类型的数据或函数:
class Base
{
public:
Base(int i) :baseI(i){};
int getI(){ return baseI; }
static void countI(){};
virtual ~Base(){}
virtual void print(void){ cout << "Base::print()"; }
private:
int baseI;
static int baseS;
};
类图如下:
在非继承下的C++对象模型下:
nonstatic 数据成员被置于每一个类对象中,而 static 数据成员被置于类对象之外。 static 与 nonstatic 函数也都被放在类对象之外,而对于 virtual 函数,则通过虚函数表 + 虚指针来支持,具体如下:
每个类生成一个表格,成为虚表(virtual table,简称vtbl)。虚表中存放着一堆指针,这些指针指向该类每一个虚函数。虚表中的函数地址将按声明时的顺序排列,不过当子类有多个重载函数时例外。
每个类对象都拥有一个虚表指针(vptr),由编译器为其生成。虚表指针的设定与重置皆由类的复制控制(也就是构造函数,析构函数,赋值操作符)来完成。vptr的位置由编译器决定,传统上它被放在所有显示声明的成员之后,不过现在许多编译器把 vptr 放在一个类对象的最前端。另外,虚函数表的前面设置了一个指向 type_info 的指针,用以支持 RTTI(运行时类型识别)。RTTI 是为多态而生成的信息,包括对象继承关系,对象本身描述等,只有具有虚函数的对象才会生成。
此时 Base 的对象模型如图:
三、继承下的C++对象模型
单继承:
如果我们定义了派生类:
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++对象模型中,对于一般继承(这个一般是相对于虚继承而言的),若子类重写了父类的虚函数,则子类虚函数将覆盖虚表中对应的父类虚函数(注意子类与父类拥有各自的一个虚函数表);若子类并没有重写父类虚函数,而是声明了自己新的虚函数,则该虚函数地址将扩充到虚函数表最后。而对于虚继承,若子类重写父类虚函数,同样的将覆盖父类子物体中的虚函数表对应位置,而若子类声明了自己新的虚函数,则编译器将为子类增加一个新的虚表指针 vptr,这与一般继承不同。
多继承:
一般的多继承(非菱形继承):
单继承中(一般继承),子类会扩展父类的虚函数表。在多继承中,子类含有多个父类的子对象,该往哪个父类的虚函数表扩展呢?当子类重写了父类的函数,需要覆盖多个父类的虚函数表吗?
- 子类的虚函数表被放在声明的第一个基类的虚函数表中
- 重写时,所有基类的 print() 函数都被子类的 print() 函数覆盖
- 内存布局中,父类按照其声明顺序排列
其中第二点保证了父类指针指向子类对象时,总是能够调用真正的函数。
为了方便查看,我们将代码粘过来:
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;
};
class Base_2
{
public:
Base_2(int i) :base2I(i){};
virtual ~Base_2(){}
int getI(){ return base2I; }
static void countI(){};
virtual void print(void){ cout << "Base_2::print()"; }
private:
int base2I;
static int base2S;
};
class Drive_multyBase :public Base, public Base_2
{
public:
Drive_multyBase(int d) :Base(1000), Base_2(2000) ,Drive_multyBaseI(d){};
virtual void print(void){ cout << "Drive_multyBase::print" ; }
virtual void Drive_print(){ cout << "Drive_multyBase::Drive_print" ; }
private:
int Drive_multyBaseI;
};
继承类图如下:
此时 Drive_multyBase 的对象模型是这样的:
菱形继承:
菱形继承也称为钻石型继承或重复继承,它指的是基类被某个派生类简单重复继承了多次。这样,派生类对象中拥有了多份基类实例(这会带来一些问题),为了方便描述,我们我们不使用上面的代码,而重新写一个重复继承的继承层次:
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类。这样不仅增大了空间,更重要的是引起了程序歧义:假如此时我想访问ib,调用的是B1的还是B2的呢?尽管我们可以通过明确指明调用路径以消除二义性,但二义性的潜在性还没有消除,此时我们可以通过虚继承来使D类只拥有一个ib实体。
五、虚继承
虚继承解决了菱形继承中派生类拥有多个间接父类实例的情况。虚继承的派生类的内存布局与普通继承很多不同,主要体现在:
- 虚继承子类,如果本身定义了新的虚函数,则编译器为其生成一个虚函数指针(vptr)以及一张虚函数表,该vptr位于对象内存最前面。与非虚继承对比,非虚继承则是直接拓展父类虚函数表。
- 虚继承的子类也单独保留了父类的vptr与虚函数表,这部分内容接与子类内容以一个四字节的0来分界。
- 虚继承的子类对象中,含有四字节的虚表指针偏移值
5.1虚基类表解析:
在C++对象模型中,虚继承而来的子类会生成一个隐藏的虚基类指针(vbptr),在VSC++中,虚基类表指针总是在虚函数表指针之后,因而,对某个实例来说,如果它有虚基类指针,那么虚基类指针可能在实例的0字节偏移处(该类没有vptr时,vbptr就处于类实例内存布局的最前面,否则vptr处于类实例内存布局的最前面),也可能在类实例的4字节偏移处。
一个类的虚基类指针指向的虚基类表,与虚函数一样,虚基类表也是由多个条目组成,条目中存放的是偏移值,第一个条目存放虚基类表指针(vbptr)所在地址到该类内存首地址的偏移值,由第一段的分析我们知道,这个偏移值为0,(类没有vptr)或者-4(类有虚函数,此时有vptr)。我们通过一张图可以更好的了解:
虚基类表的第二、三...个条目依次为该类的最左虚继承父类,次左虚继承父类...的内存地址相对于虚基类表指针的偏移值。
5.2简单虚继承
//类的内容与前面相同
class B{...}
class B1 : virtual public B
依据我们前面对虚继承的派生类的内存布局的分析,B1类的对象模型应该是这样的:
此时vbptr的第二个条目值是12(假设一个指针占4字节大小),指向基类。
5.3虚拟多继承
class D : virtual public B1, virtual public B2 {
...
}
此时,子类D中的成员放在类内存布局中的最上方,如果子类D中如果有虚函数,那么也会创建一个vptr,并由vbptr指向虚继承的多个虚基类。
5.4虚拟菱形继承
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类生成一个它自己的vptr,而是覆盖并扩展了最左父类的虚基类表,与简单继承的对象模型相同。
抄B类的内容放到了D类对象内存布局的最后
六、C++封装带来的布局成本是多大?
在C语言中,“数据”和“处理数据的操作”是分开声明的,也就是说,语言本身并没有支持数据和函数之间的关联性。在C++中,我们通过类来将属性与操作绑定到一起,称为ADT(抽象数据结构)。
由于在C++对象模型中,这些函数属于类而不属于对象,只会为类产生唯一的函数实例,所以封装没有带来任何空间或执行期的效率影响,而在下面两种情况下,C++的封装额外成本才会显示出来:
虚函数机制(virtual function),用以支持执行期绑定,实现多态
虚基类(virtual Base class),虚继承关系产生虚基类,用于在多重继承下保证基类在子类中拥有唯一实例。
不仅如此,类内数据成员的内存布局与C语言的结构体成员内存布局是相同的,C++中处在同一个访问标识符(指public,private,protected)下的声明的数据成员,在内存中必定保证以其声明顺序出现。而处于不同访问标识符声明下的成员则无此规定。
总结:不考虑虚函数与虚继承,当数据都在同一个访问标识符下,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();
}
解析(32位情况下):
编译器为空类安插1字节的空间,以便使该类对象在内存得以配置一个地址
b1虚继承于b,编译器为其安插一个4字节的虚基类表指针,此时b1已不为空,编译器不再为其安插1字节的空间。
b2同b1
d含有来自b1与b2两个父类的两个虚基类表指针,大小为8字节