MS Visual Studio对C++多继承的相关实现
本文的内容来自于在VS2013中的实验,只能说明一种可行的实现途径,不代表编译器必须这样实现。后续内容中如未特殊提及,编译器代指VS2013。
首先,我们来明确一个观点:在类成员方法中,this指针是否一定要指向这个对象的内存起始处?答案是不一定,指向起始处当然是很方便我们这些脑算成员变量偏移的,因为只用做加法就行了,但是对于编译器而言,它只需要确定好一个关于this指向的原则,随后基于这个原则,不管偏移要加法还是减法,对它来说都是没什么区别的。因此,this的指向实际上是编译器自己决定的,它可以遵照常规的想法,在进入成员方法代码前,把this调整至对象内存起始,或者是为了性能,采用它自己的原则。
显然,VS这样的编译器使用的是后者,它出于性能考虑,约定了自己的原则:this指针在一个类成员方法中的指向,取决于该方法的来源。具体来说:
- 如果该方法首次声明在本类中,那么this指向本类对象的内存起始位置。
- 如果该方法首次声明不在本类中,即来自于父类,那么this应指向第一个声明此方法的父类,也就是多继承的情况下,在子类的视角中,顺序靠前的父类覆盖靠后的父类。
由于类成员方法是可以在继承关系中调用的,即子类对象可以调用父类的public成员方法。那么,按照这个原则中的第2条,编译器需要做一些调整工作。
下面举个例子,并结合例子来讲讲this调整的过程:
namespace base_adjust_rule
{
class P1
{
public:
virtual void f1() {}
};
class P2
{
public:
virtual void f1() {}
virtual void f2() {}
void f3() {}
};
class P3
{
public:
virtual void f2() {}
virtual void f3() {}
void f4() {}
};
class C : public P1, public P2, public P3
{
public:
virtual void f1() {}
virtual void f2() {}
virtual void f3() {}
};
void test()
{
C *pc = new C;
P1 *pp1 = pc;
P2 *pp2 = pc;
P3 *pp3 = pc;
pc->f4();
pc->f1();
pc->f2();
pc->f3();
}
}
示例代码很简单,子类C继承三个父类,测试代码中使用子类的指针去调用一系列成员方法,下面依次来从编译器的角度看看每个调用涉及的一些细节以及调整实现。
首先来看pc->f4()。从编译器的角度来看,pc的类型是C*,调用的方法f4的首次声明在父类P3中,并且C没有覆盖f4,所以调用的是P3::f4,按照原则,进入此方法前,this应该指向C对象中的P3部分的开始,即this应该调整到和示例代码中的pp3相等。推断完毕,来看看编译器是怎么进行这个调整的,上反汇编:
很明显,VS生成的代码中利用ecx来传递this指针,可以看到,在进行call之前,编译器加入了一句add ecx 8
,目的就是将this调整到和pp3一致,简单地计算一下右边监视变量的值确定了这一点。
接着看pc->f1()。按照原则,要把this调整至P1部分,而因为P1是C的第一个父类,在VS的多继承内存布局中,第一个父类的指针和子类指针指向相同,所以不需要进行任何调整,ecx直接等于pc,然后通过虚表指针获取f1的地址(放至eax),就执行call了。反汇编图证明了这一点。类似于pc->f4(),pc->f2()和pc->f3()都要进行调整,只是各自的偏移不同而已。
结合示例代码及反汇编图,可以明确,VS确实是在遵守我们一开始所说的原则的,并且,即使你使用的是类对象而非对象指针去调用方法,原则依旧保持。如果感兴趣且想做进一步验证的话,可以在方法中加入一些访问成员变量的代码,然后在方法内部反汇编,查看它访问变量时的偏移计算,从而确定this指向。
上面的示例代码比较简单,都是使用pc去访问方法,而对于编译器来说,从类C的角度它可以在编译期就完全确定如何调整,所以才能生成一系列的add代码。但如果从P2的角度去分析的话,编译器将无法在编译期确定这些偏移量,下面将说明这一点并讲讲编译器如何处理这种情况。
继承结构不变,测试代码更换如下:
void test2()
{
P2 *p21 = new C;
P2 *p22 = new P2;
p21->f1();
p22->f1();
}
可以看到,在编译期是没法确定如何对P2*进行调整的,对于C而言,f1是来自于P1的,它要调整至P1,而对于P2而言,它不需要调整,因为它的f1来自于自身。所以,这个调整只能放到运行期,依赖于对象内存中的内容(实际上就是虚表)。看看内存布局,单步一下反汇编代码就可以明白VS的处理了,截图如下:
可以看到,两个调用的反汇编代码完全相同,即访问虚表中的第一项,然后调用它,传入的参数即为调用时使用的指针。而从右边可以看到,对于p22,我们知道是不需要调整的,所以虚表中该项直接就是目标函数P2::f1;而对于p21,需要调整,所以编译器为C的内存布局中B部分虚表的第一项生成了thunk代码,并且此代码的作用是调整偏移再跳转(见下图),调整的目标也是按照原则使得this指向P1部分,调试器里对于虚表中该项的命名提示性也很强,就叫adjustor{4},大括号中的内容是偏移量。
注意,这里thunk是为类C生成的,因为C的父类中出现了同名的virtual方法。
提到了thunk,就顺便讲讲另外一种情况下的多态访问,即利用成员函数指针。这里的多态访问是指:使用指针或者引用去调用成员函数指针,需要保持多态语义。按照上面示例的继承结构,下面的代码中的调用都等价(子类指针是可以调用父类成员函数指针的):
void test3()
{
C *pc = new C;
P1 *pp1 = pc;
P2 *pp2 = pc;
pp1->f1();
pp2->f1();
auto p1f1 = &P1::f1;
(pc->*p1f1)();
(pp1->*p1f1)();
auto p2f = &P2::f1;
(pc->*p2f)();
(pp2->*p2f)();
// p2f的类型为void (P2::*)(),所以可以进行如下赋值
p2f = &P2::f3;
(pc->*p2f)();
(pp2->*p2f)();
}
对于调用成员函数地址,VS也有类似一开始那样的规定,即在进入该方法前,需要将this指向调整至对象内存中该方法所属的类的开始部分,即调用&P1::f1要调整成P1,调用&P2::f1要调整至P2,因为只有该类本身及子类的指针或对象能调用,这个调整在编译期就能完成,然后把调整完后的指针传入成员函数地址所代表的函数即可。但是,要应对成员函数是虚函数和普通函数,前者需要访问虚表,而后者是固定地址,这项工作就必须通过运行期来完成了,所以,对每一个取地址的虚成员函数,都有对应的thunk代码,代码完成的工作就是访问虚表的对应项的函数,然后传递参数。这个通过查看反汇编同样能够证明,见下面的图片:
简单解释一下:
- pc和pp1相等,都指向了P1部分,所以它们调用不需要任何调整。
- pp2指向P2部分,所以调用不调整。
- pc指向P1部分,所以调用&P2::f1和&P2::f3的时候都要调整。
- 查看右边(p2f及p1f1)可以看到,对于虚函数,都是诸如
[thunk]:base_adjust_rule::P1::'vcall'{0, {flat}}
这样的内容,而普通函数则无thunk。
如果你仔细跟踪(pc->*&P2::f1)(),你会发现,编译器在编译期把指针调整成了P2,而运行期,因为f1来自于P1,又要重新调整回去,很傻,但是没办法,利用成员函数指针来调用虚函数,在父类中存在同名时,就是会产生这样的一个代价。
这个实验至少告诉我们普通用户一点:尽量别让多个父类中出现同名方法,否则会导致一次额外的jmp。= =#这种看现象猜原则的过程也是蛮痛苦的,不过还好,总算是补完了。