zoukankan      html  css  js  c++  java
  • 继承本质论

    接口 -- 粗略的技术规范
    抽象类 -- 对粗略的技术规范作了一些简单的说明
    类 -- 技术规范的具体实现

    1. 引言

    关于继承,你是否驾熟就轻,关于继承,你是否了如指掌。

    本文不讨论继承的基本概念,我们回归本质,从编译器运行的角度来揭示.NET继承中的运行本源,来发现子类对象是如何实现了对父类成员与方法的继承,以最为简陋的示例来揭示继承的实质,阐述继承机制是如何被执行的,这对于更好的理解继承,是必要且必然的。

    2. 分析

    下面首先以一个简单的动物继承体系为例,来进行说明:

        public abstract class Animal

        {

            public abstract void ShowType();

            public void Eat()

            {

                Console.WriteLine("Animal always eat.");

            }

        }

        public class Bird: Animal

        {

            private string type = "Bird";

            public override void ShowType()

            {

                Console.WriteLine("Type is {0}", type);

            }

            private string color;

            public string Color

            {

                get { return color; }

                set { color = value; }

            }

        }

        public class Chicken : Bird

        {

            private string type = "Chicken";

            public override void ShowType()

            {

                Console.WriteLine("Type is {0}", type);

            }

            public void ShowColor()

            {

                Console.WriteLine("Color is {0}", Color);

            }

        }


    然后,在测试类中创建各个类对象,由于Animal为抽象类,我们只创建Bird对象和Chicken对象。

        public class TestInheritance

        {

            public static void Main()

            {

                Bird bird = new Bird();

                Chicken chicken = new Chicken();

            }

        }


    下面我们从编译角度对这一简单的继承示例进行深入分析,从而了解.NET内部是如何实现我们强调的继承机制。

    (1)我们简要的分析一下对象的创建过程:

                Bird animal = new Bird();

    Bird bird创建的是一个Bird类型的引用,而new Bird()完成的是创建Bird对象,分配内存空间和初始化操作,然后将这个对象赋给bird引用,也就是建立bird引用与Bird对象的关联。

    (2)我们从继承的角度来分析在编译器编译期是如何执行对象的创建过程,因为继承的本质就体现于对象的创建过程。

    在此我们以Chicken对象的创建为例,首先是字段,对象一经创建,会首先找到其父类Bird,并为其字段分配存储空间,而Bird也会继续找到其父类Animal,为其分配存储空间,依次类推直到递归结束,也就是完成System.Object内存分配为止。我们可以在编译器中单步执行的方法来大致了解其分配的过程和顺序,因此,对象的创建过程是按照顺序完成了对整个父类及其本身字段的内存创建,并且字段的存储顺序是由上到下排列,object类的字段排在最前面,其原因是如果父类和子类出现了同名字段,则在子类对象创建时,编译器会自动认为这是两个不同的字段而加以区别。

    然后,是方法表的创建,必须明确的一点是方法表的创建是类第一次加载到CLR时完成的,在对象创建时只是将其附加成员TypeHandle指向方法列表在Loader Heap上的地址,将对象与其动态方法列表相关联起来,因此方法表是先于对象而存在的。类似于字段的创建过程,方法表的创建也是父类在先子类在后,原因是显而易见的,类Chicken生成方法列表时,首先将Bird的所有方法拷贝一份,然后和Chicken本身的方法列表做以对比,如果有覆写的虚方法则以子类方法覆盖同名的父类方法,同时添加子类的新方法,从而创建完成Chicken的方法列表。这种创建过程也是逐层递归到Object类,并且方法列表中也是按照顺序排列的,父类在前子类在后,其原因和字段大同小异,留待读者自己体味。

    结合我们的分析过程,现在将对象创建的过程以简单的图例来揭示其在内存中的分配情形,如下:

     

    从我们的分析,和上面的对象创建过程可见,对继承的本质我们有了更明确的认识,对于以下的问题就有了清晰明白的答案:

    • 继承是可传递的,子类是对父类的扩展,必须继承父类方法,同时可以添加新方法。
    • 子类可以调用父类方法和字段,而父类不能调用子类方法和字段。
    • 虚方法如何实现覆写操作,使得父类指针可以指向子类对象成员。
    • new关键字在虚方法继承中的阻断作用。

    你是否已经找到了理解继承、理解动态编译的不二法门。

    3. 思考

    通过上面的讲述与分析,我们基本上对.NET在编译期的实现原理有了大致的了解,但是还有以下的问题,一定会引起一定的疑惑,那就是:

                Bird bird2 = new Chicken();

    这种情况下,bird2.ShowType应该返回什么值呢?而bird2.type有该是什么值呢?有两个原则,是.NET专门用于解决这一问题的:

    • 关注对象原则:调用子类还是父类的方法,取决于创建的对象是子类对象还是父类对象,而不是它的引用类型。例如Bird bird2 = new Chicken()时,我们关注的是其创建对象为Chicken类型,因此子类将继承父类的字段和方法,或者覆写父类的虚方法,而不用关注bird2的引用类型是否为Bird。引用类型不同的区别决定了不同的对象在方法表中不同的访问权限。

    注意

    根据关注对象原则,那么下面的两种情况又该如何区别呢?

                Bird bird2 = new Chicken();

                Chicken chicken = new Chicken();

    根据我们上文的分析,bird2对象和chicken对象在内存布局上是一样的,差别就在于其引用指针的类型不同:bird2为Bird类型指针,而chicken为Chicken类型指针。以方法调用为例,不同的类型指针在虚拟方法表中有不同的附加信息作为标志来区别其访问的地址区域,称为offset。不同类型的指针只能在其特定地址区域内进行执行,子类覆盖父类时会保证其访问地址区域的一致性,从而解决了不同的类型访问具有不同的访问权限问题。

     
    • 执行就近原则:对于同名字段或者方法,编译器是按照其顺序查找来引用的,也就是首先访问离它创建最近的字段或者方法,例如上例中的bird2,是Bird类型,因此会首先访问Bird_type(注意编译器是不会重新命名的,在此是为区分起见),如果type类型设为public,则在此将返回“Bird”值。这也就是为什么在对象创建时必须将字段按顺序排列,而父类要先于子类编译的原因了。

    思考

    1. 上面我们分析到bird2.type的值是“Bird”,那么bird2.ShowType()会显示什么值呢?答案是“Type is Chicken”,根据本文上面的分析,想想到底为什么?

    2. 关于new关键字在虚方法动态调用中的阻断作用,也有了更明确的理论基础。在子类方法中,如果标记new关键字,则意味着隐藏基类实现,其实就是创建了与父类同名的另一个方法,在编译中这两个方法处于动态方法表的不同地址位置,父类方法排在前面,子类方法排在后面。

     

    4. 结论

    在.NET中,如果创建一个类,则该类总是在继承。这缘于.NET的面向对象特性,所有的类型都最终继承自共同的根System.Object类。可见,继承是.NET运行机制的基础技术之一,一切皆为对象,一切皆于继承。本文从基础出发,深入本质探索本源,分析疑难比较鉴别。对于什么是继承这个话题,希望每个人能从中寻求自己的答案,理解继承、关注封装、玩转多态是理解面向对象的起点,希望本文是这一旅程的起点。

    Bird bird2 = new Chicken();
    bird2.ShowType();
    我们的分析过程可以这样来展开:
    1 bird2对象为一个Bird类型指针,但是指向的是Chicken类型的实例,因此在内存布局上,请注意布局图中chicken方法指针指向的Method Table表;
    2 ShowType为Bird类型的一个虚函数,并且在子类Chicken中覆写,也就是说Chicken实例中的ShowType方法已经被覆写为新的实现,因此该方法的返回值为“Type is Chicken”;
    3 当bird2调用ShowType方法时,它只能获得被重新覆写过的方法,结果自然是“Type is Chicken”。

    另外,需要留意new关键字对父类方法的隐藏作用。
    #41楼 58.211.245.* 2007-10-23 10:48 | 孤舟垂钓[未注册用户]
    lz您好,想跟你请教一个问题。您文中说“首先将Bird的所有方法拷贝一份,然后和Chicken本身的方法列表做以对比,如果有覆写的虚方法则以子类方法覆盖同名的父类方法,同时添加子类的新方法,从而创建完成Chicken的方法列表。
    ”既然子类方法覆盖同名的父类方法,那么当base父类的方法时,请问上哪去找父类的方法?谢谢你的解惑。

      回复  引用  查看    
    #42楼[楼主]2007-10-23 16:16 | Anytao      
    @孤舟垂钓
    其实二者并不矛盾,首先关于子类虚方法覆写父类方法的机制问题,本文已经有了较详细的论述,所以暂不讨论。
    我想你主要关心的是base关键字的使用问题。本人另外一篇文章《第六回:深入浅出关键字---base和this 》http://www.cnblogs.com/anytao/archive/2007/05/04/must_net_06.html对此有一定的论述,仅供参考。
    简单来说,.NET提供了base关键字来主要完成两件事:一是在派生类中访问基类被覆写的方法;二是在派生类中调用基类的构造函数。这就像提供this关键字来当前实例一样,就是.NET提供的一种机制。
    如果要强调base调用父类方法是去哪儿找其父类方法这个问题,可以这样来描述,首先找到父类对象,然后根据父类对象的方法表指针定位到该方法,请注意:上述关于子类覆写发生在子类而非父类,父类方法表中仍然是父类本身的方法,你可以从本文的那张模拟图中找到答案。

    1 bird2实例和chicken在内存中的布局都是一样的,其实就是执行了托管堆中的同一块地址,这一点应该很好理解,因为都是以new Chicken()来创建的。
    关于创建过程,你可以参考
    http://www.cnblogs.com/anytao/archive/2007/12/07/must_net_19.html
    的详细论述。
    2 不同点需要关注的是bird2和chicken的类型,一个为Bird,一个为Chicken,所以二者对于new Chicken()创建的实例对象的访问权是不同的,这个由那个附加信息offset来保证。所以二者虽然指向了同一块地址,但是其可以访问的范围是有区别的。

    关于 Bird b = new Chicken();的问题,好象在面试的时候经常会考到:)
    根据你的讲解,我的理解是这样的,请指教:
    这种情况,用文中提到的两个原则来解释就好了,
    1)关注对象原则,也就是说,这个时候创建的是Chicken对象,我们只关注Chicken的内存分布情况就好了:
    字段:(按照顺序)b_type,chicken_type
    方法表:chincken的showtype方法覆盖了父类Bird的showType方法,所以此时b.showtype(),执行的就是chincken的showtype方法。
    2)执行就近原则
    如果要执行b.type,很明显,首先找到的就是b_type,输出即可

    首先感谢你的悉心研究,关于你的问题,其实没有抓住问题的要点。我在本文中自己创造了两个方法:关注对象原则和执行就近原则。对于继承的分析,按照这种思路是完全没问题的。

    一定注意的是,”brid2指针应该指向Chicken方法表“,这是没有错的,但是Chicken方法表中同时存在其父类的方法,至于为什么调用了brid类的showI()方法,则是执行就近原则的解释。

    下面,首先关注一下执行就近原则,我想应该能很好的给出关于你的示例的答案。

    执行就近原则:对于同名字段或者方法,编译器是按照其顺序查找来引用的,也就是首先访问离它创建最近的字段或者方法,例如上例中的bird2,是Bird类型,因此会首先访问Bird_type(注意编译器是不会重新命名的,在此是为区分起见),如果type类型设为public,则在此将返回“Bird”值。这也就是为什么在对象创建时必须将字段按顺序排列,而父类要先于子类编译的原因了。

    对于覆写来说其实是针对虚方法而言的,使得虚方法的调用决议于运行时的类型检查,从而可能表现出不同的调用结果;对于非虚方法而言,其在编译时皆可确定关联,因此,对于执行就近和关注对象来说这两个原则而言,其作用对象关注的是对虚方法的在运行时的调用分析,而不是非虚方法。

    如本文分析的一样,.NET的每个类型都对应一个方法表,其中包括了继承的虚方法,自定义虚方法,自定义实例方法和静态方法等。并且指向被称为Method Stubs的结构,其中包括了类似call 00001234这样用于指向JIT Compiler,详细参见下图所示。方法第一次被调用时,CLR根据其声明时类型选择相应的call xxxxxxxx调用JIT编辑,并生成CPU指令保持在动态内存yyyyyyyy,同时更新Method Stubs中的call xxxxxxxx为jmp yyyyyyyy,用于在下次调用是直接寻址。这就是JIT编译的一般过程,可以相应的解释:
    using System;

    class A
    {
    public void Foo()
    {
    Console.WriteLine("Call on A.Foo()");
    }

    }class C : A
    {
    public void Foo() //这里应该就加个new
    {
    Console.WriteLine("Call on C.Foo()");
    }

    }

    class D
    {
    static void Main()
    {

    A c1 = new C();
    c1.Foo();

    Console.ReadLine();

    }
    }
    的执行结果,可见非虚方法是在编译时就根据其声明类型确定了执行过程。大多数情况下,我建议将公有方法声明为虚方法,这样可以避免父类于子类的名称冲突问题,事实上,Java语言的所有方法都被默认为虚方法。

    对于非虚方法的调用,其决议于运行时,对客户来说其执行过程是“动态的”,子类可以通过override来覆写父类方法,因此必须清楚类型在内存中的详细情况,才能很好的分析方法的执行结果。这两个原则正是面向这一情况提出的判断方式,目的是为了在内存分配细节的基础上,给出一个更好理解的标准。所以,不管是关注对象,还是执行就近,都最终着眼于实际的内存分配状况。这是万变不离其综的原则。所以,我们可以继续分析AGPSky提出的另一个示例,做以简单的修改:
    using System;

    class A
    {
    public virtual void Foo()
    {
    Console.WriteLine("Call on A.Foo()");
    }

    public virtual void M()
    {
    Console.WriteLine("Call on A.M()");
    }

    }
    class C : A
    {
    public override void Foo()
    {
    Console.WriteLine("Call on C.Foo()");
    }

    public virtual new void M()
    {
    Console.WriteLine("Call on C.M()");
    }

    }

    class D
    {
    static void Main()
    {

    A c1 = new C();
    c1.Foo();//关注对象

    c1.M();//执行就近

    Console.ReadLine();
    }
    }
    上面代码输出:
    Call on C.Foo()
    Call on A.M()
    首先应该明确的是,内存的情况,我以下面的例图做以简要的分析:



    然后再根据两个原则来分析执行结果和过程:
    由内存情况很容易可知,关注对象原则:c1指向了C实例,在方法表中继承A的两个虚方法,一个为Foo(),一个为M(),其中Foo被子类覆写,而M()被new阻断没有覆写,因此c1.Foo()调用的实际是子类覆写之后的方法C.Foo(),如图所示;而根据执行就近原则,c1本身被声明为A类型,因此调用c1.M()方法将执行A.M(),而不是C.M()。

    所以,综上而言,两个原则是互为补充相辅相成的,我们同时要关注对象,同时要执行就近,分析的关键还是看内存的情况,方法表的指派。

  • 相关阅读:
    nuget
    C#枚举中使用Flags特性
    情感分析
    docker
    core部署
    脱壳系列_2_IAT加密壳_详细分析(含脚本)
    安全公司-* * * *-面试题:_ 安卓逆向分析分享
    18_ShadowWalker
    17_页面异常接管
    16_TLB与流水线
  • 原文地址:https://www.cnblogs.com/tangself/p/1627686.html
Copyright © 2011-2022 走看看