C# yield return 性能

15

当我在方法中使用yield return语法并执行ToList()时,底层集合保留了多少空间?与使用预定义容量创建列表的标准方法相比,存在重新分配内存的可能性,从而降低性能。

两种情况:

    public IEnumerable<T> GetList1()
    {
        foreach( var item in collection )
            yield return item.Property;
    }

    public IEnumerable<T> GetList2()
    {
        List<T> outputList = new List<T>( collection.Count() );
        foreach( var item in collection )
            outputList.Add( item.Property );

        return outputList;
    }

你能提供什么样的例子或指标表明两者之间存在偏差?我们不会完成所有工作,我们需要一个基本的底层示例。 - Greg
3
什么集合? yield return 生成一个枚举器 - 没有存储。 - Chris
3
@Chris,这里有存储空间,但仅用于维护枚举器状态。但是没有像OP所说的那样的集合。 - Sriram Sakthivel
重新分配什么? - Jon Hanna
@SolalPirelli,List<T> 的构造函数被优化为接收 ICollection<T>。尽管如此,该优化所做的区别与 GetList2 故意比 GetList1 更慢、更占用内存的悲观化完全相同,因此它是平衡的。 - Jon Hanna
显示剩余4条评论
2个回答

22

yield return不像 List 需要调整大小以创建数组;相反,它使用状态机创建了一个 IEnumerable

例如,让我们看一下这个方法:

public static IEnumerable<int> Foo()
{
    Console.WriteLine("Returning 1");
    yield return 1;
    Console.WriteLine("Returning 2");
    yield return 2;
    Console.WriteLine("Returning 3");
    yield return 3;
}
现在让我们调用它并将可枚举赋值给一个变量:

现在让我们调用它并将可枚举赋值给一个变量:

var elems = Foo();

Foo类中的任何代码都尚未执行。控制台不会打印任何内容。但是,如果我们像这样迭代:

foreach(var elem in elems)
{
    Console.WriteLine( "Got " + elem );
}

在第一次 foreach 循环迭代中,Foo 方法会被执行直到第一个 yield return。然后,在第二次迭代中,方法将从上次离开的位置(即在 yield return 1 后面)"恢复",并执行直到下一个 yield return。对于所有后续元素都是如此。
循环结束时,控制台将如下所示:

Returning 1
Got 1
Returning 2
Got 2
Returning 3
Got 3
这意味着你可以编写像这样的方法:
public static IEnumerable<int> GetAnswers()
{
    while( true )
    {
        yield return 42;
    }
}

你可以调用GetAnswers方法,每次请求元素时,它将返回42;序列永不结束。 如果使用List是无法实现此操作的,因为列表必须具有有限的大小。


13
使用 yield return 语法的方法背后没有底层集合。有一个对象,但它不是集合。它需要占用多少空间取决于它需要跟踪的内容。不会重新分配,与创建具有预定义容量的列表相比,它几乎肯定占用更少的内存。举个手动的例子,假设我们有以下代码:
public static IEnumerable<int> CountToTen()
{
  for(var i = 1; i != 11; ++i)
    yield return i;
}

使用 foreach 遍历将循环遍历从 110 的所有数字。

现在让我们按照没有 yield 的方式来完成这个任务。我们可以做如下操作:

private class CountToTenEnumerator : IEnumerator<int>
{
  private int _current;
  public int Current
  {
    get
    {
      if(_current == 0)
        throw new InvalidOperationException();
      return _current;
    }
  }
  object IEnumerator.Current
  {
    get { return Current; }
  }
  public bool MoveNext()
  {
    if(_current == 10)
      return false;
    _current++;
    return true;
  }
  public void Reset()
  {
    throw new NotSupportedException();
    // We *could* just set _current back, but the object produced by
    // yield won't do that, so we'll match that.
  }
  public void Dispose()
  {
  }
}
private class CountToTenEnumerable : IEnumerable<int>
{
  public IEnumerator<int> GetEnumerator()
  {
    return new CountToTenEnumerator();
  }
  IEnumerator IEnumerable.GetEnumerator()
  {
    return GetEnumerator();
  }
}
public static IEnumerable<int> CountToTen()
{
  return new CountToTenEnumerable();
}

现在,由于各种原因,这与使用“yield”版本的代码有很大不同,但基本原理相同。如您所见,涉及两个对象的分配(与我们拥有集合并对其执行“foreach”时一样多),以及单个int的存储。实际上,我们可以期望“yield”比这多存储几个字节,但并不多。

yield实际上做了一个技巧,在同一线程上第一次调用GetEnumerator()时返回同一对象,为两种情况提供双重服务。由于这覆盖了99%以上的用例,yield实际上只进行了一次分配而不是两次。

现在让我们看看:
public IEnumerable<T> GetList1()
{
  foreach( var item in collection )
    yield return item.Property;
}

虽然这样会使用比仅使用return collection更多的内存,但它不会增加很多;枚举器需要跟踪的唯一内容是由在collection上调用GetEnumerator()并将其包装而生成的枚举器。
这比您提到的浪费第二种方法要少得多,而且启动速度快得多。
编辑:
您已将问题更改为包括"执行ToList()时的语法",这值得考虑。
现在,我们需要添加第三种可能性:对集合大小的了解。
在这里,有可能使用new List(capacity)将防止列表的构建分配。这确实可以节省很多。
如果调用ToList的对象实现ICollection<T>,则ToList最终将首先进行一个内部数组T的单个分配,然后调用ICollection<T>.CopyTo()
这意味着你的GetList2会导致一个比GetList1更快的ToList()
然而,你的GetList2已经浪费了时间和内存,做了ToList()将处理GetList1结果的工作!
这里应该做的是直接return new List<T>(collection);然后完成。
但是,如果我们需要在GetList1GetList2内部实际执行某些操作(例如转换元素、过滤元素、跟踪平均值等),那么GetList1将更快速并且占用的内存更少。如果我们从不在其上调用ToList(),则会更轻,如果我们调用ToList(),则稍微轻一些,因为较快且轻的ToList()被较慢和更重的GetList2完全抵消。

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