透过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语句是类似的)。