C++对象模型
来源:互联网 发布:霍华德体测数据 编辑:程序博客网 时间:2024/05/16 07:02
C++对象模型学习笔记
作者:钟声
所有权利均由作者保留
学习C++对象模型无疑是一个烦琐和枯燥的事情。在《Inside The C++ Object Model》书上讲的,跟cl(我的版本是14.0),跟g++(我的版本是3.4.5,MinGW special)都有很多不一样的地方,因为C++标准给了C++编译器很大的自由发挥的空间,而书上讲的大多都只是cfont2.0的实现方法。有很多 cl和g++的具体实现,都是自己经过很多次实验才得出的结论。其实结论远不是最重要的,在我看来,在追求结论的过程中使用的方法和手段,才是真正重要的东西。现在我我用的方法都记下来,备忘。如果这篇文章又被我可爱的小学弟拿到《软件时空》上面发表的话,希望发现了我文章中错误的朋友们能及时帮我指出来,以免我误入歧途,越陷越深。我的邮箱是nicky_zs@163.com,谢谢。
注:在全文中,cl指Microsoft (R) 32-bit C/C++ Optimizing Compiler Version 14.00.50727.42 for 80x86,g++指g++ (GCC) 3.4.5 (mingw special)。所有的程序都是在cl的Release模式下运行的。
在正文开始之前,我先在这里声明一个函数:
/* 这个函数的主要作用是,从给定的地址ptr开始,把ptr指向的内容解释为int类型并打印出来,一直打印n个。*/
void showInt(void *ptr, int n)
{
int size = n < 4 ? 1 : n / 4; //多数类都4字节对齐,不需要深究size大小
cout << size << " words: ";
int *p = (int *)ptr;
for (int i = 0; i != size; ++i)
cout << *p++ << " ";
cout << endl;
}
目录
C++对象模型学习笔记
第1章 关于数据成员
1.1 单个类
1.1.1 没有虚函数存在
1.1.2 如何绑定成员到正确的内存地址
1.1.3 有虚函数存在
1.2 单一继承
1.2.1 没有虚函数存在
1.2.2 使用基类指针绑定成员到正确的内存地址
1.2.3 有虚函数存在
1.2.4 对齐带来的问题
1.3 多重继承
1.3.1 没有虚函数存在
1.3.2 有虚函数存在
1.4 虚拟继承
1.4.1 没有虚函数存在
1.4.2 对象布局的问题
1.4.3 有虚函数存在
1.5 指向数据成员的指针
1.5.1 在非虚拟继承下
1.5.2 在虚拟继承下
第2章 关于成员函数
第1章 关于数据成员
C++最开始被Bjarne酝酿出来的时候,也就是cfront1.0的时候,C++主要的目的还只是“C with classes”。即仅仅对C语言中的数据做上一层封装,并以此来支持面向对象的编程范型。
1.1 单个类
Lippman的"Inside The C++ Object Model"一书中,以Point3d这个类作为例子,我觉得很好,借鉴之。为方便,我将所有的数据都设置为int类型。
1.1.1 没有虚函数存在
class Point3d {
public:
Point3d(int x, int y, int z) : x(x), y(y), z(z) {}
private:
int x, y, z;
};
这个类对3个int型的整数做了一次封装。有很多人认为,class对于数据的封装是需要一些额外的开销的,然而Lippman却说得非常清楚:没有,一点都没有。对于这个Point3d类,它所做的所有工作,只是把x, y, z这三个int型的整数放在了一起,即一块连续的内存区域中。例证:
Point3d p3(12, 24, 36);
showInt(&p3, sizeof p3);
cl和g++上的打印的结果为:
3 words: 12 24 36
这个Point3d类的结构可以这样来表示:
int Point3d::x
int Point3d::y
int Point3d::z
注:在这样的图表中,我在每一个成员之前都加上了类名和作用域限定符。在一个继承体系中,这样表示也许会不太精确,但我的意思只是想表达每一个成员都是源自于哪个类的。凡是后面出现这样的表示,都是这个意思。
可以很清楚地看到,这样的内存结构,跟直接定义三个整形变量没有什么区别。在C++标准中保证,在同一个access level(public、private、protected)声明的数据,会在内存中以声明的先后次序排列。不过,在cl和g++中似乎都做到了,对于类中所有的数据成员,在内存中都按它们声明的次序排列,而不仅仅只限于同一个access level。
1.1.2 如何绑定成员到正确的内存地址
现在,我们知道了Point3d这个类的成员在该类的对象中是如何布局的了。有一个问题,当我要访问一个成员的时候,编译器如何寻找到正确的地址?如果现在有这样一个函数,用于比较两个点的z坐标是否相同:
//假设这个函数已经被声明为Point3d类的友元
//比较两个Point3d对象的z坐标是否相同
bool HasTheSameZ(const Point3d &p1, const Point3d &p2) {
return p1.z == p2.z;
}
那么,编译器生成的汇编代码中,会比较内存中哪两个位置的值?
对C语言比较了解的人应该知道,编译器在遇到这样的语句时,会跟据p1和p2这两个对象的首地址,以及成员z在对象布局中距离首地址的偏移量(offset)来得到这个“.z”所代表的正确的地址。C++也一样:
//伪C++代码
return *(int *)((char *)&p1 + sizeof(int) * 2) == *(int *)((char *)&p2 + sizeof(int) * 2);
这样,便很自然地完成了这一次成员的调用工作。
1.1.3 有虚函数存在
现在在这个class中加入virtual函数,那么情况会变得不同:
class Point3d {
public:
Point3d(int x, int y, int z) : x(x), y(y), z(z) {}
virtual ~Point3d() {}
private:
int x, y, z;
};
这是使用上面的打印代码:
Point3d p3(12, 24, 36);
showInt(&p3, sizeof p3);
在我的电脑上cl的打印结果为:
4 words: 4202832 12 24 36
这时的Point3d结构是这个样子:
__vptr__Point3d
int Point3d::x
int Point3d::y
int Point3d::z
在整个对象的最前面多出了一个4202832,这也就是我们常说的虚指针(vptr)。这个vptr指向了一个虚函数表(vtbl),而在这个虚函数表中,保存着这个类中所有虚函数的入口地址。这个以后再说。
Lippman的书上说,这个vptr在很多编译器中都放在对象的最后4个bytes中。然而,在cl和g++中,这个vptr都被安排在最开始的位置。回想一下上一节所说的成员绑定的问题,这样做便使得这样的class失去了跟C语言中的struct兼容的能力,因为这样的class中,每一个成员的offset都跟struct中的情况不一样了(多了一个sizeof(__vptr__Point3d))。但是这样做也有好处,这里暂不讨论。
1.2 单一继承
1.2.1 没有虚函数存在
继承是面向对象的一大基石。现在我们用继承体系来重写这个Point3d。
class Point {
public:
Point(int x) : x(x) {}
private:
int x;
};
class Point2d : public Point {
public:
Point2d(int x, int y) : Point(x), y(y) {}
private:
int y;
};
class Point3d : public Point2d {
public:
Point3d(int x, int y, int z) : Point2d(x, y), z(z) {}
private:
int z;
};
现在的这个Point3d类很显然跟1.1中的那个Point3d类有着明显的区别。1.1中的Point3d只是很简单地将3个整型变量封装在一个class中,而这里的Point3d确是经过两次继承而得来。
它们有结构上的区别吗?我们再来试验一下:
Point p1(12);
Point2d p2(12, 24);
Point3d p3(12, 24, 36);
showInt(&p1, sizeof p1);
showInt(&p2, sizeof p2);
showInt(&p3, sizeof p3);
在我的电脑上打印结果为:
1 words: 12
2 words: 12 24
3 words: 12 24 36
这三个对象的结构如下:
Point:
int Point::x
Point2d:
int Point::x
int Point2d::y
Point3d:
int Point::x
int Point2d::y
int Point3d::z
可以看出,现在的Point3d虽然是由继承得来的,但实际上它跟第1.1.1节中的那个Point3d在结构上没太大的区别。第1小节中的那个Point3d对象直接由3个int数据构成,而这里的Point3d却是由一个Point2d子对象和自己的一个int数据z构成,而这个Point2d子对象又是由一个Point的子对象和自己的一个int数据y构成,而这个Point子对象就只有一个int数据x。
1.2.2 使用基类指针绑定成员到正确的内存地址
注意到,每一个派生类的对象中的基类子对象,都被放在派生类对象中最开始的位置。这样安排是有好处的。考虑这样的代码:
Point *p = new Point3d(12, 24, 36);
这句代码让一个指向Point类的指针指向了一个Point3d的派生类对象。从语法的角度来讲,这样做是没问题的——因为Point3d对象中必然会有一个Point子对象,所以这个指针其实是被绑定到了这个子对象上面。而每个子对象都在这个派生对象的开头,即这个子对象的地址跟这个派生对象的地址一样,这样就使得这个绑定不用做任何地址上的转换。
另外,再看一下成员比较的那个函数,这次我们用一个绑定到基类对象上的引用来做比较。注意到这个引用在实际使用的过程中,很有可能会被绑定到从这个基类派生出的派生类的一个对象上面。我们需要保证无论怎样绑定,都能得到正确的结果:
//假设它已经是Point类的友元
//这次比较的是成员x,因为基类中只有x
bool HasTheSameX(const Point &p1, const Point &p2) {
return p1.x == p2.x;
}
那么,在这种继承布局之下,无论这个指向基类的指针p被绑定在了哪一层、哪一个派生类对象上,这个函数中的return语句都可以被翻译成相同的样子:
//伪C++代码
return *(int *)((char *)&p1 + 0) == *(int *)((char *)&p2 + 0);
原因很简单,不同的派生类中,offset都跟基类是一样的。
1.2.3 有虚函数存在
现在再来考虑当class中含有virtual函数的情况。有两种情况,其一是virtual函数从基类开始,一直存在于整个继承体系中。我们先在Point类中加入一个virtual函数。
class Point {
public:
Point(int x) : x(x) {}
virtual ~Point() {}
private:
int x;
};
class Point2d : public Point {
public:
Point2d(int x, int y) : Point(x), y(y) {}
private:
int y;
};
class Point3d : public Point2d {
public:
Point3d(int x, int y, int z) : Point2d(x, y), z(z) {}
private:
int z;
};
依然用这段代码试验一下:
Point p1(12);
Point2d p2(12, 24);
Point3d p3(12, 24, 36);
showInt(&p1, sizeof p1);
showInt(&p2, sizeof p2);
showInt(&p3, sizeof p3);
在我的电脑上cl的打印结果为:
2 words: 4202832 12
3 words: 4202840 12 24
4 words: 4202848 12 24 36
这时的类结构变成了这样:
Point:
__vptr__Point
int Point::x
Point2d:
__vptr__Point2d
int Point::x
int Point::y
Point3d:
__vptr__Point3d
int Point::x
int Point2d::y
int Point3d::z
现在的类结构,跟没有加入virtual函数之前,发生了一点变化,即在每一个对象的开头,都被安插了一个vptr。每一个vptr的值当然都互不相同,因为它们都指向了各自所属的class的vtable。
现在再来考虑第二种情况。virtual函数不是从基类开始一直存在的,而是后来被加入这个继承体系之中的。我们把virtual函数放在Point2d中。
class Point {
public:
Point(int x) : x(x) {}
private:
int x;
};
class Point2d : public Point {
public:
Point2d(int x, int y) : Point(x), y(y) {}
virtual ~Point2d() {}
private:
int y;
};
class Point3d : public Point2d {
public:
Point3d(int x, int y, int z) : Point2d(x, y), z(z) {}
private:
int z;
};
还是用这段代码来试验:
Point p1(12);
Point2d p2(12, 24);
Point3d p3(12, 24, 36);
showInt(&p1, sizeof p1);
showInt(&p2, sizeof p2);
showInt(&p3, sizeof p3);
在我的电脑上cl的打印结果为:
1 words: 12
3 words: 4202832 12 24
4 words: 4202840 12 24 36
这时的类结构变成了这样:
Point:
int Point::x
Point2d:
__vptr__Point2d
int Point::x
int Point2d::y
Point3d:
__vptr__Point3d
int Point::x
int Point2d::y
int Point3d::z
由于Point类中没有virtual函数,所以Point对象中没有被安插一个vptr。但是,由于Point2d中出现了virtual函数,所以Point2d对象中被安插进一个vptr,而且这个vptr被安插在了对象结构的最开头。
这样就出现了跟先前的结论相矛盾的现象:Point2d中的Point子对象没有出现在Point2d对象的开头。这样做会出现什么问题?很显然,由于Point2d中,Point子对象的地址现在已经跟Point2d这个对象的地址不一样了,所以使用这样的代码时会做出调整:
Point2d p2(12, 24);
Point *pp = &p2;
代码证明一下:
cout << “&p2 = “ << &p2 << endl
<< “ pp = “ << pp << endl;
在我电脑上cl运行结果为:
&p2 = 0013FF4C
pp = 0013FF50
刚好,pp的值比&p2的值大了4,一个双字的长度,也就是Point2d中那个vptr的大小。即pp并不指向p2对象的开始地址,而是指向p2对象中,Point子对象开始的地址。故,一旦出现这样的指针赋值情况,编译器就会产生专门的代码在运行时做这件事:
//伪代码
pp = (&p2) ? (Point *)((char *)&p2 + sizeof(__vptr__Point2d)) : 0;
记得检查NULL指针,NULL指针是不能被变成4的!
经过这样的转换,不仅使得语法上没有错误(基类的指针始终指在基类对象上面),同时也使得前面所说过的成员绑定策略仍然是可靠的。
当然,你也许认为,除了在这两个地方以外,还可以在其他的地方加上virtual函数,甚至在这三个类中都加上virtual函数。继承体系中,那些类的结构还会有别的变化吗?答案是,在单一继承的情况下,没什么别的变化了。一个对象中,始终只会有一个vptr(如果应该有的话),并且被放在最顶端;其他的成员按序排列。
1.2.4 对齐带来的问题
最后,再顺带提一下关于对齐的问题。为了追求读取类(结构)成员的速度和效率,编译器往往对这些成员采取了对齐的措施,即要求这个类(结构)中所有成员的地址的值必须是某个值k的倍数。(《深入理解计算机系统》)这个k值通常是4。那么这就意味着,当一个类中的成员即有int型,也有char型的时候,这个char型成员也有可能占用4个字节。比如这样一个类:
class A1 {
int i;
char c;
}
那么它的结构将会是这样(本节的图尺寸稍大于别图,以便说明):
int A1::i
char A1::c
padding
padding
padding
成员i由于是int整型,它理所当然占用了4个字节;然而成员c虽然只是一个小小的char,但由于这个类中有一个int型成员,需要对齐,所以c也被分配到了4个字节。
如果再往A中加入一个char成员呢?
先这样加试试:
class A2 {
int i;
char c;
char d;
};
那么由于c和d都只占用1个字节,所以它们可以用共一个对齐:
int A2::i
char A2::c
char A2::d
padding
padding
但如果这样加:
class A3 {
char d;
int i;
char c;
};
那么结构将会是这个样子:
char A3::d
padding
padding
padding
int A3::i
char A3::c
padding
padding
padding
印象中,好像在很久以前,如果发生这种布局,那么C编译器会将struct中的char d调整一下位置,让它放在char c的后面。然而在现在C++标准的规定下,编译器不会再自作聪明地做这些调整了。一切以程序员为准。C++的语言哲学也就是充分相信程序员的思想和能力。
那么,再看一下这种继承:
class A4 {
int i;
char c;
};
class A5 : public A4 {
char d;
};
初看,这个A5的结构,应该与上面的A2的结构相仿才对。因为A5中的A4子对象可以跟A5中的char类型成员d共享一个对齐。然而,事实却不是这样,A5的结构是这样子的:
int A4::i
char A4::c
padding
padding
padding
char A5::d
padding
padding
padding
为什么会是这个样子?因为A5中必须包含一个完整的A4子对象。在C++标准中,允许将一个A4(基类)的对象复制给一个A5(派生类)的对象,复制的时候会使得A5对象中的A4子对象与复制源一样。如果将A5做成类似于A2那样的结构,那么在bit-wise复制的时候,必然会使得A5中的成员d拥有了一个错误的,来自于A4中用于对齐的那块内存的值!
这里,可以小小总结一下继承的情况,那就是在派生类对象中,任何基类子对象必定都是跟真正的基类对象是一样完整的。因为只有这样,一个指向基类的指针才有可能正确地指向一个派生类的对象——这在语法上是允许的,并且也是多态的基础。
1.3 多重继承
多重继承是C++的面向对象建模能力中非常受争议的一项能力,因为它会引起很多问题。Java语言就已经去掉了这一项能力。那么,C++中的多重继承是什么样子的呢?
1.3.1 没有虚函数存在
多重继承至少涉及到两个基类。在这里,先考虑不带virtual函数的情况。那么在这个新的多重继承体系中,一是保留1.2.1节中的不带virtual函数的Point3d继承体系不变,让Point3d作为基类之一;另外,再引入一个基类Colorful,然后派生出ColorfulPoint3d这个类。
继承体系如下:
class Colorful {
public:
Colorful(int c) : color(c) {}
private:
int color;
};
class ColorfulPoint3d : public Point3d, public Colorful {
public:
ColorfulPoint3d(int x, int y, int z, int c, int m)
: Point3d(x, y, z), Colorful(c), mark(m) {}
private:
int mark; //作标记用
}
观察结构的代码如下:
Point p1(12);
Point2d p2(12, 24);
Point3d p3(12, 24, 36);
Colorful c1(77);
ColorfulPoint3d c3(12, 24, 36, 77, 99);
showInt(&p1, sizeof p1);
showInt(&p2, sizeof p2);
showInt(&p3, sizeof p3);
showInt(&c1, sizeof c1);
showInt(&c3, sizeof c3);
在我的电脑上cl和g++的运行结果为:
1 words: 12
2 words: 12 24
3 words: 12 24 36
1 words: 77
5 words: 12 24 36 77 99
显然,这个ColorfulPoint3d的结构是这样的:
int Point3d::x
int Point3d::y
int Point3d::z
int Colorful::color
int ColorfulPoint3d::mark
其中,前3个words(x,y,z)是继承自Point3d,第4个int(color)是继承自Colorful,最后1个int(mark)属于ColorfulPoint3d自己。
注意到,跟1.2.2节中讲的一样,在多重继承的情况下,让一个指向基类的指针指向这个类对象的时候,如果这个基类在该派生类中的子对象不占有起始位置,那么这个指针也会被调整。
Colorful *pc;
pc = &c3;
会被扩展成:
//伪C++代码
pc = &c3 ? (Colorful *)((char *)&c3 + sizeof(Point3d)) : 0;
这一切似乎都十分自然,既保证了指针的正确语义,又保证了成员绑定的正确性。但加上虚函数之后呢?
1.3.2 有虚函数存在
没有虚函数,一切都是那么的自然并且简单。但是,虚函数是为了实现多态,而多态也是面向对象不可或缺的东西:我们需要虚函数。
现在,我们拿有虚函数存在的Point3d类作为多重继承体系中的一个基类,并且使用也有虚函数存在的Colorful类作为多重继承体系中的另一个基类。为求简便,Point3d类直接包含x,y,z三个成员:
class Point3d {
public:
Point3d(int x, int y, int z) : x(x), y(y), z(z) {}
virtual ~Point3d() {}
private:
int x, y, z;
};
class Colorful {
public:
Colorful(int c) : color(c) {}
virtual ~Colorful() {}
private:
int color;
};
class ColorfulPoint3d : public Point3d, public Colorful {
public:
ColorfulPoint3d(int x, int y, int z, int c, int m)
: Point3d(x, y, z), Colorful(c), mark(m) {}
virtual ~ColorfulPoint3d() {}
private:
int mark;
};
然后还是用这样的代码来打印我们想看到的:
Point3d p3(12, 24, 36);
Colorful c1(77);
ColorfulPoint3d c3(12, 24, 36, 77, 99);
showInt(&p3, sizeof p3);
showInt(&c1, sizeof c1);
showInt(&c3, sizeof c3);
在我的电脑上cl的结果是这样:
4 words: 4202828 12 24 36
2 words: 4202836 77
7 words: 4202844 12 24 36 4202852 77 99
我们已经知道了Point3d和Colorful这两个class的对象的结构,那么,不难看出ColorfulPoint3d这个类经继承得到了这样的结构:
__vptr__Point3d
int Point3d::x
int Point3d::y
int Point3d::z
__vptr__Colorful
int Colorful::color
int ColorfulPoint3d::mark
这就是7个words的内容。
注意到,Point3d中的vptr,Colorful中的vptr,以及ColorfulPoint3d中的两个vptr,它们四者的值都不是一样的。前两个不一样是肯定的,不同类的vtable的地址肯定不一样。而后面两个,即在ColorfulPoint3d中,两个子对象也各自拥有一个不同的vtable。
在最开始学习的时候不禁有此疑问,一个类中的virtual方法都是固定的,为什么一个类会有两个vtable呢?
其实答案很简单。回想一下之前所说的,如果让一个指向基类的指针指向这个派生类对象的话,这个基类的指针只能指向这个派生类中该基类的子对象,这是up-cast的原理。所以,如果基类对象中确实有一个vptr,而在派生类的对象中将它抛弃,这显然会带来转型上的问题。其他的原因在后面再解释。
在ColorfulPoint3d这个类中是否有virtual函数,是不会影响到整个class布局的(原因后面解释)。但是,如果在两个基类中,有一个基类没有virtual函数的话,整个布局就不同了。一个方面,如果位于ColorfulPoint3d的继承列表的非第一个基类没有virtual函数,则结果大家可以想象得到,那就是在ColorfulPoint3d的对象结构中会少掉这一个vptr。另一个方面,如果是位于ColorfulPoint3d的继承列表的第一个基类没有virtual函数,则结果就不会仅仅是在ColorfulPoint3d的对象结构中少掉这一个vptr了。因为cl和g++都会保证对象如果有vptr的话,这个vptr都会在对象的首地址处,因此,一旦继承列表的第一个基类没有virtual函数,而第二个有的话,编译器就会悄悄地把第一个基类跟第二个基类的位置互换过来:
class Point3d {
public:
Point3d(int x, int y, int z) : x(x), y(y), z(z) {}
//virtual ~Point3d() {} //这里的virtual函数被注释掉了
private:
int x, y, z;
};
class Colorful {
public:
Ciolorful(int c) : color(c) {}
virtual ~Colorful() {}
private:
int color;
};
class ColorfulPoint3d : public Point3d, public Colorful {
public:
ColorfulPoint3d(int x, int y, int z, int c, int m)
: Point3d(x, y, z), Colorful(c), mark(m) {}
virtual ~ColorfulPoint3d() {}
private:
int mark;
};
然后还是用这样的代码来打印我们想看到的:
Point3d p3(12, 24, 36);
Colorful c1(77);
ColorfulPoint3d c3(12, 24, 36, 77, 99);
showInt(&p3, sizeof p3);
showInt(&c1, sizeof c1);
showInt(&c3, sizeof c3);
则结果是:
3 words: 12 24 36
2 words: 4202828 77
6 words: 4202836 77 12 24 36 99
可见,编译器偷偷地互换了两个基类的先后顺序。那么导致的一个直接后果是,在将ColorfulPoint3d对象的地址赋值给指向Colorful对象的指针时,不必再转换;而将其赋值给Point3d指针时,需要转换了。
但要注意的是,这里,编译器只是调整了两个基类的布局顺序,在生成一个派生类对象的时候,基类的构造函数调用顺序,依然还是程序员在定义派生类时,在派生类的派生列表中指明的顺序。
1.4 虚拟继承
一旦涉及到虚拟继承,连Lippman都有些害怕。
1.4.1 没有虚函数存在
这次,我们要对试验代码做一些比较大的修改。为了实现虚拟继承,我们让Point2d类作为一个公共基类,然后分别让Point3d类和Colorful类虚继承这个公共基类。然后从Point3d和Colorful这两个类再派生出ColorfulPoint3d类:
class Point2d {
public:
Point2d(int x, int y) : x(x), y(y) {}
private:
int x, y;
};
class Point3d : public virtual Point2d {
public:
Point3d(int x, int y, int z) : Point2d(x, y), z(z) {}
private:
int z;
};
class Colorful : public virtual Point2d {
public:
Colorful(int x, int y, int c) : Point2d(x, y), color(c) {}
private:
int color;
};
class ColorfulPoint3d : public Point3d, public Colorful {
public:
ColorfulPoint3d(int x, int y, int z, int c, int m)
: Point2d(x, y), Point3d(x, y, z), Colorful(x, y, c), mark(m) {}
private:
int mark;
};
为了便于观察,我们还是生成有指定值的对象:
Point2d p2(12, 24);
Point3d p3(12, 24, 36);
Colorful c1(12, 24, 77);
ColorfulPoint3d c3(12, 24, 36, 77, 99);
showInt(&p2, sizeof p2);
showInt(&p3, sizeof p3);
showInt(&c1, sizeof c1);
showInt(&c3, sizeof c3);
这次,在我的电脑上,cl和结果为:
2 words: 12 24
4 words: 4202800 36 12 24
4 words: 4202800 77 12 24
7 words: 4202808 36 4202816 77 99 12 24
结构示意图:
Point2d:
int Point2d::x
int Point2d::y
Point3d:
__vptr__Point3d
int Point3d::z
int Point2d::x
int Point2d::y
Colorful:
__vptr__Colorful
int Colorful::color
int Point2d::x
int Point2d::y
ColorfulPoint3d:
__vptr__Point3d
int Point3d::z
__vptr__Colorful
int Colorful::color
int ColorfulPoint3d::mark
int Point2d::x
int Point2d::y
直到这里,我们可以发现,在虚拟继承下,为了保证每一个派生类中都有且只有一个虚基类子对象,编译器们把这个子对象安排在了对象中末尾的位置上。
这样的安排是必要的。举一个很简单的例子,如果把虚拟基类Point2d子对象放在开头的话,那么在ColorfulPoint3d对象中,Point3d子对象和Colorful子对象则必有一个不跟这个Point2d子对象在一起。那么这跟Point3d或者Colorful的结构就违背了。如果涉及到要用Point3d或者Colorful的指针指向ColorfulPoint3d对象,那往哪指?
观察一下,这里所有的类都没有virtual函数,为什么还三个派生类中会有vptr?
1.4.2 对象布局的问题
在虚拟继承之前,所有的继承层次都是十分清晰的。每一个派生类对象中的基类子对象都被放置在一个基本固定的位置上,而且一层套着一层,具有非常良好的结构,无论是在之前所说的基类指针到派生类对象的绑定上,还是某一个成员的定位,都可以非常自然地完成。
但是,在虚拟继承的情况下却会发现一些问题。以上面的继承体系为基础,考虑这样一个例子:
//假设这个函数被声明为Point3d类的友元
//比较两个Point3d对象的y坐标是否相同
bool HasTheSameY(const Point3d &p1, const Point3d &p2) {
return p1.y == p2.y;
}
我们可以很简单地理解这个函数:这只是比较两个对象的y坐标而已。但是,编译器怎么做?
细细一想, Point3d的引用,既可以绑定在一个Point3d的对象上,也可以绑定在一个ColorfulPoint3d的对象上。问题出现了,在Point3d对象内,成员y距首地址的offset是12;而在ColorfulPoint3d对象内,成员y距Point3d子对象首地址的offset是24!于是编译器在遇到p1.y和p2.y的时候,就不知道该怎样绑定了:
//????????该填几?
return *(int *)((char *)&p1 + ????????) == *(int *)((char *)&p2 + ????????);
怎么解决呢?我们先来看问题的根源。在前几种继承情况下,这样的问题并不存在,而一到了虚拟继承,问题就出现了。很明显,这是因为在虚拟继承的情况下,派生类中的公共虚基类子对象的位置不确定(它总是被放在末尾,而编译器不知道在它知道还有多少别的东西),这是编译器不能确定成员offset的原因。
知道了原因,解决就简单了,只要将不同的class对象的这个offset值保存下来,让程序能在运行时读取的话,就OK了。Lippman在书中列举出了3种方法,我这里记录一下cl的做法。
还记得上一节末尾说的那个vptr吗?我们现在来看一下(继承体系仍是1.4.1中的虚拟继承体系):
Point2d p2(12, 24);
Point3d p3(12, 24, 36);
Colorful c1(12, 24, 77);
ColorfulPoint3d c3(12, 24, 36, 77, 99);
cout << *(*(int **)&p3 + 1) << endl;
cout << *(*(int **)&c1 + 1) << endl;
cout << *(*(int **)&c3 + 1) << endl;
cout << *(*((int **)&c3 + 2) + 1) << endl;
打印的就是对象首地址中的vptr所指向的vtable中的内容。(这是我自己摸索出来的结果,细节上并不十分清楚,微软如何实现它的编译器,我无从得知。)该程序只能运行于cl上,在g++下的结果完全不一样(看上去有意义的几个值,但我看不出含义)。如果有人知道,请告诉我,谢谢。
在我的电脑上cl的运行结果为:
8
8
20
12
这就清楚了。在这四个vptr所指向的vtable中索引值为1的地方,都放置了当前的对象中,虚公共基类子对象距离这个vptr的offset。这当然也是把vptr放在开头的一个好处之一。因为这个距离,就代表了上面的问题中所需要的offset。
于是,bool HasTheSameY(const Point3d &p1, const Point3d &p2)这个函数就可以被转化成这种形式了:
//伪C++代码
//别忘记在p1和p2绑定时,可能已经发生过一次转换
return *(int *)((char *)&p1 + *(*(int **)&p1 + 1))
== *(int *)((char *)&p2 + *(*(int **)&p2 + 1));
注意到,这个寻址就跟前面的寻址有着本质区别了——这个寻址只能是运行时进行的,而前面的寻址都可以用一个简单的汇编寻址指令搞定问题。所以这将在效率上付出非常大的代价。
同样,如果要将一个ColorfulPoint3d的对象绑定到一个虚基类Point2d的指针上的话:
Point2d *p2d = &c3;
也需要借助这个offset:
//伪C++代码
Point2d *p2d = &c3 ? &c3 + *(*(int **)&c3 + 1) : 0;
1.4.3 有虚函数存在
由于在虚继承下,派生类对象中已经含有了vptr,故在有virtual函数存在的情况下,类对象的结构也不再有太大的变化了。如果虚基类本身含有virtual函数,则新的继承体系中,每一个类的大小都比之前多出了4个字节,用于存放这个虚基类的vptr。当然,这个vptr作为虚基类的一部分,也是被在派生类对象的末尾。
不过,在这里又出现了一个问题。上一段所说的情况,完全符合g++的情况;而在cl上面,则不是在任何情况下都符合。不符合的情况可以这样描述:当Point3d直接虚拟继承Point2d时,如果Point2d中有一个非析构函数的virtual函数,并且在Point3d中对这个函数进行了重写,那么Point3d对象中的Point2d子对象便会多占用4个字节。这4个字节值全部为0,并且位于Point2d子对象的vptr之前:
class Point2d {
public:
Point2d(int x, int y) : x(x), y(y) {}
virtual void f() {}
private:
int x, y;
};
class Point3d : public virtual Point2d {
public:
Point3d(int x, int y, int z) : Point2d(x, y), z(z) {}
void f() {}
private:
int z;
};
class Colorful : public virtual Point2d {
public:
Colorful(int x, int y, int c) : Point2d(x, y), color(c) {}
private:
int color;
};
class ColorfulPoint3d : public Point3d, public Colorful {
public:
ColorfulPoint3d(int x, int y, int z, int c, int m)
: Point2d(x, y), Point3d(x, y, z), Colorful(x, y, c), mark(m) {}
private:
int mark;
};
在我的电脑上cl的运行结果是:
3 words: 4202828 12 24
6 words: 4202848 36 0 4202840 12 24
5 words: 4202868 77 4202860 12 24
9 words: 4202888 36 4202896 77 99 0 4202880 12 24
结构示意图:
Point2d:
__vptr__Point2d
int Point2d::x
int Point2d::y
Point3d:
__vptr__Point3d
int Point3d::z
0
__vptr__Point2d
int Point2d::x
int Point2d::y
Colorful:
__vptr__Colorful
int Colorful::color
__vptr__Point2d
int Point2d::x
int Point2d::y
ColorfulPoint3d:
__vptr__Point3d
int Point3d::z
__vptr__Colorful
int Colorful::color
int ColorfulPoint3d::mark
0
__vptr__Point2d
int Point2d::x
int Point2d::y
这个“0”只在cl中有,在g++中没有,我一时还没有想到如何去解释这个“0”。
1.5 指向数据成员的指针
指向成员的指针,包括指向数据成员和成员函数的指针,在C++中经常被认为是没太大作用的东西。在程序设计中通过都可以用其他的替代方法来替代这两种指针的使用,而且也不会比使用这种类型的指针更麻烦。然而,通过研究这种指针,却可以让我们更清楚C++的编译对待对象成员的方式。
C++中,每一个类的定义里面都会有数据成员的声明。在类的定义体中,数据成员只是被声明,因为所有的数据成员都是存在于某一个对象之中的。那么,指向数据成员的指针,就绝不可能是像普通指针一样,指向一个绝对的地址。那么,它指向什么?
1.5.1 在非虚拟继承下
用这样的代码来观察一下:
//把数据成员设置为public,使其可以被外部访问
class Point3d {
public:
Point3d(int x, int y, int z) : x(x), y(y), z(z) {}
int x, y, z;
};
int main() {
int Point3d::*px = &Point3d::x;
int Point3d::*py = &Point3d::y;
int Point3d::*pz = &Point3d::z;
cout << “px = “ << *(int *)&px << endl;
cout << “py = “ << *(int *)&py << endl;
cout << “pz = “ << *(int *)&pz << endl;
}
在我的电脑上cl和g++的运行结果为:
px = 0
py = 4
pz = 8
结果一目了然,指向成员的指针里面,存放的是该成员的地址相对于对象首地址的偏移量(offset)。故在使用指向数据成员的指针时,编译器只需要在对象的首地址上加上一个offset就行了:
Point3d p3(12, 24, 36);
p3.*px = 13;
即被转化成:
//伪C++代码
*(int *)((char *)&p3 + *(int *)&px) = 13;
这是在cl和g++上的情况。在Lippman的书中不是这个样子的。按照书中的观点,直接将偏移存放在指向数据成员的指针中的做法是不妥的:
int Point3d::*pdm = 0;
这明明是一个空指针,然而却跟&Point3d::x拥有一样的值了。编译器怎么能发现这只是一个空指针,而不是指向成员x的数据成员指针呢?Bjarne的做法是在每一个指向数据成员的非空指针上面都加1。也就是说,上面的px、py和pz依次变成了1、5和9。这样,在每次使用时,如果发现是0,那么就是空指针;否则,将这个值减1了再用。
而cl和g++却有自己的办法。它们的做法不是将每一个非空指针加1,相反,它们是将空指针减1。也就是说,“0”值并不是一个指向对象成员的空指针,“-1”才是。上面用到了这样代码来观察指向数据成员的指针的内部的值:
cout << *(int *)&px << endl;
cout << *(int *)&py << endl;
cout << *(int *)&pz << endl;
如果其中某一个指针是空指针,或者被赋值成0,那么,打印出来的结果便是-1。这同时也是为什么不能直接这样打印的原因:
cout << px << endl;
这样的语句貌似正确,但实际上,编译器已经做了处理,这样打印是打印不出指针的真实值的。打印的结果只有两个:如果是空指针,打印0;如果是非空指针,打印1。这正是编译器在特殊处理之后,打印出来的一个能够表示出空指针的“有意义”的结果。
1.5.2 在虚拟继承下
正如前面曾说过的:一旦涉及到虚拟继承,情况便会复杂很多。
现在,我们把继承体系还原成曾经讨论过的虚拟继承版本:
class Point2d {
public:
Point2d(int x, int y) : x(x), y(y) {}
virtual ~Point2d() {}
int x, y;
};
class Point3d : public virtual Point2d {
public:
Point3d(int x, int y, int z) : Point2d(x, y), z(z) {}
int z;
};
class Colorful : public virtual Point2d {
public:
Colorful(int x, int y, int c) : Point2d(x, y), color(c) {}
int color;
};
class ColorfulPoint3d : public Point3d, public Colorful {
public:
ColorfulPoint3d(int x, int y, int z, int c, int m)
: Point2d(x, y), Point3d(x, y, z), Colorful(x, y, c), mark(m) {}
int mark;
};
然后这样来测试:
ColorfulPoint3d c3(12, 24, 36, 77, 99);
int ColorfulPoint3d::*px = &ColorfulPoint3d::x;
int ColorfulPoint3d::*py = &ColorfulPoint3d::y;
int ColorfulPoint3d::*pz = &ColorfulPoint3d::z;
int ColorfulPoint3d::*pc = &ColorfulPoint3d::color;
int ColorfulPoint3d::*pm = &ColorfulPoint3d::mark;
cout << "px = " << *(int *)&px << endl;
cout << "py = " << *(int *)&py << endl;
cout << "pz = " << *(int *)&pz << endl;
cout << "pc = " << *(int *)&pc << endl;
cout << "pm = " << *(int *)&pm << endl;
showInt(&c3, sizeof c3);
在我的电脑上cl的运行结果为:
px = 4
py = 8
pz = 4
pc = 12
pm = 16
8 words: 4202880 36 4202888 77 99 4202876 12 24
这个结果就需要思考一下了。px和pz拥有了相同的偏移量,这怎么可能?拿同样的代码到g++下运行,结果则是通过不了编译:
error: pointer to member cast from ‘int Point2d::*’ to ‘int ColorfulPoint3d::*’ is via virtual base
之前在cl上还不太明白的结果,一看到g++的错误信息,便马上明白过来了。因为成员x和成员y是虚基类的成员,就像在前面讲如何把成员绑定到正确的内存地址上面一样,指向这两个从虚基类继承得来的数据成员的指针也没办法是一个绝对值。否则,若是从一个类上面获得了这个绝对值,然后去用这个类的派生类去调用这个指针的话,那么肯定不能得到正确的值。
于是cl的做法是,在取指向继承自虚基类的数据成员的时候,取的不是这些成员在派生类中的偏移量,而实际上是取的这些成员在虚基类中的偏移量。也就是说,上面px = 4,py = 8,这两个偏移量不是针对ColorfulPoint3d这个类而言的,而是针对Point2d这个虚基类而言的。这样一来,在使用这些指针的时候,编译器就会像前面那样,通过vtable先取出整个虚基类子对象在派生类对象中的偏移量,然后再加到这个指向数据成员指针中所保存的偏移量上,通过这样的方法来通过指向对象的指针来取到虚基类子对象的成员。
举个例子,代码:
c3.*px = 13;
就被编译器转化为:
//伪C++代码
//对象首地址 + 虚基类offset + 指向对象指针中offset
//只在cl中有用
*(int *)((char *)&c3 + *(*(int **)&c3 + 1) + *(int *)&px) = 13;
明白了这个道理,也就明白了为什么px和pz虽然拥有相同的偏移量,但它们也并不会冲突了。
g++跟cl有基本相同的做法。只是在取派生类中虚基类子对象的偏移量时,g++会把它认为是一个指向虚基类的数据成员的指针,不能转换到派生类上。也就是说,如果把上面的代码改为:
ColorfulPoint3d c3(12, 24, 36, 77, 99);
int Point2d::*px = &ColorfulPoint3d::x;
int Point2d::*py = &ColorfulPoint3d::y;
int ColorfulPoint3d::*pz = &ColorfulPoint3d::z;
int ColorfulPoint3d::*pc = &ColorfulPoint3d::color;
int ColorfulPoint3d::*pm = &ColorfulPoint3d::mark;
cout << "px = " << *(int *)&px << endl;
cout << "py = " << *(int *)&py << endl;
cout << "pz = " << *(int *)&pz << endl;
cout << "pc = " << *(int *)&pc << endl;
cout << "pm = " << *(int *)&pm << endl;
showInt(&c3, sizeof c3);
在g++上就能正确运行了。结果跟cl上面一样。
第2章 关于成员函数
把数据成员在对象中的布局弄清楚了之后,成员函数就简单一点了。因为成员函数不像数据成员那样在每个对象中都有一份,而是永远只会有一个实体位于程序运行时的代码区中,所以成员函数的不同的情况就少了很多。
- Objective-C对象模型
- Objective-C对象模型
- objective C 对象模型
- C ++ 对象模型
- Objective-C 对象模型
- 【C++】对象模型
- Objective-C对象模型
- C/C++的对象模型
- Objective-C的对象模型
- 图解Objectvie-C对象模型
- C/C++的对象模型
- Objective-C对象模型--类对象和元类对象
- 深度探索C++对象模型
- Objective-C 对象和消息模型
- Objective-C对象模型及应用
- Objective-C对象模型及应用
- Objective-C对象模型及应用
- Objective-C对象模型及应用
- 中国未来20年政治局势走向分析
- 转载清华大学校长留给毕业生的一段话
- 使用ASP.NET 2.0 Profile存储用户信息[翻译] Level 200
- iframe中对滚动条设置的注意事项
- 这个存储过程执行的速度还不错.500W速度分页只要2秒
- C++对象模型
- 双语:值得永世珍藏的40句至理名言(一)
- 漫谈C++ Builder多线程编程技术
- asp.net javascript验证 服务器控件
- 扩展GridView控件(索引) - 增加多个常用功能
- cpu架构
- 4月21号--任务的开始
- 双语:值得永世珍藏的40句至理名言(二)
- 新瓶旧酒ASP.NET AJAX系列文章索引