Argument order for '==' with Nullable<T>
Asked Answered
F

2

21

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

 

Questions

  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

[edit 2023]

Another new syntax, "property pattern matching", is available starting in C# 8. The following gives the same "best" IL as example D:

static void E(ISupportInitialize x)
{
    if (x is ISupportInitializeNotification { IsInitialized: true })
        throw null;
}
Farant answered 18/8, 2017 at 8:22 Comment(15)
In the first case the compiler is able to short-circuit the entire check when it sees that the first call on the left side is null, which means the left argument is null no matter what else it contains. Comparing it to anything other than null is guaranteed to be false.Rhythmist
In the second case though the left part is true so no short-circuiting is involved. The compiler has to calculate the result of the entire expression. If you check the calls, it is creating a nullable object due to the use of ?., and trying to call IsInitialized if the nullable has a valueRhythmist
Can you get rid of ? operator in your examples and check again, will it gives the same results?Stichter
@ASprin surely that code wouldn't be probative for this case. Omitting ? changes everything, since then you also no longer need == true, and you'd end up with if ((x as ISupportInitializeNotification).IsInitialized)…, and furthermore at that point you might as well just do if (((ISupportInitializeNotification)x).IsInitialized)…Farant
@PanagiotisKanavos That's a good description of the IL the compiler happens to end up producing here. For example, I now notice that B's problems begin right away: It gets trapped by its first instruction ldc.i4.1 since that commits it to eventually doing a non-nullable compare. But you stop short of explaining why the same short-circuit isn't detected on the right side… I do realize C# guarantees left-to-right evaluation order perhaps in force here due to the possibilty of side-effecting IsInitialized, but surely failure of left side true could be precluded...? Optimization bug?Farant
Any reason why you'd want to place the true literal on the left side of the evaluation? Other than a stylistic desire to make the code look C++-like, that is?Buttaro
Not for me, but I was wondering if anyone was going to bring that up. As you probably know, there's actually a legitimate reason to always put literals on the left in C / C++: it eliminates the possibility of unintentional assignment (via =) when equality (==) is intended, a nasty bug which can be hard to track down since if (a = b)… looks so similar to if (a == b)… (It's a moot issue in C#). So the reason instead is that I generally like to have a vague notion of the IL I'll be getting as I write C# code, so surprises I don't understand require some investigation of "why."Farant
This is code generated with optimizations turned off. You should expect it to be not optimized.Prostration
You could use the idiomatic combination of the null-propagating and the null-coalescing operator: if ((x as ISupportInitializeNotification)?.IsInitialized ?? false). Even better the new is-expressions with patterns: if (x is ISupportInitializeNotification y && y.IsInitialized). They both result in equal or shorter IL than A.Carbazole
@EricLippert Thanks, I edited the post to include Release IL for all examples, including the two additional suggested by @Johnbot. The assymetry noted in the original question persists.Farant
I would say you have found a case where the C# compiler is not producing the best output possible. Almost a bug.Hydromel
What type does IsInitialized return? Is it bool, Nullable<bool> or something else? What version of VC#? For now, VTC for no minimal reproducible example. I really don't see anything like this in the spec and in a test with bool, there's no difference.Ceuta
@Ceuta ISupportInitialize and ISupportInitializeNotification are both defined in the .NET namespace System.ComponentModel, provided by the CLR in System.dll. I omitted this information since I assumed it would be well-known and thus would only create clutter here. I have now modified the post to indicate this at the top. I believe the minimal example requirement is satisfied by the contrast between A and B, which are indeed complete, minimal, and verifiable. Please provide additional remarks if you aren't able to retract your VTC. Thanks.Farant
@Ceuta Re-checking your note, I've also now added the VC# and .NET versions I'm using.Farant
Just small comment on recent edit - property pattern matching was available since C# 8 some docs, C# 10 only improved this feature to support succinct nested properties matching.Procopius
F
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.

Futhark answered 24/10, 2017 at 11:48 Comment(0)
C
1

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.

Ceuta answered 21/8, 2017 at 11:45 Comment(1)
Very vague? That's the most detailed part of the C# spec. Perhaps you meant arcane, since it's nearly impossible to make sense of by an average human reader, but that's another matter altogether.Decorticate
F
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.

Futhark answered 24/10, 2017 at 11:48 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.