对象
C++在布局以及存取时间上主要的额外负担是由virtual引起的,包括:
- 虚函数机制:用以支持一个有效率的执行期绑定(runtime binding)
- 虚基类机制:用以实现多次继承基类(直接或间接),而只有一个单一被共享的实例
- 多重继承机制:子类和基类再转换时可能产生的开销
C++对象模型
class Point{ public: Point (float x_val); virtual ~Point(); float x() const; static int PointCount(); protected: virtual ostream& print( ostream& os ) const; float _x; static int _point_count; ;
它的布局如下图所示:
思考:
- 静态数据成员放在了哪里?
- 成员函数放在了哪里?
虚函数支持机制
- 每个类产生出一堆指向虚函数的指针,放在表格中。这个表格即为虚表(virtual table, vtbl,读作v-table--《C++面向对象高效编程》)。
- 每个类对象被安插一个指针,指向相关的虚表。这个指针通常被称为vptr。每个类所关联的对象类型信息(type_info object,用以支持运行时类型识别(runtime type identitification,RTTI))也经由虚表被指出来,通常放在表格的第一个slot。
struct与class
- 如果不用虚函数或虚继承,结构体与class的唯一区别是:若不加声明,结构体中的数据成员都是public,类中的数据成员都是private。
- 把C和C++结合起来的唯一可行办法是组合(composition),而非继承。组合即,将结构体作为数据成员添加到类中。
类对象的内存
- 非静态数据成员(nonstatic data members)的总大小,注意:不包括成员函数
- 由于内存对齐(alignment)而填补(padding)上去的空间,在32位机器上通常是4bytes,以使总线“运输”达到最高效率。
- 为支持虚机制而内部产生的额外负担
指针类型
指针本身所需内存大小都是固定的,占一个字(word)的大小。32位机器上是32bits=4bytes,即4字节;64位机器上是64bits=8bytes,即8字节。
指向不同类型的指针之间的差异,既不在于其指针表示法不同,也不在其内容不同,而在于其寻址得出的对象类型不同。即,指针类型会指导编译器如何解释某个特定地址中的内存内容及其大小。
这也是为什么一个void*类型的指针只能够持有一个地址,而不能通过它操作所指向的对象的缘故。
因此,转换(cast)其实是一种编译器指令。大部分情况下它不改变一个指针所含的真正地址,它只影响被指向的内存的大小和其内容的解释方式。
如何让一个父类指针存取(access)子类对象中的成员?
Base *pb = new Derived(); //pb指针将不能操作Derived类的特有部分。
- 显式静态类型转换,static_cast<基类指针类型>
- 显式动态类型转换,dynamic_cast<基类指针类型>,更好的选择,它是运行时操作(runtime operation),但成本较高
数据
类的大小
空类的大小并不是0,编译器通常为其分配一个字节的大小。为何要这么做?
X a, b; if ( &a == &b ) cerr << "yipes!" << endl;
- 这使得该类的两个对象能够在内存中分配独一无二的位置。
在多重虚继承下,一个类的大小是怎样的?考虑如下继承(64位机器):
注:此处为笔者臆测,跟书中知识有较大出入,有待进一步探索。但它们符合执行结果,具体可能跟编译器有关。
class X {}; //1 class Y : public virtual X {}; //8 class Z : public virtual X {}; //8 class A : public Y, public Z {}; //16
- 之所以sizeof( X )=1,是因为编译器往X中加入了一个字节的大小,这么做可以让两个X类对象的内存地址区分开来。
- 之所以sizeof( Y )或者sizeof( Z )=8,是因为Y或Z中的X没有非静态数据成员部分,而因为Y或Z中需要有一个虚指针,该指针占内存,故虽然两者是空类,但声明多个Y或Z对象是可以区分内存地址的。因此也就不再需要X中的一个字节大小了,故它们大小为0+8=8。
- 之所以sizeof( A )=16,是A中含有Y和Z的两个虚指针,同样不再需要X中的一个字节大小了,故它们大小为8+8=16。
思考如下例子:
class A{ int a; char c; }; //1+4+3 = 8 class B : public virtual A{}; //1+4+3+8 = 16 class C : public virtual A{}; //1+4+3+8 = 16 class D : public B, public C{}; //1+4+3+8+8 = 24
数据成员的绑定
typedef int length; class Point3d { public: // 可能错误: length被决议为int mumble( length val ) { _val = val; } // 正确,_val 被决议为 Point3d::_val length mumble() { return _val; } private: typedef float length; length _val; };
对于普通成员函数来说,参数列(argument list)中的名称会在第一次遭遇时被决议完成。
对于inline成员函数来说,其内的任何一个数据成员的绑定都会在整个类声明完成之后发生。
tips:总是把嵌套类型(nested type)声明放在类的起始处,这样可以保证绑定的正确性。
数据成员的布局
非静态数据成员在类对象中的排列顺序和其被声明的顺序一样,任何静态数据成员都不会被放进对象布局。
C++Standard要求在同一个访问区域(access section,即public、private、protected),成员的排列符合较晚出现的成员在类对象中有较高的地址。若是不在一个访问区域,那么排列顺序就不一定了,取决于编译器厂商。目前各家编译器都把一个以上的访问区域联合起来,依照声明的顺序,成为一个连续区块。
数据成员的存取
class Point3d { public: // ... private: float x; static List<Point3d*> *freeList; float y; static const int chunkSize = 250; float z; };
对于:
Point3d origin, *pt = &origin; origin.x = 0; pt->x = 0;
两者有何重大差异?
- 当Point3d处于单一继承、多重继承、非继承(单一class或者struct)体系时,两者无差别,因为它们都通过offset获取,而offset在编译器就已经知道了。当Point3d的继承结构中存在虚基类,并且被存取的数据x正是从其中继承而来时,两者就会有差异:这个时候pt指向对象的类型是不确定的,直到执行期才被确定,因此也就无从知道该x的真正offset,这需要经过一个间接引导才能解决;而使用对象origin存取就不会有这些问题,因为它的类型在编译器就已经确定了,那么x的ofset也就随之而确定了。
静态数据成员
静态数据成员被编译器提出于类之外,并被视为一个全局变量(只在类声明范围之内可见)。每个静态数据成员只有一个实例,存放在程序的数据段(data segment)中,每次程序取用静态成员时,就会被内部转化为对该唯一extern实例的直接参考操作。如:
origin.chunkSize = 250; pt->chunkSize = 250;
都被转化为:
Point3d::chunkSize = 250
此时,通过一个指针和通过一个对象来存取成员,没有差别。
非静态数据成员
成员函数对数据成员的操作都是经过this指针完成的。
欲对一个非静态数据成员进行存取操作,编译器需要把类对象的·起始地址加上数据成员的偏移位置(offset)。
&origin.y = &origin + (&Point3d::y-1);
“-1”是因为,指向数据成员的指针,其offset值总被+1,这样可以使编译系统区分出一个指向数据成员的指针(用以指出类的第一个成员),和一个指向数据成员的指针(没有指向任何成员)。考虑如下例子:
float Point3d::*p1 = 0; float Point3d::*p2 = &Point3d:;x; //Point3d::* 是类成员指针用法,指向类数据成员 //&Point3d::x得到的是数据成员x在类对象中的offset //&origin.x得到绑定于类对象的数据成员的地址,这将会得到x在内存中的真正地址 //那么p1 == p2
此时,为了区分p1和p2,每个成员offset值都被+1。
而&origin.x所得结果减去z的offset值(已+1),并+1,将得到origin的起始地址。
继承体系下的数据成员
无虚机制
class Concrete { public: // ... private: int val; char c1; char c2; char c3; }; //8字节(32位)
对象布局如下:
- val占用4字节
- c1, c2, c3各占用1字节
- 对齐需要1字节
分裂为三层继承结构:
class Concrete1 { public: // ... protected: int val; char bit1; }; class Concrete2 : public Concrete1 { public: // ... protected: char bit2; }; class Concrete3 : public Concrete2 { public: // ... protected: char bit3; };
布局如下所示:
这所以每个subobject部分都保持自己的对齐,是为了把父类与子类特有部分分开,如此,当一个子类对象被父类对象赋值时,子类对象特有数据成员就不会被覆盖。如下图(将对齐内存用于存储各个subobject数据成员):
存在虚机制
class Point2d { public: protected: float _x, _y; }; class Vertex : public virtual Point2d { public: ... protected: Vertex *next; }; class Point3d : public virtual Point2d { public: protected: float _z; }; class Vertex3d : public Point3d, public Vertex { public: protected: float mumble; };
其布局如下(注:cfront编译器如下布局。它在每一个衍生类对象中安插一些指针,每个指针指向虚基类。):
函数
非静态成员函数(Nonstatic Member Functions)
C++设计准则之一就是:非静态成员函数至少必须和一般的非成员函数(nonmember functions)有同样的效率。考虑:
float get( const A *_this){ return _this->x * _this->y; } //非成员函数形式 float A::get ( ) const { return x * y; } //成员函数形式
实际上,成员函数被内化为非成员函数的形式:
- 安插一个额外的参数到成员函数中,用以提供一个存取管道,使得类对象经此函数调用,这个参数即,this指针。float A::get ( Const A * const this ) { }
- 将每个队非静态数据成员的存取操作,改为经由this指针来存取。{ return this->x * this->y; }
- 将成员函数重新写成一个外部函数。将函数名称重整(name magling),使它在程序中成为独一无二的函数。extern get__1AFv ( register A *const this )。 (register关键字请求编译器尽可能的将变量存在CPU内部寄存器中,而不是通过内存寻址访问,以提高效率)
虚成员函数
单一继承
class Point{ public: virtual ~Point(); virtual Point& mult ( float ) = 0; float x() { return _x; } const; virtual float y() const { return 0; }; virtual float z() const { return 0; };
protected: Point ( float x = 0.0 ); float _x; }; class Point2d : public Point{ public: Point2d() {}; ~Point2d() {}; //改写 Point2d& mult ( float ) = 0; float y() const { return _y; }; protected: float _y; }; class Point3d : public Point2d{ public: Point3d() {}; ~Point3d() {}; //改写 Point3d& mult ( float ) = 0; float z() const { return _z; }; protected: float _z; };
三个类各自的布局如下图所示:
对于函数调用ptr->z(),如何在编译时期设定虚函数的调用呢?
- 经由ptr指针可以存取到该对象的虚表,但在编译时期不知道指针所指的真正对象
- z() 函数在虚表中的的地址是已知的,因不知道真正对象,故对应函数调用也不确定
而在执行期,该被调用的z() 函数实例才被确定。
由上图,原调用可转化为:
(* ptr->vptr[4] ) ( ptr );
多重继承
class Base1 { public: Base1(); virtual ~Base1(); virtual void speakClearly(); virtual Base1 *clone() const; protected: float data_Base1; }; class Base2 { public: Base2(); virtual ~Base2(); virtual void mumble(); virtual Base2 *clone() const; protected: float data_Base2; }; class Derived : public Base1, public Base2 { public: Derived(); virtual ~Derived(); virtual Derived *clone() const; protected: float data_Derived; };
在多重继承中支持虚函数,其复杂性主要落在第二个及后继的基类身上,因为必须在执行其调整this指针。上述多重继承的布局如下:
由上图可看出,在多重继承情况下,一个子类内含n-1个额外的虚表(子类本身的虚表不算额外),n是其上层基类的个数。
需要调整this指针的情况:
(1)通过一个第二个基类类型指针(Base2 *),调用子类虚函数:
Base2 *ptr = new Derived; //ptr指向Derived对象的Base2subobject,即Base2部分 delete ptr; //调用Derived::~Derived,指针必须向后调整sizeof( Base1 ),以指向Derived对象的起始地址
(2)通过子类类型指针(Derived *),调用从第二个基类中继承来的虚函数
Derived *pder = new Derived; pder->mumble(); //调用Base2::mumble() ,指针必须向前调整sizeof( Base1 ),以指向Base2subobject。
(3)虚函数的返回值类型在继承中被覆写(override),由一个第二个基类类型指针(Base2 *)指向该返回值
Base2 *pb1 = new Derived; Base2 *pb2 = pb1->clone(); //调用Derived::clone() ,返回值必须向前调整sizeof( Base1 ),以指向Base2subobject。
如何调整:
通过thunk技术,效率高。thunk是一段assembly代码,用来:
- 以适当的offset调整this指针
- 跳到虚函数中
伪代码(经由Base2指针调用Derived类的析构函数):
this += sizeof( base1 ); Derived::~derived( this );
虚继承
class Point2d { public: Point2d( float = 0.0, float = 0.0 ); virtual ~Point2d(); virtual void mumble(); virtual float z(); protected: float _x, _y; }; class Point3d : public virtual Point2d public: Point3d( float = 0.0, float = 0.0, float = 0.0 ); ~Point3d(); float z(); protected: float _z; };
其对象布局如下:
注意:下面的两个Point3d::mumble()函数可能是Point2d::mumble()函数。
注意:此时子类特有部分布局靠前。两个类并不共享一个虚表。
tips:不要在一个虚基类中声明非静态数据成员。
可以看出Point3d类和Point2d类的起始地址并不一样,因此当发生转换时需要调整this指针的位置。
当通过基类指针调用子类中特有函数时,推测:
this指针向后调整sizeof( 子类非静态数据成员 )
原文链接: https://www.cnblogs.com/icky1024/p/cpp-object-model.html
欢迎关注
微信关注下方公众号,第一时间获取干货硬货;公众号内回复【pdf】免费获取数百本计算机经典书籍;
也有高质量的技术群,里面有嵌入式、搜广推等BAT大佬
原创文章受到原创版权保护。转载请注明出处:https://www.ccppcoding.com/archives/351257
非原创文章文中已经注明原地址,如有侵权,联系删除
关注公众号【高性能架构探索】,第一时间获取最新文章
转载文章受原作者版权保护。转载请注明原作者出处!