IEnumerable<T>.GetEnumerator()
返回类型 IEnumerator<T>
,这些结构体会立即装箱成引用类型,导致花费比起一开始就是引用类型更高。那么为什么在BCL泛型集合中,所有枚举器都是可变结构体?肯定有一个很好的理由。我能想到的唯一的事情是,结构体可以轻松复制,因此在任意点上保留枚举器状态。但是将
Copy()
方法添加到IEnumerator
接口可能会更加麻烦,所以我不认为这本身是一个逻辑上的理由。即使我不同意设计决策,我也希望能够理解背后的原因。
IEnumerable<T>.GetEnumerator()
返回类型 IEnumerator<T>
,这些结构体会立即装箱成引用类型,导致花费比起一开始就是引用类型更高。Copy()
方法添加到IEnumerator
接口可能会更加麻烦,所以我不认为这本身是一个逻辑上的理由。确实是出于性能考虑。BCL团队在决定采用您所说的一种可疑和危险的做法之前,进行了很多关于这一点的研究:使用可变值类型。
你问为什么这不会导致装箱。因为C#编译器不会在foreach循环中生成将东西装箱到IEnumerable或IEnumerator的代码,如果可以避免的话!
当我们看到
foreach(X x in c)
首先我们检查 c 是否有名为GetEnumerator的方法。如果有,那么我们会检查它返回的类型是否有MoveNext方法和current属性。如果有,那么foreach循环就完全使用直接调用这些方法和属性来生成。只有当“模式”无法匹配时,我们才会回退到寻找接口。
这有两个好处。
第一,如果集合是一个int集合,但在泛型类型发明之前编写了该集合,则不会承受将Current值装箱为object,然后再将其拆箱为int的惩罚。如果Current是返回int的属性,我们只需使用它。
第二,如果枚举器是值类型,则不会将枚举器装箱为IEnumerator。
正如我所说,BCL团队进行了大量研究,并发现绝大多数情况下,分配和释放枚举器的代价足够大,以至于即使这样做可能会导致一些奇怪的错误,也还是值得将其设置为值类型。
例如,请考虑以下代码:
struct MyHandle : IDisposable { ... }
...
using (MyHandle h = whatever)
{
h = somethingElse;
}
你可以合理地期望试图改变h的尝试失败,实际上它确实失败了。编译器会检测到你试图更改一个具有挂起处理的内容的值,并且这样做可能会导致需要处理的对象实际上无法被处理。
现在假设你有以下代码:
struct MyHandle : IDisposable { ... }
...
using (MyHandle h = whatever)
{
h.Mutate();
}
这里会发生什么?你也许会合理地期望编译器会像 h 是一个 readonly 字段时所做的那样:创建一个副本并对副本进行修改,以确保该方法不会丢弃需要处理的值。
然而,这与我们对此应该发生的直觉相冲突:
using (Enumerator enumtor = whatever)
{
...
enumtor.MoveNext();
...
}
我们期望在使用using块内部执行MoveNext将移动枚举器到下一个,无论它是结构体还是引用类型。
不幸的是,C#编译器今天存在一个bug。如果您处于这种情况,我们选择要遵循的策略是不一致的。目前的行为是:
如果通过方法变异的值类型变量是普通局部变量,则会正常变异
但是,如果它是托管的本地变量(因为它是匿名函数或迭代器块的闭合变量),则该本地变量实际上是作为只读字段生成的,并且负责确保变异发生在副本上的机制接管了该变量。
不幸的是,规范对此事提供了很少的指导。显然,某些东西出了问题,因为我们的行为不一致,但是应该做正确之事并不清楚。
IEnumerable<T>
与原始泛型集合相比存在(最小的)性能损失 -- 在快速发布模式下测试枚举包含一千万个条目的 List<int>
和转换为 IEnumerable<int>
的时间差异保持一致,我看到一个稳定的时间差异为 2:1(在这种情况下,约为 100ms vs 约为 50ms)。 - Ben MGetEnumerator
方法;它需要生成代码来枚举集合。如果数组是一种行为完全可知的特殊类型,可以在不调用 GetEnumerator
的情况下完成枚举,那么编译器可以选择这样做。 - Eric Lippert如果在编译时确定结构类型,那么结构方法将被内联,通过接口调用方法速度较慢,因此答案是:出于性能原因。