何时不应使用yield(返回)

169
这个问题在StackOverflow上已经有答案了:返回IEnumerable时,是否有不使用'yield return'的原因? 关于yield return的好处,SO上有几个有用的问题,例如: 我想知道什么情况下不应该使用yield return。例如,如果我需要返回集合中的所有项,似乎yield并没有用处,对吗? 在什么情况下使用yield会有限制,不必要,会导致问题或者应该避免使用?

3
有很多种做事情错误的方式,这只是一种想象力的练习。我会改述你的问题为:yield return 的常见不当用法是什么? - Jader Dias
程序员需要像其他领域一样多运用想象力。 - jnm2
4
这个问题被标记为重复,但没有提供指向重复问题的链接…应该取消重复标记吗? - einsteinsci
3
这是一个重要的问题,有趣且有用的答案,应该重新开放。 - Colonel Panic
11个回答

155

在哪些情况下使用yield会限制、不必要、让我陷入麻烦,或者其他情况下应该避免使用它?

当处理递归定义的结构时,仔细考虑您对“yield return”的使用是一个好主意。例如,我经常看到这种情况:

public static IEnumerable<T> PreorderTraversal<T>(Tree<T> root)
{
    if (root == null) yield break;
    yield return root.Value;
    foreach(T item in PreorderTraversal(root.Left))
        yield return item;
    foreach(T item in PreorderTraversal(root.Right))
        yield return item;
}

代码看起来很合理,但它存在性能问题。假设树的高度为h,则最多会有O(h)个嵌套迭代器被构建。在外部迭代器上调用“MoveNext”将使O(h)个嵌套的MoveNext调用。由于对于具有n个项的树,它要执行O(n)次,因此该算法的时间复杂度为O(hn)。由于二叉树的高度是lg n <= h <= n,这意味着该算法在时间上最好情况下是O(n lg n),最坏情况下是O(n^2),在堆栈空间中最好情况下是O(lg n),最坏情况下是O(n)。每个枚举器都分配在堆上,因此在堆空间中是O(h)。(在我所知道的C#实现中;符合规范的实现可能具有其他堆栈或堆空间特性。)
但遍历一棵树可以在时间上是O(n),在堆栈空间上是O(1)。您可以改写成以下形式:
public static IEnumerable<T> PreorderTraversal<T>(Tree<T> root)
{
    var stack = new Stack<Tree<T>>();
    stack.Push(root);
    while (stack.Count != 0)
    {
        var current = stack.Pop();
        if (current == null) continue;
        yield return current.Value;
        stack.Push(current.Left);
        stack.Push(current.Right);
    }
}

现在我们仍然使用yield return,但是更加智能。现在我们的时间复杂度为O(n),堆空间复杂度为O(h),栈空间复杂度为O(1)。

更多阅读:请查看Wes Dyer关于此主题的文章:

http://blogs.msdn.com/b/wesdyer/archive/2007/03/23/all-about-iterators.aspx


1
关于第一个算法:你说它在堆空间中是O(1)。难道不应该是在堆空间中是O(h)吗?(并且随着时间分配的对象数量是O(n)) - CodesInChaos
13
我希望下一个版本的C#中会出现一个“yield foreach”的功能... - Gabe
1
Stephen Toub撰写了一篇文章(http://blogs.msdn.com/b/toub/archive/2004/10/29/249858.aspx),讨论了这个特定的例子,以及一个汉诺塔难题求解器,使用两种迭代方法来展示性能差异。 - Brian
1
@EricLippert我建议您在推送之前添加一个检查空值的条件,以避免空叶遍历 if(current.Right != null) stack.Push(current.Right); if (current.Left != null) stack.Push(current.Left); 但我仍然不明白您是如何通过添加自己的堆栈来进行优化的。两者仍然都使用yield return,这将以相同的方式运行。能否解释一下? - CME64
1
@CME64:不要使用完整的二叉树,尝试使用我发布的第一个算法和第二个算法,其中第二个算法使用具有100个节点的二叉树,其中每个右侧节点都为null,即最大程度地不平衡的二叉树。你会发现在第一个算法中,yield return被调用了数千次,而在第二个算法中只有数百次。你明白这是为什么吗? - Eric Lippert
显示剩余7条评论

63

在哪些情况下使用yield会受到限制,是不必要的,会让我陷入麻烦或者应该避免使用?

我能想到几种情况,例如:

  • Avoid using yield return when you return an existing iterator. Example:

    // Don't do this, it creates overhead for no reason
    // (a new state machine needs to be generated)
    public IEnumerable<string> GetKeys() 
    {
        foreach(string key in _someDictionary.Keys)
            yield return key;
    }
    // DO this
    public IEnumerable<string> GetKeys() 
    {
        return _someDictionary.Keys;
    }
    
  • Avoid using yield return when you don't want to defer execution code for the method. Example:

    // Don't do this, the exception won't get thrown until the iterator is
    // iterated, which can be very far away from this method invocation
    public IEnumerable<string> Foo(Bar baz) 
    {
        if (baz == null)
            throw new ArgumentNullException();
         yield ...
    }
    // DO this
    public IEnumerable<string> Foo(Bar baz) 
    {
        if (baz == null)
            throw new ArgumentNullException();
         return new BazIterator(baz);
    }
    

25
如果代码抛出异常,推迟执行就会变成推迟异常。因此,“+1 for deferred execution”的意思是支持推迟执行的好处。 - Davy8
1
虽然您通常是正确的,但我不同意没有理由同时使用foreachyield return - 例如,当您拥有私有集合时,返回集合本身将允许最终用户对其进行修改(通过适当的强制转换),而第一种方法则不会。 - Grx70
1
@Grx70 所以使用 .AsReadOnly() 将您的列表返回为 IReadOnlyCollection。问题解决了。 - ErikE

34

重要的是要认识到yield的用途,然后您可以决定哪些情况不需要使用它。

换句话说,当您不需要惰性评估序列时,可以跳过使用yield。什么情况下会这样呢?当您不介意立即在内存中拥有完整的集合时,就应该这样做。否则,如果您有一个巨大的序列会对内存产生负面影响,那么您将希望使用yield逐步处理它(即惰性处理)。使用分析器比较两种方法时可能会有所帮助。

请注意,大多数LINQ语句返回IEnumerable<T>。这使我们能够连续串联不同的LINQ操作,而不会在每个步骤(即推迟执行)中对性能产生负面影响。另一种方式是在每个LINQ语句之间放置ToList()调用。这将导致执行每个先前的LINQ语句,然后在执行下一个(链式)LINQ语句之前,放弃任何惰性评估的好处并利用IEnumerable<T>直至需要。


27

这里有很多优秀的答案。我想再补充一个建议:在你已经知道值的情况下,不要将yield return用于小型或空集合:

IEnumerable<UserRight> GetSuperUserRights() {
    if(SuperUsersAllowed) {
        yield return UserRight.Add;
        yield return UserRight.Edit;
        yield return UserRight.Remove;
    }
}
在这些情况下,创建 Enumerator 对象所需的成本更高,而且比仅生成数据结构更加冗长。
IEnumerable<UserRight> GetSuperUserRights() {
    return SuperUsersAllowed
           ? new[] {UserRight.Add, UserRight.Edit, UserRight.Remove}
           : Enumerable.Empty<UserRight>();
}

更新

这里是我的基准测试结果

基准测试结果

这些结果显示执行 1,000,000 次操作所需的时间(以毫秒为单位)。较小的数字更好。

重新审视后,性能差异并不足以引起担忧,因此您应该选择最容易阅读和维护的方法。

更新2

我相当确定上述结果是在禁用编译器优化的情况下实现的。使用现代编译器在发布模式下运行时,两者之间的性能似乎几乎无法区分。选择对您来说最可读的方法即可。


1
这真的会更慢吗?我想构建数组可能会同样慢甚至更慢。 - PRMan
1
@PRMan: 是的,我可以理解你为什么会那样想。我更新了我的回答,并添加了一个基准测试来展示差异。我不知道我的原始测试是否做得不正确,或者自从我第一次回答这个问题以来,.NET框架是否提高了性能,但是性能差异并没有像我记得的那么大——在大多数情况下,无需太多担心。 - StriplingWarrior
1
似乎在测试中使用属性而不是常量会产生不同的结果(双关语)。至少在发布模式下,调用和迭代基于yield结果的方法更快。 - Melvyn
@Yaurthek:你能提供一个代码示例来展示你的意思吗?我从返回属性访问中看到了类似的结果:在未经优化的情况下,yield return 的速度要慢得多,在发布模式下略微慢一些。 - StriplingWarrior
@StriplingWarrior 我怀疑你的实现被优化掉了。在发布模式下尝试这个。(我增加了迭代次数,因为否则我得不到稳定的结果) - Melvyn
有趣。我仍然没有得到一致的结果:yield return 有时看起来稍微快一点,然后数组看起来更快,然后它们看起来相等。将迭代次数增加到500_000_000次,yield return 看起来约快4%,在你的测试和我的测试中都是如此。 - StriplingWarrior

19

Eric Lippert提出了一个很好的观点(太糟糕了,C#没有像Cw那样的流展开)。我想补充一下,有时枚举过程因其他原因而变得昂贵,因此如果您打算多次迭代IEnumerable,则应使用列表。

例如,LINQ-to-objects是建立在"yield return"之上的。如果您编写了一个缓慢的LINQ查询(例如将大型列表过滤为小型列表或进行排序和分组的查询),则最好调用查询结果上的ToList(),以避免多次枚举(这实际上会执行多次查询)。

如果您在编写方法时在“yield return”和List<T>之间进行选择,请考虑:每个单独的元素是否计算昂贵,并且调用者是否需要多次枚举结果?如果您知道答案是肯定的,并且是肯定的,那么就不应该使用yield return(除非,例如,生成的列表非常大,您无法承受它将使用的内存。请记住,yield的另一个好处是结果列表不必完全一次性在内存中)。

不使用"yield return"的另一个原因是如果交错操作是危险的。例如,如果您的方法看起来像这样,

IEnumerable<T> GetMyStuff() {
    foreach (var x in MyCollection)
        if (...)
            yield return (...);
}

如果调用者进行了某些操作可能会导致 MyCollection 更改,那么这将会非常危险:

foreach(T x in GetMyStuff()) {
    if (...)
        MyCollection.Add(...);
        // Oops, now GetMyStuff() will throw an exception
        // because MyCollection was modified.
}

yield return 可能会在调用者更改某些假定不会更改的内容时出现问题。


++ 用于多次枚举结果 - 我刚刚因此浪费了几天时间进行调试。 - tofutim

7
如果你期望在调用方法时出现副作用,我建议避免使用yield return。这是由于延迟执行的缘故,正如Pop Catalin所提到的
一个副作用可能会修改系统,在IEnumerable<Foo> SetAllFoosToCompleteAndGetAllFoos()这样的方法中可能会发生这种情况,这违反了单一职责原则。这很明显(现在...),但一个不太明显的副作用可能是将缓存结果或类似的内容设置为优化。
我的经验法则是(再次强调,现在...):
  • 只有当返回的对象需要一些处理时才使用yield
  • 如果需要使用yield,则方法中没有副作用
  • 如果必须有副作用(并将其限制在缓存等方面),则不要使用yield,并确保扩展迭代的好处大于成本

2
这应该是“何时不使用”的头号答案。考虑一个返回IEnumerable<T>RemoveAll方法。如果你使用yield return Remove(key),那么如果调用者从未迭代,这些项将永远不会被删除! - Bruce Pierson
这是一个很好的主要原因,也很容易记住。您还可以考虑潜在抛出异常也是副作用。它们也将被延迟。此外,如果您已经拥有可枚举对象,例如键的集合,则只需返回该集合即可。:) 惰性求值在这里不会给您带来任何好处。 - Jonas

6

当您需要随机访问时,yield 操作可能会受到限制或变得不必要。如果您需要访问第 0 个元素和第 99 个元素,则基本上消除了延迟计算的有用性。


2
当你需要随机访问时,IEnumerable 无法帮助你。如果要访问 IEnumerable 中的元素 0 或 99,该怎么办?我猜我不明白你想说什么。 - quentin-starin
1
@qstarin,没错!访问第99个元素的唯一方法是通过0-98个元素,因此除非您只需要20亿个项目中的第99个项目,否则惰性评估对您没有任何好处。我不是说您可以访问enumberable[99],我是说如果您只对第99个元素感兴趣,则枚举不是正确的选择。 - Robert Gowland
3
这与产量完全无关。它是IEnumerator固有的特性,无论是使用迭代器块实现还是不使用。 - quentin-starin
1
@qstarin,这确实与yield有一些关系,因为yield会产生一个枚举器。原帖询问何时避免使用yield,yield的结果是一个枚举器,在需要随机访问时使用枚举器是不方便的,因此在需要随机访问时使用yield是一个不好的想法。事实上,他可以用不同的方法生成可枚举对象,并不能否定使用yield不好的事实。你可以用枪打死一个人,也可以用棒子击打一个人……用棒子杀了一个人并不能否定你不应该开枪。 - Robert Gowland
@qstarin,然而,您指出生成IEnumerator的其他方法是正确的。 - Robert Gowland

6

可能会遇到的问题是,如果你正在序列化枚举的结果并将它们发送到网络上。由于执行被延迟到需要结果时,你会序列化一个空的枚举并将其发送回来,而不是你想要的结果。


3

我需要维护一堆来自一个完全着迷于yield return和IEnumerable的人的代码。问题在于我们使用了很多第三方API,以及我们自己的很多代码都依赖于列表(List)或数组(Array)。所以我最终不得不这样做:

IEnumerable<foo> myFoos = getSomeFoos();
List<foo> fooList = new List<foo>(myFoos);
thirdPartyApi.DoStuffWithArray(fooList.ToArray());

并不一定是坏事,但处理起来有点烦人,在某些情况下,为了避免重新设计所有内容,会导致在内存中创建重复的列表。


5
myFoos.ToArray() 应该就足够了。 - Ahmad Mageed
3
如果你使用的是.NET 3.5或更高版本,那么"myFoos.ToArray() should suffice"就足够了。 - Joe
1
你们两个都说得很好。我已经习惯了用旧的方法来做事情。现在我们大多数情况下都使用3.5版本。 - Mike Ruhlin

2

当你不想让代码块返回一个迭代器以便顺序访问底层集合时,你不需要使用 yield return。你只需直接返回该集合即可,使用 return


2
考虑返回一个只读的包装器。调用者可能会将其强制转换回原始集合类型并进行修改。 - billpg

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