背景&现象
我们知道,在Cpp中的基类构造函数里调用虚函数是一种十分危险的行为,因为在构造子类的时候,Cpp是先构造基类部分,然后再初始化子类的虚函数指针,那么在基类中调用这个虚函数的时候,虚函数指针并没有指向子类的虚函数表,导致基类构造函数中虚函数的行为呈现为基类中的实现——这往往不是我们所预期的。
下面给出一个简单的实验来验证上面的话:
#include <iostream>
using namespace std;
class A{
public:
int _value = 10;
A(){
IncreaseValue();
cout << "A constructor: value = " << _value << endl;
}
virtual void IncreaseValue(){
_value *= 2;
}
};
class B : public A{
public:
B(){
cout << "B constructor before: value = " << _value << endl;
IncreaseValue();
cout << "B constructor after: value = " << _value << endl;
}
void IncreaseValue() override{
_value++;
}
};
int main() {
B instance;
}
运行后输出如下:
A constructor: value = 20
B constructor before: value = 20
B constructor after: value = 21
显然,在执行A的构造函数的时候,执行的是A中的IncreaseValue
的实现。
在C#中,情况变得不那么一样了,在基类构造函数中调用虚函数会得到我们所预期的行为,即基类构造函数中调用了子类中对该函数的实现,这一事实可以由以下代码来证明:
using System;
var instance = new B();
class A
{
public int _value = 10;
public A()
{
IncreaseValue();
Console.WriteLine($"A constructor: value = {_value}");
}
public virtual void IncreaseValue()
{
_value *= 2;
}
}
class B : A
{
public B()
{
Console.WriteLine($"B constructor before call: value = {_value}");
IncreaseValue();
Console.WriteLine($"B constructor after call: value = {_value}");
}
public override void IncreaseValue()
{
_value++;
}
}
运行结果如下:
A constructor: value = 11
B constructor before call: value = 11
B constructor after call: value = 12
可以看出,基类的构造函数是调用的子类的实现。
分析
对于这个问题的分析可以从不同角度、不同层面来分析,比如可以从CLR分析,可以从C#语法思想分析等,但是这里就通过汇编代码来对这一问题进行分析。
为了调试效果,将上述代码更改为以下代码,在VS中开启反汇编选项之后进行调试:
using System;
var instance = new B(6);
Console.ReadLine();
class A
{
public int _value = 10;
public A()
{
IncreaseValue();
}
public virtual void IncreaseValue()
{
_value *= 2;
}
}
class B : A
{
public B(int a)
{
}
public override void IncreaseValue()
{
_value++;
}
}
将断点打在Console.ReadLine()
前面,然后再开始调试即可,我们可以在反汇编界面看到如下的代码;
var instance = new B(6);
00007FFDC55640A0 push rbp
00007FFDC55640A1 push rdi
00007FFDC55640A2 push rsi
......
00007FFDC55640DA mov qword ptr [rbp+30h],rax
00007FFDC55640DE mov rcx,qword ptr [rbp+30h]
00007FFDC55640E2 mov edx,6
00007FFDC55640E7 call Method stub for: B..ctor(Int32) (07FFDC5563638h)
00007FFDC55640EC mov rax,qword ptr [rbp+30h]
00007FFDC55640F0 mov qword ptr [rbp+38h],rax
Console.ReadLine();
00007FFDC55640F4 call CLRStub[MethodDescPrestub]@7ffdc53dca40 (07FFDC53DCA40h)
00007FFDC55640F9 mov qword ptr [rbp+28h],rax
00007FFDC55640FD nop
中间部分代码略去,其实我也不是很懂汇编,但是明显可以看出mov edx 6
这一语句是将参数入栈,然后下面的CALL指令是调用B的构造函数,那么就顺着CALL指令所调用的地址07FFDC5563638h
去进行查找,我们可以看到如下的一段汇编代码:
00007FFDC5563639 movsxd ecx,dword ptr [rdi]
00007FFDC556363B add byte ptr [rax],al
00007FFDC556363D pop rdi
00007FFDC556363E add byte ptr [rcx],al
00007FFDC5563640 jmp B.IncreaseValue() (07FFDC5564660h)
00007FFDC5563645 pop rdi
00007FFDC5563646 add eax,dword ptr [rax]
00007FFDC5563648 xor byte ptr [rsi+7FFDC55Eh],0
00007FFDC556364F add al,ch
00007FFDC5563651 cmp esi,dword ptr [rcx-60h]
00007FFDC5563654 pop rdi
这里只需要关注第五行的B.IncreaseValue() (07FFDC5564660h)
即可,如果足够敏锐,就会发现这是很奇怪的,因为B类的构造函数中,并没有调用IncreaseValue
这个函数,更何况是调用的B中的实现。唯一的解释就是:在C#中,子类的构造函数并不是简单先构造基类部分,而是以一种inline的形式直接改写了子类的构造函数,从而使得基类构造过程中,可以获取到子类的实现。
然而,上面的结论仍然有很大的漏洞,就是成员的初始化,上面的汇编代码并没有体现出A中对于成员_value
的初始化过程,因此我们不妨继续做实验来探索以下,将代码改成如下形式:
using System;
var instance = new B(6, 8);
Console.ReadLine();
class A
{
public int _valueA = 10;
public A(int x)
{
IncreaseValue(x);
}
public virtual void IncreaseValue(int x)
{
_valueA = x;
}
}
class B : A
{
public int _valueB = 35;
public B(int a, int b) : base(a)
{
_valueB = b;
}
public override void IncreaseValue(int x)
{
_valueA = 2 * x;
}
}
与刚才类似,我们直接找到实例化B的时候入栈后操作的起始位置,如下:
00007FFDD9063FF8 jmp B..ctor(Int32, Int32) (07FFDD9064500h)
00007FFDD9063FFD pop rdi
00007FFDD9063FFE add byte ptr [rcx],al
00007FFDD9064000 jmp B.IncreaseValue(Int32) (07FFDD90645E0h)
00007FFDD9064005 pop rdi
00007FFDD9064006 add eax,dword ptr [rax]
00007FFDD9064008 cwde
00007FFDD9064009 mov dh,0Eh
00007FFDD906400B fscale
00007FFDD906400D jg CLRStub[MethodDescPrestub]@7ffdd906400f (07FFDD906400Fh)
00007FFDD906400F add al,ch
00007FFDD9064011 jnp CLRStub[MethodDescPrestub]@7ffdd906407a (07FFDD906407Ah)
这里与刚才最明显不同的地方是,在开头先调用了B..ctor
,名字上看起来是B的构造函数的意思,这里也蛮奇怪的,按理来说,现在应该已经在B的构造函数中了才对,为什么又调用了一次呢?那么我们按照地址跳转过去,可以看到B.Ctor
实际上是下面的这些汇编代码:
class B : A
{
public int _valueB = 35;
00007FFDD9064500 push rbp
00007FFDD9064501 push rdi
00007FFDD9064502 push rsi
00007FFDD9064503 sub rsp,20h
00007FFDD9064507 mov rbp,rsp
00007FFDD906450A mov qword ptr [rbp+40h],rcx
00007FFDD906450E mov dword ptr [rbp+48h],edx
00007FFDD9064511 mov dword ptr [rbp+50h],r8d
00007FFDD9064515 cmp dword ptr [7FFDD8F6AD20h],0
00007FFDD906451C je B..ctor(Int32, Int32)+023h (07FFDD9064523h)
00007FFDD906451E call 00007FFE38B9CEE0
00007FFDD9064523 mov rcx,qword ptr [rbp+40h]
00007FFDD9064527 mov dword ptr [rcx+0Ch],23h
public B(int a, int b) : base(a)
00007FFDD906452E mov rcx,qword ptr [rbp+40h]
00007FFDD9064532 mov edx,dword ptr [rbp+48h]
00007FFDD9064535 call Method stub for: A..ctor(Int32) (07FFDD9063C48h)
00007FFDD906453A nop
{
00007FFDD906453B nop
_valueB = b;
00007FFDD906453C mov rax,qword ptr [rbp+40h]
00007FFDD9064540 mov edx,dword ptr [rbp+50h]
00007FFDD9064543 mov dword ptr [rax+0Ch],edx
}
35的16进制表示是23H,明显可以看出,mov dword ptr [rcx+0Ch],23h
这一行代码是执行了对_valueB
的初始化,然后下面又调用了A..ctor
,我们如法炮制,最终会运行如下代码:
class A
{
public int _valueA = 10;
00007FFDD9064570 push rbp
00007FFDD9064571 push rdi
00007FFDD9064572 push rsi
00007FFDD9064573 sub rsp,20h
00007FFDD9064577 mov rbp,rsp
00007FFDD906457A mov qword ptr [rbp+40h],rcx
00007FFDD906457E mov dword ptr [rbp+48h],edx
00007FFDD9064581 cmp dword ptr [7FFDD8F6AD20h],0
00007FFDD9064588 je A..ctor(Int32)+01Fh (07FFDD906458Fh)
00007FFDD906458A call 00007FFE38B9CEE0
00007FFDD906458F mov rcx,qword ptr [rbp+40h]
00007FFDD9064593 mov dword ptr [rcx+8],0Ah
public A(int x)
00007FFDD906459A mov rcx,qword ptr [rbp+40h]
00007FFDD906459E call CLRStub[MethodDescPrestub]@7ffdd8eb0028 (07FFDD8EB0028h)
00007FFDD90645A3 nop
{
00007FFDD90645A4 nop
IncreaseValue(x);
00007FFDD90645A5 mov rcx,qword ptr [rbp+40h]
00007FFDD90645A9 mov edx,dword ptr [rbp+48h]
00007FFDD90645AC mov rax,qword ptr [rbp+40h]
00007FFDD90645B0 mov rax,qword ptr [rax]
00007FFDD90645B3 mov rax,qword ptr [rax+40h]
00007FFDD90645B7 call qword ptr [rax+20h]
00007FFDD90645BA nop
}
同理,mov dword ptr [rcx+8],0Ah
这一行代码是执行_valueA
的初始化。不过这里与B不同的地方是,在public A(int x)
下方,不是跳转到其它ctor代码了,而是跳转到了一个叫CLRStub[MethodDescPrestub]
的地方,这是一个stub调用,跟jit有关,常常在使用虚函数的时候出现。
说来惭愧,用了这么久C#,还没有好好读过CLR相关的书(准备去读一下),这一块我也无法给出详细的解释,只能说,根据程序的输出来看,这里的stub会让栈从A的构造函数处返回到B处,然后最后执行那个inline版本的IncreaseValue
。
总结
回过头来整理一下,通过上面的这些尝试,我认为,在C#汇编层面上来讲,初始化过程其实就是对象构造的一部分,但是,初始化过程在汇编代码中仍属于各自类的部分,而原本的“构造函数”部分,其实是会被inline处理的,当子类被初始化的时候,调用顺序应该是下面这样:
- 初始化子类成员
- 初始化基类成员
- 执行基类构造函数部分
- 执行子类构造函数部分
这里的结论正确与否,其实还待进一步验证,只能说,当前这个结论可以对我的疑惑进行一个合理的解释,如果你看到了这篇文章觉得哪里不对,还请给出指正意见。
参考资料
https://mattwarren.org/2019/09/26/Stubs-in-the-.NET-Runtime/