64

我们都知道可变结构通常是邪恶的。我也很确定,因为IEnumerable<T>.GetEnumerator()返回 type IEnumerator<T>,结构会立即被装箱到引用类型中,这比它们一开始只是引用类型的成本要高。

那么,为什么在 BCL 泛型集合中,所有枚举数都是可变结构?肯定有一个很好的理由。我唯一想到的是可以轻松复制结构,从而在任意点保留枚举器状态。但是向接口添加一个Copy()方法IEnumerator会不那么麻烦,所以我不认为这本身就是一个合乎逻辑的理由。

即使我不同意某个设计决定,我也希望能够理解其背后的原因。

4

2 回答 2

93

实际上,这是出于性能原因。BCL 团队对这一点进行了大量研究,然后才决定采用您正确称为可疑且危险的做法:使用可变值类型。

你问为什么这不会导致拳击。这是因为如果可以避免的话,C# 编译器不会在 foreach 循环中生成将内容装箱到 IEnumerable 或 IEnumerator 的代码!

当我们看到

foreach(X x in c)

我们要做的第一件事是检查 c 是否有一个名为 GetEnumerator 的方法。如果是,那么我们检查它返回的类型是否具有方法 MoveNext 和属性 current。如果是这样,则完全使用对这些方法和属性的直接调用来生成 foreach 循环。只有当“模式”无法匹配时,我们才会返回寻找接口。

这有两个理想的效果。

首先,如果集合是一个整数集合,但是是在泛型类型被发明之前编写的,那么它不会受到将 Current 的值装箱到对象然后将其拆箱到 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 是一个只读字段,您可能会合理地期望编译器会做它所做的事情:制作一个副本,并改变该副本,以确保该方法不会丢弃需要处理的值中的内容。

然而,这与我们对这里应该发生的事情的直觉相冲突:

using (Enumerator enumtor = whatever)
{
    ...
    enumtor.MoveNext();
    ...
}

我们希望在 using 块中执行 MoveNext会将枚举数移动到下一个,无论它是结构还是 ref 类型。

不幸的是,今天的 C# 编译器有一个错误。如果您处于这种情况,我们会选择不一致地遵循哪种策略。今天的行为是:

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

  • 但是如果它是一个提升的局部变量(因为它是一个匿名函数的封闭变量或在一个迭代器块中),那么这个局部变量实际上是作为一个只读字段生成的,并且确保在副本上发生突变的齿轮需要超过。

不幸的是,该规范在这个问题上几乎没有提供指导。很明显,有些东西是坏的,因为我们做的不一致,但什么是正确的做法并不明确。

于 2010-07-02T19:02:19.553 回答
6

当编译时结构类型已知时,结构方法被内联,并且通过接口调用方法很慢,所以答案是:因为性能原因。

于 2010-07-02T18:46:48.563 回答