透过IL看C# (2)switch语句(下)
原创:刘彦博
摘要: switch语句是 C#中常用的跳转语句,可以根据一个参数的不同取值执行不同的代码。本文介绍了当向 switch语句中传入不同类型的参数时,编译器为其生成的 IL代码。这一部分介绍的是,在 switch语句中使用字符串类型的情况。
之前我们介绍了在switch语句中使用整数类型和枚举类型的情况。这一部分继续介绍使用 string类型的情况。string类型是switch语句接受的唯一一种引用类型参数。
下面来看一段 C#代码。
代码 1 -使用 string类型参数的 switch语句
view plaincopy to clipboardprint?
static void TestSwitchString(string s)
{
switch(s)
{
case null: Console.WriteLine("<null>"); break;
case "one": Console.WriteLine(1); break;
case "two": Console.WriteLine(2); break;
case "three": Console.WriteLine(3); break;
case "four": Console.WriteLine(4); break;
default: Console.WriteLine("<Unknown>"); break;
}
Console.WriteLine("After switch.");
}
代码1展示的方法中只有一个 switch语句,它接收一个字符串类型的参数s,并根据6种不同的情况显示不同的文字。它将被编译器翻译成什么样子的代码呢?这个switch语句是否依然能利用 IL中的switch指令呢?
答案马上揭晓。且看由代码 1得到的IL,如代码2所示。
代码 2 -代码 1得到的 IL代码
view plaincopy to clipboardprint?
.method private hidebysig static void TestSwitchString(string s) cil managed
{
// Code size 124 (0x7c)
.maxstack 2
.locals init (string V_0)
IL_0000: ldarg.0
IL_0001: dup
IL_0002: stloc.0
IL_0003: brfalse.s IL_003b
IL_0005: ldloc.0
IL_0006: ldstr "one"
IL_000b: call bool [mscorlib]System.String::op_Equality(string,
string)
IL_0010: brtrue.s IL_0047
IL_0012: ldloc.0
IL_0013: ldstr "two"
IL_0018: call bool [mscorlib]System.String::op_Equality(string,
string)
IL_001d: brtrue.s IL_004f
IL_001f: ldloc.0
IL_0020: ldstr "three"
IL_0025: call bool [mscorlib]System.String::op_Equality(string,
string)
IL_002a: brtrue.s IL_0057
IL_002c: ldloc.0
IL_002d: ldstr "four"
IL_0032: call bool [mscorlib]System.String::op_Equality(string,
string)
IL_0037: brtrue.s IL_005f
IL_0039: br.s IL_0067
IL_003b: ldstr "<null>"
IL_0040: call void [mscorlib]System.Console::WriteLine(string)
IL_0045: br.s IL_0071
IL_0047: ldc.i4.1
IL_0048: call void [mscorlib]System.Console::WriteLine(int32)
IL_004d: br.s IL_0071
IL_004f: ldc.i4.2
IL_0050: call void [mscorlib]System.Console::WriteLine(int32)
IL_0055: br.s IL_0071
IL_0057: ldc.i4.3
IL_0058: call void [mscorlib]System.Console::WriteLine(int32)
IL_005d: br.s IL_0071
IL_005f: ldc.i4.4
IL_0060: call void [mscorlib]System.Console::WriteLine(int32)
IL_0065: br.s IL_0071
IL_0067: ldstr "<Unknown>"
IL_006c: call void [mscorlib]System.Console::WriteLine(string)
IL_0071: ldstr "After switch."
IL_0076: call void [mscorlib]System.Console::WriteLine(string)
IL_007b: ret
} // end of method Program::TestSwitchString
呵呵,第一感觉就是,没见到switch指令。下面我们来简要地分析一下这些代码。
首先是 IL_0000到 IL_0002,这里还是首先将参数复制到了一个局部变量中,在前面介绍使用整数和枚举的情况时,我们也看到了类似的情况。老刘对此的猜测是,这是由于 IL中没有修改方法参数的指令(如 starg),而C#语言支持在方法体内给参数赋值 ——虽然这个值的改动不会影响到调用方法时传递进来的实参。因此,为了满足 C#语言的这种特点,编译器生成了一个局部变量,并在方法一开始将参数值复制进来,之后便可以操作这个参数了(修改其值)。再次重申,这是老刘自己的猜测。
如果上述猜测成立的话,那么 C#编译器实际上还可以做一些改进,即判断如果方法体内没有修改参数值,则可以省去这个局部变量。
接下来的 IL_0003一行是一个条件跳转, ILDasm给出的指令是 brfalse,其实写 brnull更合适。 brfalse和 brnull还有 brzero指令是一组同义词(他们底层的指令代码是一样的)。这组指令的作用是,从栈顶取出一个元素,判断其值是否为 0(值类型所有字段全零、引用类型是 null),如果是的话,则跳转到指令参数所指定的语句去执行,否则继续执行下一条指令。
很明显,如果参数 s是 null的话,该指令将导致执行流程直接跳转到表示 case null的指令块中。
接下来,从 IL_0005到 IL_0037,每四条指令为一组,分别比较了 s和四个不同的字符串的相等性,如果与某一个值相等,则跳转到对应的地址,该地址就是这个字符串常量对应的 case子句。字符串的相等性是通过 op_Equality方法进行的,这相当于使用 “==”运算符判断字符串是否相等。
每个指令块( case子句)执行完毕之后,都会有一行 br.s IL_0071,这个 IL_0071对应的就是 switch语句之后的其他语句。
由此可见,对于代码 1所示的 C#程序片段,编译器实际上是将 switch语句翻译成了相当于一串 if语句的形式。那么,如此一来,当 case子句过多时,岂不是会导致程序变慢?
下面再来看一段代码,我们在 switch中放入更多的 case子句,请参见代码 3。
代码 3 -拥有更多 case子句的 switch语句
view plaincopy to clipboardprint?
static void TestSwitchString2(string s)
{
switch (s)
{
case null: Console.WriteLine("<null>"); break;
case "one": Console.WriteLine(1); break;
case "two": Console.WriteLine(2); break;
case "three": Console.WriteLine(3); break;
case "four": Console.WriteLine(4); break;
case "five": Console.WriteLine(5); break;
default: Console.WriteLine("<Unknown>"); break;
}
Console.WriteLine("After switch.");
}
哈哈,老刘不厚道啊,不就多了一个 case "five"子句么。
是的,就多这一个。下面我们来看一下代码 3对应的 IL代码。
代码 4 -代码 3对应的 IL代码
view plaincopy to clipboardprint?
.method private hidebysig static void TestSwitchString2(string s) cil managed
{
// Code size 205 (0xcd)
.maxstack 4
.locals init (string V_0,
int32 V_1)
IL_0000: ldarg.0
IL_0001: dup
IL_0002: stloc.0
IL_0003: brfalse.s IL_0084
IL_0005: volatile.
IL_0007: ldsfld class [mscorlib]System.Collections.Generic.Dictionary`2<string,int32> '<PrivateImplementationDetails>{16CB032A-D97A-40BA-84F1-233334FEF4FA}'::'$$method0x6000007-1'
IL_000c: brtrue.s IL_0057
IL_000e: ldc.i4.5
IL_000f: newobj instance void class [mscorlib]System.Collections.Generic.Dictionary`2<string,int32>::.ctor(int32)
IL_0014: dup
IL_0015: ldstr "one"
IL_001a: ldc.i4.0
IL_001b: call instance void class [mscorlib]System.Collections.Generic.Dictionary`2<string,int32>::Add(!0,
!1)
IL_0020: dup
IL_0021: ldstr "two"
IL_0026: ldc.i4.1
IL_0027: call instance void class [mscorlib]System.Collections.Generic.Dictionary`2<string,int32>::Add(!0,
!1)
IL_002c: dup
IL_002d: ldstr "three"
IL_0032: ldc.i4.2
IL_0033: call instance void class [mscorlib]System.Collections.Generic.Dictionary`2<string,int32>::Add(!0,
!1)
IL_0038: dup
IL_0039: ldstr "four"
IL_003e: ldc.i4.3
IL_003f: call instance void class [mscorlib]System.Collections.Generic.Dictionary`2<string,int32>::Add(!0,
!1)
IL_0044: dup
IL_0045: ldstr "five"
IL_004a: ldc.i4.4
IL_004b: call instance void class [mscorlib]System.Collections.Generic.Dictionary`2<string,int32>::Add(!0,
!1)
IL_0050: volatile.
IL_0052: stsfld class [mscorlib]System.Collections.Generic.Dictionary`2<string,int32> '<PrivateImplementationDetails>{16CB032A-D97A-40BA-84F1-233334FEF4FA}'::'$$method0x6000007-1'
IL_0057: volatile.
IL_0059: ldsfld class [mscorlib]System.Collections.Generic.Dictionary`2<string,int32> '<PrivateImplementationDetails>{16CB032A-D97A-40BA-84F1-233334FEF4FA}'::'$$method0x6000007-1'
IL_005e: ldloc.0
IL_005f: ldloca.s V_1
IL_0061: call instance bool class [mscorlib]System.Collections.Generic.Dictionary`2<string,int32>::TryGetValue(!0,
!1&)
IL_0066: brfalse.s IL_00b8
IL_0068: ldloc.1
IL_0069: switch (
IL_0090,
IL_0098,
IL_00a0,
IL_00a8,
IL_00b0)
IL_0082: br.s IL_00b8
IL_0084: ldstr "<null>"
IL_0089: call void [mscorlib]System.Console::WriteLine(string)
IL_008e: br.s IL_00c2
IL_0090: ldc.i4.1
IL_0091: call void [mscorlib]System.Console::WriteLine(int32)
IL_0096: br.s IL_00c2
IL_0098: ldc.i4.2
IL_0099: call void [mscorlib]System.Console::WriteLine(int32)
IL_009e: br.s IL_00c2
IL_00a0: ldc.i4.3
IL_00a1: call void [mscorlib]System.Console::WriteLine(int32)
IL_00a6: br.s IL_00c2
IL_00a8: ldc.i4.4
IL_00a9: call void [mscorlib]System.Console::WriteLine(int32)
IL_00ae: br.s IL_00c2
IL_00b0: ldc.i4.5
IL_00b1: call void [mscorlib]System.Console::WriteLine(int32)
IL_00b6: br.s IL_00c2
IL_00b8: ldstr "<Unknown>"
IL_00bd: call void [mscorlib]System.Console::WriteLine(string)
IL_00c2: ldstr "After switch."
IL_00c7: call void [mscorlib]System.Console::WriteLine(string)
IL_00cc: ret
} // end of method Program::TestSwitchString2
耶?有奇怪的东西出现。你是不是第一眼也看到了 IL_0007这一条指令了?别忙,我们一点一点地拆解它。
首先,这条指令是 ldsfld——加载静态字段。然后给出了字段的类型,是 class [mscorlib]System.Collections.Generic.Dictionary`2<string,int32>类型,这是一个已经实例化的字典泛型类。然后就是具体要加载的字段了,其形式应该为 “ClassName::FieldName”;因此可以看出,这个字段所属的类型是 <PrivateImplementationDetails>{16CB032A-D97A-40BA-84F1-233334FEF4FA},字段的名字是 $$method0x6000007-1。
注解
在 ILAsm语言中,#、$、@、_(下划线)和 `(注意不是单引号,而是和波浪线 “~”位于同一键位上的撇字符)都是标识符中的合法字符。另外, ILAsm还支持用单引号将标识符包围起来,这样甚至还可以在其中使用一些非法字符。
用 ILDasm以图形化界面打开生成的程序集,果然可以看到这样一个类型和他的这个字段,如图 1所示。
图 1 -编译器为 switch语句生成的内部类型
在继续进行之前,老刘再来给大家做一个猜测 ——这个类型的名字和字段的名字是咋来的。
首先是类型的名字,名字中的尖括号和花括号主要是用来防止与用户编写的标识符发生冲突,因为在绝大多数高级语言中,尖括号和花括号都不能用在标识符中。尖括号中的 “PrivateImplementationDetails”明确指出了这个类型是编译器内部实现的,是不属于用户的。尖括号后面,一对花括号之间很明显是一个 GUID,观察一下就会发现,这个 GUID就是当前模块的 MVID(双击 “M A N I F E S T”节点可以看到 mvid)。
接下来是字段的名字,前导的两个 $也是防止命名冲突的。之后的 method0x6000007,表示这是给元数据标识为 “0x6000007”的方法使用的。最后的 “-1”表示这个字段是这个方法中用到的第一个内部实现的结构。
好了,现在我们知道了,编译器自动为我们生成了一个类型,并在其中提供了一个字典类的静态字段。接下来,我们详细看一下发生了什么。
首先 IL_0000至 IL_0003这几条指令和代码 2中的一样,此处不再赘述。 IL_0005一行是一个前缀指令 volatile.,表明它后面的 ldsfld指令要加载的字段是一个 “易变 ”字段,也就是说这个字段可能会被其他进程改变。这就告诉了运行时环境,在访问字段时不要缓存它的值。
注解
在 IL指令中,前缀指令只修饰紧随其后的一条指令,其他指令不受影响。
IL_0007和 IL_000C两行判断之前提到的那个 “内部字段 ”是否为 null,如果不是 null则跳转到 IL_0057,否则继续执行下面的指令,建立一个新的 Dictionary<string,int32>类型的字段。同样,这里的 brtrue写作 brinst更为合适( brtrue和 brinst也是一组同义词,其指令代码是一样的)。
接下来的 IL_000e到 IL_0052,先是初始化了一个字典类对象,然后分别将 case子句中出现的五个字符串( null除外)作为 key插入到了这个字典中,每个字符串对应一个整数,从 0到 4。最后将这个对象保存在 “内部字段 ”中。
接下来走到了 IL_0057,也就是之前判断 “内部字段 ”不为空时跳转到的位置。从 IL_0057到 IL_0061是通过调用字典类的 TryGetValue方法尝试从字典中找到 key的值是 switch参数 s所指定的项。
从这里,我们可以看到,在 IL中调用方法时,参数是自左向右依次压入堆栈的;如果调用的是实例方法,则在哪个对象上调用方法,应该最先将这个对象压入堆栈。例如在这里,首先压入了 “内部字段 ”,然后是第 0个局部变量(复制进来的参数 s),最后是第 1个参数的地址。
此外,我们还看到了 C#中 out参数是如何实现的。对于方法声明, out参数会被声明为类似 “Type&”这样的类型,这是一个托管指针。在传值时,通过 ldloca指令可以得到局部变量的地址。
IL_0066,如果上述 TryGetValue方法没有找到对应的 key,则跳转到 IL_00b8——从这一行的内容来看 ——是 default子句的位置。
IL_0069,啊哈,看到了我们所熟悉的 switch指令。根据刚刚取到的整数值,跳转到个个 case子句中去运行。
再看 switch指令之后的 IL_0082位置上的指令,这是一个无条件跳转,直接跳到 IL_00b8——default子句。回顾一下 switch指令的用法,当从栈顶取到的整数值比 switch指令中的跳转地址数量要大时,会忽略 switch指令,直接执行接下来的指令。所以,可以认为 switch指令后面紧随的指令应该类似于 C#语言中 switch语句中的 default子句。但在这里,编译器按照习惯,将 default子句对应的 IL代码放到了最后,并在 switch指令之后紧接着放置一个无条件跳转,跳转到 default子句中。
至此,这段代码基本就分析完了。
小结
本文介绍了在 switch语句中使用字符串对象作为参数的情形。
可以看到,当 case子句数量不多时,编译器会将其翻译为类似于一系列 if语句这样的结构,并通过 “==”运算符来与每种 case进行比较。
当 case子句的数量较多时,编译器则会生成一个内部类,并提供一个字典字段。这个字典字段的 key是字符串类型, value是整数类型;其中 key记录了每种 case,而 value记录了对应 case子句的序号。之后,以 switch语句的参数 s作为 key,取出对应的 value,再利用 switch指令做跳转。
这样做是利用了 Dictionary<TKey,TValue>类型通过 key来取值的时间复杂度接近于 O(1)这种特性(请参见MSDN上关于Dictionary泛型类的说明),有助于提高效率。此外,这个字段在需要的时候才进行初始化,并且只初始化一次,进一步提高了程序的整体效率。
如果你的程序中用了大量 if语句来判断一个字符串对象是否具有给定的值,不妨将其改为用 switch语句实现。如果你有其他引用类型对象,要进行类似的判断,又不能使用 switch语句( C#语法不允许),可以尝试自己写一个字典类的字段,以给定的几种可能的对象做 key,以连续的整数值作为 value,然后每次判断时,通过以给定对象(参数)作为 key,取到 vlaue后再用 switch进行判断。