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
andinitobj
; - 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
- Is there any functional, semantic, or other substantial runtime difference between A and B? (We're only interested in correctness here, not performance)
- If they are not functionally equivalent, what are the runtime conditions that can expose an observable difference?
- 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?
- 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. - 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
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:
What caught my eye is the argument list
{E1, E2, .., En}
. If you compare aNullable<bool>
to abool
the argument list should be something like{Nullable<bool> a, bool b}
and for that argument list theNullable<bool>.Equals(object o)
method seems to be the best function, because it only takes one implicit conversion frombool
toobject
.However if you revert the order of the argument list to
{bool a, Nullable<bool> b}
theNullable<bool>.Equals(object o)
method no longer is the best function, because now you would have to convert fromNullable<bool>
tobool
in the first argument and then frombool
toobject
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.
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 atrue
/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.