代码改变世界

您真的了解类型转换吗?请止步,解惑!

2011-08-29 00:11  空逸云  阅读(2290)  评论(33编辑  收藏

不久前,因为对类型转换CLR的底层实现很朦胧,万不得已下,发了一篇博文请园里的各位同学,大大解惑。

您真的了解类型转换吗?请止步,求解!

很多热心的园友纷纷发表了自己的意见和见解,在各位童鞋的帮助下,逐渐理清了类型转换的内幕(也可能并不是很正确!),于是想再整理一次,欢迎大家指正,而且也延发了其他的问题,想与大家一起讨论。

类型转换的疑惑

在上个问题中,我声明了两个类,父类Person,子类Employee,当我实例化一个子类实例,并将其赋给父类的一个变量时,我很好奇,且不了解明明是子类的实例,结果能识别到父类的方法,也就是为什么能知道是父类调用了方法。

public class ClassConvert
{
    public static void Main()
    {
        new ClassConvert().Run();
    }

    Employee kinsen;
    object obj;
    Person kong;


    public void Run()
    {
        kinsen = new Employee("Kinsen", "Chan");
        kinsen.SayHello();
        kong = kinsen;
        kong.SayHello();
        obj = kong;

        Console.ReadLine();

        Console.WriteLine(kinsen.GetType());
        Console.WriteLine(kong.GetType());
        Console.WriteLine(obj.GetType());
        Console.ReadLine();
    }
}
public class Person
{
    public string FirstName { get; set; }
    public string LastName { get; set; }
    public Person(string firstname, string lastName)
    {
        this.FirstName = firstname;
        this.LastName = lastName;
    }
    public void SayHello()
    {
        Console.WriteLine("Hello,Word");
    }

    public override string ToString()
    {
        return FirstName + " " + LastName;
    }
}
public class Employee : Person
{
    public Employee(string firstname, string lastname) : base(firstname, lastname) { }
    new public void SayHello()
    {
        Console.WriteLine("Hello,Word!My Name is " + base.FirstName);
    }

运行结果如下:

image

我们知道,引用类型主要数据信息是存放在托管堆中,而这块内存中包含三大块,同步快,类型句柄已经实例信息,对于类型转换也仅仅是一个isinst或castclass指令(相见《.Net本质论 79页》)。最后把块托管堆上的地址赋给线程栈上的变量,也就是说线程栈上的内存仅仅保存了一个指向托管堆上的实例地址,而托管堆上仅有该实例的数据,已经类型句柄等数据,其中类型句柄又指向该实例的具体方法实例,其中包含了方法表等类型信息。于是最后我们看到三个变量的GetType都是第一个New出来的对象。但是为什么调用的方法输出却不同,父类变量能正确的调用它的方法,这又是为什么呢?

方法表与方法槽表

这其中涉及到方法表与方法槽表等方面的知识,然而关于这块内部的实现,MSDN却没有什么官方资料,仅仅只能从一些MVP和开发者的笔下了解到这块的存在。在上篇博文中,Anders Tan大哥对这方面做了一个解释:

方法表并不是指简单的方法列表。它包含了很多的东西,它代表了一个class(不是class的实例对象),其中既有方法列表也有static成员等等,所以这也就解释了为什么同一个class的实例中的static都是一样的,因为static成员就存放在方法表中,而每个实例的type handle都指向自身class的方法表。接着方法表是一个包含很多元素的一个对象,所以在其中的方法列表也就是给了另外一个概念,就是方法槽表,在方法槽表中的方法也是按一定顺序排列的。首先是父类的virtual方法,然后是自身的virtual方法,如果是override了父类的方法,那么父类的virtual方法就会被子类的方法所覆盖(这也就解释了在polymorphism下,向上转型后调用virtual方法会执行真正实例的方法),接着是实例方法和静态方法。在方法槽后就是static成员。单是方法槽表本身并不包含其中各个方法的地址,我们知道.net程序在编译后是IL代码,但是在执行的时候由JIT再编译为本地代码,那么在方法调用和方法体的关联就会在执行后发生变化,而这个变化并不在方法槽表中处理,而是交给了方法描述。

《.Net本质论》对这块也有详细的描述:

方法表是一个带有长度前缀的内存地址数组,每个方法都有一个入口项。CLR方法表既包含实例来方法的入口,有包括静态方法的入口。(《.Net本质论》P155第一段倒数第三行,以下若无特别说明,则都摘自《.Net 本质论》)

CLR通过方法的声明类型的方法表路由(route)所有的方法调用。(P155第二段)

类型方法表的每个入口项指向一个唯一的存根例程(stub routine)。初始化时,每个存根例程包含一个对于CLR的JIT编译器的调用(它由内部的PreStubWorker程序公开)P156第二段

在这里,我并不打算深究方法表与方法槽表,仅仅是对于它们做一个简单的介绍,了解它们是什么,好方便我进一步的探究。

那什么是方法槽表,本质论中没有对槽表做一个明确的定义,但是从描述中我们也可以“想象”出它该有的形象,方法槽表顾名思义,是一张表结构的数据类型,可以将其想象成一排排的USB接口(槽口),槽口上插入(保存)的就是方法表上的偏移量(定位了方法表上的方法)。方法槽表上的顺序首先是父类的virtual方法,然后是自身的virtual方法,再是自身的方法。

多态方法表(槽表)的内在模式

CLR中,声明一个方法,都会为该方法加上一个newslot标记,表明这是一个新方法,若一个方法声明为virtual且没有标明newslot标记,那么CLR就将其看成是一个新方法,否则就看成是基类同名方法的重写,如果标明了newslot,那槽表上开辟多一个槽位来保存这个新方法,若虚方法没有标明newslot,则把方法槽表上相应的“槽位”变成新方法的方法表偏移量,否则(没有重写虚方法),保存了基类方法的方法表偏移量。

具体调用

那么说了那么多,到底类型是怎么转换的?原来,我都过多的把中心放在数据中(内存),期望从托管堆,线程栈上找到什么。当然,这注定失败,我完全忽略了代码的作用,毕竟,程序的执行也就是逐步执行代码(指令/机器码),上篇博文中qmxle童鞋提到IL的实现,让我醍醐灌顶,茅舍顿开。

楼主,SayHello()方法不是虚方法的话,是在编译时绑定的。看看IL代码就明白了:
IL_000b: newobj instance void ConsoleApplication15.Program/Employee::.ctor(string,
string)
IL_0010: stloc.0
IL_0011: ldloc.0
IL_0012: callvirt instance void ConsoleApplication15.Program/Employee::SayHello()
IL_0017: nop
IL_0018: ldloc.0
IL_0019: stloc.1
IL_001a: ldloc.1
IL_001b: callvirt instance void ConsoleApplication15.Program/Person::SayHello()
第一个SayHello()方法,绑定的是Employee类型;第二个SayHello()方法,绑定的是Person类型。

这也就符合了《.Net本质论》中的说法。

当从一个对象引用的类型转换到另一个对象引用的类型时,必须考虑两个类型之间的关系。如果初始化引用的类型被认定与新引用的类型兼容,那么,CLR所要做的转换只是一个简单的IA-32 mov指令。这通常出现于这样的赋值情形中;当一个派生类型的引用到一个直接或间接基类的引用,或则到一个一直兼容的接口引用。

所以引用类型之间的类型转换并不存在什么效率消耗的问题,它们之间的效率消耗仅仅在转换之前做一个兼容性检查时会消耗CPU时间,而不像装箱,拆箱那样很大的性能消耗。对于新类型的操作,就是依靠CPU指令(代码)来识别了。看最后生成的IL:

//省略前面...
  IL_000c:  newobj     instance void DebugTest.Employee::.ctor(string,
                                                               string)
  IL_0011:  stfld      class DebugTest.Employee DebugTest.ClassConvert::kinsen
  IL_0016:  ldarg.0
  IL_0017:  ldfld      class DebugTest.Employee DebugTest.ClassConvert::kinsen
  IL_001c:  callvirt   instance void DebugTest.Employee::SayHello()
  IL_0021:  nop
  IL_0022:  ldarg.0
  IL_0023:  ldarg.0
  IL_0024:  ldfld      class DebugTest.Employee DebugTest.ClassConvert::kinsen
  IL_0029:  stfld      class DebugTest.Person DebugTest.ClassConvert::kong
  IL_002e:  ldarg.0
  IL_002f:  ldfld      class DebugTest.Person DebugTest.ClassConvert::kong
  IL_0034:  callvirt   instance void DebugTest.Person::SayHello()
 //省略后面...

从上面可以看出,kinsen变量调用的是Employee的SayHello方法,而Kong变量调用的是Person的SayHello方法。这是因为

SayHello方法不是虚方法,且Employee类对SayHello方法用了new关键字,CLR识别了它们不是同一个方法,但是这样,我又引发了另一个问题。

新问题!您知道吗?

从上面的IL中,可以看到,分别调用了Employee和Person类SayHello,下面,我们把SayHello改成虚方法,并重写。

public class Person
{
    public string FirstName { get; set; }
    public string LastName { get; set; }
    public Person(string firstname, string lastName)
    {
        this.FirstName = firstname;
        this.LastName = lastName;
    }
    public virtual void SayHello()
    {
        Console.WriteLine("Hello,Word");
    }

    public override string ToString()
    {
        return FirstName + " " + LastName;
    }
}
public class Employee : Person
{
    public Employee(string firstname, string lastname) : base(firstname, lastname) { }
    override public void SayHello()
    {
        Console.WriteLine("Hello,Word!My Name is " + base.FirstName);
    }
}

结果如下:

image

现在他们的输出一样了,我们再看看生成的IL。

  IL_000c:  newobj     instance void DebugTest.Employee::.ctor(string,
                                                               string)
  IL_0011:  stfld      class DebugTest.Employee DebugTest.ClassConvert::kinsen
  IL_0016:  ldarg.0
  IL_0017:  ldfld      class DebugTest.Employee DebugTest.ClassConvert::kinsen
  IL_001c:  callvirt   instance void DebugTest.Person::SayHello()
  IL_0021:  nop
  IL_0022:  ldarg.0
  IL_0023:  ldarg.0
  IL_0024:  ldfld      class DebugTest.Employee DebugTest.ClassConvert::kinsen
  IL_0029:  stfld      class DebugTest.Person DebugTest.ClassConvert::kong
  IL_002e:  ldarg.0
  IL_002f:  ldfld      class DebugTest.Person DebugTest.ClassConvert::kong
  IL_0034:  callvirt   instance void DebugTest.Person::SayHello()

现在,它们调用的都是Person类的SayHello了,为什么会变成Person呢?预想中应该是Employee类的SayHello才对,如果调用

Employee类的SayHello方法,那一切都能合理的解释,但调用Person,程序是如何确定是Employee的SayHello方法呢?另外,我知道每个类型维护一张方法表,依稀记得一篇MSDN杂志上的文章说,如果在子类中找不到相关调用的方法,则会去父类的方法表中找,那么也就是子类和父类维护的方法表不一样,子类的方法表中不会出现父类的方法?或许,在这里的调用程序如此,首先,会根据当前实例instance找到类型句柄,定位到方法槽表,然后寻找槽表中匹配的方法,随后调用?如果是如我猜想的这般,子类重写的虚方法在方法槽表中的名称还是父类方法的全名,而非子类的名称?

尾声

希望各位童鞋发表自己的见解,也希望各位大大能抒发所学,不吝解答!对于上篇文章,很多童鞋都给出了自己的见解,给我理清概念有很大的帮助,在此很感谢大家,希望大家继续发光发热!:-)