Weird(?) behavior while using c# switch expression

205 Views Asked by At

I noticed this while using c#'s switch expressions; I'd like to know if this is by design, or something else altogether.

public interface IBase
{
}

public struct Base1: IBase
{
    //fields, methods, etc
}

public struct Base2: IBase
{
    //fields, methods, etc
}

public struct BaseContainer: IBase
{
    public IBase InnerBase{get;}

    public BaseContainer(IBase value)
    {
        //validation and other goodies...
        InnerBase = value;
    }

    public static implicit operator BaseContainer(Base1 value) => new BaseContainer(value);

    public static implicit operator BaseContainer(Base2 value) => new BaseContainer(value);
}

public class Util
{
    public static IBase NewBase(int somethingToSwitchOn)
    {
        return somethingToSwitchOn switch
        {
            1 => new Base1(),
            2 => new Base2(),
            _ => default(BaseContainer)
        };
    }
}

Here's where it gets weird:


public static void Main(string[] args)
{
    var @base = Util.NewBase(1);
    
    Console.WriteLine(@base.GetType().Name);
}

The above code outputs "BaseContainer", instead of "Base1" as expected, until ANY of the switch steps is explicitly casted to IBase, e.g:

1 => new Base1(),
2 => (IBase) new Base2(),
_ => default(BaseContainer)
2

There are 2 best solutions below

2
On BEST ANSWER

The compiler needs to figure out a type for the switch expression. Its type can't be Base1, Base2 and BaseContainer at the same time, after all.

To do this, it finds the best common type of the expressions in each of switch expression's arms. According to the specification, the type is the same type as the type inferred when calling a generic method like this:

Tr M<X>(X x1 ... X xm)

with the expressions of the switch expression's arms passed as arguments.

I won't go into the details of type inference, but in general it is quite intuitive (as it is here). The best common type for new Base1(), new Base2(), and default(BaseContainer) is BaseContainer, and the best common type for new Base1(), (IBase)new Base2(), and default(BaseContainer) is IBase.

So if you don't cast, then the switch expression produces a BaseContainer, which means that the new Base1 object you created has to be converted to a BaseContainer first. This calls your implicit operator, which returns a BaseContainer object, and so GetType returns BaseContainer.

If you cast any of the arms to IBase, then the whole switch expression produces IBase. Converting from Base1 to IBase is a boxing conversion, and so doesn't change its dynamic type (i.e. what GetType returns).

3
On

This is all to do with type inference. The fact that there is an implicit conversion from both Base1 and Base2 to BaseContainer means that it will be considered first, before a possible boxing conversion to an interface.

We can see the language spec talking about this

Finding the best common type of a set of expressions

..snip.. More precisely, the inference starts out with an unfixed type variable X. Output type inferences are then made from each Ei to X. Finally, X is fixed and, if successful, the resulting type S is the resulting best common type for the expressions. If no such S exists, the expressions have no best common type.

What is fixing?

Fixing

An unfixed type variable Xi with a set of bounds is fixed as follows:

  • The set of candidate types Uj starts out as the set of all types in the set of bounds for Xi.
  • We then examine each bound for Xi in turn: ..snip.. For each lower bound U of Xi all types Uj to which there is not an implicit conversion from U are removed from the candidate set. For each upper bound U of Xi all types Uj from which there is not an implicit conversion to U are removed from the candidate set.
  • If among the remaining candidate types Uj there is a unique type V from which there is an implicit conversion to all the other candidate types, then Xi is fixed to V.