为什么BCL集合使用结构枚举器而不是类?

63
我们都知道可变结构体通常是有害的。我也很确定因为 IEnumerable<T>.GetEnumerator() 返回类型 IEnumerator<T>,这些结构体会立即装箱成引用类型,导致花费比起一开始就是引用类型更高。
那么为什么在BCL泛型集合中,所有枚举器都是可变结构体?肯定有一个很好的理由。我能想到的唯一的事情是,结构体可以轻松复制,因此在任意点上保留枚举器状态。但是将Copy()方法添加到IEnumerator接口可能会更加麻烦,所以我不认为这本身是一个逻辑上的理由。
即使我不同意设计决策,我也希望能够理解背后的原因。

相关页面,供其他人参考:https://dev59.com/l3RC5IYBdhLWcg3wMd5Shttp://www.eggheadcafe.com/software/aspnet/31702392/c-compiler-challenge--s.aspx - James Manning
2个回答

98

确实是出于性能考虑。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。如果您处于这种情况,我们选择要遵循的策略是不一致的。目前的行为是:

  • 如果通过方法变异的值类型变量是普通局部变量,则会正常变异

  • 但是,如果它是托管的本地变量(因为它是匿名函数或迭代器块的闭合变量),则该本地变量实际上是作为只读字段生成的,并且负责确保变异发生在副本上的机制接管了该变量。

不幸的是,规范对此事提供了很少的指导。显然,某些东西出了问题,因为我们的行为不一致,但是应该做正确之事并不清楚。


1
+1 表示传递 IEnumerable<T> 与原始泛型集合相比存在(最小的)性能损失 -- 在快速发布模式下测试枚举包含一千万个条目的 List<int> 和转换为 IEnumerable<int> 的时间差异保持一致,我看到一个稳定的时间差异为 2:1(在这种情况下,约为 100ms vs 约为 50ms)。 - Ben M
2
我想知道为什么这个答案如此详尽,然后我看到了谁写的。 - Pharap
3
现在情况怎么样?情况有所改善了吗? - Akmal Salikhov
还想知道是否有关于这方面的任何更改?感谢Eric提供的相当有见地的答案。 - Vedran Mandić
3
@Backwards_Dave:没错。编译器并不需要调用 GetEnumerator 方法;它需要生成代码来枚举集合。如果数组是一种行为完全可知的特殊类型,可以在不调用 GetEnumerator 的情况下完成枚举,那么编译器可以选择这样做。 - Eric Lippert
显示剩余3条评论

6

如果在编译时确定结构类型,那么结构方法将被内联,通过接口调用方法速度较慢,因此答案是:出于性能原因。


但是这些是内部结构体,因此类型在编译时永远不会知道;所有最终用户代码都通过接口访问它们。 - Stephen Cleary
如果您查看例如List<T>.GetEnunmerator方法(http://msdn.microsoft.com/en-us/library/b0yss765.aspx),您可以看到它返回List<T>::Enumerator结构。在C#中的foreach循环不直接使用IEnumerable接口,只需要如果类具有GetEnumerator方法。因此枚举器的类型在编译时已知。 - STO
1
+1,这是准确的。JIT编译器可以生成更高效的代码。 - Hans Passant

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