第三章 Data语义学
#include <iostream>
class X {};
class Y : public virtual X {};
class Z : public virtual X {};
class W : public Y, public Z {};
int main() {
std::cout << "sizeof x = " << sizeof(X) << std::endl;
std::cout << "sizeof y = " << sizeof(Y) << std::endl;
std::cout << "sizeof z = " << sizeof(Z) << std::endl;
std::cout << "sizeof w = " << sizeof(W) << std::endl;
return 0;
}
//输出
sizeof x = 1
sizeof y = 8
sizeof z = 8
sizeof w = 16
X是一个空类,但是其大小为1,编译器在编译的时候会给一个空类分配一个char
大小的空间,这样X a
生成一个对象,并给对象取地址。
Y和Z虚继承X,在Y中有一个指针指向基类X,大小为4,Y本身是一个空类,所以Y的大小本身为5,根据内存对齐原则编译器会自动补齐剩下的空间大小为8。
3.1 Data Member的绑定
int x;
class Point2d {
public :
float func() { return x; }
private:
float x;
};
func()
函数返回值为float x
, 会自动屏蔽到全局的x,这是应为对于内部的成员函数,其函数成员类型的定义需要等到整个类完全声明完之后才能确定的。
但是这种情况对于参数列表例外:
typedef int length;
class Point3d {
public:
Point3d(length val) : val_(val) {} //length 的类型为int
length mumble() { return val_; }
private:
typedef float length;
int val_;
};
参数列表里面的类型会在第一次定义的时候就完成类型的选择,应为刚定义参数类型的时候typedef float length
在其下面,所以此时会选择全局的typedef int length
。要采用一种防御性的代码风格,将nested type
声明放在class
的起始处。
3. 2 Data成员布局
一个类的成员在底层的布局通常是按照声明的顺序出现,不要求每一个成员的地址都相邻,但是在类中后面出现的成员要放在高地址处,每个成员地址之间的空隙是编译器为了内存对齐自动生成的。同时编译器会自己生成vptr
放在类成员的头部。
3.3 Data成员的存取
静态数据成员的存取
一个类的静态数据成员存放在data
段,一个类无论有多少个子类或者对象都只有一个静态数据成员。对一个静态数据成员取地址获得的地址并不是其类的类型而是数据成员原本的类型。
class Point3d{
public:
static int chunksize;
};
&Point3d::chunksize; //类型为int *
如果有多个类的静态数据成员的名称相同,但是最后都放在data
段中,编译器是如何处理命名冲突的呢?
class Point3d{
public:
static int chunksize;
};
class Point2d {
public:
static int chunksize;
};
编译器会给每一个静态数据成员编码来解决命名冲突。
非静态数据成员的存取
Point3d origin, *pt = &origin;
origin.y
和pt->y
效率之间的差异:
- 如果Point3d不含有虚函数,那么二者的效率相同,在编译期就已经确定每一个类的数据类型
- 如果Point3d包含虚函数,
*pt
需要等到运行期才能知道其指向的数据类型,因为会产生动态绑定。
一个类的成员的存取origin.y
等效于&origin + (&Point3d::y - 1)
,意思是一个数据成员的存取就是类对象的其实地址再加上成员的偏移量,但是为什么要-1
?
应为这样编译器会区分出一个指向数据成员的指针,指出class
的第一个成员和一个指向数据成员的指针,没有指出任何数据成员之间的区别。
3.4 继承与Data布局
多态下的成员布局
#include <iostream>
class Point2d {
public:
Point2d(float x = 0.0, float y = 0.0) : x_(x), y_(y) {}
virtual float z() const { return 0.0; }
virtual void z(float) {}
virtual void operator+=(const Point2d& rhs) {
x_ += rhs.x_;
y_ += rhs.y_;
}
protected:
float x_, y_;
};
class Point3d : public Point2d {
public:
Point3d(float x = 0.0, float y = 0.0, float z = 0.0) : Point2d(x, y), z_(z) {};
float z() const override { return z_; }
void z(float new_z) override { z_ = new_z; }
void operator+=(const Point2d& rhs) override {
Point2d::operator+=(rhs);
z_+= rhs.z();
}
protected:
float z_;
};
class Base {
public:
virtual void func() {};
};
class Deriverd : public Base {
public:
virtual void func() override {};
};
int main(int argc, char const *argv[])
{
std::cout << "Point2d sizeof = " << sizeof(Point2d) << "\n";
std::cout << "Point3d sizeof = " << sizeof(Point3d) << "\n";
std::cout << "Base sizeof = " << sizeof(Base) << "\n";
std::cout << "Derived sizeof = " << sizeof(Deriverd) << "\n";
return 0;
}
Point2d sizeof = 16
Point3d sizeof = 24
Base sizeof = 8
Derived sizeof = 8
将虚指针放在对象前端的好处:
在旧版本中为了兼容C程序将虚指针放在了尾端,但是在支持虚函数的时候开始将虚指针放在了前端,这样做可以快速获取对象,而不用按照顺序逐步查找,但是这样做会丧失C语言的兼容性。
多重继承
class Based {
public:
virtual void func() {
std::cout << "Based func()\n";
}
};
class Based2 : public Based {
public:
//.....
};
class Based3 {
public:
virtual void func3() {
std::cout << "Based func3()\n";
}
};
class Derived : public Based2, public Based3 {
public:
virtual void func() override {
std::cout << "Derived func()\n";
}
private:
};
int main() {
Derived pd;
Based *pb1;
Based2 *pb2;
Based3 *pb3;
return 0;
}
对于多重继承,如果是pb2 = &pd
,将地址指定给最左边的基类的指针,付出的成本只有地址指定的操作。但是如果是将后面的基类的指针指向pd
,需要先移动Based
的大小,然后再进行指针的赋值。
Derived pd;
Based *pb1;
Based2 *pb2;
Based3 *pb3;
pb3 = &pd;
// 等价于下面这段
pb3 = (Based3*)(((char*)&pd) + sizeof(Based2));
但是如果是两个指针之间直接进行赋值操作是不行!,指针需要先进行一次判空操作,如果pd
是一个空指针,pb1将要获得sizeof(Based2)
的值。
Derived *pd;
Based3 *pb1;
pb1 = pd ? (Based3*)(((char*)&pd) + sizeof(Based2)) : 0
对于多重继承,如果一个类有N个基类,N个基类都有虚指针,那么这个类也有N个虚指针。
虚拟继承
为了解决菱形继承产生的数据冗余问题可以采用虚拟继承来解决,虚拟继承底层的数据布局采用什么方法实现?
通过在虚函数表里面增加一个offset
来指向虚基类的成员的位置,如果偏移量<0
,表明指针指向的是函数成员,如果偏移量是整数表明指向的是虚函数。