Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Wrong overload is overridden when two methods have identical signatures after substitution of type arguments

We believe this example exhibits a bug in the C# compiler (do make fun of me if we are wrong). This bug may be well-known: After all, our example is a simple modification of what is described in this blog post.

using System;

namespace GenericConflict
{
  class Base<T, S>
  {
    public virtual int Foo(T t)
    { return 1; }
    public virtual int Foo(S s)
    { return 2; }

    public int CallFooOfT(T t)
    { return Foo(t); }
    public int CallFooOfS(S s)
    { return Foo(s); }
  }

  class Intermediate<T, S> : Base<T, S>
  {
    public override int Foo(T t)
    { return 11; }
  }

  class Conflict : Intermediate<string, string>
  {
    public override int Foo(string t)
    { return 101;  }
  }


  static class Program
  {
    static void Main()
    {
      var conflict = new Conflict();
      Console.WriteLine(conflict.CallFooOfT("Hello mum"));
      Console.WriteLine(conflict.CallFooOfS("Hello mum"));
    }
  }
}

The idea is simply to create a class Base<T, S> with two virtual methods whose signatures will become identical after an 'evil' choice of T and S. The class Conflict overloads only one of the virtual methods, and because of the existence of Intermediate<,>, it should be well-defined which one!

But when the program is run, the output seems to show that the wrong overload was overridden.

When we read Sam Ng's follow-up post we get the expression that that bug was not fixed because they believed a type-load exception would always be thrown. But in our example the code compiles and runs with no errors (just unexpected output).


Addition in 2020: This was corrected in later versions of the C# compiler (Roslyn?). When I asked this question, the output was:

11
101

As of 2020, tio.run gives this output:

101
2
like image 572
Jeppe Stig Nielsen Avatar asked Apr 16 '12 15:04

Jeppe Stig Nielsen


1 Answers

We believe this example exhibits a bug in the C# compiler.

Let's do what we should always do when exhibiting a compiler bug: carefully contrast the expected and observed behaviours.

The observed behaviour is that the program produces 11 and 101 as the first and second outputs, respectively.

What is the expected behaviour? There are two "virtual slots". The first output should be the result of calling the method in the Foo(T) slot. The second output should be the result of calling the method in the Foo(S) slot.

What goes in those slots?

In an instance of Base<T,S> the return 1 method goes in the Foo(T) slot, and the return 2 method goes in the Foo(S) slot.

In an instance of Intermediate<T,S> the return 11 method goes in the Foo(T) slot and the return 2 method goes in the Foo(S) slot.

Hopefully so far you agree with me.

In an instance of Conflict, there are four possibilities:

  • Possibility one: the return 11 method goes in the Foo(T) slot and the return 101 method goes in the Foo(S) slot.
  • Possibility two: the return 101 method goes in the Foo(T) slot and the return 2 method goes in the Foo(S) slot.
  • Possibility three: the return 101 method goes in both slots.
  • Possibility four: the compiler detects that the program is ambiguous and issues an error.

You expect that one of two things will happen here, based on section 10.6.4 of the specification. Either:

  1. The compiler will determine that the method in Conflict overrides the method in Intermediate<string, string>, because the method in the intermediate class is found first. In this case, possibility two is the correct behaviour. Or:
  2. The compiler will determine that the method in Conflict is ambiguous as to which original declaration it overrides, and therefore possibility four is the correct one.

In neither case is possibility one correct.

It is not 100% clear, I admit, which of these two is correct. My personal feeling is that the more sensible behaviour is to treat an overriding method as a private implementation detail of the intermediate class; the relevant question to my mind is not whether the intermediate class overrides a base class method, but rather whether it declares a method with a matching signature. In that case the correct behaviour would be to pick possibility four.

What the compiler actual does is what you expect: it picks possibility two. Because the intermediate class has a member which matches, we choose it as "the thing to override", regardless of the fact that the method is not declared in the intermediate class. The compiler determines that Intermediate<string, string>.Foo is the method overridden by Conflict.Foo, and emits the code accordingly. It does not produce an error because it judges that the program is not in error.

So if the compiler is correctly analyzing the code, choosing possibility two, and not producing an error, then why at runtime does it appear that the compiler chose possibility one, not possibility two?

Because making a program that causes two methods to unify under generic construction is implementation-defined behaviour for the runtime. The runtime can choose to do anything in this case! It can choose to give a type load error. It can give a verifiability error. It can choose to allow the program but fill in the slots according to some criterion of its own choosing. And in fact the latter is what it does. The runtime takes a look at the program emitted by the C# compiler and decides on its own that possibility one is the correct way to analyze this program.

So, now we have the rather philosophical question of whether or not this is a compiler bug; the compiler is following a reasonable interpretation of the specification, and yet we still do not get the behaviour we expect. In that sense, it very much is a compiler bug. The job of the compiler is to translate a program written in C# into an exactly equivalent program written in IL. The compiler is failing to do so; it is translating a program written in C# into a program written in IL that has implementation-defined behavior, not the behaviour specified by the C# language specification.

As Sam clearly describes in his blog post, we are well aware of this mismatch between what type topologies the C# language endows with specific meanings and what topologies the CLR endows with specific meanings. The C# language is reasonably clear that possibility two is arguably the correct one, but there is no code we can emit that makes the CLR do that because the CLR fundamentally has implementation-defined behaviour any time two methods unify to have the same signature. Our choices are therefore:

  • Do nothing. Allow these crazy, unrealistic programs to continue to have behaviour that does not precisely match the C# specification.
  • Use heuristics. As Sam notes, we could be more clever about using metadata mechanisms to tell the CLR what methods override what other methods. But... those mechanisms use the method signatures to disambiguate ambiguous cases and now we are back in the same boat as we were before; we are now using a mechanism with implementation-defined behaviour in order to disambiguate a program with implementation-defined behaviour! This is a non-starter.
  • Cause the compiler to produce warnings or errors whenever it might be emitting a program whose behaviour is implementation-defined by the runtime.
  • Fix the CLR so that behaviour of type topologies that cause methods to unify in signature is well-defined and matches that of the C# language.

The last choice is extremely expensive. Paying that cost buys us a vanishingly small user benefit, and directly takes budget away from solving realistic problems faced by users writing sensible programs. And in any event, the decision to do that is entirely out of my hands.

We on the C# compiler team have therefore chosen to take a combination of the first and third strategies; sometimes we produce warnings or errors for such situations, and sometimes we do nothing and allow the program to do something strange at runtime.

Since in practice these sorts of programs very rarely arise in realistic line-of-business programming scenarios, I don't feel very bad about these corner cases. If they were cheap and easy to fix then we would fix them, but they're neither cheap nor easy to fix.

If this subject interests you, see my article on yet another way in which causing two methods to unify leads to a warning and implementation-defined behaviour:

http://blogs.msdn.com/b/ericlippert/archive/2006/04/05/odious-ambiguous-overloads-part-one.aspx

http://blogs.msdn.com/b/ericlippert/archive/2006/04/06/odious-ambiguous-overloads-part-two.aspx

like image 195
Eric Lippert Avatar answered Oct 23 '22 18:10

Eric Lippert