Effective C#:改善C#代码的50个有效方法(原书第3版)
上QQ阅读APP看书,第一时间看更新

第3条:优先考虑is或as运算符,尽量少用强制类型转换

既然选择了C#,那么就必须适应静态类型检查机制,该机制在很多情况下都会起到良好的作用。静态类型检查意味着编译器会把类型不符的用法找出来,这也令应用程序在运行期能够少做一些类型检查。然而有的时候还是必须在运行期检查对象的类型,比方说,如果你所使用的框架已经在方法签名里面把参数类型写成了object,那么可能就得先将该参数转成其他类型(例如其他的类或接口),然后才能继续编写代码。有两种办法能够实现转换,一是使用as运算符,二是通过强制类型转换(cast)来绕过编译器的类型检查。在这之前,可以先通过is判断该操作是否合理,然后再使用as运算符或执行强制类型转换。

在这两种办法中,应该优先考虑第一种办法,也就是采用as运算符来实现类型转换,因为这样做要比盲目地进行类型转换更加安全,而且在运行的时候也更有效率。as及is运算符不会考虑由用户所定义的转换,只有当运行期的类型与要转换到的类型相符时,该操作才能顺利地执行。这种类型转换操作很少会为了类型转换而构建新的对象(但若用as运算符把装箱的值类型转换成未装箱且可以为null的值类型,则会创建新的对象)。

下面来看一个例子。如果需要把object对象转换为MyType实例,那么可以这样写:

此外,也可以这样来写:

大家应该会觉得第一种写法比第二种更简单,而且更好理解。由于它不需要使用try/catch结构,因此程序的开销与代码量都比较低。如果采用第二种写法,那么不仅要捕获异常,而且还得判断t是不是null。强制类型转换在遇到null的时候并不抛出异常,这导致开发者必须处理两种特殊情况:一种是o本来就为null,因此强制转换后所得的t也是null;另一种是程序因o无法类型转换为MyType而抛出异常。如果采用第一种写法,那么由于as操作在这两种特殊情况下的结果都是null,因此,只需要用if(t!=null)语句来概括地处理就可以了。

as运算符与强制类型转换之间的最大区别在于如何对待由用户所定义的转换逻辑。as与is运算符只会判断待转换的那个对象在运行期是何种类型,并据此做出相应的处理,除了必要的装箱与取消装箱操作,它们不会执行其他操作。如果待转换的对象既不属于目标类型,也不属于由目标类型所派生出来的类型,那么as操作就会失败。反之,强制类型转换操作则有可能使用某些类型转换逻辑来实现类型转换,这不仅包含由用户所定义的类型转换逻辑,而且还包括内置的数值类型之间的转换。例如可能发生从long至short的转换,这种转换可能导致信息丢失。

下面举个例子来演示这两种类型转换方式怎样处理开发者在自己定义的类型中所写的类型转换。假设写了这样一个类:

假设在早前那段代码里面由Factory.GetObject()函数所返回的对象o实际上是个SecondType类型的对象。现在来看下面这两种写法:

这两种写法都无法完成类型转换。你也许觉得第二种写法可以完成类型转换,因为强制类型转换操作会把由用户所定义的转换逻辑也考虑进去。没错,确实会考虑进去,只不过它针对的是源对象的编译期类型,而不是实际类型。具体到本例来说,由于待转换的对象其编译期的类型是object,因此,编译器会把它当成object看待,而不考虑其在运行期的类型。查看了object与MyType类的定义之后,编译器发现用户并没有在这两种类型之间定义类型转换逻辑,于是,就直接据此来编译(而不理会开发者在SecondType类里面定义的那段逻辑)。编译好的程序在运行期要判断对象o的运行期类型与MyType是否相符,由于o的运行期类型是SecondType,与MyType不相符,因此,强制类型转换操作会失败。编译器所考虑的是对象o的编译期类型与目标类型MyType之间有没有转换逻辑,而不是该对象的运行期类型与MyType之间的关系。

要想把对象o从SecondType强制类型转换为MyType,可以将代码改成下面这个样子:

这段代码虽然可以实现强制类型转换,但是显得相当别扭,因为开发者应该可以通过适当的检查语句来避免无谓的异常处理。尽管现实工作中很少有人这么写,但这段代码所暴露的问题却比较常见,因为开发者在编写某些函数时,可能要把参数类型设为object,然后在函数里面把该参数转换成自己想要的类型:

用户自定义的转换逻辑针对的是对象的运行期类型,而非编译期类型。因此,即便o的运行期类型与MyType之间确实有转换关系,编译器也是不知道的(或者说,编译器也是不会顾及的)。下面这种写法其效果要根据st的声明类型来定。(在SecondType类里面不包含用户自定义转换逻辑的前提下,如果把st声明成object,那么可以编译,但是运行的时候会抛出异常,反之,若声明成SecondType,则无法编译。)

假如换用下面这种写法,那么当st声明成object时可以编译,但是运行的时候,t的结果是null。反之,若声明成SecondType,则无法编译。由此可见,应该尽量采用as来进行类型转换,因为这么做不需要编写额外的try/catch结构来处理异常。对于SecondType与MyType这样两个在继承体系中没有上下级关系的类来说,即便SecondType类确实含有由用户所定义的转换逻辑,但只要把st声明成了SecondType类型,as语句就依然会产生编译错误。

讲述了应该优先考虑as的原因之后,接下来看看在什么样的情况下不能使用as。下面这种写法就无法通过编译:

这是因为int是值类型,无法保存null。当o不是int的时候,as语句的执行结果应该是null,但由于i是int,因此,无论选择什么样的整数,都无法表示这个null,因为它的每一种取值都是有效的整数,无法理解成null这个特殊的值。有些人可能觉得,要实现这样的类型转换,就必须执行强制类型转换操作,并编写异常捕获结构。其实不用那样做,只需用as运算符把o转换成一种值可以为null的类型就可以了(具体到本例,就是int?类型),然后判断变量i是不是null:

如果as运算符所在的赋值语句的赋值符号左侧的变量是值类型或可以为null的值类型,那么可以运用这项技巧来实现类型转换。

明白了is、as与cast(强制类型转换)之间的区别之后,现在考虑一个问题:foreach循环在转换类型的时候用的是as还是cast?这种循环所针对的是非泛型的IEnumerable序列,它会在迭代过程中自动转换类型。(其实在可以选择的情况下,还是应该尽量采用类型安全的泛型版本。之所以使用非泛型的版本,是为了顾及某些历史原因以及某些需要执行后期绑定的场合。)

foreach语句是用cast实现类型转换的,它会把对象从object类型转换成循环体所需要的类型。下面这段手工编写的代码可以用来模拟foreach语句所执行的类型转换操作:

foreach语句需要同时应对值类型与引用类型,而这种采用cast的类型转换方式使得它在处理这两种类型时,可以展示出相同的行为。但是要注意,由于是通过cast方式来转换类型的,因此可能抛出InvalidCastException异常。

IEnumerator.Current返回的是System.Object型的对象,该类型并没有定义类型转换操作符,因此,如果以一系列SecondType对象为参数来执行刚才那段代码,那么其中的cast就会失败,这是因为cast并不考虑it.Current的运行期类型,而只会判断它的编译期类型(System.Object)与循环变量的声明类型(MyType)之间有没有用户所定义的类型转换逻辑。(由于并没有这种逻辑,因此,它不会调用开发者定义在SecondType类里面的那一段类型转换代码,这导致程序在运行期会试着直接把SecondType对象转换为MyType对象,从而抛出异常。)

最后还要注意:如果想判断对象是不是某个具体的类型而不是看它能否从当前类型转换成目标类型,那么可以使用is运算符。该运算符遵循多态规则,也就是说,如果变量fido所属的类型Dog继承自Animal,那么fido is Animal的值就是true。此外,GetType()方法可以查出对象的运行期类型,从而令开发者写出比is或as更具体的代码,因为该方法所返回的对象类型能够与某种特定的类型做比较。

依然以下面这个函数为例:

假设MyType类有个名为NewType的子类,那么用一系列NewType对象来当参数是可以正常调用UseCollection函数的:

如果该函数是面向MyType及它的各种子类而编写的,那么这样做的效果自然没有问题。但有的时候,开发者编写这样的函数仅仅是为了处理类型恰好为MyType的那些对象,而不想把MyType的子类对象也一并加以处理。针对这种需求,可以在foreach循环中以GetType()来判断循环变量的准确类型。这样的需求最常出现在那些需要执行相等测试的场合。除此之外的其他场合则可以考虑使用as与is,因为它们在那些场合之下的语义是正确的。

.NET Base Class Library(BCL,基类库)里面有个方法能够把序列中的各元素分别转换成同一种类型,这个方法就是Enumerable.Cast<T>(),它必须在支持IEnumerable接口的序列上面调用:

上面这段代码中的查询语句其实也是用这个方法实现出来的,因此,它与最后那条直接调用Cast<T>方法的语句实际上是同一个意思,它们都会利用该方法把序列中的对象转换成目标类型T。与as运算符不同,该方法是采用旧式的cast方式来完成转换的,这意味着Cast<T>不考虑类型参数所应受到的约束。使用as运算符会受到一定的制约,而针对不同的类型来实现不同的Cast<T>方法又显得比较麻烦,因此,BCL团队决定把所有的类型转换操作都用这样一个旧式的cast运算符来完成。你在编写自己的代码时也需要做出类似的权衡,如果你想转换的那个对象,其源类型是通过某个泛型参数指定的,那么就要考虑:是给泛型参数施加类型约束(class constraint),还是采用cast运算符来转换类型?如果用后者,那么就需要编写额外的代码来处理不同的情况。

此外还要注意,涉及泛型的cast操作是不会使用转换运算符的。因此,在由整数所构成的序列上面无法执行Cast<double>()。在4.0及后续版本的C#语言里面,开发者可以通过动态类型检查及运行期类型检查进一步绕过C#类型系统,如果要分别处理不同类型的对象,那么可以根据对象的行为来划分,而不一定非要去判断该对象是否属于某个类型或是否提供某个接口,因为有很多种办法都可以判断出它能不能表现出你想要的行为。

使用面向对象语言来编程序的时候,应该尽量避免类型转换操作,但总有一些场合是必须转换类型的。此时应该采用C#语言的as及is运算符来更为清晰地表达代码的意图。至于那些自动执行的类型转换(coercing type)操作,则各有其不同的规则,但一般来说,采用is及as运算符几乎总是可以写出含义正确的代码,这两种运算符只会在受测对象确实可以进行类型转换时才给出肯定的答案,而cast则与之相反,这种运算符经常会产生违背开发者预期的效果。