-
-
-
- The Semantics of Data
- 0引例
- 1 The Binding of a Data Member
- 2Data Member Layout
- 3Data Member 的存取
- 4继承与Data Member
- 5Object Member Efficiency
- 6Pointer to Members
- The Semantics of Data
-
-
时隔很久,再次拾起
<<深度探索C++对象模型>>
一书.期间因为学习<<C++ Primer>>
的缘故暂时放下了.现在学习遇到平台期,故又重新拾起。收获颇丰,故于诸君共勉。
The Semantics of Data
3.0.引例
class X{};
class Y :public virtual X{};
class Z :public virtual X{};
class A :public Y, public Z{};
如上的X,Y,Z,A分别是多大呢?
首先是A的大小。我们通过sizeof进行测试,发现大小为1.原因是因为编译器安插一个char字节进去用于区分两个空对象。
X obj1,obj2;
安插一个字节使得他们获得独一无二的内存地址。那么下面我们猜测下X,Y的大小。
我VS 2013平台上大小是4.书上却存在一种大小是8的情况。
下面介绍下决定对象大小的几个因素:
1.支持虚机制带来的负担,比如虚函数,虚基类。
2.Alignment的限制,比如调整字节以达到最大的运输量。
3.编译器提供的特殊优化处理,比如对空虚基类的优化的处理。
对于Y的实例化,在编译器不优化的情况下,我们的Y因空的原因多一个char字节。又因为支持虚基类,产生了一个指向虚机类的指针(假定指针是4字节)。加上边界调整一共是8个字节。
但是微软的编译器会对空类进行优化。因为Y虚拟继承自X,Y中多了一个指向虚基类的指针,这导致Y不在是空的了,所以编译器优化了那个char字节,同时也边界调整也不在需要了,所以我们会看见4字节的结果。补充两张图片可以说明一切。
图片:
现在我们关注A的大小,那么应该是多大呢?我测试得到的结果是8,书上的例子得到的是12,下面就解释下原因。
1.考虑到虚基类的性质,X只存在一份实例。占据一个字节。
2.Y中有一个指向虚基类的指针占据4个字节,同理Z.
3.A自身大小为0.我存在点疑惑,为何不是1字节?
4.边界调整
所以一共是12个字节。对于微软的编译器,X的一个自己被拿掉了,所以边界调整也不需要了,一共是8个字节。
3.1. The Binding of a Data Member
extern float x;
class Point3d{
public:
Point3d(float, float, float);
float X(){ return x; }
void X(float new_X) const{ x = new_X; }
private:
float x, y, z;
};
如果问我们Point3d::X()
函数中返回的x是哪个,我们肯定说是类中定义的x.这个因为我们知道C++
中作用域查找规则,但是以前的编译器却是指向了全局的x.因此导致了两种防御性程序设计风格。
class Point3d{
float x, y, z;
public:
Point3d(float, float, float);
float X(){ return x; }
void X(float new_X) const{ x = new_X; }
};
//在类的一开始就声明数据成员。
第二种就是把所有的inline function
声明到class之外。目的显而易见。
然而这种做法应该早就消失了,因为现在的C++ Standard
规定了内联函数即使是在类内定义的话,那么对其进行评估求值是要等到看见类声尾部的}
括号才开始。所以防御性的设计风格可以随风而去了。
然而这个是对于类的数据成员而言,对于member function
的参数表而言却并非如此。
using length = int;
class Point3d{
public:
void mumble(length val){ _Val = val; }//length 的类型是什么?
length mumble(){ return _Val; }
private:
using length = float;
length _Val;
};
在我的编译器观察到length 的类型是int,并非如我们所料想的一样是int.所以上面提到的决议规则不适用。所以防御性的风格还是有必要的。
using length = int;
class Point3d{
public:
using length = float;
void mumble(length val){ _Val = val; }
length mumble(){ return _Val; }
//the type of length is float,not int ;
private:
length _Val;
};
3.2.Data Member Layout
class Point3d{
public:
/**/
private:
float x;
static std::list<Point3d*>* freeList;
float y;
static const int chunkSize = 250;
float z;
};
考虑如类的的对象中会有什么。大部分人都会知晓,静态数据成员是属于所有对象的,不单属于某一个对象。C++ Standard
要求,在同一个access section 中,数据成员的排列只要满足较晚出现的成员具有高地址即可。也就是说排列不一定是连续的,中间可能夹杂其他东西。同时编译器为了支持一些特性,会合成一些成员插入到对象中,但是并未规定插入到哪里。
一个判读地址的函数:
template<typename class_type,typename data_type1,typename data_type2>
char& access_order(data_type1 class_type::*mem1, data_type2 class_type::* mem2){
assert(mem1 < mem2);
return mem1 < mem2 ? "mem1 occurs first \n" : "mem2 occurs occurs first \n";
}
/*Call the function */
access_order(&Point3d::y, &Point3d::z);
//now,the class type is Point3d,data_type is float .
在开始新的讨论之前,我很好奇&Point3d::y
是什么鬼?
是y在内存中的地址嘛?而且我们通常存取非静态成员是通过对象,但是这个地方却是通过域操作符,煞是奇怪。先埋个伏笔,后面会介绍到。
3.3.Data Member 的存取
Point3d origin ;origin.x=0.0;
Point3d* pt=&origin; pt->x;
上述二者之间是否存在很大差异呢?
1.Static Data Member :我们一开始就提到过,静态数据成员不在类的对象之中。但是我们却可以通过对象对其进行存取,同时我们也看过通过域操作员直接进行存取。对于第一种方式,在编译器内部会被转化成对静态成员的直接参考操作。
origin.chunkSize; 等价于 Point3d::chunkSize;
通过指针存取也是进行同等的转换操作。从指令执行的角度来看,这是C++
中唯一一种通过指针和通过一个对象对存取数据成员,结论完全相同的唯一一种情况。
如果chunkSize
是从某复杂继承体系中继承而来的成员,那么存取操作依然如此直接的,因为它独立于对象之外。
如果通过函数存取静态数据成员会是什么情况呢?
foobar().chunkSize;
在C++ Standard
中规定,foobar必须被求值,但是其值是被丢弃的。最终仍是转换为:Point3d::chunkSize;
对一个静态数据成员取地址操作获得的是其在内存中的真是地址,得到的是一个指向其数据类型的指针,不是指向其class member
的指针。原因还是静态成员不在对象之中。
&Point3d::chunkSize;
的到的指针类型是const int*;
并不是int Point3d::*;
如果两个类都声明同名的静态数据成员,那么如何区分?编译器会对静态数据成员进行编码(name-mangling)这样大家都是独一无二的,后面我们会经常遇到编码技术,所以编译器究竟做了什么真是很难知道。
name-mangling
有两个工作:1是使用一个算法推到出独一无二的名称,2是编译系统必须和使用者交谈,那些独一无二的名称可以被推到回原来的名称。
2.nonstatic data member :我们可以通过类的实例进行隐式或显式的存取(隐式指的是this指针).排除复杂继承的情况,其存取操作同结构体无大区别。
origin.y=0.0; 会转换为*(&origin+&Point3d::y-1);
关于为什么减去1,留给自己去探究吧。我只知道,指向data member 的指针其offset总是被加1。
3.4.继承与Data Member:
在C++
继承模型中,一个derived class object
所表现出的东西,是其自己的members
加上其base classes members
总和。至于派生类和基类的成员排列顺序,并未进行强制规定。大部分编译器是把base classes members
放在开头。但是遇到了vitrual
特性之后,一切就变得复杂了。
1.只要继承不要多态:
在此中情况下是比较简单的。派生类对象的内存模型符合基类数据成员+自身的数据成员。但是是否考虑过如下一点,如果基类中含有边界调整的内容,那么会被派生类继承而来嘛?但是是肯定的。简单阐释下原因,当我们用基类指针指向派生类时,为了不发生错误,所以边界调整的内容也必须要继承下来。详细的示例可以参考此书。
2.加上多态:
这种情形就不能以基本的对象成员模型进行量化了,因为编译器要合成一个vptr
成员,我们必须妥善安置这个指针。关于把vptr
放在哪里一直是编译器领域的一个主要话题。一开始的cfront
编译器是放在尾部,这样可以保留base class C struct
布局。但是后来C++
中出现新的特性,比如虚拟继承。此时有人就把指针放在头部。我们上述讨论的话题是基于如下的情况:基类中没用虚函数,但是派生类中定义了虚函数。
当我们的基类中定义了虚函数,那么虚指针可以跟随基类一起继承而来,如果派生类中定义了自己的虚函数,那么在相应的索引位置上进行替换,这个模型现在比较常见。
3.多重继承:
多重继承的情况很复杂。但是仍然满足基类的对象要被完整的继承而来。继承的顺序有一定的影响,比较复杂,感兴趣的可以自己研究。
4.虚拟继承:
偶然看过有人称为钻石模型,最近简单的例子就是C++中的ios
库的继承模型了,是典型的虚拟继承。在虚拟继承中我们知道一点,虚基类只会在派生类中存在一份实例,然而如何实现却是大难点。有的编译器是通过安插指向虚基类的指针解决问题,但是间接存取却带来了性能上的麻烦。还有一种解决方式是把信息索引存在一个指针里面。这个地方说的指针也是虚指针,但其同时包含了虚机类的偏移信息。具体的可以上图一看,感兴趣的可以细细研究。
3.5.Object Member Efficiency:
直接说结论,在优化的模式下,封装不会造成存取成本的负担。也就是说在通过函数存取对象成员抑或是通过对象直接存取,效率进化相等。但是遇到虚特性一切又变得很复杂。
3.6.Pointer to Members:
考虑如下代码,得到的应该是什么:
& Point3d::z
大部分肯定会说是地址,但是细细问下是z在内存中的地址嘛?答案是否定的,我们获得不是z
在内存中的地址,而是其在对象中的偏移位置+1bytes.你知道为何要加上一个字节嘛?这个问题在于如何区分一个没有指向任何data members
的指针,和一个指向第一个data member
的指针。考虑如下代码:
int Point3d::* p1=0;
int Point3d::* p2=&Point3d::x;
p1==p2 ?
我们如何区分p1和p2(我们假设x成员在对象的头部,即偏移位置是0)。所以CPP
的设计者决定对便宜位置加上1.不得不承认是个极好的解决办法。
下面解释下&origin.z;
和&Point3d::z
的区别。其实很容器猜到了。对第一个进行取址获得是在内存中真是地址,不再是什么偏移量之类的东西。
最后说一点,间接存取肯定会造成性能上负担。例如上文提到的,通过指向data members‘s pointer
进行存取会造成负担.int Point3d::*ax=&Point3d::x
然后我们以pa.*ax
的方式进行存取操作势必会带来极大的负担。
后记:其实最近我一直在纠结是否应该写下去,这篇文章的前半部分我是早一个星期前写下的,当时由于种种原因写了一半。当时我就放在桌面上时刻提醒自己。但是有那么些瞬间我开始怀疑自己,是否有必要写下去。因为我很清楚的意识到写的这些玩意可能效果微乎其微,但是不写却又是如鲠在咽。最后强迫症烦了,所幸写完。其实我一直期望看似无用功的东西能给我带来一点欢愉,那么我将感到很开心。哈哈,博客马上都要成为写心情的地方了。
May 3, 2015 9:16 PM
By Max