C++对象模型学习笔记


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 章 关于数据成员 ................................................................................................. 5 1.1 单个类............................................................................................................ 5 1.1.1 没有虚函数存在 ................................................................................... 5 1.1.2 如何绑定成员到正确的内存地址........................................................... 6 1.1.3 有虚函数存在....................................................................................... 6 1.2 单一继承 ........................................................................................................ 7 1.2.1 没有虚函数存在 ................................................................................... 7 1.2.2 使用基类指针绑定成员到正确的内存地址............................................. 9 1.2.3 有虚函数存在..................................................................................... 10 1.2.4 对齐带来的问题 ................................................................................. 13 1.3 多重继承 ...................................................................................................... 16 1.3.1 没有虚函数存在 ................................................................................. 16 1.3.2 有虚函数存在..................................................................................... 18 1.4 虚拟继承 ...................................................................................................... 21 1.4.1 没有虚函数存在 ................................................................................. 21 1.4.2 对象布局的问题 ................................................................................. 23 1.4.3 有虚函数存在..................................................................................... 25 1.5 指向数据成员的指针..................................................................................... 27 1.5.1 在非虚拟继承下 ................................................................................. 27 1.5.2 在虚拟继承下..................................................................................... 29 第 2 章 关于成员函数 ............................................................................................... 32 2.1 非成员函数................................................................................................... 32 2.1.1 非成员函数的实质.............................................................................. 32 2.1.2 参数与返回值..................................................................................... 33 2.1.3 常见的函数调用约定 .......................................................................... 34 2.1.4 函数重载............................................................................................ 35 2.2 成员函数 ...................................................................................................... 35 2.2.1 面向对象编程..................................................................................... 35 2.2.2 成员函数的实现 ................................................................................. 36 2.2.3 thiscall 调用约定 ................................................................................. 36 2.2.4 成员函数的地址 ................................................................................. 37 2.2.5 静态成员函数..................................................................................... 38 2.3 加上多态呢? ............................................................................................... 38 2.3.1 重载多态............................................................................................ 38 2.3.2 单继承下的重写多态 .......................................................................... 40 2.3.3 重写多态的实现 ................................................................................. 40 2.3.4 多重继承下的情况.............................................................................. 42 2.3.5 虚继承下的情况 ................................................................................. 45 2.4 指向成员函数的指针..................................................................................... 49 2.4.1 非虚成员函数的指针 .......................................................................... 49 2.4.2 虚成员函数的指针.............................................................................. 50 2.5 虚表里面还有什么?..................................................................................... 51 第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章 关于成员函数 把数据成员在对象中的布局弄清楚了之后,成员函数就简单一点了。在讨论成员函数的 时候,就不用再像讨论数据成员那样详细了。因为成员函数在多态的情况下,原理都是一样 的,弄清楚了对象的布局,就可以明白一切了。 2.1 非成员函数 所谓“非成员函数”,是指那些不在类的内部定义的那些全局的函数。C++是一门面向 对象的语言,之所以会有这样的函数的存在,很大程度上是为了跟 C 语言的兼容。而 C++ 中的非成员函数,也跟 C 语言中的函数有着基本相同的性质。 2.1.1 非成员函数的实质 无论是 C++还是 C,程序中的一个函数,实际上就是一段可以执行的 CPU 指令。这一段 CPU 指令是由函数的调用编译而来,放在目标文件中的.text 中,也就是程序加载进内存之后 的“代码区”中。而当我们要调用这个函数的时候,实际上就是把这个函数的第一条指令 在.text 中的地址告诉 CPU,让 CPU 从那里去执行。 有一段很简单的代码,涉及一个简单的函数调用: void sayHello() { cout << "Hello" << endl; } int main(int argc, char *argv[]) { sayHello(); } 打印结果是: Hello 对于 sayHello 的调用,实际上就是让 CPU 从 sayHello 对应的机器码的第一条指令开始执 行。而 sayHello 的第一条指令在哪呢?我们可以通过取址操作符来得到: &sayHello 既然 sayHello 是一个函数,那么这个取址的结果,自然是一个函数指针: void (*pf)() = &sayHello; 函数指针的赋值非常严格,只能用类型完全一样的函数地址来赋值。我们可以把这个值 打印出来: cout << pf << endl; cout << (int)pf << endl; 在我的电脑上打印结果是: 00411046 4264006 前者作为一个地址打印,用了十六进制;后者是普通整数,即十进制。 如果还不相信这个值就是函数对应的机器码的第一条指令的位置,我们还可以这样: int i = (int)sayHello; //“&”可以省略 __asm call dword ptr i //如果在 Linux/Unix 的 GCC 下,这句内联汇编要写成: //asm(“call %0” : : ”r”(i) : ); //为方便,在后续的实验中,只使用 MS 汇编,不使用 GAS 汇编 “Hello”依然被打印了出来,即 sayHello 方法被调用。 2.1.2 参数与返回值 通过上面的例子我们可以知道,函数调用就是让 CPU 从函数的入口地址开始执行。然 而,上面的那个函数调用太简单了,没有参数,也没有返回值。如果加上参数和返回值,那 又该怎么办呢? 实际上,函数的参数是通过活动记录(Activity Record)即函数调用栈来传递的。在函 数调用之前,先把所有的参数压入栈中,然后再让 CPU 转到函数的入口地址去执行,执行 完毕之后再清空这些参数。而函数的返回值,如果够小,则由寄存器 eax 来返回;否则,如 果 eax 不足以容纳返回值,那么在调用函数之前,则会先在栈上开辟一个空间,然后再调用 函数,也就是用这个空间来保存返回值(这时相当于是给函数隐式地添加了一个参数)。 这一段程序可以说明问题: int __stdcall incr(int i) { return i + 1; } int main(int argc, char *argv[]) { int result = 0; __asm { push 10; call incr; mov result, eax; } cout << result << endl; } 打印结果是: 11 在“call incr”之前先将“10”入栈,于是“10”便成为了 incr 函数的参数。在调用完 成之后,将 eax 中的内容复制到 result 中,打印 result,结果是 11,正是这次函数调用的返 回值。 2.1.3 常见的函数调用约定 函数的调用涉及到参数的传递,也就是参数的压栈和出栈。参数的压栈,不用想,肯定 只能由调用者完成;但是参数的出栈却是调用者和被调用者都可以完成的。而参数的压栈, 虽然只能由调用者完成,但是压栈的顺序却也有多种可能。于是,出现了函数的调用约定, 即规定了参数由什么顺序压栈,并且由谁负责出栈。 在 C++程序中比较常见的非成员函数的调用约定有三种:__cdecl,__stdcall 和__fastcall。 __cdecl 是 C 程序的调用约定,而__stdcall 是 pascal 的调用约定。这两者的参数入栈顺序是 一样的,都是参数列表从右向左入栈。而不同的是,在__cdecl 调用约定下,由调用者负责 清理栈中的参数(即参数出栈),而在__stdcall 调用约定下,由被调用函数自己负责清理栈 中的参数。__fastcall 基本上跟__stdcall 一样,只不过参数中的前两个 DWORD(32 位)的参 数是通过 ecx 和 edx 这两个寄存器来传递的。 值得一提的是,C 和 C++都支持不确定参数的函数。而对于这两种调用约定,由于在使 用不确定的参数时,只有调用者知道参数个数,而被调用者不会知道,所以只有__cdecl 支 持不确定参数的函数。 其实调用约定还涉及到决定函数的名字等等内容,调用约定也还有__declspec(naked)等 等,这里就不作讨论了。 2.1.4 函数重载 函数的重载,在其逻辑意义上,大家都比较容易接受。但是从其实现来说,一个函数名 只能对应一段指令,故 C++为支持函数重载,对函数名都有一个 mangling 过程。大致意思就 是在编译之后,将函数名做一些调整,使其包括参数的信息,以便重载决定。比如 foo(int, int) 就可能被 mangle 成 foo_ii;而 foo(double, double)就有可能被 mangle 成 foo_dd;等等。如何 mangle,由编译器自己决定。 2.2 成员函数 与 C 程序不同的是,在 C++程序中,这种简单的非成员函数并不是唯一的函数类型。作 为 C++面向对象的一个组成部分,C++程序引入了成员函数。我们这里所讨论的成员函数, 如果不加特别说明,都是指非静态的成员函数。 2.2.1 面向对象编程 如果说,面向过程编程的实质是不同函数之间的相互调用,那么面向对象编程的实质就 是不同对象之间的相互发送消息了。不同对象之间的消息发送,其实就是一个对象调用了另 一个对象的成员函数。 所谓成员函数,即在一个类的内部定义的函数。它的定义和用法这里就不必多说了,我 们关心的是这个: class A { public: int value; void show() { cout << value << endl; } }; int main(int argc, char *argv[]) { A a = {5}, b = {10}; a.show(); b.show(); } 结果大家都清楚: 5 10 但问题是,两次调用都是对函数 show 进行的,函数 show 在编译之后就是一些 CPU 指 令而已。而很显然,a.value 的地址跟 b.value 的地址完全不在一个地方。那为什么第一次打 印的是 a.value 而第二次打印的是 b.value 呢?难道机器码还可以变化? 2.2.2 成员函数的实现 成员函数跟非成员函数有什么区别呢?从编译的角度来讲,它们都会编译成一段 CPU 指令,这没什么区别;从语法的角度上来讲,前者在调用时需要加上对象名,而后者可以直 接调用。那么我们回到上面的问题上来,为什么 a.show()打印了 a.value 而 b.show()打印了 b.value。 其实,可以这样认为:C++程序在写代码的时候有“类”的概念,一旦程序编译好之后, 放在目标文件中的代码,是没有“类”这个概念的。这时候的代码,跟 C 程序编译出来的代 码是一个样子的。而我们又可以认为,C 程序是基本跟汇编、机器码挂钩的,于是,C++代 码可以被转换成“等价”的 C 代码,这样可以反应 C++程序的底层实现。 于是,上面的 show 函数的定义就变成了这个样子: //C++伪代码 void show(A * const this) { cout << this->value << endl; } 而 main 函数中的 a.show(); b.show(); 则变成了: show(&a); show(&b); 可以看到,实际上,C++在处理类的成员函数的时候,隐式地在这个成员函数中加了一 个参数 this;并且在函数体内所有使用了成员的地方,都在成员前面自动加上“this->”。然 后,在调用成员函数时,会把“X.show()”的这个“X”的地址当作参数传递给 show 函数。 这样,就可以使得成员函数可以在任何对象上正确地调用了。 2.2.3 thiscall 调用约定 在这里,顺带说一下 thiscall。thiscall 也是一种调用约定,但它本身并不是一个关键字, 所以不能被程序员指定,它只是代表了一种含意,一种调用的约定。根据上面所说的,这种 约定顾名思义,是 C++调用成员函数的约定。 它的含义可以简单地用上面已经提到过的__cdecl 和__stdcall 来说明。一般情况下,也 就是参数个数确定的情况下,thiscall 跟__stdcall 是一样的,但是 this 指针通过 ecx 来传递; 而在参数个数不确定的情况下,thiscall 则跟__cdecl 是一样的,并且 this 指针是在所有参数 都入栈之后再入栈。 2.2.4 成员函数的地址 在 C++中有函数指针,它可以用来指向非成员函数,但却不能用来指向成员函数。语意 上的原因很简单:函数指针和类型必须跟函数的类型完全匹配,然而,成员函数的调用约定 thiscall 不是一个关键字,程序员没办法去声明它。换句话说,成员函数含有 this 这个隐式 的参数,如果要声明一个同类型的函数指针,那么在它的形参表中怎样处理这个 this 呢? 但实际上,跟非成员函数一样,成员函数也有自己的地址。这个地址虽然不能赋给任何 一个普通函数指针,但却可以赋给一个特殊的函数指针——指向成员函数的指针。 对于上面的类 A 的定义,在 main 函数中写下这样的代码: A a = {5}, b = {10}; void (A::*pf)() = &A::show; (a.*pf)(); (b.*pf)(); 那么它的结果是: 5 10 跟上面其实是一样的。我们来看看 pf 到底是什么: cout << *(int *)&pf << endl; 在我的电脑上,结果是: 4264201 注意一点,在这里要用“*(int *)&pf”这样的表达式才能看到 pf 真实的值。否则,由于 cout 的 operator <<函数重载的关系,直接 cout << pf 会打印出一个“1”。 这个值,其实就是 show 函数在代码区中的位置,也就是所谓的 show 函数的入口点。 可见,其实 C++中的成员函数,跟非成员函数,甚至 C 程序中的函数,没什么本质区别。我 们甚至还可以直接用这个数字来调用该函数。回忆一下 thiscall: A a = {5}, b = {10}; void (A::*pf)() = &A::show; __asm { lea ecx, a; call pf; lea ecx, b; call pf; } 结果是: 5 10 这证明了 pf 中的值就是 show 的入口地址,而且 show 跟非成员函数没什么本质区别。 注:实际上,在 VS2005 下查看反汇编时,对类的成员函数取地址,所得到的值并不真 正是该函数的入口地址,而是一条 jmp 指令的地址,这条 jmp 指令直接跳转到所取函数的 入口地址。而 jmp 指令是不会做任何判断工作,也没有任何跳转前提的。故我们这里简单 地理解成,取出来的就是函数的入口地址。 2.2.5 静态成员函数 要跟一个初学者讲明白什么是静态成员函数也许不太容易,但现在,我们可以很明确地 知道什么是静态成员函数了:没有被隐式地加入 this 参数的函数。没有 this 参数,所以这一 类函数不能直接访问所属类的成员。实际上,这一类函数其实跟非成员函数已经相差不远了。 它跟非成员函数的一个很大的区别,只在于,它可以访问它所属类的保护和私有成员。 对这类函数取地址,则取出来的则完全是它的入口地址。它的类型已经不再是指向成员 函数的指针,而是一个指向普通函数的指针了。这里就不用更多的代码去验证了。 2.3 加上多态呢? C++的多态有编译期多态(模板)和执行期多态,而执行期多态又包括重载多态和重写 多态。其中,编译期多态和重载多态都是编译器在编译代码的时候就能够确定下来真正要被 调用的函数的,这个函数的入口地址会被编译器直接写进代码中。而我们这里讨论的多态, 正是这两种多态之外的重写多态。 2.3.1 重载多态 构造这样的一个继承体系: class Base { public: virtual ~Base() {} virtual void show() { cout << "Base" << endl; } }; class Derived : public Base { public: void show() { cout << "Derived" << endl; } }; 子类 Derived 类重写了基类 Base 中的 show 方法。 编写下面的测试代码: Base b; Derived d; b.show(); d.show(); 结果是: Base Derived Base 的对象调用了 Base 的方法,而 Derived 的对象调用了 Derived 的方法。因为直接用 对象来调用成员函数时不会开启多态机制,故编译器直接根据 b 和 d 各自的类型就可以确定 调用哪个 show 函数了,也就是在这两句调用中,编译器为它们每一个都确定了一个唯一的 入口地址。这实际上类似于一个重载多态,虽然这两个 show 函数拥有不同的作用域。 那这样呢: Base b; Derived d; b.show(); b = d; b.show(); 现在,一个 Base 的对象被赋值为子类 Derived 的对象。 结果是: Base Base 对于熟悉 Java 的人而言,这不可理解。但实际上,C++不是 Java,它更像 C。“ b = d”的 意思,并不是 Java 中的“让一个指向 Base 类的引用指向它的子类对象”,而是“把 Base 类 的子类对象中的 Base 子对象分割出来,赋值给 b”。所以,只要 b 的类型始终是 Base,那么 b.show()调用的永远都是 Base 类中的 show 函数;换句话说,编译器总是把 Base 中的那个 show 函数的入口地址作为 b.show()的入口地址。这根本就没用上多态。 2.3.2 单继承下的重写多态 那我们再这样: Base b; Derived d; Base *p = &b; p->show(); p = &d; p->show(); 这时,结果就对了: Base Derived p 是一个指向基类对象的指针,第一次它指向一个 Base 对象,p->show()调用了 Base 类 的 show 函数;而第二次它指向了一个 Derived 对象,p->show()调用了 Derived 类的 show 函 数。这就叫重写多态,想必大家都了解,但这其中包含了一个很大的问题: 把 p 当成参数 this 传递给 show 函数。p 的类型是一定的,始终都是 Base *,那么,编 译器是不可能在编译的时候就可以确定下来函数的入口地址的。因为 p 只是一个指向基类的 指针,在执行的时候它既可能指针一个 Base 对象,也有可能指向一个 Derived 对象。怎么办? 2.3.3 重写多态的实现 C++是建立在 C 语言基础之上的一门面向对象的程序设计语言,而多态正是面向对象编 程的核心。这个问题的答案,也就是真正 C++对象模型的实现方案了。C++诞生之初,其父 Bjarne 开发出了 C++的第一个编译器——cfront,它以 C++源代码作为输入,以 C 源代码作为 输出。也就是说,C++的面向对象的程序是完全被转换为了面向过程的执行流的。 对于虚成员函数的多态,实现方案大致如下: 编译器为这个含有虚函数的类维持一个虚表 vtable,并且在这个类的对象结构中维持一 个虚指针 vptr,在构造这个对象时,使其 vptr 指向该对象所属的类的虚表 vtable。而该虚表 中保存着该类的所有虚成员函数的入口地址。当用一个指向基类的指针或者引用来调用该类 的虚成员函数时,编译器很清楚这个成员函数在这个类的虚表中的索引,于是像 p->show() 这样的调用就被转换为: //C++伪代码 (*p->__vptr[1])(p); 别小看这种转换,它使得所有对于 Base 的成员函数 show 的调用,无论来自于基类对 象,还是来自子类对象,都拥有了相同的形式。对于编译器来说,虽然它不能在编译时确定 函数的入口地址,但这并不意味着编译器由于信息不够而无法生成汇编代码,因为这种转换 把确定函数入口的任务留到了运行时,所以编译器在编译的时候就很清楚应该如何生成汇编 码了,并且这样的形式也保证了所有不同的调用的正确性。 如果有兴趣的话,我们可以这样把指针 p 所指向的对象(不管是 Base 对象还是 Derived 对象)的 vptr 取出来: Base b, *p; Derived d; p = &b; int vtbl4base = *(int *)p; p = &d; int vtbl4derived = *(int *)p; cout << vtbl4base << endl; cout << vtbl4derived << endl; 在我电脑上的结果为: 4290304 4290328 可以看出来,它们位于比较接近的位置。前者指向了 Base 类的 vtable,而后者指向了 Derived 类的 vtable。 同样,如果感兴趣,我们还可以把 Base 类中的 show 函数的入口地址,以及 Derived 类 中的 show 函数入口地址,从这两个 vtable 中取出来。 int show4base = *((int *)vtbl4base + 1); int show4derived = *((int *)vtbl4derived + 1); cout << show4base << endl; cout << show4derived << endl; 在我电脑上的打印结果为: 4264246 4264116 注意,这两个函数都不在各自 vtable 的第一个索引处,那是因为这两个类的析构函数也 是 virtual 的,它们占据了各自 vtable 的第一个索引。show 的索引在第二个。 我们可以很简单地用内联汇编来调用这两个入口地址: __asm { call dword ptr show4base; call dword ptr show4derived; } 结果是: Base Derived 因为这两个函数不涉及对 this 的操作,所以不把 this 参数传到 ecx 中也不要紧。 2.3.4 多重继承下的情况 我们现在把继承体系修改成一个多重继承体系: class Base1 { public: virtual ~Base1() {} virtual void show() { cout << "Base1" << endl; } }; class Base2 { public: virtual ~Base2() {} virtual void show() { cout << "Base2" << endl; } }; class Derived : public Base1, public Base2 { public: void show() { cout << "Derived" << endl; } }; 然后取出每个类的 vtable 的地址: Base1 b1; Base2 b2; Derived d; int vtbl4base1 = *(int *)&b1; int vtbl4base2 = *(int *)&b2; int vtbl4derived = *(int *)&d; cout << vtbl4base1 << endl; cout << vtbl4base2 << endl; cout << vtbl4derived << endl; 在我的电脑上,结果为: 4290304 4290328 4290476 再取出每个 vtable 中 show 函数的地址: int pshow4base1 = *((int *)vtbl4base1 + 1); int pshow4base2 = *((int *)vtbl4base2 + 1); int pshow4derived = *((int *)vtbl4derived + 1); cout << pshow4base1 << endl; cout << pshow4base2 << endl; cout << pshow4derived << endl; 在我电脑上的结果为: 4264661 4264651 4264116 为验证,我们再用内联汇编去调用: __asm { call dword ptr pshow4base1; call dword ptr pshow4base2; call dword ptr pshow4derived; } 结果为: Base1 Base2 Derived 这刚好验证了我们预想中的结果。但是别忘了,我们刚才的实验已经忘掉了一种情况。 根据第一章中所讨论的,这种继承体系下,对象 d 中应该会含有两个 vptr。其中一个是属于 Base1 子对象的,另一个是属于 Base2 子对象的。我们刚才的实验,取出的应该是 Base1 子 对象的 vptr。 int dAsBase1 = (int)(Base1 *)&d; int dAsBase2 = (int)(Base2 *)&d; cout << dAsBase1 << endl; cout << dAsBase2 << endl; 我电脑上的结果为: 1244984 1244988 原因在第一章中有描述,这里不再讨论。那么,这两个 vptr 各指向哪呢?答案是,在 这种情况下,Derived 类需要两个 vtable。可以想像,将 d 的地址赋给一个 Base1 指针时, 该 Base1 指针必须要能够像调用 Base1 的方法那样来调用 Derived 的重写方法,故此时的 vtable 必须跟 Base1 的 vtable 拥有一样的索引;而当把 d 的地址赋给一个 Base2 指针时,该 Base2 指针也必须要能够像调用 Base2 的方法那样来调用 Derived 的重写方法,故此时的 vtable 也必须跟 Base2 的 vtable 拥有一样的索引。而很显然,当 Derived 从 Base1 和 Base2 继承了不同的方法时,一个 vtable 满足不了这两点要求。 所以,dAsBase1指向了Derived的一个来自于Base1 的vtable,而 dAsBase2指向了Derived 的一个来自于 Base2 的 vtable。我们再修改一下我们的继承体系,来看看如果 Derived 类继 承了两个不同的方法时会发生什么情况: class Base1 { public: virtual ~Base1() {} virtual void show1() { cout << "Base1" << endl; } }; class Base2 { public: virtual ~Base2() {} virtual void show2() { cout << "Base2" << endl; } }; class Derived : public Base1, public Base2 { public: void show1() { cout << "Derived/Base1" << endl; } void show2() { cout << "Derived/Base2" << endl; } }; 测试代码: Base1 b1; Base2 b2; Derived d; int dAsBase1 = (int)(Base1 *)&d; int dAsBase2 = (int)(Base2 *)&d; int pshow1 = *(*(int **)dAsBase1 + 1); int pshow2 = *(*(int **)dAsBase2 + 1); cout << pshow1 << endl; cout << pshow2 << endl; __asm { call dword ptr pshow1; call dword ptr pshow2; } 在我电脑上的结果是: 4264686 4264181 Derived/Base1 Derived/Base2 这样,便可保证多态在多重继承下也能正常工作了。 2.3.5 虚继承下的情况 为观察在虚继承情况下编译器的行为,我们的继承体系又被复杂化了: class Grand { public: virtual void say() { cout << "Grand say" << endl; } }; class Base1 : virtual public Grand { public: void say() { cout << "Base1 say" << endl; } virtual void show1() { cout << "Base1" << endl; } }; class Base2 : virtual public Grand { public: void say() { cout << "Base2 say" << endl; } virtual void show2() { cout << "Base2" << endl; } }; class Derived : public Base1, public Base2 { public: void say() { cout << "Derived say" << endl; } void show1() { cout << "Derived/Base1" << endl; } void show2() { cout << "Derived/Base2" << endl; } }; 然而,面对这样一个复杂的继承体系,我的确毫无办法。用最开始引入的 showInt 方法 来观察这四个类的结构: Grand g; Base1 b1; Base2 b2; Derived d; showInt(&g, sizeof g); showInt(&b1, sizeof b1); showInt(&b2, sizeof b2); showInt(&d, sizeof d); 在 cl 下的结果为: 1 words: 4294428 3 words: 4294468 4294476 4294456 3 words: 4294528 4294536 4294516 5 words: 4294600 4294624 4294588 4294608 4294576 而在 g++下的结果为: 1 words: 4477236 1 words: 4477196 1 words: 4477220 2 words: 4477260 4477288 我不知道为什么 cl 和 g++的差异如此之大。而且这种棘手的结果显然是被编译器处理过 的,所以看似毫无道理。于是无奈之下,我又加进去了一些数据成员: class Grand { public: int a; Grand() : a(111) {} virtual void say() { cout << "Grand say" << endl; } }; class Base1 : virtual public Grand { public: int b; Base1() : b(222) {} void say() { cout << "Base1 say" << endl; } virtual void show1() { cout << "Base1" << endl; } }; class Base2 : virtual public Grand { public: int c; Base2() : c(333) {} void say() { cout << "Base2 say" << endl; } virtual void show2() { cout << "Base2" << endl; } }; class Derived : public Base1, public Base2 { public: int d; Derived() : d(444) {} void say() { cout << "Derived say" << endl; } void show1() { cout << "Derived/Base1" << endl; } void show2() { cout << "Derived/Base2" << endl; } }; 这样一来,结果便清楚多了。在 cl 上面: 2 words: 4290320 111 6 words: 4290356 4290364 222 0 4290344 111 6 words: 4290400 4290408 333 0 4290388 111 10 words: 4290456 4290476 222 4290444 4290464 333 444 0 4290432 111 相比于 cl,g++上面的结果则更清楚: 2 words: 4477356 111 4 words: 4477232 222 4477252 111 4 words: 4477296 333 4477316 111 7 words: 4477376 222 4477400 333 444 4477420 111 这些结果都是 Lippman 的书上从来没有的,看来只有自己动手了。 观察 cl 上面的结果,从开始到最后,一共有 12 个内容看起来像是 vptr 的数据。于是, 定义 12 个 int 变量,把这些值所为地址时指向的那个地址的值给取出来: int t11; int t21, t22, t23; int t31, t32, t33; int t41, t42, t43, t44, t45; t11 = **(int **)&g; t21 = **(int **)&b1, t22 = **((int **)&b1 + 1), t23 = **((int **)&b1 + 4); t31 = **(int **)&b2, t32 = **((int **)&b2 + 1), t33 = **((int **)&b2 + 4); t41 = **(int **)&d, t42 = **((int **)&d + 1), t43 = **((int **)&d + 3), t44 = **((int **)&d + 4), t45 = **((int **)&d + 8); cout << t11 << endl; cout << t21 << " " << t22 << " " << t23 << endl; cout << t31 << " " << t32 << " " << t33 << endl; cout << t41 << " " << t42 << " " << t43 << " " << t44 << " " << t45 << endl; 在我电脑上的结果为: 4264021 4264406 -4 4264311 4264531 -4 4264541 4264291 -4 4263946 -4 4264386 这一下子算是明白很多了。虽然 g,b1,b2 和 d 这四个对象的内容很乱,好像到处都 是 vptr 的样子,但实际上,真正的 vptr 也只有那几个,而且跟第一章的讨论结果是一致的。 那我们依次 call 一下这些有意义的函数入口地址: __asm { call dword ptr t11; call dword ptr t21; call dword ptr t23; call dword ptr t31; call dword ptr t33; call dword ptr t41; call dword ptr t43; call dword ptr t45; } 结果是: Grand say Base1 Base1 say Base2 Base2 say Derived/Base1 Derived/Base2 Derived say 这样一来,就一目了然了。虽然在这种虚继承下,VS2005 把类的结构搞得不明不白的, 但是基本的虚函数的实现仍然还是一样的。 2.4 指向成员函数的指针 我们之间讨论过,普通的函数指针是没办法指向一个成员函数的。只有指向成员函数的 指针才能指向一个成员函数。 2.4.1 非虚成员函数的指针 这是最简单的情况。非虚成员函数跟普通的非成员函数最接近,指向这一类成员函数的 指针中存放的就是这些函数的实际入口地址: class A { public: void show() { cout << "A" << endl; } }; int main(int argc, char *argv[]) { void (A::*pf)() = &A::show; int i = *(int *)&pf; __asm call dword ptr i; } 打印结果为: A 2.4.2 虚成员函数的指针 继承体系: class Base { public: virtual void show() { cout << "Base" << endl; } }; class Derived : public Base { public: void show() { cout << "Derived" << endl; } }; 测试代码: Base b; int show4base = **(int **)&b; cout << show4base << endl; Derived d; int show4derived = **(int **)&d; cout << show4derived << endl; void (Base::*pfb)() = &Base::show; int i = *(int *)&pfb; cout << i << endl; void (Derived::*pfd)() = &Derived::show; int j = *(int *)&pfd; cout << j << endl; 这段代码的目的很明显,打印 4 个值:第一个是 Base 中的 show 函数的地址,第二个 是 Derived 中的 show 函数的地址,第三个是指向 Base 中的 show 函数的指针中的内容,第 四个是指向 Derived 中的 show 函数的指针中的内容。 在我的电脑上结果是: 4264166 4264436 4264076 4264076 很显然,这两个函数指针的内容是一样的,但都不等于这两个函数的实际地址。 原因是,指针可以被基类对象调用,也可以被子类对象调用。凭指针是不能确定到底调 用哪个函数的。其实,这两个指针的内容类似于一个函数地址。当调用到这个指针时,程序 会检查 ecx 中的值,然后根据 vptr 来跳转到正确的那个函数下面。所以,如果要用汇编来调 用指针,就应该这么做: __asm lea ecx, b; __asm call dword ptr i; 结果为: Base 而如果是这样的话: __asm lea ecx, d; __asm call dword ptr i; 那么结果就是: Derived 这跟我们之间的讨论是一致的。 至于多重继承和虚继承下的虚函数指针,这里就不讨论了。 2.5 虚表里面还有什么? C++有一个名叫“RTTI”的机制,即“Runtime Type Identification”,也就是运行时类型识 别,即在运行时动态地识别某一个指针所指对象的真实类型。实际上,C++的这种机制,也 是在 vtable 的帮助下完成的。 在这样一个继承体系中: class Base { public: virtual void show() { cout << "Base" << endl; } }; class Derived : public Base { public: void show() { cout << "Derived" << endl; } }; 我们进行这样的测试: Base b; Derived d; for (int i = -5; i < 5; i++) { cout << *(*(int **)&b + i) << endl; } 即把 Base 类的 vtable 的前前后后都打印出来,我电脑的结果是: 32 1919907616 540701540 0 4293796 4264236 0 1702060345 0 0 显然,这 10 个数字中不是每一个数字都对于 vtable 有意义;但我们也可以很清楚地看 出来,在对 vptr 解引用的偏移量为 0 的地方,“4264236”这个值我们之前讨论过了,是虚 函数 show 的入口地址;而在偏移量为-1 的地方,“4293796”这个值跟函数 show 的入口地 址相差不大,肯定也指向了程序的代码区。 我们再进一步观察: int i = *(*(int **)&b - 1); cout << typeid(*p).name(); 这一段的结果就不用看了,我们主要来看一看它对应的反汇编。查到变量 i 的值是 4293796,在 VS2005 的汇编查看器中输入这个地址: Base::`RTTI Complete Object Locator': 004184A4 00 00 add byte ptr [eax],al 004184A6 00 00 add byte ptr [eax],al 004184A8 00 00 add byte ptr [eax],al „„ 原来,这个地址就是“Base::`RTTI Complete Object Locator”,也就是说,在 vtable 的索 引处为-1 的地方,保存的是这个类的类型信息。 由于手头没有更多的资料,要想继续弄明白微软的编译器是如何实现 vtable 以及 type_info 就有点困难了。我们的讨论到此为止,如果有谁知道更多东西的话,恳请赐教。
还剩52页未读

继续阅读

下载pdf到电脑,查找使用更方便

pdf的实际排版效果,会与网站的显示效果略有不同!!

需要 15 金币 [ 分享pdf获得金币 ] 2 人已下载

下载pdf

pdf贡献者

hudan2

贡献于2012-01-02

下载需要 15 金币 [金币充值 ]
亲,您也可以通过 分享原创pdf 来获得金币奖励!
下载pdf