• C#继承

    最高层的实体往往具有最一般、最普遍的特征,而越下层的事物越具体,并且下层包含了上层的特征。

    它们之间的关系是基类与派生类之间的关系。继承 (inheritance) 是面向对象语言刻画实际生产生活对象的一个有力工具。

    通过继承一个现有的类,新创建的类可以不需要写任何代码,就可以按照继承来的父类中的合适的访问权限直接拥有所继承的类的功能,同时还可以创建自己的专门功能,这使得代码可以重用。

    显然,方法表是幕后的最大功臣。在创建子类对象时,子类的方法表包括了父类的虚方法和指向父类方法表的指针,使得子类可以调用父类的方法。

    另外,如果存在方法的重写,则会用子类的方法替代掉父类的方法。

    当我们要修改类中共同的成员时,只需要修改父类中的共同类成员,就可修改继承这个父类的子类们,而如果我们想单独地修改子类中的类成员,是不会影响到父类的,更不会影响到从父类中继承的其他子类们,这说明了继承关系只会向下传递,子类本身的调整并不会影响基类。

    有些语言支持多重继承,一个子类可以同时有多个父类,比如 C++,而在有些程序语言中,一个子类只能继承自一个父类,比如 Java/C# 程序语言,这时可以利用接口来实现与多重继承相似的效果。

    C# 中类型只能继承自一个类型,但可以再继承多个接口。如果是这样的情况,那么方法表中会增加两项:

    • 指向方法表中“接口虚表”的指针。
    • “接口虚表”包括了一组指针,每个指针都指向该类型实现的一个接口的方法表。 接口虚表指针的顺序按照代码中书写的继承顺序排列。

    在学习引用类型的初始化时,我们知道子类型的实例对象中含有父类型的实例字段成员,因此,子类型可以轻易的访问父类型的字段。

    对于静态成员,子类型不能访问父类型的静态成员,这是因为静态成员是属于类型的。

    那么,现在在字段层面上,继承问题解决了。 我们来看看方法层面上情况是怎么样的。

    子类型的方法表含有父类型的虚方法,所以虚方法的调用没问题,但是普通方法呢?子类型不含有父类型的非虚方法,那么,子类型该去哪里寻找父类型的普通方法?实际上,子类型的方法表根本不需要再加入父类型的普通方法,它们已经存在于父类型的方法表之中,没必要再重复。

    所以,为了让子类型可以调用到父类型的方法,子类型的方法表包括一个指向父类型方法表的指针。而父类型的虚方法不在方法槽表中,是因为它们有被重写的可能。

    类型加载器负责初始化整个方法表,它会遍历类型以及它的所有父类,包括接口的元数据。在方法表的排列过程中,如果遇到方法的重写,就替换掉父类的虚方法。

    方法表与继承

    在讨论引用类型的初始化时,我们知道子类型的实例对象中含有父类型的实例字段成员,因此,子类型可以轻易的访问父类型的字段。

    对于静态成员,子类型不能访问父类型的静态成员,这是因为静态成员是属于类型的。那么,现在在字段层面上,继承问题解决了。 我们来看看方法层面上情况是怎么样的。

    子类型的方法表含有父类型的虚方法,所以虚方法的调用没问题,子类型的方法表不需要再加入父类型的普通方法,它们已经存在于父类型的方法表之中, 没必要再重复。

    所以,为了让子类型可以调用到父类型的方法,子类型的方法表包括一个指向父类型方法表的指针。而父类型的虚方法不在方法槽表中,是因为它们有被重写的可能。

    类型加载器负责初始化整个方法表,它会遍历类型以及它的所有父类,包括接口的元数据。

    在方法表的排列过程中,如果遇到方法的重写,就替换掉父类的虚方法。

    Call 与 Callvirt

    Call 是一个十分朴实的关键字,它会直接奔向方法的本地代码,不管调用方法的实例是否合法。

    而 Callvirt,顾名思义,在诞生时是用于调用虚函数的,不过,随着时间的推移,它的用处已经不仅仅只是调用虚函数那么简单。

    首先,我们要明确 Call 与 Callvirt 的最大区别,前者不会检查实例是否为 null,而后者会检查实例是否为null,如果是,则爆发运行时异常;否则,就会去类型对象找到方法表。

    由于隐式类型转换的存在,栈上的引用(编译时类型)和实例对象(运行时类型)未必是同一个类型。

    因此,Call 的使用场景为:

    • 实例不可能为 null:值类型的方法调用(不论什么方法)。
    • 不需要检查实例是否为 null:任何类型的静态方法调用,以及方法内部调用同类型其他方法。

    其他场景都需要 Callvirt 检查实例是否为 null,例如引用类型的实例方法调用。

    最后,有一个特殊情况:显式使用 base 关键字调用父类(一定是引用类型)的虚方法,也会使用 Call 而不是 Callvirt 进行调用。

    例如,如果 IL 使用 Callvirt 调用 base.ToString,那么实际上这等同于 this.ToString,形成了无限循环(Callvirt 去了自己的方法表,然后,父类的虚方法又被子类重写,因此,只能调用到子类的方法)。

    可以用下面的 C# 和 IL 代码证明:

    class C
    {
        public override string ToString()
        {
            return base.ToString();
        }
    }

    对应的 IL 代码为:

    .method public hidebysig virtual
                   instance string ToString () cil managed
        {
            // Method begins at RVA 0x209c
            // Code size 12 ( 0xc)
            .maxstack 1
            .locals init (
                [0] string
            )
            IL_0000: nop
            IL_0001: ldarg.0
            IL_0002: call instance string [mscorlib]System.Object::ToString()
            IL_0007: stloc.0
            IL_0008: br.s IL_000a
    
            IL_000a: ldloc.0
            IL_000b: ret
        } //end of method C: : ToString

    我们可以看到 IL 是使用 call 调用父类的虚方法的。

全部加载完成