Search code examples
c#overridingvirtual

Why does a "new" method block overriding?


public class A
{
    public virtual void Write() { Console.Write("A"); }
}
public class B : A
{
    public override void Write() { Console.Write("B"); }
}
public class C : B
{
    new public virtual void Write() { Console.Write("C"); }
}
public class D : C
{
    public override void Write() { Console.Write("D"); }
}

// ...
D d = new D();
C c = d;
B b = c;
A a = b;
d.Write();
c.Write();
b.Write();
a.Write();

Why is the output DDBB and not DDDD?

Why doesn't D Write() override B Write() if the actual object that b is referencing is D?


Solution

  • To truly understand what's going on we have to have the mental model of how the type's list of methods (its method table) is maintained to allow for virtual calls at runtime. It is contiguous with 3 regions in this order.

    1. Inherited virtual methods
    2. Declared virtual methods
    3. Instance methods

    If we take your example class by class:

    public class A {
        public virtual void Write() { Console.Write("A"); }
    }
    

    virtual by itself creates new virtual slot for the method in region 2. It already inherited 4 methods from System.Object, so the method table of A looks like this:

    1. Object.ToString
    2. Object.Equals
    3. Object.GetHashCode
    4. Finalize
    --- end of region 1
    --- start of region 2
    5. Write (A::Write)
    --- end of region 2
    --- start of region 3
    --- end of region 3
    

    Next we have override for B

    public class B : A {
        public override void Write() { Console.Write("B"); }
    }
    

    This reuses the slot we just declared in A, so B's table looks like this (Write is now in region 1, inherited virtual methods)

    1. Object.ToString
    2. Object.Equals
    3. Object.GetHashCode
    4. Finalize
    5. Write (still A::Write)
    --- end of region 1
    --- start of region 2
    --- end of region 2
    --- start of region 3
    --- end of region 3
    

    Then in C we have new virtual

    public class C : B {
    
        public new virtual void Write() { Console.Write("C"); }
    }
    

    This lets us declare a new virtual slot for this implementation, again in region 2:

    1. Object.ToString
    2. Object.Equals
    3. Object.GetHashCode
    4. Finalize
    5. Write (A::Write)
    --- end of region 1
    --- start of region 2
    6. Write (C::Write)
    --- end of region 2
    --- start of region 3
    --- end of region 3 
    

    Finally, if we modified your example for D a little to not override but just use new

    public class D : C {
        public new void Write() { Console.Write("D"); }
    }
    

    This declares a new slot in region 3

    1. Object.ToString
    2. Object.Equals
    3. Object.GetHashCode
    4. Finalize
    5. Write (A::Write)
    6. Write (C::Write)
    --- end of region 1
    --- start of region 2
    --- end of region 2
    --- start of region 3
    7. Write (D::Write)
    --- end of region 3 
    

    The c# compiler does the disambiguating which Write method to call by actually emitting different IL based on the reference type it got:

    1. D::Write
    2. C::Write
    3. A::Write

    It goes from bottom to top of the method table for the type of the reference of the variable making the method call and looks for a matching name and then emits different IL method calls for the different slots:

    1. For D reference -> slot #7 (D::Write)
    2. For C reference -> slot #6 (C::Write)
    3. For B reference -> slot #5 (A::Write)
    4. For A reference -> slot #5 (A::Write)

    Thus for the modified example the IL be this:

    // D d = new D();
    IL_0001: newobj instance void D::.ctor()
    // C c = d;
    // B b = c;
    // A a = b;
    // d.Write();
    IL_000e: callvirt instance void D::Write()
    // c.Write();
    IL_0015: callvirt instance void C::Write()
    // b.Write();
    IL_001c: callvirt instance void A::Write()
    // a.Write();
    IL_0023: callvirt instance void A::Write()
    

    and the output DCBB.