C#中最高效的循环是什么?

39

有多种不同的方法可以在C#中完成对对象中项目的简单循环。

这让我想知道是否有任何理由,无论是性能还是易用性,来选择其中一种。或者只是个人偏好。

以一个简单的对象为例。

var myList = List<MyObject>; 

假设对象已经被填充,我们想要遍历其中的项。
方法一。
foreach(var item in myList) 
{
   //Do stuff
}

方法二

myList.Foreach(ml => 
{
   //Do stuff
});

方法三

while (myList.MoveNext()) 
{
  //Do stuff
}

方法四
for (int i = 0; i < myList.Count; i++)
{
  //Do stuff   
}

我想知道这些编译后是否都变成了相同的东西?使用其中一种是否有明显的性能优势?
还是这只是编码时的个人偏好?
我有遗漏吗?

15
如果您想知道它们在性能方面的差异,您可以自行进行基准测试。System.Diagnostics.StopWatch是您的好帮手。 - Nolonar
4
请查看此链接:http://forums.asp.net/t/1041090.aspx - Zaki
你错过了 do..while()。此外,为什么不只是编译它们并在 ILDASM 中查看结果呢? - Peter
2
你有进行性能基准测试吗?你的结果是什么?大部分情况下,你自己的逻辑将成为瓶颈,你的迭代方式很大程度上是微不足道的,并且有时会受到情况的限制。这只是一个纯学术问题,不值一提。 - Grant Thomas
2
使用最易读的方式(适合您自己),以及您需要的方式。因此,如果您想稍后更改实现,请将 List<MyObject> 更改为 IEnumerable<MyObject> 并使用 foreach,或者如果您根本不需要索引。我必须承认,在过去的10年中,我从未使用过 List.ForEach,除了在 SO 上回答问题之外。 - Tim Schmelter
显示剩余11条评论
3个回答

70

大多数情况下,答案是没有关系。循环中的项目数量(即使是您认为“大量”的项目,例如数千个)也不会对代码产生影响。

当然,如果您在特定情况下确定这是瓶颈,请务必解决它,但首先必须确定瓶颈。

话虽如此,每种方法都有一些需要考虑的因素,我将在此处概述。

让我们首先定义一些内容:

  • 所有测试都在32位处理器上的.NET 4.0上运行。
  • TimeSpan.TicksPerSecond在我的计算机上= 10,000,000
  • 所有测试都在单独的单元测试会话中执行,而不是在同一个会话中执行(以免可能干扰垃圾回收等)

这里是每个测试所需的一些帮助程序:

MyObject类:

public class MyObject
{
    public int IntValue { get; set; }
    public double DoubleValue { get; set; }
}

一种创建任意长度的MyClass实例List<T>的方法:
public static List<MyObject> CreateList(int items)
{
    // Validate parmaeters.
    if (items < 0) 
        throw new ArgumentOutOfRangeException("items", items, 
            "The items parameter must be a non-negative value.");

    // Return the items in a list.
    return Enumerable.Range(0, items).
        Select(i => new MyObject { IntValue = i, DoubleValue = i }).
        ToList();
}

对列表中每个项目执行的操作(因为方法2使用委托,需要调用某个东西来衡量影响):

public static void MyObjectAction(MyObject obj, TextWriter writer)
{
    // Validate parameters.
    Debug.Assert(obj != null);
    Debug.Assert(writer != null);

    // Write.
    writer.WriteLine("MyObject.IntValue: {0}, MyObject.DoubleValue: {1}", 
        obj.IntValue, obj.DoubleValue);
}

创建一个写入到null Stream(也就是一个数据汇)的TextWriter方法:
public static TextWriter CreateNullTextWriter()
{
    // Create a stream writer off a null stream.
    return new StreamWriter(Stream.Null);
}

让我们将项目数量固定为一百万(1,000,000),这应该足够高以强制确保它们的性能影响大致相同:

// The number of items to test.
public const int ItemsToTest = 1000000;

让我们开始介绍方法:

方法一:foreach

下面是代码:

foreach(var item in myList) 
{
   //Do stuff
}

编译成以下内容:
using (var enumerable = myList.GetEnumerable())
while (enumerable.MoveNext())
{
    var item = enumerable.Current;

    // Do stuff.
}

这里有很多内容。你会看到方法调用(编译器在这种情况下尊重鸭子类型,所以它可能与IEnumerator<T>IEnumerator接口无关),而且// Do stuff被提升到了while结构中。

以下是用于衡量性能的测试:

[TestMethod]
public void TestForEachKeyword()
{
    // Create the list.
    List<MyObject> list = CreateList(ItemsToTest);

    // Create the writer.
    using (TextWriter writer = CreateNullTextWriter())
    {
        // Create the stopwatch.
        Stopwatch s = Stopwatch.StartNew();

        // Cycle through the items.
        foreach (var item in list)
        {
            // Write the values.
            MyObjectAction(item, writer);
        }

        // Write out the number of ticks.
        Debug.WriteLine("Foreach loop ticks: {0}", s.ElapsedTicks);
    }
}

输出:

Foreach循环计时: 3210872841

方法2: List<T>上的.ForEach方法

List<T>上的.ForEach方法的代码大致如下:

public void ForEach(Action<T> action)
{
    // Error handling omitted

    // Cycle through the items, perform action.
    for (int index = 0; index < Count; ++index)
    {
        // Perform action.
        action(this[index]);
    }
}

请注意,这与方法4在功能上是等效的,唯一的例外是被提升到for循环中的代码被作为委托传递。这需要解除引用才能执行需要执行的代码。虽然委托的性能从.NET 3.0开始有所改善,但仍存在开销。

然而,这个开销可以忽略不计。测试性能的方法:

[TestMethod]
public void TestForEachMethod()
{
    // Create the list.
    List<MyObject> list = CreateList(ItemsToTest);

    // Create the writer.
    using (TextWriter writer = CreateNullTextWriter())
    {
        // Create the stopwatch.
        Stopwatch s = Stopwatch.StartNew();

        // Cycle through the items.
        list.ForEach(i => MyObjectAction(i, writer));

        // Write out the number of ticks.
        Debug.WriteLine("ForEach method ticks: {0}", s.ElapsedTicks);
    }
}

输出:

ForEach方法的时钟滴答声: 3135132204

这实际上比使用foreach循环快了约7.5秒。这并不完全令人惊讶,因为它使用直接数组访问而不是使用IEnumerable<T>

但请记住,这相当于每个项节省0.0000075740637秒。对于小型列表来说,这并不值得。

方法3:while (myList.MoveNext())

如方法1所示,这正是编译器所做的事情(还有using语句的添加,这是一个好习惯)。在这里,通过自己展开编译器将生成的代码,你没有获得任何东西。

出于好奇,我们还是试一下:

[TestMethod]
public void TestEnumerator()
{
    // Create the list.
    List<MyObject> list = CreateList(ItemsToTest);

    // Create the writer.
    using (TextWriter writer = CreateNullTextWriter())
    // Get the enumerator.
    using (IEnumerator<MyObject> enumerator = list.GetEnumerator())
    {
        // Create the stopwatch.
        Stopwatch s = Stopwatch.StartNew();

        // Cycle through the items.
        while (enumerator.MoveNext())
        {
            // Write.
            MyObjectAction(enumerator.Current, writer);
        }

        // Write out the number of ticks.
        Debug.WriteLine("Enumerator loop ticks: {0}", s.ElapsedTicks);
    }
}

输出结果:

枚举循环计时:3241289895

方法四:for

在这个特定的情况下,你会获得一些速度优势,因为列表索引器直接访问底层数组以执行查找(顺便说一下,这是一种实现细节,没有什么可以保证它不可能是支持List<T>的树结构)。

[TestMethod]
public void TestListIndexer()
{
    // Create the list.
    List<MyObject> list = CreateList(ItemsToTest);

    // Create the writer.
    using (TextWriter writer = CreateNullTextWriter())
    {
        // Create the stopwatch.
        Stopwatch s = Stopwatch.StartNew();

        // Cycle by index.
        for (int i = 0; i < list.Count; ++i)
        {
            // Get the item.
            MyObject item = list[i];

            // Perform the action.
            MyObjectAction(item, writer);
        }

        // Write out the number of ticks.
        Debug.WriteLine("List indexer loop ticks: {0}", s.ElapsedTicks);
    }
}

输出:

列表索引器循环计数:3039649305

然而,这可能会对数组产生影响。编译器可以将数组展开以一次处理多个项目。

与其在十个项目的循环中执行十次单个项目的迭代,编译器可以将其展开为在十个项目的循环中执行五次双项目的迭代。

然而,我不能确定这是否真的发生了(我必须查看IL和编译后的IL输出)。

以下是测试:

[TestMethod]
public void TestArray()
{
    // Create the list.
    MyObject[] array = CreateList(ItemsToTest).ToArray();

    // Create the writer.
    using (TextWriter writer = CreateNullTextWriter())
    {
        // Create the stopwatch.
        Stopwatch s = Stopwatch.StartNew();

        // Cycle by index.
        for (int i = 0; i < array.Length; ++i)
        {
            // Get the item.
            MyObject item = array[i];

            // Perform the action.
            MyObjectAction(item, writer);
        }

        // Write out the number of ticks.
        Debug.WriteLine("Enumerator loop ticks: {0}", s.ElapsedTicks);
    }
}

输出:

数组循环计数: 3102911316

需要注意的是,Resharper默认提供了一种重构建议,将上述for语句更改为foreach语句。这并不意味着这是正确的,但基础是减少代码中的技术债务。


TL;DR

除非在您的情况下测试显示您有真正的瓶颈(并且您必须拥有大量项目才能产生影响),否则您真的不应该关心这些事情的性能。

通常情况下,您应该选择最可维护的方法,此时Method 1(foreach)是正确的选择。


1
+1 优秀的回答!但我认为 tl;dr 部分本身不应该被忽略,它实际上是非常重要的结论。 - Ken Kin
1
啊..我认为这不应该是“tl;dr”,它很重要,必读 - Ken Kin
3
@KenKin,我认为TL;DR强调需要阅读的部分是那个。 另外,这是顶部内容的重复。 - casperOne
3
TL;DR 的意思是“如果你不想读所有其他文字,这是你应该读的内容”。这个词组用于明确表达这一点。 - casperOne
1
做得好,有趣的是,从Chris Gessler之前提供的链接看来,使用Linq比foreach()慢,但他们正在测试.Any()和.TakeWhile(),但从你的工作中可以看出,linq结果是最快的,除了移动到for()。再次感谢你的出色工作。 - TheAlbear
显示剩余17条评论

4

关于最后一个问题,“我有遗漏吗?” 是的,虽然这个问题很旧,但我觉得不提到这个问题是我的疏忽。虽然那四种方法在执行时间上差不多,但有一种方式没有显示在上面,它比所有方法都要快。当迭代列表中的项目数量增加时,速度相当显著。实际上,它与最后一种方法完全相同,只是在循环的条件检查中不使用.Count,而是将此值分配给变量,然后设置循环并使用该变量。这会让你得到以下内容:

var countVar = list.Count;
for(int i = 0; i < countVar; i++)
{
 //loop logic
}

通过这种方式,您每次迭代只需要查找变量值,而不是解析Count或Length属性,这样效率会更高。


1
我非常同意这个分析。缓存列表的长度可以减少完成时间,更加高效。 - omostan

2
我建议使用一种更好但不太为人知的方法来更快地遍历列表。我建议您首先了解 Span<T>。请注意,如果您正在使用 .NET Core,则可以使用它。(原文链接)
List<MyObject> list = new();
foreach (MyObject item in CollectionsMarshal.AsSpan(list))
{
    // Do something
}

注意以下警告:

CollectionsMarshal.AsSpan 方法是不安全的,只有在你知道自己在做什么时才应该使用。 CollectionsMarshal.AsSpanList<T> 的私有数组上返回一个 Span<T>。 遍历 Span<T> 很快,因为 JIT 使用与优化数组相同的技巧。使用此方法,它不会检查枚举期间列表是否被修改。

这是一个更详细的解释,介绍了它在幕后的操作和更多有趣的内容!


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