Spiga

LINQ中的强制类型转换Cast函数其实挺坑爹的

2011-10-17 23:13 by 木鱼, 1397 visits, 收藏, 编辑

今天被LINQ的Cast函数坑了一次,不过细究之下其实还是学到了新东西的。其实强制类型转换大部分人都会天天接触,可是谁会知道这里面还有点小秘密呢?

1.强制类型转换?

我想能看到这里的同学应该都不需要我去解释,所谓强制类型转换就是指将一个变量由一个数据类型强制转换为另一个类型,当然前提是对象和目标类型是兼容的。
下面这两行便执行了一个强制类型转换:

  1. double a = 23.0;
  2. int b = (int)a;

由于过于简单,这里说太多就有失水准鸟。
不过呢,这里要求俩类型具有兼容性;所谓的兼容性就是说要么它们是派生类的关系,要么系统知道如何去转换他们。
因此,对于自定义类型,我们往往会通过实现隐式转换或显示运算符来让它们支持转换,像下面的这个A和B类,这么搞一下俩人便能良好地兼容鸟:

  1. public class A
  2. {
  3.     public static implicit operator B(A a) { return new B(); }
  4.     public static implicit operator A(B a) { return new A(); }
  5. }
  6. public class B
  7. {
  8. }
  9. static void Test()
  10. {
  11.     //直接强制乱转,木有问题
  12.     var a = new A();
  13.     var b = (B)a;
  14. }

在这个全民皆LINQ的时代,CLR也为我们带来了一个类型转换的扩展方法:Cast。
先来看一下MSDN中关于Cast方法的定义:将 IEnumerable 的元素转换为指定的类型。 如果元素无法强制转换为 TResult 类型,则此方法将引发异常。
说白了,就是说Cast可以将一个序列从一个类型转换为另一个类型的序列。于是我便想当然地认为,那这个Cast不就是执行了一个强制类型转换嘛。

2.可是真的是完整的强制类型转换吗?

我回过头再去看的时候才发现原来MSDN中的介绍真的不怎么详细,只说是转换。可是这个『转换』到底是个嘛儿啊,到底怎么样的转换啊。
咱用事实来说话。祭出ILSpy看看Cast这个函数是怎么定义的:

// System.Linq.Enumerable
/// <summary>Converts the elements of an <see cref="T:System.Collections.IEnumerable" /> to the specified type.</summary>
/// <returns>An <see cref="T:System.Collections.Generic.IEnumerable`1" /> that contains each element of the source sequence converted to the specified type.</returns>
/// <param name="source">The <see cref="T:System.Collections.IEnumerable" /> that contains the elements to be converted.</param>
/// <typeparam name="TResult">The type to convert the elements of <paramref name="source" /> to.</typeparam>
/// <exception cref="T:System.ArgumentNullException">
///   <paramref name="source" /> is null.</exception>
/// <exception cref="T:System.InvalidCastException">An element in the sequence cannot be cast to type <paramref name="TResult" />.</exception>
public static IEnumerable<TResult> Cast<TResult>(this IEnumerable source)
{
    IEnumerable<TResult> enumerable = source as IEnumerable<TResult>;
    if (enumerable != null)
    {
        return enumerable;
    }
    if (source == null)
    {
        throw Error.ArgumentNull("source");
    }
    return Enumerable.CastIterator<TResult>(source);
}

这个代码本身不复杂,分三段。一段就是看类型是不是可以直接转换过去,如果可以的话就直接返回;值得注意的是在.Net 4.0中 IEnumerable<T>接口是协变的,所以派生类的IEnumerable<T>可以直接转换到基类的IEnumerable<T>。如果返回的是null,那就说明转换失败了,就会由最后一个 CastIterator 返回一个 IEnumerable。
用ILSpy看CastIterator其实也很简单:

private static IEnumerable<TResult> CastIterator<TResult>(IEnumerable source)
{
    foreach (object current in source)
    {
        yield return (TResult)current;
    }
    yield break;
}

似乎就是一个强制类型转换嘛。

那好吧,我们写下这样的代码:

  1. public static void Test()
  2. {
  3.     var aa=new []{new A()};
  4.     var bb=aa.Cast<B>().ToArray();
  5. }

从第一节的例子我们能看到,A完全是可以强制类型转换为B的。可是出人意料的,这里会抛出一个InvalidCastException:

Unable to cast object of type 'A' to type 'B'.

image

如果不是之前已经说过了能正常转换,我一定会觉得是自己的转换代码出了问题。事实上也确实如此,我下午在项目里转悠了一个小时找到底是哪里写错了。没理由强制类型转换却转换不了不是?

好在到最后我终于把怀疑的目标指向了Cast函数本身。于是写下这段代码测试一下:

  1. public static void Test()
  2. {
  3.     double[] a = new[] { 12.4 };
  4.     int[] b = a.Cast<int>().ToArray();
  5. }

果然这个Cast还是抛出了InvalidCastException异常:

image

这个Cast函数到底干的啥啊?啥都转不了啊这个?

3.那这下怎么办?

Cast不能用,那直接用 Select 不就结了?这样就行了。

  1. public static void Test()
  2. {
  3.     var aa = new[] { new A() };
  4.     var bb = aa.Select(s => (B)s).ToArray();
  5. }

4.追根究底

可是我还是想知道上面看起来像是强制类型转换的那货为啥会抛异常?

在MSDN和ILSpy中转悠了半天后,祭出Google大神,终于找到一篇有用的资料:Linq Cast extension method and InvalidCastException

这位遇到相同命运的作者遇到的问题和上面我从double到int的转换测试类似,不过他是从byte转换到int,理论上隐式转换都行。
值得注意的是这篇博客本身没有解释问题,真正有价值的是在它的回复中。翻译过来就是这样:

在C#中,(type)<表达式> 是一个基本的类型转换运算符。也就是说,当编译器知道一个表达式可以直接转换为另一个类型时,它会生成一个直接转换代码;如果不能,那么它会查找是否有自定义转换的方法,并生成调用它的代码。在直接面对类型的转换中,编译器知道哪些类别可以强制转换(虽然不能直接转换)。这时,因为byte不是int,所以不能直接转换,但是它可以通过正确的IL代码来转换为int(这里是 conv_i4操作码)。
然而,这样的操作只有当编译器在编译时知道表达式可以转换到目标类型才可以。当通过扩展方法的类型参数进行转换,编译器对TIn是否能直接转换或强制转换为TOut是一无所知的。正因为这些信息的缺失,我认为在Cast扩展方法中的IL代码是使用简单的"castclass"操作码,这个操作码并不具备在运行时查找显式转换方法的能力(或者它可以找,但是Cast运算符不进行那样的操作)。
这样便很简单了:因为byte不是int,所以Cast转换就会因为InvalidCastException失败,这和在这里描述的行为是完全一致的: http://msdn.microsoft.com/en-us/library/system.reflection.emit.opcodes.castclass%28VS.85%29.aspx

通过对这段话的解读,可以知道实际上(type)强制类型转换运算符涉及到编译时的操作。当通过类型参数进行转换时,因为编译器无法预知目标的类型信息,所以只能生成最基本的castclass操作代码,而无法对自定义转换函数等进行支持。这也就解释了为什么A和B实现了隐式运算符却会抛异常,而int和byte之间的Cast也会抛异常的问题。

5.再来验证下

依据上一节中的理解,那么我们可以认为下面这样一段代码是无法正常工作的:

  1. static TResult CastTest<TSource, TResult>(TSource s)
  2. {
  3.     return (TResult)s;
  4. }
  5. public static void Test()
  6. {
  7.     var b = CastTest<A, B>(new A());
  8. }

但当我们实际编译的时候,会惊奇地发现:何止不能工作,连编译都无法编译通过:

错误 1 无法将类型"TSource"转换为"TResult"

可是Cast方法的怎么编译成功的?再回头看了一下Cast的实现代码,才发现在foreach循环中先是将目标对象转换为了object,再实施强制类型转换的。由于object是万物的始祖,这么一来就搞得好像是和派生类之间的强制转换一样了。不得不说这真的是一个很曲线救国的方案。不管怎样,咱依葫芦画个瓢再说。

  1. static TResult CastTest<TSource, TResult>(TSource s)
  2. {
  3.     return (TResult)((object)s);
  4. }
  5. public static void Test()
  6. {
  7.     var b = CastTest<A, B>(new A());
  8. }

结果这段代码抛出了一模一样的错误,很是光荣:

image

查看下IL,用的是 unbox.any 操作码:

.method private hidebysig static 
    !!TResult CastTest<class TSource, TResult> (
        !!TSource s
    ) cil managed 
{
    // Method begins at RVA 0x29a4
    // Code size 17 (0x11)
    .maxstack 1
    .locals init (
        [0] !!TResult CS$1$0000
    )

    IL_0000: nop
    IL_0001: ldarg.0
    IL_0002: box !!TSource
    IL_0007: unbox.any !!TResult
    IL_000c: stloc.0
    IL_000d: br.s IL_000f

    IL_000f: ldloc.0
    IL_0010: ret
} // end of method Test1::CastTest

根据MSDN中对 unbox_any 操作码的介绍,当目标值是个引用对象时,这个操作码的行为和 castclass 是一致的。可见上节中的解释,虽然不完全正确,但大致是不差的。

6.总结

总结很简单:

  • Cast扩展方法不具备调用自定义转换运算符的能力(包括隐式和显式的转换运算符)
  • 但回头想想这样的设计也很合理,因为这是为了保持原有的序列的操作,如果真的类型转换了,那么序列就会被变更(虽然有时候这是故意的);那这么说这个扩展函数的名称改为AsType是不是更加合适?
  • 强制类型转换包括类型兼容性和自定义转换运算符。对自定义转换运算符需要编译器在编译时的参与,否则无法起作用
  • ILSpy显示的代码有时候仅供参考

PS,Live Writer有个毛病和VS2003的HTML设计器一样很讨厌,就是粘贴HTML代码的时候,它喜欢乱加font标签。

Add your comment

9 条回复

  1. #1楼 中华小鹰      2011-10-17 23:33
    结论第一条,Cast扩展方法不具备调用自定义转换运算符的能力(包括隐式和显式的转换运算符)。自然应该是如此,因为转换运算符需要编译器来识别,这就跟is、as运算不适用于转换运算符是一个道理。
     回复 引用 查看   
  2. #2楼 toEverybody      2011-10-18 00:23
    不成熟的“技术”都叫坑爹
     回复 引用 查看   
  3. #3楼 诺贝尔      2011-10-18 09:20
    说了这么多,到底linq cast有什么应用场合?
     回复 引用 查看   
  4. #4楼 初始小花      2011-10-18 09:20
    有点意思
     回复 引用 查看   
  5. #5楼 Ivony...      2011-10-18 10:33
    这不是LINQ或者Cast的问题,是泛型的问题。
    简单的说就是显示/隐式类型转换运算符重载是C#编译器编译期所支持的,在运行期无法动态查找,而非确定泛型在编译期无法确定真正的类型,所以非确定泛型类型不支持确定的显示/隐式类型转换运算符重载。

    其实引申开来,运算符重载也就是个方法,其实这个问题在方法绑定上同样存在,只不过情况有所不同:

    考虑如下的伪代码:
    public static void SomeMethod( T c )
    {
      c.Add( new object() );//编译错误
    }
    

    这个是编译错误,所以很容易大家可以分辨。


    public class BaseClass
    {
      public void SomeMethod()
      {
        throw new Exception();
      }
    }
    
    public class SubClass : BaseClass
    {
      public new void SomeMethod()
      {
        Console.Write( "OK" );
      }
    }
    
    public static Main()
    {
      SomeMethod( new SubClass() );
    }
    
    public static void SomeMethod( T c ) : where T : BaseClass
    {
      c.SomeMethod();//编译通过
    }
    


    如果是这样?那么到底是抛异常还是输出OK呢?
     回复 引用 查看   
  6. #6楼 陈梓瀚(vczh)      2011-10-18 11:40
    我觉得Cast的作用就是把一个存放了string的IEnumerable转成IEnumerable<string>这样的工作……适合用来处理WinForm的老旧列表对象。显然你用错他了……
     回复 引用 查看   
  7. #7楼 陈梓瀚(vczh)      2011-10-18 11:41
    @Ivony...
    抛异常
     回复 引用 查看   
  8. #8楼 沐訫      2011-10-18 13:15
    学习了..刚好前几天自己定义转换无论是隐式还是显示 cast死活 不行..原来如此,谢谢分享
     回复 引用 查看   
  9. #9楼 SnowDreamist      2011-10-18 16:31
    cast()我觉得一般是用在你确定这个类型是什么,但是没有在泛型中合适的表示的情况下使用的,比如你得到的泛型枚举是IEnumerable<object>,但是你知道都是string,那么就cast<string>()这样以后操作方便一些,实际意义倒没什么。另外就是转换到基类型时看起来比较清晰。
    我觉得cast在完善linq的链式表达式的意义大于实际使用的意义。
    比如XXX.where(...).cast().xxxx 比 (XXX.where(...) as T).xxxx看起来要好一些,嘿嘿。
     回复 引用 查看