我们都知道,可变结构通常是邪恶的。我也很确定,因为IEnumerable<T>.GetEnumerator()返回类型IEnumerator<T>,所以结构会立即装箱成引用类型,这比起它们仅仅是引用类型的成本要高。
IEnumerable<T>.GetEnumerator()
IEnumerator<T>
那么,为什么在BCL泛型集合中所有枚举数都是可变的结构?当然必须有一个很好的理由。我唯一想到的是,可以轻松地复制结构,从而将枚举数状态保留在任意点。但是向接口添加Copy()方法的IEnumerator麻烦将减少,因此我认为这本身并不是逻辑上的理由。
Copy()
IEnumerator
即使我不同意设计决定,我也希望能够理解其背后的原因。
确实,这是出于性能原因。BCL团队在这一点上进行了 大量 研究,然后才决定采用您正确地称呼为可疑和危险的做法:使用可变值类型。
您问为什么这不会导致拳击。这是因为C#编译器不会避免将代码填充到foreach循环中的IEnumerable或IEnumerator中的东西!
当我们看到
foreach(X x in c)
我们要做的第一件事是检查c是否具有名为GetEnumerator的方法。如果是,那么我们检查一下它返回的类型是否具有方法MoveNext和属性current。如果是这样,那么将直接使用对这些方法和属性的直接调用来生成foreach循环。只有当“模式”无法匹配时,我们才回过头去寻找接口。
这具有两个理想的效果。
首先,如果该集合是int的集合,但是在发明泛型类型之前编写的,则它不会对将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 会将 枚举数移动到下一个枚举数,而不管它是struct类型还是ref类型。
不幸的是,今天的C#编译器存在一个错误。如果您处于这种情况,我们会选择不一致的策略。今天的行为是:
如果通过方法进行变异的值型变量是普通局部变量,则通常进行变异
但是,如果它是一个悬挂本地(因为它是一个匿名函数迭代器块的或在一个封闭变量),那么本地 被 作为只读字段实际产生,并且齿轮,其确保突变发生在副本上花费过度。
不幸的是,该规范在此问题上提供的指导很少。显然,由于我们的操作不一致,导致某些问题已损坏,但是 正确 的做法尚不清楚。