转载

匹夫细说C#:从园友留言到动手实现C#虚函数机制

前言

上一篇文章匹夫通过CIL代码简析了一下C#函数调用的话题。虽然点击进来的童鞋并不如匹夫预料的那么多,但也还是有一些挺有质量的来自园友的回复。这不,就有一个园友提出了这样一个代码,这段代码如果被编译成CIL代码的话,对虚函数的调用会使用call而非callvirt:

override string ToString() {     return Base.ToString(); } 

至于为何是这样,匹夫在回复中也做了解释,因为上面那段代码其实相当于是这样的:

override string ToString() {     return this.ToString(); }

所以如果使用callvirt的话,会产生无限递归的情况。当然,关于CIL代码中的call和callvirt的讨论是上一篇文章的内容,在本篇文章,匹夫还是想就这个例子作为引子,聊一聊C#中的虚函数机制。

假如只有静态函数

上一篇文章中,匹夫举了一个使用“call”来调用对象为null的实例函数的例子,通过那个例子我们发现了原来实例函数需要将当前实例的引用作为参数传入。由于是上一篇文章《 用CIL写程序:从“call vs callvirt”看方法调用 》中的园友回复才让匹夫有了写这篇文章的想法,所以这里匹夫还使用上一篇文章中的例子,只不过把那篇文章中的CIL代码换成C#。

现在假设我们有了以下3个类。

public class People {  private string name = "People";  public virtual void Introduce()  {   string str = "我是People类,我叫" + this.name;   System.Console.WriteLine(str);  } } public class Murong : People {  private string name = "慕容小匹夫";  public override void Introduce()  {   string str = "我是Murong类,我叫" + this.name;   System.Console.WriteLine(str);  } } public class ChenJD : People {  private string name = "陈嘉栋";  public void Introduce()  {   string str = "我是ChenJD类,我叫" + this.name;   System.Console.WriteLine(str);  } } 

还记得前一篇文章中,匹夫提到过的 编译时类型和运行时类型 吗?简单回顾一下,对编译器来说,变量的类型就是你声明它时的类型,也就是编译时类型,假设为TypeA。但是,往往有这种情况,就是你实例化了另一个类型,假设为TypeB,并且将这个实例的引用赋值给了你之前声明的那个变量。这就是说,在这段程序运行的时候,编译阶段被定义为TypeA类型的变量所指向的是一块存储了类型TypeB的实例的内存。这里,TypeB便是运行时类型。搞清楚这一点,我们才能继续下文的内容。那就是我们声明一个People类型的变量,然后再将它的派生类实例的引用赋值给这个变量,看看会有一些什么有趣的事情发生。

public class Test1 {  static void Main()  {   //编译时类型是People,运行时类型是People   People person = new People();   person.Introduce();   //编译时类型是People,运行时类型是Murong   person = new Murong();   person.Introduce();   //编译时类型是People,运行时类型是ChenJD   person = new ChenJD();   person.Introduce();   //编译时类型是ChenJD,运行时类型是ChenJD   ChenJD chen = new ChenJD();   chen.Introduce();  } } 

这组实现其实在上一篇文章中,匹夫使用CIL代码实现过。那么这里我们再重新用C#来做一遍。老套路,编译运行。

匹夫细说C#:从园友留言到动手实现C#虚函数机制

这四条输出结果,前2条都十分正常,没有什么可奇怪的。但是在ChenJD这个类中,并没有使用override关键字去重写基类People中的虚方法Introduce,而是直接实现了一个自己的实例函数Introduce。

而奇怪的事情也恰恰发生在和ChenJD这个类相关的操作中,那就是当变量声明为People类时,即使将ChenJD类的实例引用赋值给这个变量,调用Introduce方法,但输出的却不是ChenJD中重新定义的那个Introduce方法,反而很奇怪的调用起了基类People的Introduce方法。

与此同时,声明为ChenJD类的变量chen,在调用Introduce方法时,的确是选择了ChenJD类重新定义的Introduce。

那么我们能直观的发现一些什么(从最直观的角度看)?

  • 即便将变量声明为People,换言之变量的编译时类型是People。但是将不同的实例引用赋值给它,它也会根据运行时类型寻找正确的被重写的方法去调用。例如Murong中使用override关键字重写的方法Introduce。
  • 将变量声明为People,和将变量声明为ChenJD(也就是说编译时类型一个是People,一个是ChenJD),即使它们的运行时类型都是ChenJD,但是调用的Introduce方法显然不同。

的确有点意思了,是吗?

那么现在假设我们的手中只有静态方法,换言之上面例子中的虚方法,实例方法其实全部是静态方法变化而来的,那么我们应该如何通过静态方法来实现实例方法和虚方法的功能呢?

先从实例方法下手

实例方法和静态方法有什么不同呢?大概你会说一个目标是实例,一个目标是类。不错,但除此之外它们还有什么本质的区别吗?貌似没有了。那么好,如果我们只有静态方法,如何去实现一个实例方法的功能呢?不错,把实例的引用当做这个静态方法的一个参数。

那么我们就以上面的ChenJD类中的Introduce方法入手,使用静态方法的形式去实现一个实例方法的功能。

//用静态方法实现实例方法     public static void Introduce(ChenJD _this)     {         string str = "我是ChenJD类,我叫" + _this.name;         System.Console.WriteLine(str);     }

那么我们该如何调用呢?

很简单,直接调用ChenJD这个类的静态方法Introduce,同时将它的实例引用作为参数传入这个静态方法。

//调用静态函数实现的实例函数 ChenJD chen = new ChenJD(); ChenJD.Introduce(chen);

编译运行的结果和上面调用实例函数是一样的。

所以,实例函数的实现,其实就是靠将当前实例的引用作为参数_this传入一个静态函数中,只不过这个参数_this对我们不可见罢了。

OK,可为什么匹夫你饶了一大圈聊怎么用静态函数实现实例函数呢?这个和本文的主题有关系吗?当然有,因为如果所谓的虚函数也是用静态方法实现的呢?

从静态方法到虚函数

其实,实现c#的虚函数机制只需要静态函数和委托就够了。所以,进入下面的内容之前我们要先抛弃现有的一些现成的概念,比如实例函数。

此时,我们假设我们手中只有静态函数和委托,下面匹夫就带领各位一起去一探虚函数的究竟吧。

_this是谁很重要

虚函数有什么特点呢?嗯~,匹夫简单想了想,最大的特点可能就是需要具备在运行时选择正确的重写版本的能力。

假如没有现成的虚函数的存在,那么在运行时才决定要调用哪个函数的能力,会让你想到谁呢?

不错,前方一大波 delegate 仿佛就在眼前。

但是还是要注意啊,我们现在没有所谓的实例方法的存在,有的只是静态方法。那么我们所有的虚函数和重写方法,应该怎么表示呢?

不错,和刚刚才说过的实例方法的实现方式一样,将 _this 作为静态函数不可见的第一个参数传入。那么问题来了, _this 到底应该是什么类型的呢?

这为什么是一个问题呢?

因为你可以有很多派生类,派生类中又可以重写虚函数,那么这个虚函数的第一个参数 _ this 到底是谁就很重要了。所以,第一个参数 _this 就是声明这个函数的那个类型实例。具体到刚刚的例子,声明为People类型的变量,即便被赋值为Murong的实例引用、ChenJD的实例引用却都是去最初声明了虚函数Introduce的People中去分派符合的重写方法的版本,当Murong使用了override关键字的时候,People能够找到Murong的重写版本,所以调用了Murong的重写版本。而由于ChenJD类中并没有重写基类的虚方法,而是重新定义了一个自己的Introduce方法,所以People找不到符合的重写版本,输出的就是最初定义的Introduce。而声明为ChenJD的变量,在调用Introduce方法时,_this已经变成了ChenJD类,和People已经没有关系了。

明白了这一点,我们探索C#的虚函数机制就完成了51.23198%了。

delegate有话说

上文已经说了,为了实现虚函数能够在运行时选择正确重写版本的能力,我们可以考虑使用委托。将调用函数换个思路,变成对委托的调用,这样自然就实现了根据不同的情况,调用不同函数。

那么我们再来改写一下上文中的例子。

public class People {  //新增的  public Action<People> DelegateIntroduce;  public string name = "People";  public static void Introduce(People _this)  {   string str = "我是People类,我叫" + _this.name;   System.Console.WriteLine(str);  } } public class Murong : People {  public string name = "慕容小匹夫";  public static void Introduce(People _this)  {   string str = "我是Murong类,我叫" + _this.name;   System.Console.WriteLine(str);  } } public class ChenJD : People {  public string name = "陈嘉栋";  public static void Introduce(People _this)  {   string str = "我是ChenJD类,我叫" + _this.name;   System.Console.WriteLine(str);  } } 

到此,匹夫将之前例子中的实例函数全部替换成了静态函数,而且还新增了一个委托Action<People> DelegateIntroduce。那么现在我们就利用这个委托,来实现我们的目标, 将对具体函数的调用,转换成对委托的调用。 那么首先我们显然需要一个方法,来对各个派生类的委托字段初始化赋值。

不过在此之前,匹夫查阅资料时发现了很有趣的一点,那就是现实的C#的虚函数槽(上一篇文章中提到过这个概念)其实是在类实例分配完内存之后,但是在实例构造器调用之前就被初始化了。所以,为了模拟这一点,我们不使用实例构造器(实例构造器主要负责实例的初始化,比如字段赋值等等),而引入一个静态Create方法,使用new来分配内存,之后初始化我们的DelegateIntroduce也就是委托字段,之后再做一些实例构造器做的事情。这里仅仅写出基类People的Create方法,它的派生类类似。

//使用静态方法创建实例       public static People Create()     {         People people = new People();//仅仅分配内存         People.InitVirCall(people);//初始化我们的委托         //TODO         //之后实例构造器要做的事情     }

之后就到了我们实现委托字段初始化的阶段了。那么无非是将派生类各自的重写方法赋值给委托。所以,我们在此将虚函数和使用了override关键字的重写版本赋值给对应的委托,而没有使用override关键字的方法则不在此列,例如ChenJD类中的Introduce方法。在此需要注意,对于派生类来说,首先要调用基类中定义的为委托字段赋值的方法,将虚函数最初的定义首先赋值给委托,这其实也就是为何当没有正确的重写版本时,会调用在基类中最初定义的那个方法。

//基类,也就是Introduce方法的原始定义的类。  public static void InitVirCall(People people)  {   people.DelegateIntroduce = People.Introduce;//保证了最原始(定义)的Introduce方法赋值给委托  } //派生类Murong,重写了Introduce方法  public static void InitVirCall(Murong murong)  {   People.InitVirCall(murong);//首先保证最原始也就是定义的方法在赋值给委托。   murong.DelegateIntroduce = Murong.Introduce;//其次如果有重写版本,再赋值给委托。如没有重写版本则不赋值。  } //派生类ChenJD,没有重写Introduce方法,而是重新定义了该方法。 //因为没有重写基类的Introduce方法,所以不能赋值给DelegateIntroduce  public static void InitVirCall(ChenJD chen)  {   People.InitVirCall(chen);   //因为ChenJD类中的Introduce是重新定义的,所以不加到委托中。  } 

到此。。。我们似乎又发现了一个新的问题。因为会涉及到实例的字段的问题,但是传入各个Introduce的都是People类的实例,那么字段的值就不能保证是派生类自己的了。比如编译一下上面的代码,输出的其实是:

匹夫细说C#:从园友留言到动手实现C#虚函数机制

所以为了能够匹配正确的类型,我们还需要进行一步转化。将People转化成对应的派生类。到此,我们就利用委托和静态方法实现了虚函数的机制。代码如下:

using System; public class Test1 {  static void Main()  {   People person =  People.Create();   person.DelegateIntroduce(person);   person = Murong.Create();   person.DelegateIntroduce(person);   person = ChenJD.Create();   person.DelegateIntroduce(person);   ChenJD chen = ChenJD.Create();   ChenJD.Introduce(chen);  } } public class People {  //新增的  public Action<People> DelegateIntroduce;  public string name = "People";  public static void Introduce(People _this)  {   string str = "我是People类,我叫" + (_this as People).name;   System.Console.WriteLine(str);  }  public static People Create()  {   People people = new People();//仅仅分配内存   People.InitVirCall(people);//初始化我们的委托   //TODO   return people;  }  public static void InitVirCall(People people)  {   people.DelegateIntroduce = People.Introduce;//保证了最原始(定义)的Introduce方法赋值给委托  } } public class Murong : People {  public string name = "慕容小匹夫";  public static void Introduce(People _this)  {   string str = "我是Murong类,我叫" + (_this as Murong).name;   System.Console.WriteLine(str);  }  public static Murong Create()  {   Murong murong = new Murong();//仅仅分配内存   Murong.InitVirCall(murong);//初始化我们的委托   //TODO   return murong;  }  public static void InitVirCall(Murong murong)  {   People.InitVirCall(murong);//首先保证最原始也就是定义的方法在赋值给委托。   murong.DelegateIntroduce = Murong.Introduce;//其次如果有重写版本,再赋值给委托。如没有重写版本则不赋值。  } } public class ChenJD : People {  public string name = "陈嘉栋";  public static void Introduce(ChenJD _this)  {   string str = "我是ChenJD类,我叫" + _this.name;   System.Console.WriteLine(str);  }  public static ChenJD Create()  {   ChenJD chen = new ChenJD();//仅仅分配内存   ChenJD.InitVirCall(chen);   //TODO   return chen;  }  //因为没有重写基类的Introduce方法,所以不能赋值给DelegateIntroduce  public static void InitVirCall(ChenJD chen)  {   People.InitVirCall(chen);   //因为ChenJD类中的Introduce是重新定义的,所以不加到委托中。  } } 

那么编译运行的结果如图,和本文开头时的结果一致:

匹夫细说C#:从园友留言到动手实现C#虚函数机制

到此,我们只使用 委托和静态方法 就基本实现了虚函数的机制。但是,似乎还缺点什么?假如我们有很多很多虚函数呢?是不是就意味着我们需要很多很多个委托字段呢?而且每个相同的类的实例的委托字段其实都是一样的,但是每一个实例本身都会包含一套委托字段。那样是不是太浪费,太任性了呢?

的确,所以c#虚函数的实现虽然也是这样的思路,但是具体的实现却要机智的多。

委托的集合---vtable

为了解决上文提到的空间浪费的问题,CLR其实是实现了一套所谓的虚函数分派表,或者叫做vtable。在c#中,一个vtable其实就是一套委托的集合。而这个虚表的作用就是使CLR具备了在运行时选择正确的虚函数重写版本的能力。

结合上文的例子实现一个vtable:

sealed class VTable     {       public readonly Action<People> Introduce;       public VTable(Action<People> delegateIntroduce)       {         this.Introduce = delegateIntroduce;       }     }

当然这个并非是个很好的例子,因为我们只有一个虚函数,所以显得略微势单力薄,不过你应该可以想象的出有很多虚函数时的样子吧。那我们应该如何利用这个虚表类来构建上例那三个类的对应的vtable呢?很简单,使用override关键字重写了虚方法的类,在 VTable 的构建函数中传入重写后的方法,如果没有重写,则传入该方法的原始定义。

//基类People的vtable的构建 private static VTable PeopleTable = new VTable(People.Introduce);   //派生类Murong的vtable的构建 //由于重写了Introduce,所以此处传入重写版本 private static VTable MurongTable = new VTable(Murong.Introduce);   //派生类ChenJD的vtable的构建 //由于没有使用override重写,所以此处直接传入在people中定义的原始Introduce private static VTable ChenJDTable = new VTable(People.Introduce);

这样,我们利用vtable就将原本在每个实例中的委托字段,作为类的静态字段放在对应的类中,而我们需要做的,就是为每个类构建正确的vtable,至于各个实例则只需要保留一份正确vtable的引用就可以了。

而作为实现C#的虚函数机制的结果就如下所示了:

using System; public class Test1 {  static void Main()  {   People person =  People.Create();   person.VTable.Introduce(person);   person = Murong.Create();   person.VTable.Introduce(person);   person = ChenJD.Create();   person.VTable.Introduce(person);   ChenJD chen = ChenJD.Create();   ChenJD.Introduce(chen);  } } public class People {  //供实例引用  public VTable VTable;  //构建类的vtable  private static VTable PeopleTable = new VTable(People.Introduce);   public string name = "People";  public static void Introduce(People _this)  {   string str = "我是People类,我叫" + (_this as People).name;   System.Console.WriteLine(str);  }  public static People Create()  {   People people = new People();//仅仅分配内存   people.VTable = People.PeopleTable;   //TODO   return people;  } } public class Murong : People {  private static VTable MurongTable = new VTable(Murong.Introduce);   public string name = "慕容小匹夫";  public static void Introduce(People _this)  {   string str = "我是Murong类,我叫" + (_this as Murong).name;   System.Console.WriteLine(str);  }  public static Murong Create()  {   Murong murong = new Murong();//仅仅分配内存   murong.VTable = Murong.MurongTable;   //TODO   return murong;  } } public class ChenJD : People {  //由于没有使用override重写,所以此处直接传入在people中定义的原始Introduce  private static VTable ChenJDTable = new VTable(People.Introduce);   public string name = "陈嘉栋";  public static void Introduce(ChenJD _this)  {   string str = "我是ChenJD类,我叫" + _this.name;   System.Console.WriteLine(str);  }  public static ChenJD Create()  {   ChenJD chen = new ChenJD();//仅仅分配内存   chen.VTable = ChenJD.ChenJDTable;   //TODO   return chen;  }  } //虚表 public class VTable {   public readonly Action<People> Introduce;   public VTable(Action<People> delegateIntroduce)   {  this.Introduce = delegateIntroduce;   } } 

编译运行,结果如图:

匹夫细说C#:从园友留言到动手实现C#虚函数机制

如果各位看官觉得文章写得还好,那么就容小匹夫跪求各位给点个“推荐”,谢啦~

正文到此结束
Loading...