C++【对象模型】 | 【05】类与类之间各种关系下对数据成员的存取、绑定、布局

本文深入探讨了C++对象模型,包括类继承带来的内存负担,数据成员(静态与非静态)的布局与存取,以及在不同编译器下的差异。重点讲解了虚基类、对象成员的效率和多重继承的影响,揭示了编译器如何处理这些复杂情况,并讨论了最佳实践和潜在的性能问题。
摘要由CSDN通过智能技术生成

索引

C++【对象模型】| 【01】简单了解C++对象模型的布局
C++【对象模型】|【02】构造函数何时才会被编译器自动生成?
C++【对象模型】|【03】拷贝构造是如何工作的,何时才会用到呢?
C++【对象模型】 | 【04】程序在内部被编译器如何转化?
C++【对象模型】 | 【05】类与类之间各种关系下对数据成员的存取、绑定、布局
C++【对象模型】| 【06】类中各种函数的刨析
C++【对象模型】| 【07】构造、析构、拷贝做了哪些事?
C++【对象模型】| 【08】类在执行期会处理哪些事呢?
C++【对象模型】| 【09】类模板、异常处理及执行期类型识别

1、类继承造成的负担

class X {};
class Y : public virtual X {};
class Z : public virtual X {};
class A : public Y, public Z {};

int main(int argc, char* argv[])
{
	cout << sizeof(X) << endl;
	cout << sizeof(Y) << endl;
	cout << sizeof(Z) << endl;
	cout << sizeof(A) << endl;

	return 0;
}

vc下的运行结果
在这里插入图片描述
gcc下的运行结果
在这里插入图片描述

造成以上结果的不同,是由于vc下对空类进行优化;它提供了一个virtual interface,一个空虚基类被视为子类对象的开头的一部分,这将会不
为其花费额外空间,故节省了1字节char,因此也节省了3个bytes的补齐;

为什么空类大小不为0?

当我们对空类进行查看大小的时候,一般认为空类理应为0;然后,测试结果告诉我们这是错误的;由于编译器确保这个类在内存中能配置到独一
无二的地址,故安插一个char进去;

类的大小受什么因素影响?

上述两个不同编译器的测试结果可以告诉我们,类的大小跟编译器也是有关系的;但其一般受三个因素影响:
- 语言本身所造成的额外负担,由于虚基类的支持,需要在类中增加一个指针大小;
- 编译器对特殊情况所提供的优化处理,如上述两个编译器的差异;
- Alignment的限制,一般会将数值调整到整数倍,以此来提供程序的效率;

virtual base class subobject在子类中只会存在一份实例

由于这种机制,X和Y的大小都为8,而A的大小为12而不是16;
- 由于共享的实例virtual base class subobject为X带下为1bytes;
- Z和Y减去相同的X故为4;
- 4+4+1在进行填补后为12bytes;
C++标准中并没有强制要求base class subobjects或data member的排列顺序;

2、data member

data member可以表示在class中执行的某种状态;分为一下两类:
- 非静态数据成员:对于个别类的数据,置于类内,没有强制定义顺序;
- 静态数据成员:对于整个类的数据,置于类外,没有类实例也可存在,且只有一份实例;

C++对象模型对数据成员在空间和存取上都有优化,且能够兼容struct;

3、data member的绑定

早期编译器上如果有全局的变量,在类中对于同名的数据成员进行存取操作,很可能出现指向全局变量的情况;
因此提出了两种具有防御性的程序设计风格:

第一种:

将所有的数据成员放置开头,以确保正确的绑定;
class Point3d {
    float x, y, z;
public:
    float X() const { return x; }
};

第二种

将所有的内联函数,都放置在类声明之外;
class Point3d {
    float x, y, z;
public:
    Point3d();
    float X() const;
    void X(float ) const;
};

inline float Point3d::X() const {
    return x;
}
但该风格在C++2.0后就不在使用,如果一个内联函数在类声明之后立刻被定义,则会对其评估求值;
即当该X()函数本体分析延时至class的}出现;如下
class Point3d {
public:
	float X() const { return x; }
private:
	float x;
};

注意,但对于typedef仍需使用第一种方法

typedef int length;
class Point3d {
typedef float length;	// 在此处typedef
public:
	void mumble(length val) { _val = val; }
private:
	length _val;
};

4、data member的布局

非静态数据成员在类中的排序顺序和声明一样;排序顺序只符合出现较晚的成员具有较高的地址;
- 数据成员地址并不一定连续,由于可能需要边界的调整;且可能在class内部插入vptr来支持对象模型;

5、data member的存取

静态数据成员
静态数据成员的存取不会招致空间或时间上的额外负担;
其存取方式不需要通过类对象;
若对于一个静态数据成员的地址,会得到一个指向数据类型的指针,而不是一个指向类成员的指针;

如果两个类中都有声明同名的静态数据成员,则会导致命名冲突,而编译器会对其进行编码让其成为独一无二的名称;
非静态数据成员
该成员放置于类内,一般对其进行存取都是通过成员函数来处理;

编译器如何对非静态数据成员进行存取

一般通过类的起始位置,在加上该数据成员的偏移位置(在编译时期可知),其效率和struct 成员一样;
如:origin._y ==>&origin + (&Point3d::_y - 1);
该-1操作,是由于指向数据成员的指针,offset值总是被加1;
用于区分`一个指向数据成员的指针,用以指出类的第一个成员`和`一个指向数据成员的指针,没有指出任何成员`的两种情况;

不同情况下效率

在一个struct member、class member、单一继承、多重继承下效率相同,但virtual base class下会较慢;
由于当使用pointer进行存取的时候,不知道该成员真正的offset位置,需要到执行期才确定;

6、继承与data member

在继承中,一个子类的数据成员是当前类的成员与基类成员的总和,大部分编译器让基类成员先出现;
一般具体继承相对于虚拟继承不会增加空间或存取时间上的额外负担;
class Point2d {
public:
    Point2d(float x = 0.0, float y = 0.0) : _x(x), _y(y) {}

    float x() const { return _x; }
    float y() const { return _y; }

    void x(float newX) { _x = newX; }
    void y(float newY) { _y = newY; }

    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 { return _z; }
    void z(float newZ) { _z = newZ; }
    
    void operator+=(const Point3d& rhs) {
        Point2d::operator+=(rhs);
        _z += rhs.z();
    }

protected:
    float _z;
};
将类凑成一对type/subtype常见错误
- 若经验不足,可能会出现设计了相同的操作函数,且函数没有被做成内联函数;
- 将一个类分解成多个层,导致空间膨胀;

例如

以下将数据成员拆分分布在3个类中,在使用继承的方式;由于内存进行边界调整,导致原先8bytes变成了16bytes;
class Concrete{
public:
private:
    int val;
    char c1;
    char c2;
    char c3;
};

class Concrete1 {
public:
private:
    int val;
    char c1;
};

class Concrete2 : public Concrete1 {
public:
private:
    char c2;
};

class Concrete3 : public Concrete2 {
public:
private:
    char c3;
};

加上多态

当我们不在乎是适用在一个二维或三维的实例中,我们需要在继承中提供一个虚函数接口;
当然,该做法将会给我们带来空间和时间上的额外负担:
- 类中需要导入vptr,构造函数需要能够为vptr设定初值,析构函数需要抹消vptr;
如果该函数使用该方法实现,仅仅用于二三维坐标,将没有必要的,使用虚函数会加大程序的开销;
class Point2d {
public:
    Point2d(float x = 0.0, float y = 0.0) : _x(x), _y(y) {}

    float x() const { return _x; }
    float y() const { return _y; }

    void x(float newX) { _x = newX; }
    void y(float newY) { _y = newY; }

    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 { return _z; }
    void z(float newZ) { _z = newZ; }

    void operator+=(const Point3d& rhs) {
        Point2d::operator+=(rhs);
        _z += rhs.z();
    }

protected:
    float _z;
};

vptr放在类对象中的哪里最好?

【将vptr放置在尾端】
该方式能够保留base class C struct的对象布局,可以在C中使用;
【将ptr放在头部】
C++2.0后由于支持虚拟继承以及抽象基类,故开始将vptr放置在头部,但该做法丧失了C兼容性;
多重继承
在继承中,类在基类与子类之间转换,而起始的地址都是相同的,只是子类的地址会比较大;
当一个子类转换为基类时,需要编译器的介入,来调整地址;
class Point2d {
public:
protected:
    float _x, _y;
};

class Point3d : public Point2d {
public:
protected:
    float _z;
};

class Vertex {
public:
protected:
    Vertex *next;
};

class Vertex3d : public Point3d, public Vertex {
public:
protected:
    float mumble;
};


void test() {
    Vertex3d v3d;
    Vertex *pv;
    Point2d *p2d;
    Point3d *p3d;

    pv = &v3d;
    // => pv = (Vertex*)(((char*)&v3d) + sizeof(Point3d);

    Vertex3d *pv3d;

    pv = pv3d;
    // => pv = (Vertex*)((char*)pv3d) + sizeof(Point3d); ❌
    // => pv = pv3d ? (Vertex*)((char*)pv3d) + sizeof(Point3d) : 0;
}
pv3d转换需要添加一个条件测试,为了防止当pv3d为0的时候出现错误;
虚拟继承
虚拟继承上,必须支持某形式的shared subobject继承(virtual base class subobject在子类中只会存在一份实例);

是如何实现的呢?

class如果内含一个或多个virtual base class subobjects将被分割为两个区域:
【不变区域】:其offset是固定的,该部分数据可直接被存取;
【共享区域】:表现为virtual base class subobject,会根据派生的操作而变化;
类中布局,一般是先安排好子类的不变部分,在建立共享部分;

如何存取共享部分数据

- 部分编译器会将每一个子类对象中安插一些指针,指向虚基类,用来存取虚基类中的成员;

【该实现方式的缺点】:
- 该实现会导致每个都必须针对每一个虚基类增加一个指针;
- 且会让虚拟继承串链加长,导致间接存取层次的增加;

【优化缺点】:
对于第一个问题,在Microsoft编译器引入virtual base class table,用来存储virtual base classes;
或者在virtual function table放置virtual base class的offset;
对于第二个问题,使用拷贝操作取得所有的内嵌虚基类指针,放到子类中;

7、对象成员的效率

对数据成员进行数据封装(不会带来执行期的效率成本),并使用inline函数;
一般使用get/set函数对数据成员进行存取操作;
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

Jxiepc

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值