Why is the C# compiler using the parent type of a value returned by an implicit operator to invoke overloaded operators

Viewed 168

I have the following 2 classes:

public class Parent
{
    public static Parent operator +(Parent l, Parent r)
    {
        return new Parent(); //do something meaningful
    }
}

public class Child: Parent
{
    public static Child operator +(Child l, Parent r)
    {
        return new Child(); //do something meaningful, child related
    }
}

Then I have a wrapper class that uses implicit conversion to return the wrapped value:

public class Wrapper<T>
{
    private T value;

    public T Value => value;

    public static implicit operator T(Wrapper<T> wrapper)
    {
        return wrapper.value;
    }
}

Then I am combining the 2 as follows:

public class Usage
{
    private Parent someField;
    private Wrapper<Child> wrappedValue;

    public void UseOperatorWithImplicitConversion()
    {
        //Child sum1 = wrappedValue + someField; //<-- compilation error
        Parent sum2 = wrappedValue + someField;

        Child temp = wrappedValue; //works but defeats the purpose of reduced verbosity
        Child sum3 = temp + someField;
    }
}

I was expecting the sum1 line to work. I had a look in the generated IL and it seems that the types are there:

IL_0001: ldarg.0      // this
IL_0002: ldfld        class Example.Wrapper`1<class Example.Child> Example.Usage::wrappedValue
IL_0007: call         !0/*class Example.Child*/ class Example.Wrapper`1<class Example.Child>::op_Implicit(class Example.Wrapper`1<!0/*class Example.Child*/>)
IL_000c: ldarg.0      // this
IL_000d: ldfld        class Example.Parent Example.Usage::someField
IL_0012: call         class Example.Parent Example.Parent::op_Addition(class Example.Parent, class Example.Parent)
IL_0017: stloc.0      // sum2

Though the IL_0012 is a call to op_Addition of the Parent and not the Child.

Is there something that I'm missing here?

I am using .NET Framework 4.6.1 C# 7.2

2 Answers

C# does not consider every possible user defined operator overload from every single class when trying to determine which operator overload to call in a particular situation. It only considers operator overloads defined in the (compile time) types of one of the operands. It doesn't consider operators defined in every type that any of the operands has an implicit conversion to.

I think that @Servy's answer is correct. I just want to extend it by providing links to C# specification and adding an explanation.

Binary operator overload resolution is used to determine a set of candidate operators:

An operation of the form x op y, where op is an overloadable binary operator, x is an expression of type X, and y is an expression of type Y, is processed as follows:

  • The set of candidate user-defined operators provided by X and Y for the operation operator op(x,y) is determined. The set consists of the union of the candidate operators provided by X and the candidate operators provided by Y, each determined using the rules of Candidate user-defined operators. If X and Y are the same type, or if X and Y are derived from a common base type, then shared candidate operators only occur in the combined set once.
  • (Other items are not important)

In this line of code

Parent sum2 = wrappedValue + someField;

x is an expression of type Wrapper<Child> and y is an expression of type Parent.

According to the rules of binary operator overload resolution a set of candidate operators is a union of the operators provided by these two types. For each of this types a set of candidate operators is determinded by using rules Candidate user-defined operators:

Given a type T and an operation operator op(A), where op is an overloadable operator and A is an argument list, the set of candidate user-defined operators provided by T for operator op(A) is determined as follows:

  • For all operator op declarations in T and all lifted forms of such operators, if at least one operator is applicable (Applicable function member) with respect to the argument list A, then the set of candidate operators consists of all such applicable operators in T.
  • (Other items are not important)

and Applicable function member:

A function member is said to be an applicable function member with respect to an argument list A when all of the following are true:

  • For each argument in A, the parameter passing mode of the argument (i.e., value, ref, or out) is identical to the parameter passing mode of the corresponding parameter, and
    • for a value parameter or a parameter array, an implicit conversion (Implicit conversions) exists from the argument to the type of the corresponding parameter.
    • (Other items are not important)

Using these rules we can conclude the following:

  • For type Wrapper<Child> a set of candidate operators for operation operator +(Wrapper<Child>, Parent) is empty.
  • For type Parent a set of candidate operators for operation operator +(Wrapper<Child>, Parent) consists of a single operator defined in Parent: operator +(Parent, Parent). This operator is applicable (according to the Applicable function member) as a candidate operator because there is an implicit conversion from Wrapper<Child> to Parent.

So we have one candidate operator operator +(Parent, Parent) therefore it is applied in our case.

Also we can conclude the following:

  • A set of the candidate operators is defined only for actual types of the operands used in the operation. Types to which operands can be implicitly converted are not taken into account in this process. Therefore operator +(Child, Parent) from the class Child was not considered as a candidate operator.
  • Implicit conversion is used when defining if an operator is applicable to its arguments. Therefore operator +(Parent, Parent) was defined as applicable for the case operator +(Wrapper<Child>, Parent).
Related