Argument order for '==' with Nullable

2019-03-14 14:52发布

问题:

The following two C# functions differ only in swapping the left/right order of arguments to the equals operator, ==. (The type of IsInitialized is bool). Using C# 7.1 and .NET 4.7.

static void A(ISupportInitialize x)
{
    if ((x as ISupportInitializeNotification)?.IsInitialized == true)
        throw null;
}

static void B(ISupportInitialize x)
{
    if (true == (x as ISupportInitializeNotification)?.IsInitialized)
        throw null;
}

But the IL code for the second one seems much more complex. For example, B is:

  • 36 bytes longer (IL code);
  • calls additional functions including newobj and initobj;
  • declares four locals versus just one.

IL for function 'A'…

[0] bool flag
        nop
        ldarg.0
        isinst [System]ISupportInitializeNotification
        dup
        brtrue.s L_000e
        pop
        ldc.i4.0
        br.s L_0013
L_000e: callvirt instance bool [System]ISupportInitializeNotification::get_IsInitialized()
L_0013: stloc.0
        ldloc.0
        brfalse.s L_0019
        ldnull
        throw
L_0019: ret

IL for function 'B'…

[0] bool flag,
[1] bool flag2,
[2] valuetype [mscorlib]Nullable`1<bool> nullable,
[3] valuetype [mscorlib]Nullable`1<bool> nullable2
        nop
        ldc.i4.1
        stloc.1
        ldarg.0
        isinst [System]ISupportInitializeNotification
        dup
        brtrue.s L_0018
        pop
        ldloca.s nullable2
        initobj [mscorlib]Nullable`1<bool>
        ldloc.3
        br.s L_0022
L_0018: callvirt instance bool [System]ISupportInitializeNotification::get_IsInitialized()
        newobj instance void [mscorlib]Nullable`1<bool>::.ctor(!0)
L_0022: stloc.2
        ldloc.1
        ldloca.s nullable
        call instance !0 [mscorlib]Nullable`1<bool>::GetValueOrDefault()
        beq.s L_0030
        ldc.i4.0
        br.s L_0037
L_0030: ldloca.s nullable
        call instance bool [mscorlib]Nullable`1<bool>::get_HasValue()
L_0037: stloc.0
        ldloc.0
        brfalse.s L_003d
        ldnull
        throw
L_003d: ret

 

Quesions

  1. Is there any functional, semantic, or other substantial runtime difference between A and B? (We're only interested in correctness here, not performance)
  2. If they are not functionally equivalent, what are the runtime conditions that can expose an observable difference?
  3. If they are functional equivalents, what is B doing (that always ends up with the same result as A), and what triggered its spasm? Does B have branches that can never execute?
  4. If the difference is explained by the difference between what appears on the left side of ==, (here, a property referencing expression versus a literal value), can you indicate a section of the C# spec that describes the details.
  5. Is there a reliable rule-of-thumb that can be used to predict the bloated IL at coding-time, and thus avoid creating it?

      BONUS. How does the respective final JITted x86 or AMD64 code for each stack up?


[edit]
Additional notes based on feedback in the comments. First, a third variant was proposed, but it gives identical IL as A (for both Debug and Release builds). Sylistically, however, the C# for the new one does seem sleeker than A:

static void C(ISupportInitialize x)
{
    if ((x as ISupportInitializeNotification)?.IsInitialized ?? false)
        throw null;
}

Here also is the Release IL for each function. Note that the asymmetry A/C vs. B is still evident with the Release IL, so the original question still stands.

Release IL for functions 'A', 'C'…

        ldarg.0
        isinst [System]ISupportInitializeNotification
        dup
        brtrue.s L_000d
        pop
        ldc.i4.0
        br.s L_0012
L_000d: callvirt instance bool [System]ISupportInitializeNotification::get_IsInitialized()
        brfalse.s L_0016
        ldnull
        throw
L_0016: ret

Release IL for function 'B'…

[0] valuetype [mscorlib]Nullable`1<bool> nullable,
[1] valuetype [mscorlib]Nullable`1<bool> nullable2
        ldc.i4.1
        ldarg.0
        isinst [System]ISupportInitializeNotification
        dup
        brtrue.s L_0016
        pop
        ldloca.s nullable2
        initobj [mscorlib]Nullable`1<bool>
        ldloc.1
        br.s L_0020
L_0016: callvirt instance bool [System]ISupportInitializeNotification::get_IsInitialized()
        newobj instance void [mscorlib]Nullable`1<bool>::.ctor(!0)
L_0020: stloc.0
        ldloca.s nullable
        call instance !0 [mscorlib]Nullable`1<bool>::GetValueOrDefault()
        beq.s L_002d
        ldc.i4.0
        br.s L_0034
L_002d: ldloca.s nullable
        call instance bool [mscorlib]Nullable`1<bool>::get_HasValue()
L_0034: brfalse.s L_0038
        ldnull
        throw
L_0038: ret

Lastly, a version using new C# 7 syntax was mentioned which seems to produce the cleanest IL of all:

static void D(ISupportInitialize x)
{
    if (x is ISupportInitializeNotification y && y.IsInitialized)
        throw null;
}

Release IL for function 'D'…

[0] class [System]ISupportInitializeNotification y
        ldarg.0 
        isinst [System]ISupportInitializeNotification
        dup 
        stloc.0 
        brfalse.s L_0014
        ldloc.0 
        callvirt instance bool [System]ISupportInitializeNotification::get_IsInitialized()
        brfalse.s L_0014
        ldnull 
        throw 
L_0014: ret 

回答1:

So I was curious about the answer and took a look at the c# 6 specification (no clue where the c# 7 spec is hosted). Full disclaimer: I do not guarantee that my answer is correct, because I did not write the c# spec/compiler and my understanding of the internals is limited.

Yet I think that the answer lies in the resultion of the overloadable == operator. The best applicable overload for == is determined by using the rules for better function members.

From the spec:

Given an argument list A with a set of argument expressions {E1, E2, ..., En} and two applicable function members Mp and Mq with parameter types {P1, P2, ..., Pn} and {Q1, Q2, ..., Qn}, Mp is defined to be a better function member than Mq if

for each argument, the implicit conversion from Ex to Qx is not better than the implicit conversion from Ex to Px, and for at least one argument, the conversion from Ex to Px is better than the conversion from Ex to Qx.

What caught my eye is the argument list {E1, E2, .., En}. If you compare a Nullable<bool> to a bool the argument list should be something like {Nullable<bool> a, bool b}and for that argument list the Nullable<bool>.Equals(object o) method seems to be the best function, because it only takes one implicit conversion from bool to object.

However if you revert the order of the argument list to {bool a, Nullable<bool> b} the Nullable<bool>.Equals(object o) method no longer is the best function, because now you would have to convert from Nullable<bool> to bool in the first argument and then from bool to object in the second argument. That's why for case A a different overload is selected which seems to result in cleaner IL code.

Again this is an explanation that satisfies my own curiosity and seems to be in line with the c# spec. But I have yet to figure out how to debug the compiler to see what's actually going on.



回答2:

Looks like the 1st operand is converted to the 2nd's type for the purpose of comparison.

The excess operations in case B involve constructing a Nullable<bool>(true). While in case A, to compare something to a true/false, there's a single IL instruction (brfalse.s) that does it.

I couldn't find the specific reference in the C# 5.0 spec. 7.10 Relational and type-testing operators refers to 7.3.4 Binary operator overload resolution that in turn refers to 7.5.3 Overload resolution, but the latter one is very vague.