
Most of the people know that switch works like if-else
blocks. The only difference between the switch and if-else is its being
for the many conditional states, and also writing switch is more simple
than if-else block. This knowledge is really a big
mistake because switch has
a different algorithm and working-procedure comparing to if-else block.
To demonstrate this, I wrote a simple example which indicates the
performance differences between switch and if-else in many different
situations.
Download the source codes of this
demonstration example and than let's speak about it a little bit more.
First let me explain the details of this Example. In the example, there
are three arguments, which we are using in our tests.
These arguments are as follows;
# of iterations: Indicates that how many times we are
testing the same situation. We cannot understand the difference of time
by only one test because of the CPU's speed. CPU will do the switch or
if-else operations in a very short time. So to understand the real
difference, we need to do the same test again and again. Then we have to
check the time difference in total.
Parameter (Search Value): In the example, there is an
if-else or a switch block which is searching from 1 - [# of cases]. Here
the Parameter will indicates that in which case or else-if block our
value is in. If you give the parameter a value of 1, this means
that first case or first if block will return true. If you give a 99,
this means that the 99th case or else-if block will return true.
Actually this is the number of how many if-else conditions will be
checked.
# of cases: This number indicates the total number of
the conditions, which means how many cases or if-else block we have.
After giving these arguments; if you click to the Run Test
button, it will make a test by using the given arguments and add a line
to the results, which indicates properties and results of the test.
The above picture is showing the results of my 7 tests. Now let's speak
about these tests and than let me explain why the result is this way.
In the tests 1, 2 and 3 the arguments of the tests are same. The only
difference is the Search Value which I try to select from the beginning,
from the middle and finally from the end of the conditional block. As
we see in the column for the switch, it does not matter what we are
searching for. The time is nearly the same for each test (88, 86, 87).
But for the if-else block, the time will dramatically increase according
to the Search value. If we are searching for a number which is at the
bottom of the if-else block, it takes too long; and if we select a
number which is from the beginning of if-else block, it takes less time.
But even for the smaller and for the bigger numbers, switch is always
faster than if-else block.
To talk about other tests, we only change the # of case.
This means the only difference is the number of the total conditional
operations. But again the results show us that the switch is faster than
if-else block, and number of conditions it is very important for the
if-else block but not very important for the switch.
As I say at the beginning, most of the people think that switch works
same as if-else block but this test shows us it is not true. So how does
switch work?
Switch has a very simple but good algorithm. When we write a switch, it
will automatically loads the first case's value, then calculates the
index number of the target case block, and adds an array of addresses of
case block in the IL. For example:
public void GetSwitchResults(int value)
{
switch (vaue)
{
case 1:
return 1;
case 2:
return 2;
case 3:
return 3;
case 4:
return 4;
case 5:
return 5;
default:
return int.MinValue;
}
} |
.method public hidebysig newslot
virtual final instance int32
GetSwitchResult(int32 value) cil managed
{
.maxstack 2
.locals init (
int32 num1,
int32 num2)
L_0000: nop
L_0001: ldarg.1
L_0002: stloc.1
L_0003: ldloc.1
L_0004: ldc.i4.1 // loads the first
cases value (1)
L_0005: sub // calculation [our
value - first value]
L_0006: switch (L_0021,L_0025,L_0029,L_002D,L_0031)
L_001f: br.s L_0035 // address of
default case
L_0021: ldc.i4.1 // case 1
L_0022: stloc.0
L_0023: br.s L_003D
L_0025: ldc.i4.2 // case 2
L_0026: stloc.0
L_0027: br.s L_003D
L_0029: ldc.i4.3 // case 3
L_002a: stloc.0
L_002b: br.s L_003D
L_002d: ldc.i4.4 // case 4
L_002e: stloc.0
L_002f: br.s L_003D
L_0031: ldc.i4.5 // case 5
L_0032: stloc.0
L_0033: br.s L_003D
L_0035: ldc.i4 -2147483648 //
default case
L_003a: stloc.0
L_003b: br.s L_003D
L_003d: ldloc.0
L_003e: ret
}
|
|
IL codes generated by Xenocode Fox 2007
The IL output of the left code above will look like as the right one.
When compiling the code, switch puts a target for each case. And during
runtime when we give a number to the switch, it checks the difference
between the first case and our value, and the result indicates the index
of the target case. So if the index is in one of the cases, it directly
branches to the given case's address. Actually the calculation formula
is
[our value] - [first case's value], which returns us
the index of our target case in the target cases array. You can see
this in the IL. During the compile, compiler will generates the needed
calculation codes just before the switch.
Let me explain it a little bit more with an example: Considering the
code above, if we give a value 2, it will do the following action. 2 - 1
= 1. So second address (because of 0 based indexing) in the targets
list is the starting address of our case block. So it directly goes to
the L_0025 which the case 2:begins. Considering the value 3, the
calculation will be like 3 - 1 = 2. So it goes to the third address in
the target list, and so this address is L_0029(begins of the case 3).
There are 2 problems here. One of them is about the default case because
although there are 5 cases and one default case (total 6), there are
only 5 targets in the addresses array. And the second problem is what
happens if the number does not fall between in any of the cases (for ex:
23, it does not exists in the cases list). For the first problem: If
there is a default case in a switch, compiler will adds a
br.s
[TARGET] after the switch instruction which the
TARGET
indicates the starting address of the default case. If there is no
default case, compiler will again add a
br.s [TARGET]
after the switch, but this time
TARGET will indicates
the ending address of switch.
For the second problem: After the calculation, if the result (index of
the target case) is not a valid index for our target cases array, then
switch does not do anything, so the next instruction (which br.s
[TARGET] as we explain above) will execute and it will directly go to
the default case if exists; otherwise it goes out of the switch. For
example if the value is -1, then the calculation will be -1 - 1 = -2
which is not a valid index. So this value will directly go to the
default case. It is also the same as for the value 6. After the
calculation, the index will be 5 but our targets array has only 5
elements, so 5 is not a valid index for our switch again.
Now let's go into the switch deeper. As we see in these examples, there
might be a subtract operation on the switch condition to make the first
value as zero. So this means that switch condition must be an integral
type, which can be int16, int32, int64, enum etc. You cannot use float
or double values in the switch cases. For example, the following code
will not compile:
switch (a) // a is float
{
case 0.1f:
break;
case 0.2f:
break;
}
Compiler won't compile the switches as how we write them. It may add
some codes to the switch or may directly remove the switch and instead
of it, it adds an if-else block to the IL. This will be done by the
compiler automatically according to the number and values of the
switch's cases and condition of the switch. Check the following
examples:
| Code |
After Compile |
switch (a)
{
case 1:
break;
case 3:
break;
case 4:
break;
case 5:
break;
} |
switch (a)
{
case 1:
break;
case 2:
break;
case 3:
break;
case 4:
break;
case 5:
break;
} |
Switch cases must increase one by one continuously. If one item could
not be found (here case 2), compiler will add an empty case for that
number. Because of the calculation each element indicates a target. So
there must be a target for the case 2, otherwise switch algorithm will
not work. But this doesn't mean that every time we use switch compiler
fills the empty cases. In some situations, it can convert the switch to
if - else block. Here it is not important for the compiler how we write
the orders of the cases. It will first sorts the cases according to the
case values, and then it will check the empty spaces.
| Code |
After Compile |
switch (a)
{
case 5:
break;
case 24:
break;
case 28:
break;
} |
if (a == 5)
{
}else if (a == 24)
{
}else if (a == 28)
{
} |
Here in this example, compiler must add too many cases to fill all the
empty spaces but instead of adding cases, compiler will compile this
code as if-else block.
What about
char and
string cases? Can
we use them or not?
For the char cases, I can say that
yes. Because chars
can be converted to int, so compiler can use char types in the switches
without doing anything.
For the string cases, I can say again
yes but this not
common. That’s to say, again compiler decide this. In some cases, it
converts them to if - else block (especially if the number of cases is
small, for ex- 3 cases). And in some cases it will compile them as
switch cases. So how can it be? As I said before, switches need integral
types as cases conditions, and string is not an integral type. So how
can this take place?
For the strings, compiler adds a
Dictionary<string, int>
item to a Compiler Generated class in that assembly. Then it will add
the string values as string into that dictionary as int KeyValuePair
which string is the case value and int is the order of the case. And
during the runtime it will first try to get the int value of that string
from the dictionary, and then use that value in the switch.
Anyway using switches (for more conditional operations) in the .NET will
be better than using if - else block. Of course, if the conditions are
constant values and type of the value is one of the integral types or
string.