【c++工程实践】内存模型
0.文章内容简介
这篇文章主要来讨论C++对象在内存中的布局,属于第二个概念的研究范畴。而C++直接支持面向对象程序设计部分则不多讲。文章主要内容如下:
- 虚函数表解析。含有虚函数或其父类含有虚函数的类,编译器都会为其添加一个虚函数表,vptr,先了解虚函数表的构成,有助对C++对象模型的理解。
- 虚基类表解析。虚继承产生虚基类表(vbptr),虚基类表的内容与虚函数表完全不同,我们将在讲解虚继承时介绍虚函数表。
- 对象模型概述:介绍简单对象模型、表格驱动对象模型,以及非继承情况下的C++对象模型。
- 继承下的C++对象模型。分析C++类对象在下面情形中的内存布局:
- 单继承:子类单一继承自父类,分析了子类重写父类虚函数、子类定义了新的虚函数情况下子类对象内存布局。
- 多继承:子类继承于多个父类,分析了子类重写父类虚函数、子类定义了新的虚函数情况下子类对象内存布局,同时分析了非虚继承下的菱形继承。
- 虚继承:分析了单一继承下的虚继承、多重基层下的虚继承、重复继承下的虚继承。
- 理解对象的内存布局之后,我们可以分析一些问题:
- C++封装带来的布局成本是多大?
- 由空类组成的继承层次中,每个类对象的大小是多大?
至于其他与内存有关的知识,我假设大家都有一定的了解,如内存对齐,指针操作等。本文初看可能晦涩难懂,要求读者有一定的C++基础,对概念一有一定的掌握。
1.何为C++对象模型?
引用《深度探索C++对象模型》这本书中的话:
有两个概念可以解释C++对象模型:
- 语言中直接支持面向对象程序设计的部分。
- 对于各种支持的底层实现机制。
直接支持面向对象程序设计,包括了构造函数、析构函数、多态、虚函数等等,这些内容在很多书籍上都有讨论,也是C++最被人熟知的地方(特性)。而对象模型的底层实现机制却是很少有书籍讨论的。对象模型的底层实现机制并未标准化,不同的编译器有一定的自由来设计对象模型的实现细节。对象模型研究的是对象在存储上的空间与时间上的更优,并对C++面向对象技术加以支持,如以虚指针、虚表机制支持多态特性。
举个例子,下面一段代码:
1 class A { 2 public: 3 void foo(){ cout << "A foo" << endl; } 4 };
oo将编译成:
1 void foo(const A* this)( cout << "A foo" << endl; }
调用a.foo(),编译器将转换成foo(&a)
有趣的是,A* pa = NULL; pa->foo();也没有异常退出,因为没有通过this引用任何成员变量,这个时候不过this指针为NULL而已。
静态成员函数
上面说的只是面向对象的非静态成员函数,如果说到类里面的静态成员函数,解释又是另外一个,请看下文。
1、静态数据成员
特点:
A、内存分配:在程序的全局数据区分配。
B、初始化和定义:
a、静态数据成员定义时要分配空间,所以不能在类声明中定义。
b、为了避免在多个使用该类的源文件中,对其重复定义,所在,不能在类的头文件中
定义。
c、静态数据成员因为程序一开始运行就必需存在,所以其初始化的最佳位置在类的内部实现。
C、特点
a、对相于 public,protected,private 关键字的影响它和普通数据成员一样,
b、因为其空间在全局数据区分配,属于所有本类的对象共享,所以,它不属于特定的类对象,在没产生类对象时其作用域就可见,即在没有产生类的实例时,我们就可以操作它。
D、访问形式
a、 类对象名.静态数据成员名
b、 类类型名:: 静态数据成员名
E、静态数据成员,主要用在类的所有实例都拥有的属性上。比如,对于一个存款类,帐号相对 于每个实例都是不同的,但每个实例的利息是相同的。所以,应该把利息设为存款类的静态数据成员。这有两个好处,第一,不管定义多少个存款类对象,利息数据成员都共享分配在全局区的内存,所以节省存贮空间。第二,一旦利息需要改变时,只要改变一次,则所有存款类对象的利息全改变过来了,因为它们实际上是共用一个东西。
2、静态成员函数
特点:
A、静态成员函数与类相联系,不与类的对象相联系。
B、静态成员函数不能访问非静态数据成员。原因很简单,非静态数据成员属于特定的类实例。
作用:
主要用于对静态数据成员的操作。
调用形式:
A、类对象名.静态成员函数名()
B、类类型名:: 静态成员函数名()
1 #include <iostream> 2 #include <typeinfo> 3 using namespace std; 4 5 class A { 6 public: 7 static int count; 8 void foo(){ cout << "A foo this=" << this << endl; } // 如果这样声明和定义一个成员函数,将直接产生一个 foo(A& this) 类型的函数 9 static void goo(){ cout <<"static A goo"<< endl; } // 静态函数没有 this 指针 10 void too(){ cout << typeid(*this).name() << endl; } 11 }; 12 13 int A::count = 0; 14 15 class B : public A { 16 public: 17 // 如果静态函数只能通过域运算符来调用的话,那class在静态意义下就成了命名域的概念了 18 // 如果没有下面这个函数,A类的goo函数将会继承下来,说明作为类的命名空间,也可以继承 19 static void goo(){ cout << "static B goo" << endl;} // 一个问题,静态的成员函数,是怎么区分开的呢? 20 }; 21 22 int main(void) { 23 A a; 24 a.goo(); // 是否是直接翻译成 A::goo() 25 a.foo(); // this 是关键字,不能拿来作为一个全局函数的参数,在转成 foo(&a) 的时候,一定是调用 foo(A& this) 这个函数 26 a.too(); 27 cout << "============" << endl; 28 A* pa = nullptr; 29 pa->goo(); // 静态也跟普通函数一样,没有多态效果 30 pa->foo(); // 静态也跟普通函数一样,没有多态效果 31 cout << "============" << endl; 32 B::goo(); // 这个 foo 没有带参数,只能调用静态的,静态的就直接编译成类似全局函数的不带 this 参数的类型 33 A::goo(); // 这样调用是正确的,这说明它没有 this 指针作为形参 34 B::A::goo(); 35 36 return 0; 37 }
第27行a.goo()通过对象调用静态函数,已经通过类型识别,被编译器替换成A::goo(),这个是由编译器做的,所以替换之后a就只定义了,但是没用引用过。换句话,static的成员函数,只能通过域运算符来调用,无论你是用对象调用还是用指针调用。static的成员变量,也是如此,只能通过翻译成域运算符来调用。
第34行,说明了“类其实除了可以定义变量,还有一个重要的作用就是它是个命名域,相当于std::cout这样。而且,这个命名域,还能继承下来。”
C++和C语言的编译方式不同。C语言中的函数在编译时名字不变,或者只是简单的加一个下划线_
(不同的编译器有不同的实现),例如,func() 编译后为 func() 或 _func()。而C++中的函数在编译时会根据命名空间、类、参数签名等信息进行重新命名,形成新的函数名。这个重命名的过程是通过一个特殊的算法来实现的,称为名字编码(Name Mangling)。Name Mangling 是一种可逆的算法,既可以通过现有函数名计算出新函数名,也可以通过新函数名逆向推演出原有函数名。Name Mangling 可以确保新函数名的唯一性,只要命名空间、所属的类、参数签名等有一个不同,那么产生的新函数名也不同。
3.理解虚函数表
3.1.多态与虚表
C++中虚函数的作用主要是为了实现多态机制。多态,简单来说,是指在继承层次中,父类的指针可以具有多种形态——当它指向某个子类对象时,通过它能够调用到子类的函数,而非父类的函数。
1 class Base { virtual void print(void); } 2 class Drive1 :public Base{ virtual void print(void); } 3 class Drive2 :public Base{ virtual void print(void); } 4 Base * ptr1 = new Base; 5 Base * ptr2 = new Drive1; 6 Base * ptr3 = new Drive2; 7 ptr1->print(); //调用Base::print() 8 prt2->print();//调用Drive1::print() 9 prt3->print();//调用Drive2::print()
这是一种运行期多态,即父类指针唯有在程序运行时才能知道所指的真正类型是什么。这种运行期决议,是通过虚函数表来实现的。
3.2.使用指针访问虚表
如果我们丰富我们的Base类,使其拥有多个virtual函数:
1 class Base { 2 public: 3 Base(int i) :baseI(i){}; 4 virtual void print(void){ cout << "调用了虚函数Base::print()"; } 5 virtual void setI(){cout<<"调用了虚函数Base::setI()";} 6 virtual ~Base(){} 7 private: 8 int baseI; 9 };
当一个类本身定义了虚函数,或其父类有虚函数时,为了支持多态机制,编译器将为该类添加一个虚函数指针(vptr)。虚函数指针一般都放在对象内存布局的第一个位置上,这是为了保证在多层继承或多重继承的情况下能以最高效率取到虚函数表。
当vprt位于对象内存最前面时,对象的地址即为虚函数指针地址。我们可以取得虚函数指针的地址:
Base b(1000); int * vptrAdree = (int *)(&b); cout << "vptr=" << vptrAdree << ", baseI_addr=" << (int* )&(b.baseI) << endl;
运行代码出结果:
我们强行把类对象的地址转换为 int* 类型,取得了虚函数指针的地址。可以看到,虚表指针和成员函数是紧挨着的。
虚函数指针指向虚函数表,虚函数表中存储的是一系列虚函数的地址,虚函数地址出现的顺序与类中虚函数声明的顺序一致。对虚函数指针地址值,可以得到虚函数表的地址,也即是虚函数表第一个虚函数的地址:
1 typedef void(*Fun)(void); 2 Fun vfunc = (Fun)*( (int *)*(int*)(&b)); 3 cout << "第一个虚函数的地址是:" << (int *)*(int*)(&b) << endl; 4 cout << "通过地址,调用虚函数Base::print():"; 5 vfunc();
- 我们把虚表指针的值取出来: *(int*)(&b),它是一个地址,虚函数表的地址
- 把虚函数表的地址强制转换成 int* : ( int *) *( int* )( &b )
- 再把它转化成我们Fun指针类型 : (Fun )*(int *)*(int*)(&b)
这样,我们就取得了类中的第一个虚函数,我们可以通过函数指针访问它。
运行结果:
同理,第二个虚函数setI()的地址为:
(int * )(*(int*)(&b)+1)
同样可以通过函数指针访问它。
到目前为止,我们知道了类中虚表指针vprt的由来,知道了虚函数表中的内容,以及如何通过指针访问虚函数表。
4.对象模型概述
在C++中,有两种数据成员(class data members):static 和nonstatic,以及三种类成员函数(class member functions):static、nonstatic和virtual:
1 class Base 2 { 3 public: 4 5 Base(int i) :baseI(i){}; 6 7 int getI(){ return baseI; } 8 9 static void countI(){}; 10 11 virtual ~Base(){} 12 13 virtual void print(void){ cout << "Base::print()"; } 14 15 private: 16 17 int baseI; 18 19 static int baseS; 20 };
现在我们有一个类Base,它包含了上面这5中类型的数据或函数:
那么,这个类在内存中将被如何表示?5种数据都是连续存放的吗?如何布局才能支持C++多态? 我们的C++标准与编译器将如何塑造出各种数据成员与成员函数呢?
4.1.非继承下的C++对象模型
概述:在此模型下,nonstatic 数据成员被置于每一个类对象中,而static数据成员被置于类对象之外。static与nonstatic函数也都放在类对象之外,而对于virtual 函数,则通过虚函数表+虚指针来支持,具体如下:
- 每个类生成一个表格,称为虚表(virtual table,简称vtbl)。虚表中存放着一堆指针,这些指针指向该类每一个虚函数。虚表中的函数地址将按声明时的顺序排列,不过当子类有多个重载函数时例外,后面会讨论。
- 每个类对象都拥有一个虚表指针(vptr),由编译器为其生成。虚表指针的设定与重置皆由类的复制控制(也即是构造函数、析构函数、赋值操作符)来完成。vptr的位置为编译器决定,传统上它被放在所有显示声明的成员之后,不过现在许多编译器把vptr放在一个类对象的最前端。关于数据成员布局的内容,在后面会详细分析。
另外,虚函数表的前面设置了一个指向type_info的指针,用以支持RTTI(Run Time Type Identification,运行时类型识别)。RTTI是为多态而生成的信息,包括对象继承关系,对象本身的描述等,只有具有虚函数的对象在会生成。
在此模型下,Base的对象模型如图:
先在VS上验证类对象的布局:
Base b(1000);
可见对象b含有一个vfptr,即vprt。并且只有nonstatic数据成员被放置于对象内。我们展开vfprt:
vfptr中有两个指针类型的数据(地址),第一个指向了Base类的析构函数,第二个指向了Base的虚函数print,顺序与声明顺序相同。
这与上述的C++对象模型相符合。也可以通过代码来进行验证:
1 void testBase( Base&p) 2 { 3 cout << "对象的内存起始地址:" << &p << endl; 4 cout << "type_info信息:" << endl; 5 RTTICompleteObjectLocator str = *((RTTICompleteObjectLocator*)*((int*)*(int*)(&p) - 1)); 6 7 8 string classname(str.pTypeDescriptor->name); 9 classname = classname.substr(4, classname.find("@@") - 4); 10 cout << "根据type_info信息输出类名:"<< classname << endl; 11 12 cout << "虚函数表地址:" << (int *)(&p) << endl; 13 14 //验证虚表 15 cout << "虚函数表第一个函数的地址:" << (int *)*((int*)(&p)) << endl; 16 cout << "析构函数的地址:" << (int* )*(int *)*((int*)(&p)) << endl; 17 cout << "虚函数表中,第二个虚函数即print()的地址:" << ((int*)*(int*)(&p) + 1) << endl; 18 19 //通过地址调用虚函数print() 20 typedef void(*Fun)(void); 21 Fun IsPrint=(Fun)* ((int*)*(int*)(&p) + 1); 22 cout << endl; 23 cout<<"调用了虚函数"; 24 IsPrint(); //若地址正确,则调用了Base类的虚函数print() 25 cout << endl; 26 27 //输入static函数的地址 28 p.countI();//先调用函数以产生一个实例 29 cout << "static函数countI()的地址:" << p.countI << endl; 30 31 //验证nonstatic数据成员 32 cout << "推测nonstatic数据成员baseI的地址:" << (int *)(&p) + 1 << endl; 33 cout << "根据推测出的地址,输出该地址的值:" << *((int *)(&p) + 1) << endl; 34 cout << "Base::getI():" << p.getI() << endl; 35 36 } 37 Base b(1000); 38 testBase(b);
结果分析:
- 通过 (int *)(&p)取得虚函数表的地址
- type_info信息的确存在于虚表的前一个位置。通过((int)(int*)(&p) - 1))取得type_infn信息,并成功获得类的名称的Base
- 虚函数表的第一个函数是析构函数。
- 虚函数表的第二个函数是虚函数print(),取得地址后通过地址调用它(而非通过对象),验证正确
- 虚表指针的下一个位置为nonstatic数据成员baseI。
- 可以看到,static成员函数的地址段位与虚表指针、baseI的地址段位不同。
好的,至此我们了解了非继承下类对象五种数据在内存上的布局,也知道了在每一个虚函数表前都有一个指针指向type_info,负责对RTTI的支持。而加入继承后类对象在内存中该如何表示呢?
5.继承下的C++对象模型
5.1.单继承
如果我们定义了派生类
1 class Derive : public Base 2 { 3 public: 4 Derive(int d) :Base(1000), DeriveI(d){}; 5 //overwrite父类虚函数 6 virtual void print(void){ cout << "Drive::Drive_print()" ; } 7 // Derive声明的新的虚函数 8 virtual void Drive_print(){ cout << "Drive::Drive_print()" ; } 9 virtual ~Derive(){} 10 private: 11 int DeriveI; 12 };
继承类图为:
一个派生类如何在机器层面上塑造其父类的实例呢?
在C++对象模型中,对于一般继承(这个一般是相对于虚拟继承而言),若子类重写(overwrite)了父类的虚函数,则子类虚函数将覆盖虚表中对应的父类虚函数(注意子类与父类拥有各自的一个虚函数表);若子类并无overwrite父类虚函数,而是声明了自己新的虚函数,则该虚函数地址将扩充到虚函数表最后(在vs中无法通过监视看到扩充的结果,不过我们通过取地址的方法可以做到,子类新的虚函数确实在父类子物体的虚函数表末端)。而对于虚继承,若子类overwrite父类虚函数,同样地将覆盖父类子物体中的虚函数表对应位置,而若子类声明了自己新的虚函数,则编译器将为子类增加一个新的虚表指针vptr,这与一般继承不同,在后面再讨论。
我们使用代码来验证以上模型
1 typedef void(*Fun)(void); 2 3 int main() 4 { 5 Derive d(2000); 6 //[0] 7 cout << "[0]Base::vptr"; 8 cout << "\t地址:" << (int *)(&d) << endl; 9 //vprt[0] 10 cout << " [0]"; 11 Fun fun1 = (Fun)*((int *)*((int *)(&d))); 12 fun1(); 13 cout << "\t地址:\t" << *((int *)*((int *)(&d))) << endl; 14 15 //vprt[1]析构函数无法通过地址调用,故手动输出 16 cout << " [1]" << "Derive::~Derive" << endl; 17 18 //vprt[2] 19 cout << " [2]"; 20 Fun fun2 = (Fun)*((int *)*((int *)(&d)) + 2); 21 fun2(); 22 cout << "\t地址:\t" << *((int *)*((int *)(&d)) + 2) << endl; 23 //[1] 24 cout << "[2]Base::baseI=" << *(int*)((int *)(&d) + 1); 25 cout << "\t地址:" << (int *)(&d) + 1; 26 cout << endl; 27 //[2] 28 cout << "[2]Derive::DeriveI=" << *(int*)((int *)(&d) + 2); 29 cout << "\t地址:" << (int *)(&d) + 2; 30 cout << endl; 31 getchar(); 32 }
运行结果:
这个结果与我们的对象模型符合。
5.2.多继承
5.2.1一般的多重继承(非菱形继承)
单继承中(一般继承),子类会扩展父类的虚函数表。在多继承中,子类含有多个父类的子对象,该往哪个父类的虚函数表扩展呢?当子类overwrite了父类的函数,需要覆盖多个父类的虚函数表吗?
- 子类的虚函数被放在声明的第一个基类的虚函数表中。
- overwrite时,所有基类的print()函数都被子类的print()函数覆盖。
- 内存布局中,父类按照其声明顺序排列。
其中第二点保证了父类指针指向子类对象时,总是能够调用到真正的函数。
为了方便查看,我们把代码都粘贴过来
1 class Base 2 { 3 public: 4 5 Base(int i) :baseI(i){}; 6 virtual ~Base(){} 7 8 int getI(){ return baseI; } 9 10 static void countI(){}; 11 12 virtual void print(void){ cout << "Base::print()"; } 13 14 private: 15 16 int baseI; 17 18 static int baseS; 19 }; 20 class Base_2 21 { 22 public: 23 Base_2(int i) :base2I(i){}; 24 25 virtual ~Base_2(){} 26 27 int getI(){ return base2I; } 28 29 static void countI(){}; 30 31 virtual void print(void){ cout << "Base_2::print()"; } 32 33 private: 34 35 int base2I; 36 37 static int base2S; 38 }; 39 40 class Drive_multyBase :public Base, public Base_2 41 { 42 public: 43 44 Drive_multyBase(int d) :Base(1000), Base_2(2000) ,Drive_multyBaseI(d){}; 45 46 virtual void print(void){ cout << "Drive_multyBase::print" ; } 47 48 virtual void Drive_print(){ cout << "Drive_multyBase::Drive_print" ; } 49 50 private: 51 int Drive_multyBaseI; 52 };
继承类图为:
此时Drive_multyBase 的对象模型是这样的:
我们使用代码验证:
1 typedef void(*Fun)(void); 2 3 int main() 4 { 5 Drive_multyBase d(3000); 6 //[0] 7 cout << "[0]Base::vptr"; 8 cout << "\t地址:" << (int *)(&d) << endl; 9 10 //vprt[0]析构函数无法通过地址调用,故手动输出 11 cout << " [0]" << "Derive::~Derive" << endl; 12 13 //vprt[1] 14 cout << " [1]"; 15 Fun fun1 = (Fun)*((int *)*((int *)(&d))+1); 16 fun1(); 17 cout << "\t地址:\t" << *((int *)*((int *)(&d))+1) << endl; 18 19 20 //vprt[2] 21 cout << " [2]"; 22 Fun fun2 = (Fun)*((int *)*((int *)(&d)) + 2); 23 fun2(); 24 cout << "\t地址:\t" << *((int *)*((int *)(&d)) + 2) << endl; 25 26 27 //[1] 28 cout << "[1]Base::baseI=" << *(int*)((int *)(&d) + 1); 29 cout << "\t地址:" << (int *)(&d) + 1; 30 cout << endl; 31 32 33 //[2] 34 cout << "[2]Base_::vptr"; 35 cout << "\t地址:" << (int *)(&d)+2 << endl; 36 37 //vprt[0]析构函数无法通过地址调用,故手动输出 38 cout << " [0]" << "Drive_multyBase::~Derive" << endl; 39 40 //vprt[1] 41 cout << " [1]"; 42 Fun fun4 = (Fun)*((int *)*((int *)(&d))+1); 43 fun4(); 44 cout << "\t地址:\t" << *((int *)*((int *)(&d))+1) << endl; 45 46 //[3] 47 cout << "[3]Base_2::base2I=" << *(int*)((int *)(&d) + 3); 48 cout << "\t地址:" << (int *)(&d) + 3; 49 cout << endl; 50 51 //[4] 52 cout << "[4]Drive_multyBase::Drive_multyBaseI=" << *(int*)((int *)(&d) + 4); 53 cout << "\t地址:" << (int *)(&d) + 4; 54 cout << endl; 55 56 getchar(); 57 }
运行结果:
5.2.2 菱形继承
菱形继承也称为钻石型继承或重复继承,它指的是基类被某个派生类简单重复继承了多次。这样,派生类对象中拥有多份基类实例(这会带来一些问题)。为了方便叙述,我们不使用上面的代码了,而重新写一个重复继承的继承层次:
1 class B 2 3 { 4 5 public: 6 7 int ib; 8 9 public: 10 11 B(int i=1) :ib(i){} 12 13 virtual void f() { cout << "B::f()" << endl; } 14 15 virtual void Bf() { cout << "B::Bf()" << endl; } 16 17 }; 18 19 class B1 : public B 20 21 { 22 23 public: 24 25 int ib1; 26 27 public: 28 29 B1(int i = 100 ) :ib1(i) {} 30 31 virtual void f() { cout << "B1::f()" << endl; } 32 33 virtual void f1() { cout << "B1::f1()" << endl; } 34 35 virtual void Bf1() { cout << "B1::Bf1()" << endl; } 36 37 38 39 }; 40 41 class B2 : public B 42 43 { 44 45 public: 46 47 int ib2; 48 49 public: 50 51 B2(int i = 1000) :ib2(i) {} 52 53 virtual void f() { cout << "B2::f()" << endl; } 54 55 virtual void f2() { cout << "B2::f2()" << endl; } 56 57 virtual void Bf2() { cout << "B2::Bf2()" << endl; } 58 59 }; 60 61 62 class D : public B1, public B2 63 64 { 65 66 public: 67 68 int id; 69 70 71 72 public: 73 74 D(int i= 10000) :id(i){} 75 76 virtual void f() { cout << "D::f()" << endl; } 77 78 virtual void f1() { cout << "D::f1()" << endl; } 79 80 virtual void f2() { cout << "D::f2()" << endl; } 81 82 virtual void Df() { cout << "D::Df()" << endl; } 83 84 };
这时,根据单继承,我们可以分析出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实体。