《深度探索C++对象模型》第三章:Data 语意学
sizeof 内存对齐的一些规则:
- #pragma pack(n) 预编译指令,可用来设置多少个字节对齐,n的缺省数值是按照编译器自身设置,一般为8,合法的数值分别是1、2、4、8、16,其它的无效。
- offset从0开始,每个数据成员开始存放的offset值为min(n, 数据成员大小)的整数倍。
- 在数据成员完成各自的存放之后,整个类也将进行内存对齐,其大小为min(n, 整个类中最大成员的大小)的整数倍。
如果一个类是空类,即里面无任何数据成员,那么它会有一个隐藏的1 byte 大小,那是被编译器安插进去的一个char,这使得两个objects得以在内存中配置独一无二的地址。
sizeof的大小受到三个因素的影响:
- 语言本身所造成的额外负担(vptr、vbptr(有些编译器有,也有可能会共用vptr))
- 编译器对于特殊情况所提供的优化处理(如空类)
- 内存对齐的限制
class X {};
class Y : public virtual X {};
class Z : public virtual X {};
class A : public Y, public Z {};
int main()
{
cout << sizeof(X) << endl; //1
cout << sizeof(Y) << endl; //4
cout << sizeof(A) << endl; //8
return 0;
}
-
在之前,如果编译器没有对空类 X 提供特殊的支持,那么sizeof(Y) = 4 + 1 + 3 = 8,分别为vbptr + char + 对齐。
sizeof(A) = 12(X:1, Y:4, Z:4, A:0, alignment:3) -
现在,(经测试,包括vc6.0和gcc5.1.0)对A进行了特殊的处理,empty virtual base class 被视为derived class object 最开头的一部分(即vbptr),也就是说并没有花费任何额外的空间。sizeof(Y) = 4, sizeof(A) = 8
C++ Standard 并不强制规定如“base class subobjects的排列顺序” 或 “不同层级的data members 的排列顺序” 这种琐碎的细节。它也不规定virtual functions 或 virtual base classes 的实现细节。
nonstatic data members 放置的是“个别class object”感兴趣的数据,static data members 放置的是“整个class”感兴趣的数据。
static members 放置于global data segment(全局数据区),不会影响class 的大小。它需要在class内声明,class外定义(分配内存,可以赋初值,也可以不赋初值,默认为0),这有点类似于定义一个全局变量,不然某个具体的class object不能使用这些静态变量,因为它们申请内存的时候并没有申请相应的内存放置这些静态变量。
3.1 Data Member 的绑定
问题引入:
// A third party foo.h header file
// pulled in from somewhere
extern float x;
// the programmer's Point3d.h file
class Point3d
{
public:
Point3d( float, float, float );
// question: which x is returned and set?
float X() const { return x; }
void X( float new_x ) const { x = new_x; }
// ...
private:
float x, y, z;
};
在c++最早期的编译器上,Point3d::X()中的x会被绑定为全局的x。
这也引出了早期c++的两种放于风格:
- 把所有的data members 放在class声明的起头处,以确保正确的绑定
class Point3d
{
// defensive programming style #1
// place all data first ...
float x, y, z;
public:
float X() const { return x; }
// ... etc. ...
};
- 把所有的inline functions,都放在class声明之外
class Point3d
{
public:
// defensive programming style #2
// place all inlines outside the class
Point3d();
float X() const;
void X( float ) const;
// ... etc. ...
};
inline float
Point3d::
X() const
{
return x;
}
// ... etc. ...
它们的必要性从C++2.0之后消失了。现在的函数即使(不明白为什么文中一直说inline)在声明之后马上定义,对于函数本体(本体,不包括参数列表、函数返回值)的分析也会延迟到整个class声明的结束。
extern int x;
class Point3d
{
public:
...
// analysis of function body delayed until
// closing brace of class declaration seen.
float X() const { return x; }
...
private:
float x;
...
};
// in effect, analysis is done here
对于函数的参数列表,还是会在第一次碰到它们时被决议完成,如下(经vc6.0和gcc5.1.0测试):
typedef int length;
class Point3d
{
public:
// length被决议为int,故实参的6.5被转化为6
void mumble( length val ) { _val = val; cout << _val << endl;}//输出6
length mumble() { return 6.5; } //这个length也被决议为int
// ...
private:
typedef float length;
length _val;
// ...
};
int main() {
Point3d p;
p.mumble(6.5);
cout << p.mumble() << endl;//输出6
return 0;
}
所以总是要把class内的type声明放在class的起始处
3.2 Data Member 的布局
C++ Standard 要求,在同一个access section(也就是public、protected、private区段)中,members的排列只需符合“较晚出现的members 在 class object 中有较高的地址”这一条件即可。
c++ Standard 允许编译器将多个access section 之中的 data members 自由排列,不必在乎它们出现在class声明中的顺序。
3.3 Data Member 的存取
Point3d origin, *pt = &origin;
origin.x = 0.0;
pt->x = 0.0;
这两种存取方式有什么区别?
- 当Point3d是一个子类,它的继承结构中有虚基类,并且x从该虚基类继承而来,那么就会有重大差异(假设x是静态变量)。
- 编译器在编译期不知道pt具体指向的class类型(或者说具体布局),这个存取操作就必须推迟到运行期,经由一个vbptr。
- 而origin的类型已经确定,故编译期就可知offset。
关于vbptr的一些思考:
class C {
public:
int m_c;
};
class B1 : public virtual C{
public:
int m_b1;
};
class B2 : public virtual C{
public:
int m_b2;
};
class D : public B1, public B2 {
public:
int m_d;
};
D *pD;
D d;
pD = &d;
B1 *pB1 = pD;
pB1 -> m_c;
B1 b1;
pB1 = &b1;
pB1 -> m_c;
//D这个对象中的B1分量和单独实例化一个B1对象,它的布局是不一样的。甚至,B1分量的布局还会受到之后继承关系的改变(假如D又继承了一个class,或者以更深层次的子类来得到B1分量)。那么难道在编译期通过复杂的判断来确定偏移量吗?怎么通过一劳永逸的方法来取得这个虚基类的成员变量,就是通过vbptr来取得虚基类的偏移量。
Static Data Members
static 变量被视为一个全局变量,但是只在class生命范围内可见。
通过指针或对象存取变量效果完全一样,因为变量并不在class object中,只是文法上的一种简单写法。如果变量是从一个很复杂的继承结构继承下来的,也是一样的,还是只有一个唯一实例。
若取一个static变量的地址,会得到一个指向其数据类型的指针,而不是指向其class member 的指针:
&Point3d::chunkSize; //const int*
指向类成员变量的指针示例
//指向类成员的指针: 变量类型 类名::*pointer = &类名::变量名;
class Student {
public:
Student(string n, int nu) : name(n), num(nu){}
public:
string name;
int num;
};
int main() {
string Student::*pstr1 = &Student::name;
Student s1("zhangsan1", 100);
Student* ps1 = &s1;
Student s2("zhangsan2", 100);
Student* ps2 = &s2;
cout << s1.*pstr1 << endl; //zhangsan1
cout << ps1->*pstr1 << endl; //zhangsan1
cout << s2.*pstr1 << endl; //zhangsan2
cout << ps2->*pstr1 << endl; //zhangsan2
return 0;
}
如果有两个classes,每个都声明了一个static变量x,它们都放在全局变量区,那么就会导致名称冲突,其实这个时候编译器用到了name-mangling技术。(一个方法,推导出一个独一无二的名称)
Nonstatic Data Members
nonstatic 变量存放于每一个class对象中,得经过显示的或隐式的class对象存取。
所谓的隐式,即是通过成员函数,每个成员函数都有一个this指针。(这也是成员函数和静态成员函数相区别的一个地方)
欲对一个 nonstatic 变量进行存取操作,编译器需要把class object的起始地址加上data member的偏移地址(offset),像这样:
&origin._y;
//那么地址&origin._y将等于
&origin + ( &Point3d::_y - 1 ); //现在的编译器貌似不需要再减1
每一个nonstatic变量的偏移地址在编译时期就已经确定,不管它是单一继承还是多重继承(但是不能有虚继承,它得在运行时期确定)
3.4 “继承” 与 Data Member
一个子类对象所表现出来的东西是它自己的成员变量和它的基类的成员变量的总和。至于子类成员和基类成员的排列顺序,C++ Standard并没有强制规定,在大部分编译器上面,基类成员总是先出现(虚基类除外)。
只要继承不要多态
一般而言,具体继承(相对于虚继承),并不会增加空间或存取时间上的额外负担。
把原本独立不相干的classes凑成一对"type/subtype",并带有继承关系,会有什么易犯的错误(不好的结果)?
- 重复设计一些相同操作的函数
- 可能会膨胀class的空间
膨胀class空间的一个例子:
class Concrete {
public:
// ...
private:
int val;
char c1;
char c2;
char c3;
};
//sizeof(Concrete) = 8,如果把它分裂为三层结构,
//那么sizeof(Concrete3) = 16
class Concrete1 {
public:
// ...
protected:
int val;
char bit1;
};
class Concrete2 : public Concrete1 {
public:
// ...
protected:
char bit2;
};
class Concrete3 : public Concrete2 {
public:
// ...
protected:
char bit3;
};
sizeof(Concrete3) = 16的原因:Concrete1占8个字节(对齐占3字节),Concrete2占4字节(对齐占3字节),Concrete3占4字节(对齐占三字节)。
那么能不能Concrete1的char放入之后,不对齐,后面直接放Concrete2的char和Concrete3的char呢?会产生一个问题如下:
Concrete2 *pc2;
Concrete1 *pc1_1, *pc1_2;
pc1_1 = pc2; //令pc1_1指向Concrete2对象中的Concrete1子对象
*pc1_1 = *pc1_2; //pc1_2指向的Concrete1对象赋给Concrete1子对象
//它会复制8个字节过去,那么Concrete2对象中的bit2,就会被覆盖产生了非期望值
加上多态
加上虚函数之后,会带来时间和空间上的额外负担:
- vtbl
- vptr
- 加强构造函数
- 加强析构函数(设定正确的vptr)
vptr 放class尾端
vptr 放class前端(目前编译器都是这样)
多重继承
vptr 放在class前端,如果基类没有虚函数,而子类有虚函数,那么子类转换成基类时就需要编译器的介入,用以调整地址。
对于一个多重派生对象,将其地址指定给最左端基类的指针,情况和单一继承相同,因为两者都指向相同的起始地址。如果是第二个或者更后面的基类的地址指定操作,那么就要将地址修改,加上介于中间的base class subobject(s)的大小。例子见书
虚拟继承
虚拟继承时,一个class内部被分为两个部分:一个不变区域和一个共享区域,不变区域中的数据总是拥有固定的offset,共享区域数据的位置会随每次的派生操作而有所变化,所以它们只能间接存取。
一般的策略是先安排好derived class 的不变部分,然后再建立其共享部分。
编译器存取共享区域的一些办法:
- cfront在派生类中安插一些指针,每个指针指向一个虚基类。
- 微软编译器安插一个vbptr,指向virtual base class table。
- 在virtual function table 中放置virtual base class 的 offset,共用一个指针vptr。(目前gcc好像是这种方式)
3.5 对象成员的效率
略
3.6 指向 Data Member 的指针
指向 Data Member 的指针是一个有点神秘但颇有用处的语言特性。
class Point3d {
public:
virtual ~Point3d();
// ...
protected:
static Point3d origin;
float x, y, z;
};
& Point3d::z; //将得到z在class object 中的offset
printf("%p", &Point3d::z); //c,不能用cout
& Point3d::z; //float Point3d::*
& origin.z //float*