为什么这个方法会导致无限循环

本文关键字:无限循环 方法 为什么 | 更新日期: 2023-09-27 18:07:42

我的一位同事向我提出了一个关于这种导致无限循环的方法的问题。实际的代码有点太复杂了,不能在这里发布,但本质上问题归结为:

private IEnumerable<int> GoNuts(IEnumerable<int> items)
{
    items = items.Select(item => items.First(i => i == item));
    return items;
}

这个应该(你会认为)只是一个非常低效的方式来创建一个列表的副本。我用:

var foo = GoNuts(new[]{1,2,3,4,5,6});

结果是一个无限循环。奇怪。

我认为修改参数在风格上是一件不好的事情,所以我稍微改变了代码:
var foo = items.Select(item => items.First(i => i == item));
return foo;

工作。即程序完成;不例外。

更多的实验表明这也是有效的:

items = items.Select(item => items.First(i => i == item)).ToList();
return items;

和简单的

一样
return items.Select(item => .....);

好奇。

很明显,问题与重新赋值参数有关,但只有在计算延迟到该语句之后才会出现。如果我添加ToList(),它可以工作。

我对哪里出了问题有一个大致的、模糊的概念。看起来Select在自己的输出上迭代。这本身有点奇怪,因为通常IEnumerable会抛出,如果它迭代的集合发生了变化。

我不明白的是,因为我不是很熟悉这些东西是如何工作的,为什么重新赋值参数会导致这个无限循环。

有没有更了解内部结构的人愿意解释为什么这里会发生无限循环?

为什么这个方法会导致无限循环

回答这个问题的关键是延迟执行。当你这样做

items = items.Select(item => items.First(i => i == item));

迭代传递给方法的items数组。相反,您可以为它分配一个新的IEnumerable<int>,它将自己引用回来,并且仅在调用者开始枚举结果时才开始迭代。

这就是为什么你所有的其他修复已经处理的问题:所有你需要做的就是停止馈送IEnumerable<int>返回给自己:

  • 使用var foo通过使用不同的变量打破了自引用,
  • 使用return items.Select...根本不使用中间变量,从而破坏了自引用,
  • 使用ToList()通过避免延迟执行打破了自我引用:当items被重新分配时,旧的items已经被迭代,所以你最终得到一个普通的内存中的List<int>

但是如果它以自己为食,它是怎么得到任何东西的呢?

没错,它没有得到任何东西!当您尝试迭代items并向它请求第一个项目时,延迟序列会向提供给它的序列请求处理第一个项目,这意味着序列会向自己请求处理第一个项目。此时,它一直是海龟,因为为了返回要处理的第一个项目,序列必须首先从自身获取要处理的第一个项目。

看起来Select遍历了自己的输出

你是正确的。返回一个在自身上迭代的查询

关键是在lambda中引用items 。在查询迭代之前,items引用不会被解析("关闭"),此时items现在引用查询而不是源集合。这是自引用发生的地方。

想象一副牌前面有一个标记为items的牌。现在想象一个人站在一副牌旁边,他的任务是迭代名为items的集合。然后你把标志从甲板上移到。当你问这个人要第一件"物品"时,他会找那一堆标有"物品"的东西——这就是他!所以他问自己第一个项目,这是循环引用发生的地方。

当您将结果赋值给变量时,您将得到一个遍历不同集合的查询,因此不会导致无限循环。

当你调用ToList时,你将查询添加到一个新的集合,也不会得到一个无限循环。

其他会打破循环引用的东西:

  • 通过调用ToList
  • 在lambda
中补水项
  • items赋值给另一个变量并在lambda中引用
  • 在研究了给出的两个答案并进行了一些摸索之后,我想出了一个小程序来更好地说明这个问题。

        private int GetFirst(IEnumerable<int> items, int foo)
        {
            Console.WriteLine("GetFirst {0}", foo);
            var rslt = items.First(i => i == foo);
            Console.WriteLine("GetFirst returns {0}", rslt);
            return rslt;
        }
        private IEnumerable<int> GoNuts(IEnumerable<int> items)
        {
            items = items.Select(item =>
            {
                Console.WriteLine("Select item = {0}", item);
                return GetFirst(items, item);
            });
            return items;
        }
    

    如果你用:

    var newList = GoNuts(new[]{1, 2, 3, 4, 5, 6});
    

    您将反复得到此输出,直到最终得到StackOverflowException

    Select item = 1
    GetFirst 1
    Select item = 1
    GetFirst 1
    Select item = 1
    GetFirst 1
    ...
    

    这显示的正是dasblinkenlight在他更新的答案中明确表示的:查询进入一个无限循环,试图获得第一个项目。

    让我们用稍微不同的方式来写GoNuts:

        private IEnumerable<int> GoNuts(IEnumerable<int> items)
        {
            var originalItems = items;
            items = items.Select(item =>
            {
                Console.WriteLine("Select item = {0}", item);
                return GetFirst(originalItems, item);
            });
            return items;
        }
    

    如果你运行它,它成功。为什么?因为在这种情况下,很明显,对GetFirst的调用传递了对传递给该方法的原始项的引用。在第一种情况下,GetFirst传递对new items集合的引用,这还没有实现。反过来,GetFirst说,"嘿,我需要枚举这个集合。"这样就开始了第一次递归调用,最终导致StackOverflowException

    有趣的是,当我说它在消耗自己的输出时,我是对的是错的。如我所料,Select正在消耗原始输入。First正在尝试使用输出

    这里有很多教训要学。对我来说,最重要的是"不要修改输入参数的值"。

    感谢dasblinkenlight, D Stanley和Lucas Trzesniewski的帮助。