透过IL看C# (1)switch语句(上)

原创:刘彦博

摘要: switch语句是 C#中常用的跳转语句,可以根据一个参数的不同取值执行不同的代码。本文介绍了当向 switch语句中传入不同类型的参数时,编译器为其生成的 IL代码。这一部分介绍的是,在 switch语句中使用整数类型和枚举类型的情况。

switch语句是 C#中常用的跳转语句,可以根据一个参数的不同取值执行不同的代码。 switch语句可以具备多个分支,也就是说,根据参数的 N种取值,可以跳转到 N个代码段去运行。这不同于 if语句,一条单独的 if语句只具备两个分支(这是因为 if语句的参数只能具备 true或 false两种取值),除非使用嵌套 if语句。

switch语句能够接受的参数是有限制的,简单来说,只能是整数类型、枚举或字符串。本文就从整数、枚举和字符串这三种类型的 switch语句进行介绍。

switch指令

在进入正题之前,先为大家简要介绍一下 IL汇编语言中的 switch指令。 switch指令(注意和 C#中的 switch语句区分开)是 IL中的多分支指令,它的基本形式如下:

switch (Label_1, Label_2, Label_3…)

其中 switch是 IL关键字, Label_1~Label_N是一系列标号(和 goto语句中用到的标号一样),标号指明了代码中的位置。这条指令的运行原理是,从运算栈顶弹出一个无符号整数值,如果该值是 0,则跳转到由 Label_1指定的位置执行;如果是 1,则跳转到 Labe_2;如果是 2,则跳转到 Label_3;以此类推。

如果栈顶弹出的值不在标号列表的范围之内( 0~N-1),则忽略 switch指令,跳到 switch指令之后的一条指令开始执行。因此,对于 switch指令来说,其 “default子句 ”是在最开头的。

此外, Label_x所引用的标号位置只要位于当前方法体就可以,不必非要在 switch指令的后面。

好了,后面我们会看到 switch指令的实例的。

使用整数类型的 switch语句

代码 1 -使用整数类型参数的 switch语句,取值连续

view plaincopy to clipboardprint?
static void TestSwitchInt(int n)
{
    switch(n)
    {
        case 1: Console.WriteLine("One"); break;
        case 2: Console.WriteLine("Two"); break;
        case 3: Console.WriteLine("Three"); break;
    }
}

代码 1中的 switch语句接受的参数 n是 int类型的,并且我们观察到,在各个 case子句中的取值都是连续的。将这段代码写在一个完整的程序中,并进行编译。之后使用 ildasm打开生成的程序集,可以看到对应的 IL代码如代码 2所示。

代码 2 –代码 1生成的 IL代码

view plaincopy to clipboardprint?
.method private hidebysig static void  TestSwitchInt(int32 n) cil managed
{
  // Code size       56 (0x38)
  .maxstack  2
  .locals init (int32 V_0)
  IL_0000:  ldarg.0
  IL_0001:  stloc.0
  IL_0002:  ldloc.0
  IL_0003:  ldc.i4.1
  IL_0004:  sub
  IL_0005:  switch     ( 
                        IL_0017,
                        IL_0022,
                        IL_002d)
  IL_0016:  ret
  IL_0017:  ldstr      "One"
  IL_001c:  call       void [mscorlib]System.Console::WriteLine(string)
  IL_0021:  ret
  IL_0022:  ldstr      "Two"
  IL_0027:  call       void [mscorlib]System.Console::WriteLine(string)
  IL_002c:  ret
  IL_002d:  ldstr      "Three"
  IL_0032:  call       void [mscorlib]System.Console::WriteLine(string)
  IL_0037:  ret
} // end of method Program::TestSwitchInt

我们可以看到,首先 IL_0000和 IL_0001两行代码将参数 n存放到一个局部变量中,然后 IL_0002到 IL_0004三行将这个变量的值减去 1,并将结果留在运算栈顶。啊哈,参数值减去 1,要进行判断的几种情况不就变成了 0、 1、 2了么?是的。在接下来的 switch指令里,针对这三种取值给出了三个地址 IL_0017、 IL_0022和 IL_002d。这三个地址处的代码,分别就是取值为 1、 2、 3时需要执行的代码。

以上是取值连续的情形。如果各个 case子句中给出的值并不连续呢?我们来看一下下面的 C#代码:

代码 3 –使用整数类型参数的 switch语句,取值不连续

view plaincopy to clipboardprint?
static void TestSwitchInt2(int n)
{
    switch(n)
    {
        case 1: Console.WriteLine("1"); break;
        case 3: Console.WriteLine("3"); break;
        case 5: Console.WriteLine("5"); break;
    }
}

代码 3编译生成的程序集中,编译器生成的 IL代码如下:

代码 4 –代码 3生成的 IL代码

view plaincopy to clipboardprint?
.method private hidebysig static void  TestSwitchInt2(int32 n) cil managed
{
  // Code size       64 (0x40)
  .maxstack  2
  .locals init (int32 V_0)
  IL_0000:  ldarg.0
  IL_0001:  stloc.0
  IL_0002:  ldloc.0
  IL_0003:  ldc.i4.1
  IL_0004:  sub
  IL_0005:  switch     ( 
                        IL_001f,  // 0
                        IL_003f,  // 1
                        IL_002a,  // 2
                        IL_003f,  // 3
                        IL_0035)  // 4
  IL_001e:  ret
  IL_001f:  ldstr      "1"
  IL_0024:  call       void [mscorlib]System.Console::WriteLine(string)
  IL_0029:  ret
  IL_002a:  ldstr      "3"
  IL_002f:  call       void [mscorlib]System.Console::WriteLine(string)
  IL_0034:  ret
  IL_0035:  ldstr      "5"
  IL_003a:  call       void [mscorlib]System.Console::WriteLine(string)
  IL_003f:  ret
} // end of method Program::TestSwitchInt2

看到代码 4,第一感觉就是 switch指令中跳转地址的数量和 C#程序中 switch语句中的取值数不相符。但仔细观察后可以发现, switch指令中针对 0、 2、 4(即 switch语句中的 case 1、 3、 5)这三种取值给出了不同的跳转地址。而对于 1、 3这两种取值(在 switch语句中并没有出现)则给出了同样的地址 IL_003f,看一下这个地址,是语句 ret。

也就是说,对于取值不连续的情况,编译器会自动用 “default子句 ”的地址来填充 switch指令中的 “缝隙 ”。当然,代码 4因为过于简单,所以 “缝隙值 ”直接跳转到了方法的结尾。

那么,如果取值更不连续呢?那样的话, switch指令中就会有大量的 “缝隙值 ”。要知道, switch指令和之后的跳转地址列表都是指令的一部分,缝隙值的增加势必会导致程序集体积的增加啊。呵呵,不必担心,编译器很聪明,请看下面的代码:

代码 5 – 使用整数类型参数的 switch语句,取值非常不连续

view plaincopy to clipboardprint?
static void TestSwitchInt3(int n)
{
    switch(n)
    {
        case 10: Console.WriteLine("10"); break;
        case 30: Console.WriteLine("30"); break;
        case 50: Console.WriteLine("50"); break;
    }
}

在代码 5中,switch语句的每个case子句中给出的取值之间都相差20,这意味着如果再采用前面所述 “缝隙值 ”的做法, switch指令中将有多达41个跳转地址,而其中有效的只有 3个。但现代的编译器明显不会犯这种低级错误。下面给出编译器为代码 5 生成的 IL:

代码 6 –代码 5生成的 IL代码

view plaincopy to clipboardprint?
.method private hidebysig static void  TestSwitchInt3(int32 n) cil managed
{
  // Code size       51 (0x33)
  .maxstack  2
  .locals init (int32 V_0)
  IL_0000:  ldarg.0
  IL_0001:  stloc.0
  IL_0002:  ldloc.0
  IL_0003:  ldc.i4.s   10
  IL_0005:  beq.s      IL_0012
  IL_0007:  ldloc.0
  IL_0008:  ldc.i4.s   30
  IL_000a:  beq.s      IL_001d
  IL_000c:  ldloc.0
  IL_000d:  ldc.i4.s   50
  IL_000f:  beq.s      IL_0028
  IL_0011:  ret
  IL_0012:  ldstr      "10"
  IL_0017:  call       void [mscorlib]System.Console::WriteLine(string)
  IL_001c:  ret
  IL_001d:  ldstr      "30"
  IL_0022:  call       void [mscorlib]System.Console::WriteLine(string)
  IL_0027:  ret
  IL_0028:  ldstr      "50"
  IL_002d:  call       void [mscorlib]System.Console::WriteLine(string)
  IL_0032:  ret
} // end of method Program::TestSwitchInt3

从代码 6中我们会发现, switch指令不见了,在 IL_0005、 IL_000a和 IL_000f三处分别出西安了 beq.s指令,这个指令是 beq指令的简短形式。当跳转位置和当前位置之差在一个 sbyte类型的范围之内时,编译器会自动选择简短形式,目的是缩小指令集的体积。而 beq指令的作用是从运算栈中取出两个值进行比较,如果两个值相等,则跳转到目标位置(有 beq指令后面的参数指定)执行,否则继续从 beq指令的下一条指令开始执行。

由此可见,当switch语句的取值非常不连续时,编译器会放弃使用 switch指令,转而用一系列条件跳转来实现。这有点类似于 if-else if-...-else语句。

使用枚举类型的 switch语句

.NET中的枚举是一种特殊的值类型,它必须以某一种整数类型作为其底层类型(underlying type)。因此在运算时,枚举都是按照整数类型对待的, switch指令会将栈顶的枚举值自动转换成一个无符号整数,然后进行判断。

因此,在 switch语句中使用枚举和使用整数类型没有太大的区别。请看下面一段代码:

代码 7 -在 switch语句中使用枚举类型

view plaincopy to clipboardprint?
static void TestSwitchEnum(Num n)
{
    switch(n)
    {
        case Num.One: Console.WriteLine("1"); break;
        case Num.Two: Console.WriteLine("2"); break;
        case Num.Three: Console.WriteLine("3"); break;
    }
}

其中的 Num类型是一个枚举,定义为 public enum Num { One, Two, Three }

下面是编译器为代码7生成的IL代码:

代码 8 -代码 7生成的 IL代码

view plaincopy to clipboardprint?
.method private hidebysig static void  TestSwitchEnum(valuetype AndersLiu.CSharpViaIL.Switch.Num n) cil managed
{
  // Code size       54 (0x36)
  .maxstack  1
  .locals init (valuetype AndersLiu.CSharpViaIL.Switch.Num V_0)
  IL_0000:  ldarg.0
  IL_0001:  stloc.0
  IL_0002:  ldloc.0
  IL_0003:  switch     ( 
                        IL_0015,
                        IL_0020,
                        IL_002b)
  IL_0014:  ret
  IL_0015:  ldstr      "1"
  IL_001a:  call       void [mscorlib]System.Console::WriteLine(string)
  IL_001f:  ret
  IL_0020:  ldstr      "2"
  IL_0025:  call       void [mscorlib]System.Console::WriteLine(string)
  IL_002a:  ret
  IL_002b:  ldstr      "3"
  IL_0030:  call       void [mscorlib]System.Console::WriteLine(string)
  IL_0035:  ret
} // end of method Program::TestSwitchEnum

可以看到,代码 8和代码 2没有什么本质区别。这是因为枚举值就是按照整数对待的。并且,如果枚举定义的成员取值不连续,生成的代码也会和代码 4、代码 6类似。

小结

本文介绍了编译器如何翻译使用整数类型的 switch语句。如果你很在乎微乎其微的效率提升的话,应记得:

·        尽量在 switch中使用连续的取值;

·        如果取值不连续,则使用尽量少的case子句,并将出现频率高的case放在前面(因为此时switch语句和if-else if-else语句是类似的)。

 

下一篇:透过IL看C# (2) -- -- switch语句(下)-刘彦博