背景&现象

我们知道,在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处理的,当子类被初始化的时候,调用顺序应该是下面这样:

  1. 初始化子类成员
  2. 初始化基类成员
  3. 执行基类构造函数部分
  4. 执行子类构造函数部分

这里的结论正确与否,其实还待进一步验证,只能说,当前这个结论可以对我的疑惑进行一个合理的解释,如果你看到了这篇文章觉得哪里不对,还请给出指正意见。

参考资料

https://mattwarren.org/2019/09/26/Stubs-in-the-.NET-Runtime/