第三章-数据语意学

第三章-数据语意学,第1张

第三章-数据语意学

文章目录
    • 数据成员的绑定
    • 数据成员的布局
    • 数据成员的存取
      • 静态数据成员
      • 非静态数据成员
    • 继承与数据成员
      • 继承且没多态的情况
      • 加上多态
      • 多重继承
      • 虚拟继承
    • 指向数据成员的指针

数据成员的绑定

看个问题:

extern float x;
class Point3d
{
public:
	Point3d(float, float, float);
	float X() const { return X; }
	void X(float new_x) const { x = new_x; }
	//...
private:
	float x, y, z;
};

Point3d::X()到底传回哪个x,类内部的还是第一行的那个?现在肯定很多人说类内部的。确实,的确是类内部,但以前不一定,为了避免这种情况以前会有这种防御式代码风格:

  1. 把数据成员都在类的开头声明,来保证正确的绑定:
class Point3d
{
private:
	float x, y, z;
public:
	float X() const { return X; }
	//...
};
  1. 把所有的内联函数定义都放在类声明外:
class Point3d
{
public:
	Point3d(float, float, float);
	float X() const;
	void X(float new_x) const;
	//...
};
//在外面定义
inline float Point3d:: X() const
{
	return X;
}
//...

但是现在对成员函数本身的分析会直到整个类的声明都出现才开始,所以一个内联函数体内的数据成员绑定 *** 作会在整个类声明后才开始,所以现在:

extern int x;
class Point3d
{
public:
	//现在对函数体的分析会延迟到类的右大括号后
	float X() const { return x; }
	//...
private:
	float x;
};
//现在在这里分析,杜绝了之前的问题

可惜成员函数的参数列表不能幸免于难,参数列表的名称会被第一次遇到的决定:

typedef int length;
class Point3d
{
public:
	//这里两个length都被决策为int
	void mumble(length val) { _val = val; }
	length mumble() { return _val; }
	//...
private:
	typedef float length;
	//这里length的出现导致之前的 *** 作被视为非法
	length _val;
	//...
};

这里就必须要用防御性编程风格了,所以最好把typedef放在类中的起始处。

数据成员的布局

C++标准中同一个访问域中成员的排列只要符合“较晚出现的成员在对象中有较高的地址”这一点即可。所以成员并不一定连续排列,中间可以插入些什么东西,比如边界对齐填补的内存等等。编译器还会合成一些内部使用的数据成员,比如vptr。vptr一般会放在所有明确声明的成员的最后,不过也有编译器放在之前的

数据成员的存取

先留下一个问题,过会会解答:

Point3d origin, *pt = &origin;
origin.x = 0.0;
pt->x = 0.0;
//问:这两种方式有什么区别?
静态数据成员

静态数据成员会被视为在class内可见的全局变量。每个静态数据成员都只有一个实体,存放在程序的数据段中,因为静态成员并不在类对象中,所以存取静态成员不需要通过类对象进行。
取一个静态数据成员的地址会得到一个指向其数据类型的指针,而不是一个指向其类成员的指针,还是那句话:静态成员并不在一个类对象中:

//chunkSize是static const int
&Point3d::chunkSize;
//会得到一个const int*

如果有两个类,都声明了一个同名的静态数据成员,如果都放在一个程序的数据段中就会导致命名冲突。编译器的解决办法是暗中对每个静态数据成员编码。

非静态数据成员

非静态数据成员直接存放在对象中,没有办法像静态成员那样直接存取。程序员在成员函数中处理非静态数据成员时就会有个隐含的对象(this指针)来 *** 作它们。
编译器对非静态数据成员 *** 作,就会在类对象的起始地址上加上数据成员的偏移量:

origin._y = 0.0;
//这里&origin._y就等于&origin + (&Point3d::_y - 1)

为什么要-1呢?因为指向数据成员的指针都要加上1,用来让编译系统区分指向数据成员的指针是真指向了数据成员还是指向了NULL。
回到开头的问题,这里带来解答:

Point3d origin, *pt = &origin;
origin.x = 0.0;
pt->x = 0.0;
//问:这两种方式有什么区别?

如果Point3d是一个派生类,而在其继承结构中有一个虚基类,且x是从该虚基类中继承来的成员时,就会有重大差异。这时候我们就不知道pt指向哪种类类型,不知道加多少偏移量,所以存取 *** 作就要延迟到执行期。而使用origin就不会有这个问题

继承与数据成员

C++一个继承类对象表现出来的东西是其自己的对象加上基类的对象的总和。而两个类各自成员的排列次序没有规定,可以自由安排。但是大部分编译器一般基类的成员先于派生类的出现。
如果有2D类和3D类:

class Point2d
{
public:
	//...
private:
	float x, y;
};
class Point3d
{
public:
	//...
private:
	float x, y, z;
};

这两个分别写的类和3d继承2d、2d继承1d的有继承关系的类有什么不同?接下来会分情况探讨这个问题。

继承且没多态的情况

因为程序员想不论是2D还是3D对象,既能共享同一个实体,又能继续使用与类型性质相关的实体。于是出现了继承,3d继承了2d,就可以共享数据本身和数据的处理方法。一般继承并不会增加空间或存取时间上的负担。

class Point2d
{
	Point2d(float x = 0.0, float y = 0.0) : _x(x), _y(y) {}
	float x() { return _x; }
	float y() { return _y; }
	void setX(float newX) { _x = newX; }
	void setY(float newY) { _y = newY; }
	void operator+=(const Point2d &rhs)
	{
		_x += rhs.x();
		_y += rhs.y();
	}
protected:
	float _x, _y;
};
class Point3d : public Point2d
{
public:
	Point2d(float x = 0.0, float y = 0.0, float z = 0.0) : Point2d(x, y), _z(z) {}
	float z() { return _z; }
	void setZ(float newZ) { _z = newZ; }
	void operator+=(const Point3d &rhs)
	{
		Point2d::operator+=(rhs);
		_z += rhs.z();
	}
protected:
	float _z;
};

这样设计的好处就是x、y的代码和管理的代码都在2d中,z的代码在3d中,有很好的局部性。还可以表现出两个类之间紧密的联系。但是也有缺点:

  1. 经验不足的人可能会设计一些 *** 作相同的重复 *** 作。比如上面的构造函数和重载的+=,在2d中没有被做为inline函数,而在3d中又用到了2d的这两个函数。所以选择哪些函数作为inline函数非常重要
  2. 把一个类分为多层有可能会膨胀所需空间

具体讲讲第二点:
假设有个类

class Concrete
{
public:
	//...
private:
	int val; //占用4B
	char c1, c2, c3; //总共占用3B
}; //最后对齐成8B再加上1B
//现在把这一个类分为3个,形成一条继承链
class Concrete1
{
	//...
private:
	int val;
	char c1;
};
class Concrete2 : public Concrete1
{
	//...
private:
	char c2;
};
class Concrete3 : public Concrete2
{
	//...
private:
	char c3;
};

可以猜猜上面这个Concrete3总共多大?还是和分层之前的Concrete一样8B吗?事实是Concrete3对象有16B,因为Concrete1一共5B,对齐变成了8B;猜猜Concrete2多大?会是继承Concrete1的5B然后加上自己的1B,再为了对齐填补2B吗?不不,Concrete2直接继承Concrete1对齐后的8B!再8B后面加上自己的1B,然后再对齐成12B!同样的原因Concrete3变成了16B。
为什么要有上面这种对齐 *** 作?肯定有人要问

加上多态
class Point2d
{
	Point2d(float x = 0.0, float y = 0.0) : _x(x), _y(y) {}
	float x() { return _x; }
	float y() { return _y; }
	void setX(float newX) { _x = newX; }
	void setY(float newY) { _y = newY; }

	virtual float z() { return 0.0; }
	virtual void setZ(float) {}
	
	virtual void operator+=(const Point2d &rhs)
	{
		_x += rhs.x();
		_y += rhs.y();
	}
protected:
	float _x, _y;
};

这里加上一些虚函数,得以实现多态性质,比如现在可以给2d引用形参传递3d实参实现三个坐标的加减等等。可惜实现这种 *** 作是要付出代价的,我们给Point2d带来了额外的空间和时间负担:

  1. 出现一个vtbl存放所有的虚函数,还加上了一两个槽来支持RTTI
  2. 在每个类对象中导入了vptr,用以指向自己对应的vtbl
  3. 扩展了构造函数,使得为vptr设定初始值来指向正确的vtbl
  4. 扩展了析构函数,使得能够析构vptr

还有个vptr放在哪个位置的问题,主要分为放在对象首部和尾部。vptr放在尾部可以保留C语言兼容性,所以在C代码中也能使用。(3.2a中左边类没有虚函数,右边有虚函数,所以有vptr)

后来虚继承以及抽象基类的出现使得vptr放在对象起始处,这样在多重继承下通过指向成员的指针调用虚函数会更加方便。代价就是丧失了向C语言的兼容

3.3显示了派生类继承基类的vptr后的布局,这里vptr是放在尾部的。

多重继承

单一的继承提供一种自然多态形式,上图3.2a和3.3可以看出基类和派生类的对象都从同一个地址开始,也就是说如果让基类指针指向一个派生类,编译器并不需要去修改地址,可以很自然的发生。
但是再看3.2b,派生类有vptr,还放在对象的起始处,且基类没有虚函数(没有vptr),这时候就不在同一个地址开始了,打破了自然多态。此时把一个派生类转换为一个基类类型,就需要编译器调整地址

class Point2d
{
public:
	//...有虚函数,所以有vptr
protected:
	float _x, _y;
};
class Point3d : public Point2d
{
public:
	//...
protected:
	float _z;
};
class Vertex
{
public:
	//...有虚函数,所以有vptr
protected:
	Vertex *next;
};
class Vertex3d : public Point3d, public Vertex
{
public:
	//...
protected:
	float mumble;
};

对一个多重派生对象,将其地址指定给继承列表中最左边的基类(上面代码中的Point3d),而想指定继承列表之后的基类,就需要将地址修改为:加上介于中间的基类对象大小:

Vertex3d v3d;
Vertex *pv;
Point2d *p2d;
Point3d *p3d;

pv = &v3d;
//这个赋值 *** 作在内部会被转化成这样:
//pv = (Vertex*)(((char*)&v3d) + sizeof(Point3d));

因为多重派生对象地址和最左边基类地址一致,所以存取最左边基类的成员不需要付出额外成本,那么存取后面基类的成员呢?实际也不需要,成员的位置在编译时就确定了,所以存取它们只需要一个位移运算即可。

虚拟继承

待理解

指向数据成员的指针
class Point3d
{
public:
	virtual ~Point3d();
	//...
private:
	static Point3d origin;
	float x, y, z;
};

现在一个Point3d对象中有三个成员x、y、z和一个vptr,静态成员origin放在对象外,vptr可能放在对象开头也可能放在尾部。取一个数据成员的地址是有什么含义呢?

&Point3d::z;

上面这句的作用是取得z在类对象中的偏移量,至少得之x和y的大小总和,因为不知道vptr在头还是尾,所以上面那句获得的值要么是8要么是12(32位中vptr是4,float也是4)。实际上真去取数据成员的地址得出的值总会多1,以前说过的,为了区分指针到底有没有指向到数据成员上。所以在真正使用该值时要先减去1。
现在就很好分辨&Point3d::z和&origin.z了,前者会得到在类中的偏移值,后者会得到一个绑定在对象身上的数据成员的地址,是该成员在内存中的真正地址。后者减去z的偏移值再加1就会得到origin的起始地址
考虑下面这种情况:

class base1 { int val1; };
class base2 { int val2; };
class Derived : base1, base2 { ... };
void func1(int *dmp, Derived *pd)
{
	//期望第一个参数是指向一个Derived成员的指针
	pd->*dmp;
}
void func2(Derived *pd)
{
	int *bmp = &base2::val2; //现在bmp == (0 + 1)即1
	func1(bmp, pd);
	//这里传的却是指向基类成员的指针
	//在func1中pd->*dmp将是val1!
	//val2在(4 + 1)即5处!
}

要解决上面这个问题,调用func1必须传入func1(bmp + sizeof(base1), pd)

欢迎分享,转载请注明来源:内存溢出

原文地址: http://outofmemory.cn/zaji/4751870.html

(0)
打赏 微信扫一扫 微信扫一扫 支付宝扫一扫 支付宝扫一扫
上一篇 2022-11-08
下一篇 2022-11-08

发表评论

登录后才能评论

评论列表(0条)

保存