Do I use a 'switch' or sequential 'if's? A shallow dive into their CIL
I recently found myself needing to convert a calculation on ints to use double values. One of the functions within this calculation was a switch statement and as switch only supports integral types, this needed more than just a type change.
I was fortunate in that this particular double value was always at most one decimal place which left me with two possible options; either replace the switch with a bunch of sequential if statements or multiply the double by 10, cast it to an int and switch on the resulting int.
Ignoring the debate over whether the cast is a bit too hacky, it got me thinking; what is the difference in compiled output (CIL) between switch and sequential ifs covering the same test cases? Given that the cases of a switch are compile time constants and together as a block, does the compiler make any attempt to optimise the comparison or does it just grind through all the cases linearly until a match is found (making it essentially syntactic sugar for sequential ifs).
Out came a test project and ILSpy and this is what I found.
Here's the test code and corresponding annotated CIL (Release build, .Net 4.5):
public static int SwitchTest(int x) { switch (x) { case 10: return 1; case 20: return 2; case 30: return 3; } return -1; } public static int IfTest(int x) { if (x == 10) return 1; if (x == 20) return 2; if (x == 30) return 3; return -1; }
.method public hidebysig static int32 SwitchTest ( int32 x ) cil managed { // Method begins at RVA 0x2062 // Code size 25 (0x19) .maxstack 8 IL_0000: ldarg.0. // load the argument onto the stack IL_0001: ldc.i4.s 10. // push 10 onto the stack IL_0003: beq.s IL_0011 // if both are equal go to IL_0011 IL_0005: ldarg.0 // repeat above for 20 and 30 IL_0006: ldc.i4.s 20. IL_0008: beq.s IL_0013 IL_000a: ldarg.0 IL_000b: ldc.i4.s 30 IL_000d: beq.s IL_0015 IL_000f: br.s IL_0017 IL_0011: ldc.i4.1. // push 1 onto the stack IL_0012: ret. // return IL_0013: ldc.i4.2 // repeat above for 2 and 3 IL_0014: ret IL_0015: ldc.i4.3 IL_0016: ret IL_0017: ldc.i4.m1. // push -1 onto the stack IL_0018: ret. // return } // end of method Program::SwitchTest .method public hidebysig static int32 IfTest ( int32 x ) cil managed { // Method begins at RVA 0x207c // Code size 23 (0x17) .maxstack 8 IL_0000: ldarg.0 // load the argument onto the stack IL_0001: ldc.i4.s 10 // push 10 onto the stack IL_0003: bne.un.s IL_0007. // if both are NOT equal go to IL_0007 IL_0005: ldc.i4.1. // push 1 onto the stack IL_0006: ret // return IL_0007: ldarg.0 // repeat above for 20, 30 IL_0008: ldc.i4.s 20 IL_000a: bne.un.s IL_000e IL_000c: ldc.i4.2 IL_000d: ret IL_000e: ldarg.0 IL_000f: ldc.i4.s 30 IL_0011: bne.un.s IL_0015 IL_0013: ldc.i4.3 IL_0014: ret IL_0015: ldc.i4.m1. // push -1 onto the stack IL_0016: ret. // return } // end of method Program::IfTest
Although only a single example for my very simple use case, the only significant difference between the two compiled outputs is that the switch example uses beq.s positive comparison (equals) and the if example uses bne.un.s negative comparison (not equals) to achieve the same result. Because of this, in the scenario where no match is made the switch example involves executing one more instruction.
Will the extra instruction be make or break for the performance of your application? Certainly not, but contrary to the spirit of endless abstraction I find it grounding to occasionally head in the opposite direction and see what even the most basic of language constructs represent under the hood.
As far as I can see in this case the only real argument is that of code legibility. Now I know...