为什么IEnumerable<struct>无法转换为IEnumerable<object>?

28

为什么最后一行不被允许?

IEnumerable<double> doubleenumerable = new List<double> { 1, 2 };
IEnumerable<string> stringenumerable = new List<string> { "a", "b" };
IEnumerable<object> objects1 = stringenumerable; // OK
IEnumerable<object> objects2 = doubleenumerable; // Not allowed

这是因为double是一个值类型而不是派生自object,所以协变性不起作用吗?

这是否意味着没有办法使其工作:

public interface IMyInterface<out T>
{
    string Method(); 
}

public class MyClass<U> : IMyInterface<U>
{
    public string Method()
    {
        return "test";
    }
}

public class Test
{
    public static object test2() 
    {
        IMyInterface<double> a = new MyClass<double>();
        IMyInterface<object> b = a; // Invalid cast!
        return b.Method();
    }
}

这意味着我需要编写自己的 IMyInterface<T>.Cast<U>() 方法来实现这一点吗?


可能是为什么协变和逆变不支持值类型的重复问题。 - nawfal
3个回答

47
为什么最后一行不允许?
因为double是值类型,而object是引用类型;协变只有在两种类型都是引用类型时才有效。
这是因为double是值类型而导致协变无法工作吗?
不是的。Double确实从object派生出来。所有值类型都从object派生出来。
现在你应该问的问题是:
为什么协变不能将IEnumerable转换为IEnumerable?
因为"谁来装箱"?从double到object的转换必须对double进行装箱。假设你调用了IEnumerator.Current,它实际上是对IEnumerator.Current的实现进行的调用。调用方希望返回一个对象。被调用方返回一个double。那么代码在哪里执行装箱指令,将由IEnumerator.Current返回的double转换为装箱的double呢?
它不存在,这就是为什么这个转换是非法的原因。调用Current将在计算堆栈上放置一个8字节的double,使用者期望在计算堆栈上放置一个4字节的装箱double的引用,因此使用者将崩溃并可怕地死亡,堆栈错位并且指向无效内存的引用。
如果你想要执行装箱的代码,则必须在某个时候编写它,而你就是要编写它的人。最简单的方法是使用Cast扩展方法:
IEnumerable<object> objects2 = doubleenumerable.Cast<object>();

现在您调用一个包含装箱指令的辅助方法,将八字节的双精度浮点数转换为引用。

更新:一位评论者指出我已经提出了问题——也就是说,我通过预设解决一个与原始问题同样困难的问题的机制来回答了一个问题。那么,Cast<T>的实现如何解决知道是否需要装箱的问题呢?

它的工作方式类似于这个草图。请注意,参数类型不是通用的:

public static IEnumerable<T> Cast<T>(this IEnumerable sequence) 
{
    if (sequence == null) throw ...
    if (sequence is IEnumerable<T>) 
        return sequence as IEnumerable<T>;
    return ReallyCast<T>(sequence);
}

private static IEnumerable<T> ReallyCast<T>(IEnumerable sequence)
{
    foreach(object item in sequence)
        yield return (T)item;
}

确定从对象到T的转换是否是拆箱转换还是引用转换的责任被推迟到运行时。Jitter知道T是引用类型还是值类型。当然,99%的情况下它将是一个引用类型。

6
离题了,但我希望你能发推文。 - Joe
17
如果我有什么符合一般兴趣且在120个字符以内的话要说,我会这样做。不过要等很久。 - Eric Lippert
3
@EricLippert,也许您可以提供一篇关于如何使用图论来提高洗衣效率的博客链接。 - Joe
1
@EricLippert 关于强制转换运算符的一个问题,Cast<>扩展方法在编译时并不知道源类型是值类型还是引用类型,那么它如何知道应该使用castclass还是box呢? - Felix K.
2
@FelixK:Cast扩展方法首先采取简单的方法;如果对象已经是所需类型的序列,则使用该对象。否则,它使用非泛型IEnumerable将序列视为对象序列,并将每个对象转换为T。Jitter负责确定从对象到T的转换是作为取消装箱转换还是引用转换实现的。 - Eric Lippert
显示剩余5条评论

5
为了理解什么是允许的和不允许的,以及为什么事情会表现出这样的行为,了解底层发生了什么是有帮助的。对于每种值类型,都存在一个相应的类对象类型,就像所有对象一样,它们都将继承自 System.Object 。每个类对象都包含一个32位字(x86)或64位长字(x64),用于标识其类型。然而,值类型存储位置不持有这样的类对象或对它们的引用,也没有存储具有类型数据的字。相反,每个原始值类型位置只是简单地保存表示值所需的位,每个结构值类型存储位置只是保存该类型的所有公共和私有字段的内容。
当将Double类型的变量复制到Object类型时,将创建与Double关联的类对象类型的新实例,并将所有字节从原始实例复制到该新类对象中。尽管装箱的Double类类型与Double值类型具有相同的名称,但这并不会导致歧义,因为它们通常不能在相同的上下文中使用。值类型存储位置保存原始位或字段组合,没有存储类型信息;将一个这样的存储位置复制到另一个位置将复制所有字节,因此也会复制所有公共和私有字段。相比之下,从值类型派生的堆对象是堆对象,并且像堆对象一样运行。尽管C#将值类型存储位置的内容视为Object的衍生物,但在底层,这样的存储位置的内容只是字节集合,实际上是超出了类型系统。由于只能通过知道字节表示什么的代码来访问它们,因此不需要将此类信息与存储位置本身一起存储。尽管在结构体上调用GetType时需要装箱的必要性通常是以非遮蔽、非虚函数的方式描述的,但真正的必要性源于值类型存储位置的内容(与位置本身不同)没有类型信息。

0

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接