本文源于C++对话系列
类层次结构相当简单:
class parent { public: virtual void f(); // etc... }; class child : public virtual parent { public: void f(); };
我所做的修改之一就是把child虚拟地继承于parent,使它在这个类层次结构的任何地方都能使用。我尽了最大努力,但看上去我别无选择——我打算深入挖掘鲍勃的代码了。“又要搞破坏了,亲爱的朋友”,我嘀咕着,设置了一个断点,准备单步跟踪这个函数。经过半小时的严格调试,我从一团杂乱中找到了问题的根源:
void parentPtrPtr(parent **b) { (*b)->f(); } void childPtrPtr(child **d) { parentPtrPtr(reinterpret_cast<parent **>(d)); }
对于最后一个函数,我自有想法。我知道要谨慎对待cast,特别是reinterpret_cast。我去掉reinterpret_cast,不出所料,编译器卡在这行代码上,抱怨paraent**和child**是不相关的类型。
“你的怀疑是对的,我的学徒工。”当Guru柔和的声音在我身后响起时,我记得只有一次没被她吓一跳。这次也是如此,但没有太吃惊。“指向child的指针的指针,”她接着说:“不能被隐式转换为指向base的指针的指针。
我向后踢了踢腿,伸了伸懒腰,“我也这样想。鲍勃所做的一切好象就是为了避免编译器报错。我不理解为什么它能在原先的类上工作,但现在却会导致崩溃。唉,我甚至不明白为什么编译器不能做隐式类型转换。”
“你现在所体验的崩溃正是它不允许这么做的十足理由,”Guru理了理耳后一络银色的头发,“你的派生类虚拟地继承于基类。”我象一只在车灯前不知所措的鹿一样紧紧盯着她,让她知道我没搞明白。“考虑一种简单继承的情况,”她说,拿起一支笔,开始在我的白色书写板上写起来:
class parent { /* 不管怎样都行,但至少要有一个虚拟函数*/ }; class child : public parent { /* 随便怎样都行 */ };
“虚拟函数的一种典型实现,”她接着说,“在内存布局中,编译器首先会给类附加一个指向虚拟函数表(vptr)的指针,然后是parent类的数据,再接下来是child类的数据。”
parent::vptr |
parent data |
child data |
“等一下,”我打断说,“你不是经常告诉我虚拟函数表并非标准所必需,而只是实现方案的细枝未节?”
“是这样,我的的孩子。神圣的标准并没有规定必须要有虚拟函数表,事实上,它甚至没提到它。然而,庄严的标准委员会的成员们的目标之一就是规范化现有编译器的实际情况。允许child** 隐式转换为parent**将在许多现有的编译器上造成问题。其中一个特别的问题就是我接下来要说的,如果你允许我说下去的话。”她严肃地看着我,我没敢说什么。
“这种布局方式意味着this所指向的地址可以既是基类又是派生类。使用我们所假设的编译器,this始终指向vptr。派生类的成员函数可以简单地通过this取得其数据成员的地址,只要加上一个包括基类大小和vptr大小的偏移量即可。
“然而,当你在这个混合体里再加上虚拟继承时,情况就变得更加复杂,考虑一下:”
class parent { /* whatever */ }; class child1 : public virtual parent { /* whatever */ }; class child2 : public virtual parent { /* whatever */ }; class multi : public child1, public child2 { /* whatever */ };
“显然,由于上面parent类的数据紧随child类的数据之后,编译器无法对中间类child1和child2采取和child一样的布局形式,因为虚拟继承规定只能出现基类数据的一份拷贝。解决方案——确切地说,解决方案之一就是在每个子对象的开始处附加一个vptr,如下所示:”
parent::vptr |
parent data |
child1::vptr |
child1 data |
child2::vptr |
child2 data |
multi::vptr |
multi data |
“这样,当编译器触发各种成员函数时,它可以动态地调整隐式的this参数,从而使成员函数能够正确地访问成员数据。”
“哦!我明白你的意思了,”我说,“当你有一个multi对象时,指向它的指针的值有时会稍有不同,这取决于它是指向parent子对象还是child1子对象。这也就解释了为什么中间类必须使用virtual关键字——我以前一直认为这是个错误,而multi应该是使用virtual的类。所以,不管怎样,让我们回到原来的那个问题。这就是说……嗯……不,我还是不太明白,这跟指向指针的指针又有什么关系呢?”
她慢条斯理地说:“让我们给这些值分配任意的内存地址,假定每个类恰好包含一个整型数,这样整型数和指针的大小都是4个字节,不存在填充和对齐问题。让我们再加上一些代码,举几个multi类的实例:”
void f() { multi m; multi *pm = &m; multi **ppm = ± //...
Address | Description | Value |
0x1000 | parent::vptr | ? |
0x1004 | parent data | ? |
0x1008 | child1::vptr | ? |
0x100c | child1 data | ? |
0x1010 | child2::vptr | ? |
0x1014 | child2 data | ? |
0x1018 | multi vptr | ? |
0x101c | multi data | ? |
0x1020 | Pm | 0x1000 |
0x1024 | Ppm | 0x1020 |
“编译器将multi对象地址的第一个字节作为multi指针所指向的地方,也就是0x1000。现在假定你往这个函数里再添加一些变量:”
//... child1 * pc1 = &m; child1 ** ppc1 = &pc1; //...
“编译器简单地取m的地址,将它调整为正确的子对象(在这里,也就是child1子对象的起始处),把它赋值给pcl。ppcl只是简单地包含pc1的地址:”
Address | Description | Value |
0x1028 | Pc1 | 0x1008 |
0x102c | ppc1 | 0x1028 |
//... “但现在假定你想把ppm赋值给ppcl:” //… ppc1 = ppm; //该怎么处理呢? //…
“假定编译器允许你把装在ppm内的值直接赋值给ppc1,ppc1的值就会被设为0x1020,也就是pm的地址,pm指向的是multi的基地址0x1000。当你对ppc1进行“提领”(dereference)操作时,你希望得到一个指向child1指针,但事实上你得到的却是指向parent的指针(或指向multi的指针,它们在我们假定的编译器里有相同的基地址。)”
“我明白了,”我惊叹道,“但等一下,为什么它在原来的类里没问题呢?”
“在非虚拟的继承里,这个特定的编译器把相同的基地址赋值给child和parent子对象。本质上,这两个地址的比特模式(bit-pattern)恰好相同,所以reinterpret_cast碰巧能正确工作。”
“太好了,”我说,“我明白这个道理了。现在我该怎么解决这个问题呢?”
“记住这句话‘每个问题都可以通过附加一个间接层来解决’。现在这种情况下,我的孩子,我们的间接层已经太多了。”
“太多的间接层,”我陷入沉思,突然,灵光一闪,“去掉一个间接层!”我迅速地在白色写字板写了一些代码。
void childPtrPtr(child **d) { parent *pp = *d; parentPtrPtr(&pp); }
“很好,我的孩子。当你将child*赋值给parent*时,就会对this参数进行调整。现在,parentPtrPtr函数得到的是正确的指向parent的指针的指针。”