• 【CLR】继承的实现


       最近在看JavaScript的面向对象,通过prototype实现继承链,这时就在想,作为需要编译的语言C#是怎么在中间代码层面实现继承的呢?翻开很久没看的CLR VIA C#,也复习了我之前的文章

    重温CLR(一)CLR基础

    重温CLR(二)生成、部署以及程序集

    重温CLR(九) 接口

           这次我把里面涉及到数据类型和关系的内容整理如下:

    元数据

    元数据:(metadata)元数据简单地说就是一个数据表集合。一些数据表描述了模块中定了什么(比如类型及成员),另一些描述了模块引用了什么(比如导出的类型及其成员)。元数据是一些老技术的超集,不如COM的类型库和IDL文件,元数据总是与包含IL代码的文件管理。元数据有多种用途,下面仅列举一部分:

    1 元数据避免了编译时对原生C/C++头和 库文件的需求,因为在实现类型/成员的IL代码文件中,已包含有关引用类型/成员的全部信息。编译器直接从托管模块读取元数据。

    2 Visual Studio用元数据帮助你写代码。智能感知(intelliSense)技术会解析元数据,告诉你一个类型提供了哪些方法、属性、事件和字段。以及相应参数。

    3 CLR的代码验证过程使用元数据确保代码只执行”类型安全”的操作

    4 元数据允许垃圾回收器跟踪对象生成期。垃圾回收器能判断任何对象的类型,并从元数据知道哪个对象中的那些字段引用了其他对象。

      我们都知道,一般.net 程序都是用Program作为入口,但是整个项目编译完成后,Program被编译成什么样子呢?托管PE文件由4部分组成:PE头、CLR头、元数据及IL。

      这里PE头是windows要求的标准信息,不多解释。

    CLR头是一个小的信息块,包含模块生成时所面向的CLR的major(主)和minor(次)版本号;一些flag;一个MehtodDef token,该token制定了模块的入口,;一个可选的强名称数字签名。最后,CLR头还包含模块内部的一些元数据表的大小和偏移量。

           元数据是本文的重点。元数据是由几个表构成的二进制数据块。有三种表,分别是定义表(definition table)、引用表(reference table)和清单表(mainifest table)。

    定义表

     

           编译器编译源代码时,代码定义的任何东西都导致在表2-1列出的某个表中创建一个记录项。

    引用表

           在创建的元数据中包含一组引用表,他们记录了所引用的内容,如下

     

     

           可用多种工具检查托管PE文件中的元数据。ILDasm是其中一种,显示信息如下

     

           program.exe包含名为Program的TypeDef。Program是公共密封类,由system.object派生。program类型还定义了两个方法:main和.ctor(构造器)。

           main是公共方法,用IL代码实现。main返回类型是void,无参。构造器(名称始终是.ctor)是公共方法,也用IL代码实现。构造器返回类型是void,无参,有一个this指针(指向调用方法时构造对象的内存)。

    清单表

     

    由于有了清单的存在,程序集的使用者不必关心程序集划分细节。

    “运行时”如何解析类型引用

    class Program
    {
        static void Main(string[] args)
        {
            Console.WriteLine("Hi");
        }
    }

    编译以上代码并生成程序集(假定名为Program.exe)。运行应用程序,CLR会加重并初始化自身,读取程序集CLR头,查找表示了应用程序入口方法(main)的MethodDefToken,检索MethodDefToken元数据表找到方法的IL代码再文件中的偏移量,将IL代码JIT编译成本机代码,最后执行本机代码。

     

           对这些代码进行JIT编译,CLR会检测所有类型和成员的引用,加载他们的定义程序集(如果尚未加载)。具体地说,IL call指令应用了元数据toekn 0A000003。该token表示memberRef元数据表中的记录项3。CLR检查该memberRef记录项,发现它的字段引用了TypeRef表中的记录项(system.Console类型)。按照

    TypeRef记录项,CLR被引导至一个AssemblyRef记录项

     

    这时CLR知道了它需要的是哪个程序集。接着,CLR必须定位并加载该程序集。

     

    运行时执行过程

           本节将解释类型、对象、线程栈和托管堆在运行时的相互关系。此外,还将解释调用静态方法、实例方法和虚方法的区别。

           图4-2展示了已加载clr的一个windows进程。该进程可能有多个线程。线程创建时会分配到1mb的栈。栈空间用于向方法传递实参,方法内部定义的局部变量也在栈上。栈从高位内存地址向低位内存地址构建,图中现在已执行了一些代码,栈上已有一些数据了(栈顶阴影区域)。现在,假定线程执行的代码要调用M1方法。

     

           最简单的方法包含“序幕”(prologue)代码,在方法开始做工作前对其进行初始化;还包含“尾声”(epilogue)代码,在方法做完工作后对其进行清理,以便返回至调用者。M1方法开始执行时,它的“序幕”代码在线程栈上分配局部变量name的内存,如下图。

     

    然后M1调用M2方法,将局部变量name作为实参传递。这造成name局部变量中的地址被压入栈,如图4-4。M2方法内部使用参数变量s标识栈位置(注意:有的cpu架构用寄存器传递实参以提升性能,但这个区别对于当前问题不重要)。另外,调用方法时还会将“返回地址”压入栈,被调用的方法在结束之后应返回至该位置。

     

           M2方法开始执行时,它的“序幕”代码在线程栈中为局部变量length和tally分配内存,如图4-5。然后M2方法内部代码开始执行,最终,M2抵达它的return语句,造成CPU的指令执针被设置成栈中的返回地址,M2的栈展开(unwind)。之后,M1继续执行M2调用之后的代码,M1的栈帧将准确反应M1需要的状态。

     

           最终,M1会返回到它的调用者。

           现在,让我们围绕CLR来调整一下讨论,假如有以下两个类定义:

     

           windows进程已启动,clr已加载到其中,托管堆已初始化,而且已创建一个线程(连同它的1mb栈空间)。线程已执行了一些代码,马上就要调用M3方法。图4-6展示了目前的状态。

     

           jit编译器将M3的IL代码转换成本机CPU指令时,会注意到M3内部引用的所有类型,这时CLR要确认定义了这些类型的所有程序集都已加载。然后,利用程序集的元数据,clr提取与这些类型有关的信息,创建一些数据结构来表示类型本身。

           稍微讨论一下这些类型对象。堆上的所有对象都包含两个额外成员:类型对象指针(type object pointer)和同步块索引(sync block index)。定义类型时,可在类型内部定义静态数据字段。为这些静态数据字段提供支援的字节在类型对象中分配。每个类型对象最后都包含一个方法表。在方法表中,类型定义的每个方法都有对应的记录项。

           当CLR确认方法需要的所有类型对象都已创建,M3的代码编译之后,就允许线程执行M3的本机代码。M3的“序幕”代码执行时必须在线程栈中为局部变量分配内存,如图4-8所示。顺便说一句,作为“序幕”代码的一部分,clr自动将所有局部变量初始化为null或者0.然而,如果代码试图访问尚未显式初始化的局部变量,c#会报告错误,使用了未赋值的局部变量。(跟JavaScript的变量声明提升类似)

     

           此外,在调用类型的构造器(本质上是可能修改某些实例数据字段的方法)之前,clr会先初始化同步块索引,并将对象的所有实例字段设为null或者0new 操作符返回对象的内存地址,该地址保存在变量e中(变量e在线程栈上)。

     

    M3的下一行代码调用Employee的静态方法Lookup。调用静态方法时,clr会定位与定义静态方法的类型对应的类型对象。然后,jit编译器在类型对象的方法表中查找与被调用方法对应的记录项,对方法进行jit编译(如果需要的话),再调用Jit编译好的代码。由于lookup方法在堆上构造一个新的manager对象,用joe的信息初始化它,返回该对象的地址。该地址保存到局部变量e中。这个操作结果如图4-10。

           注意,e不再引用第一个manager对象。事实上,由于没有变量引用该对象。所以他是未来垃圾回收的主要目标。

     

           M3的下一行代码调用Employee的非虚实例方法GetYearsEmployed。调用非虚实例方法时,jit编译器会找到与“发出调用的那个变量e的类型”对应的类型对象Employee。如果Employee类型没有定义正在正在调用的那个方法,jit编译器会回溯类层次结构(一直回溯到object,这跟JavaScript去查原型链类似,这也是方法覆盖的实现原理),并在沿途的每个类型中查找该方法。之所以能这样回溯,因为每个类型对象都有一个字段引用了它的基类型,这个信息在图中没有显示。

           M3调用Employee的虚实例方法GetProgressReport时,jit编译器要在方法中生成一些额外的的代码;方法每次调用都会执行这些代码。这些代码首先检查发出调用的变量,并跟随地址来到发出调用的对象。变量e当前引用的是代表“joe”的manager对象,所以会调用manager的GetProgressReport实现

     

           以上我们讨论了源代码、il和jit编译的代码之间的关系。还讨论了线程栈、实参、局部变量以及这些实参和变量如果引用托管堆上的对象。结束本章之前,我们一起探讨下clr内部发生的事情。

           注意Employee和Manageer类型对象都包含“类型对象指针”成员。这是由于类型对象本质上也是对象clr创建类型对象时,必须初始化这些成员。初始化成什么呢?clr开始在一个进程中运行时,会立即为MSCorLib.dll中定义的system.Type类型创建一个特殊的类型对象Employee和Manageer类型对象都是该类型的“实例”。因此,他们的类型对象指针成员会初始化成对System.type类型对象的引用。如图4-13

           当然,System.Type类型对象本身也是对象,内部也有“类型对象指针”成员。这个指针指向什么?他指向它它而本身,因为system.type类型对象本身是一个类型对象的“实例”。顺便说一句,system.object的getType方法返回存储在指定对象的“类型对象指针”成员中的地址。也就是说,getType方法返回指向对象的类型对象的指针。这样就可判断系统中任何对象(包括类型对象本身)的真实类型。

     

    clr如何调用虚方法、属性和事件

           本方法代表在类型或类型的实例上执行某些操作的代码。在类型上执行操作,称为静态方法;在类型的实例上执行操作,称为非静态方法。所有方法都有名称、签名和返回类型(可为void)。clr允许类型定义多个同名方法,只要每个方法都有一组不同的参数或者一个不同的返回乐行。所以,完全能定义两个同名、同参数的方法,只要两者返回类型类型不同。但除了IL汇编语言,大多数语言(包括c#)在判断方法的唯一性时,除了方法名之外,都只以参数为准,方法返回类型会被忽略。

           一下employee类定义了3种不同的方法

    Internal class Employee{
           //非虚实例方法
           public int32 GetYearsEmployed(){………}
           //非虚实例方法
           public virtual string GetProgressReport(){………}
           //非虚实例方法
           public static Employee Lookup(string name){………}
    }

           编译上述代码,编译器会在程序集的方法定义表中写入3个记录向,每个记录项都用一组标志(flag)指明方法时实例方法、虚方法还是静态方法。

           写代码调用这些方法,生成调用代码的编译器会检查方法定义的标志(flag),判断应如何生成il代码来正确调用方法。clr提供两个方法调用指令。

    Call

           该il指令可调用静态方法、实例方法和虚方法。用call指令调用静态方法,必须指定方法的定义类型。用call指令调用实例方法或虚方法,必须指定引用了对象的变量。call 指令鉴定该变量不为null。换言之,变量本身的类型指明了方法的定义类型。如果变量的类型没有定义该方法,就检查基类型来查找匹配方法。call指令经常用于以非虚方式调用虚方法。

    callvirt

           该il指令可调用实例方法或虚方法,不能调用静态方法。与call命令的区别是,callvirtual会先检查调用变量是否为空,所以执行速度比call稍慢。

     

    编译器有时用call而不是callvirt调用虚方法,比如有时候子类使用base.tostring(),这是c#编译器生成call指令来确保以非虚方式调用基类的tostring方法。

           无论用call还是callvirt调用实例方法或虚方法,这些方法通常接收隐藏this实参作为方法第一个参数。this实参引用要操作的对象。

           设计类型时应尽量减少虚方法数量。首先,调用虚方法的速度比调用非虚方法慢。其次,jit编译器不能内嵌(inline)虚方法,这进一步影响性能。第三。虚方法使组件版本控制变得更脆弱。第四,定义基类型时,经常要提供一组重载的简便方法。如果希望这些方法时多态的,最好的办法就是使最复杂的方法成为虚方法,使所有重载的简便方法称为非虚方法。

    隐式和显式接口方法实现(幕后发生的事情)

           类型加载到clr中时,会为该类型创建并初始化一个方法表。在这个方法表中,类型引入的每个新方法都有对应的记录项;另外,还未该类型继承的所有虚方法添加了记录下。继承的虚方法既有继承层次结构中的各个基类型定义的,也有接口类型定义的。所以,对于下面这个简单的类型定义:

    interter sealed class SimpleType : IDisposable {
        public void Dispose() { console.WriteLine("Dispose"); }
    }

    类型的方法表将包含以下方法对应的记录项:

    1)Object(隐式继承的基类)定义的所有虚实例方法。

    2)IDisposable(实现的接口)定义的所有接口方法。本例只有一个方法,即Dispose,因为IDisposable只定义了该方法。

    3)SimpleType 引入的新方法Dispose。

    为简化编程,C#编译器假定SimpleType引入的Dispose方法是对IDisposable的Dispose方法的实现。C#编译器之所以做出这样的假定,是因为Dispose方法的可访问性是public,而且接口方法的签名和新引入的方法完全一致,也就是说,这两个方法具有相同的参数和返回类型。还有,如果新的Dispose方法被标记为virtual,C#编译器仍会认为该方法匹配于接口方法。

    C#编译器将一个新方法和一个接口方法匹配起来之后,便会生成元数据,指明SimpleType类型的方法表中的两个记录项引用同一个实现。下面的代码演示了如果调用类的公共Dispose方法以及如何调用IDisposable的Dispose方法在类中的实现:

    public static void Main()
    {
        SimpleType st = new SimpleType();
        // 调用公共的 Dispose 方法实现
        st.Dispose();
        // 调用 IDisposable 的 Dispose 方法实现
        IDisposable d = st;
        d.Dispose();
    }

    执行后,两者是没有任何却别的。输出结果都是Dispose

    在第一个dispose方法调用中,调用的是SimpleType定义的dispose方法。然后定义IDisposable接口类型的变量d,它引用SimpleType对象。调用SimpleType时,调用的是IDisposable接口的dispose方法。由于c#要求公共dispose方法同时是IDisposable的Dispose方法实现,所以会执行相同的代码。

    现在我们更改一下SimpleType,以便看出区别:

    public sealed class SimpleType : IDisposable
    {
         public void Dispose() { Console.WriteLine("public Dispose"); }
         void IDisposable.Dispose() { Console.WriteLine("IDisposable Dispose"); }
    }

    现在再次程序运行,会得到如下结果;

    public Dispose

    IDisposable Dispose

           在c#中,将定义方法的那个接口的名称作为方法名前缀(例如IDisposable.Dispose),就会创建显式接口方法实现(explicit interface Method Implementation,EIMI)。注意,c#中不允许在定义显式接口方法时指定可访问性(比如public或private)。但是,编译器生成方法的元数据时,可访问性会自动设为private,防止其他代码在使用类的实例时直接调用接口方法。只有通过接口类型的变量才能调用接口方法。

           还要还要好主意,eimi方法不能标记为virtual,所以不能被重写。这是由于eimi方法并非真的是类型的对象模型的一部分,它只是将接口和类型连接起来,同时避免公开行为/方法。

  • 相关阅读:
    【BZOJ1645】[Usaco2007 Open]City Horizon 城市地平线 离散化+线段树
    【BZOJ4196】[Noi2015]软件包管理器 树链剖分
    【BZOJ4698】Sdoi2008 Sandy的卡片 后缀数组+RMQ
    【BZOJ4278】[ONTAK2015]Tasowanie 后缀数组
    mysql中使用concat例子
    SAP basis 常用事物
    推和敲
    踏和走
    下一个该你啦
    长城:恐惧的纪念碑
  • 原文地址:https://www.cnblogs.com/qixinbo/p/12753137.html
Copyright © 2020-2023  润新知