俗话说,细节决定成败...
集合枚举的两种方法最大的区别在于 foreach
会带有状态,而 ForEach(x => { })
则不会。
但是让我们深入探讨一下,因为有些事情可能会影响您的决策,并且在编写任一情况下的代码时应该注意一些注意事项。
让我们在我们的小实验中使用 List<T>
来观察行为。对于这个实验,我使用的是 .NET 4.7.2:
var names = new List<string>
{
"Henry",
"Shirley",
"Ann",
"Peter",
"Nancy"
};
首先我们使用 foreach
进行迭代:
foreach (var name in names)
{
Console.WriteLine(name);
}
我们可以将其扩展为:
using (var enumerator = names.GetEnumerator())
{
}
手持枚举器,深入了解之后我们得到以下内容:
public List<T>.Enumerator GetEnumerator()
{
return new List<T>.Enumerator(this);
}
internal Enumerator(List<T> list)
{
this.list = list;
this.index = 0;
this.version = list._version;
this.current = default (T);
}
public bool MoveNext()
{
List<T> list = this.list;
if (this.version != list._version || (uint) this.index >= (uint) list._size)
return this.MoveNextRare();
this.current = list._items[this.index];
++this.index;
return true;
}
object IEnumerator.Current
{
{
if (this.index == 0 || this.index == this.list._size + 1)
ThrowHelper.ThrowInvalidOperationException(ExceptionResource.InvalidOperation_EnumOpCantHappen);
return (object) this.Current;
}
}
两件事情立即变得明显:
- 我们返回了一个有关底层集合的有状态对象。
- 集合的副本是浅拷贝。
当然,这样做并不安全。正如上面所指出的那样,在迭代时更改集合只会带来麻烦。
但是,如果在我们迭代期间通过其他方式使集合无效,该怎么办?最佳实践建议在操作和迭代期间对集合进行版本控制,并检查版本以检测基础集合何时发生更改。
这里的问题就变得非常模糊了。根据Microsoft的文档:
如果对集合进行更改,例如添加、修改或删除元素,则枚举器的行为未定义。
那这是什么意思呢?例如,仅因为 List<T>
实现了异常处理,并不意味着所有实现 IList<T>
的集合都会这样做。这似乎是Liskov替换原则的明显违反:
超类的对象应该能够被其子类的对象替换,而不会破坏应用程序。
另一个问题是枚举器必须实现 IDisposable
—— 这意味着潜在的内存泄漏来源增加了,不仅如果调用者弄错了,而且如果作者没有正确实现 Dispose
模式。
最后,我们有一个生命周期问题... 如果迭代器有效,但基础集合已经不存在怎么办?我们现在有了曾经存在的快照...当你将集合和其迭代器的生命周期分开时,你会遇到麻烦。
现在让我们来看看 ForEach(x => { })
:
names.ForEach(name =>
{
});
这将扩展为:
public void ForEach(Action<T> action)
{
if (action == null)
ThrowHelper.ThrowArgumentNullException(ExceptionArgument.match);
int version = this._version;
for (int index = 0; index < this._size && (version == this._version || !BinaryCompatibility.TargetsAtLeast_Desktop_V4_5); ++index)
action(this._items[index]);
if (version == this._version || !BinaryCompatibility.TargetsAtLeast_Desktop_V4_5)
return;
ThrowHelper.ThrowInvalidOperationException(ExceptionResource.InvalidOperation_EnumFailedVersion);
}
需要注意的是以下代码:
for (int index = 0; index < this._size && ... ; ++index)
action(this._items[index]);
这段代码不会分配任何枚举器(没有需要Dispose
的内容),并且在迭代时不会暂停。
需要注意的是,这也执行基础集合的浅复制,但此时该集合是某个时间点的快照。如果作者没有正确实现检查集合是否改变或过时的功能,则该快照仍然有效。
这并不保护您免受生存期问题的影响...如果基础集合消失了,则现在您有一个指向已消失内容的浅复制...但至少您不必处理孤立的迭代器中的Dispose
问题...
是的,我说的是迭代器...有时拥有状态是有优势的。假设您想要维护类似于数据库游标的东西...也许多个foreach
风格的Iterator<T>
是可行的。我个人不喜欢这种设计方式,因为存在太多的生存期问题,并且您依赖于您所依赖的集合的作者的好意(除非您真正自己从头开始编写所有内容)。
总有第三种选择...
for (var i = 0; i < names.Count; i++)
{
Console.WriteLine(names[i]);
}
虽然不太吸引人,但它很有用(向汤姆·克鲁斯和电影《法律事务所》道歉)
这是你的选择,但现在你已经知道了,可以做出明智的决定。