switch语句(下)(转载)
之前我们介绍了在switch语句中使用整数类型和枚举类型的情况。这一部分继续介绍使用string类型的情况。string类型是switch语句接受的唯一一种引用类型参数。
下面来看一段C#代码。
代码1 - 使用string类型参数的switch语句
代码1展示的方法中只有一个switch语句,它接收一个字符串类型的参数s,并根据6种不同的情况显示不同的文字。它将被编译器翻译成什么样子的代码呢?这个switch语句是否依然能利用IL中的switch指令呢?
答案马上揭晓。且看由代码1得到的IL,如代码2所示。
代码2 - 代码1得到的IL代码
呵呵,第一感觉就是,没见到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语句
哈哈,老刘不厚道啊,不就多了一个case "five"子句么。
是的,就多这一个。下面我们来看一下代码3对应的IL代码。
代码4 - 代码3对应的IL代码
耶?有奇怪的东西出现。你是不是第一眼也看到了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进行判断。