【问题标题】:Why am I able to edit a LINQ list while iterating over it?为什么我能够在迭代时编辑 LINQ 列表?
【发布时间】:2019-05-02 20:53:26
【问题描述】:

我最近遇到了一个问题,我能够更改在 foreach 循环中迭代的 IEnumerable 对象。我的理解是,在 C# 中,您不应该能够编辑您正在迭代的列表,但经过一番挫折后,我发现这正是正在发生的事情。我基本上循环了一个 LINQ 查询并使用对象 ID 在数据库中对这些对象进行更改,这些更改影响了 .Where() 语句中的值。

有人对此有解释吗?似乎每次迭代时 LINQ 查询都会重新运行

注意:解决此问题的方法是在 .Where() 之后添加 .ToList(),但我的问题是为什么会发生这个问题,即如果这是一个错误或我不知道的事情

using System;
using System.Linq;

namespace MyTest {
    class Program {
        static void Main () {
            var aArray = new string[] {
                "a", "a", "a", "a"
            };
            var i = 3;
            var linqObj = aArray.Where(x => x == "a");
            foreach (var item in linqObj ) {
                aArray[i] = "b";
                i--;
            }
            foreach (var arrItem in aArray) {
                Console.WriteLine(arrItem); //Why does this only print out 2 a's and 2 b's, rather than 4 b's?
            }
            Console.ReadKey();
        }
    }
}

此代码只是一个可重现的模型,但我希望它循环 4 次并将aArray 中的所有字符串更改为 b。但是,它只循环了两次,并将aArray中的最后两个字符串变成了b的

编辑:经过一些反馈并更简洁地说,我的主要问题是:“为什么我能够改变我正在循环的内容因为我正在循环它”。看起来压倒性的答案是 LINQ 确实延迟执行,因此在我循环遍历 LINQ IEnumerable 时它正在重新评估。

编辑2:实际上,似乎每个人都关心.Count() 函数,认为这就是这里的问题所在。但是,您可以注释掉该行,我仍然遇到 LINQ 对象更改的问题。我更新了代码以反映主要问题

【问题讨论】:

  • linqLISTaArray 数组的延迟执行过滤。与名称相反,它不是 一个列表,因此如果您已经阅读过不允许在迭代列表时更改列表,则会引发异常,这不一定适用于此。由于数组无法通知惰性求值的枚举器内部的变化,因此枚举器不会停止工作,相反,当它到达每个项目时,它将根据谓词过滤它。如果它下面的元素发生变化,这将影响尚未过滤的过滤项目。
  • 因为它被推迟了,.Count() 实际上会强制进行一次评估,每次,这就是它不断变化的原因。
  • 如果我做对了,您的问题是:1) 为什么我在枚举“列表”时可以更改它? 2) 为什么.Count() 在这种情况下会不断变化? 3) 每次迭代时,LINQ 查询是否都会重新运行? (简短的回答:是的,这可能应该回答所有其他问题的组合) 4)foreach 是如何真正工作的?
  • 如果你想了解这个游乐场sharpLab
  • 人们给出答案后,您不应该更改问题来源。

标签: c# linq


【解决方案1】:

为什么我可以在迭代时编辑 LINQ 列表?

所有说这是因为延迟“延迟”执行的答案都是错误的,因为它们没有充分解决所提出的问题:“为什么我能够在迭代列表时编辑它?”延迟执行解释了为什么运行两次查询会给出不同的结果,但没有说明为什么问题中描述的操作可能

问题其实是原发者有一个错误的信念

我最近遇到了一个问题,我能够更改我在 foreach 循环中迭代的 IEnumerable 对象。据我了解,在 C# 中,您不应该能够编辑您正在迭代的列表

您的理解是错误的,这就是混乱的来源。 C# 中的规则不是“不可能从枚举中编辑枚举”。规则是您不应该从枚举中编辑枚举,如果您选择这样做,可能会发生任意坏事

基本上你在做的是跑一个停车标志,然后问“跑一个停车标志是违法的,为什么警察不阻止我跑这个停车标志?”警察不需要阻止您进行非法行为; 您有责任一开始就没有尝试,如果您选择这样做,您就有可能被罚单,或造成交通事故,或您的错误选择造成的任何其他不良后果。 通常运行停车标志的后果是完全没有后果,但这并不意味着这是一个好主意。

在枚举时编辑可枚举是一种不好的做法,但运行时不需要是交通警察并且防止你这样做。也不需要将操作标记为非法但有异常。它可能这样做,有时它确实这样做,但没有要求它始终如一地这样做。

您已发现运行时未检测到问题且未引发异常的情况,但您确实得到了意想不到的结果。没关系。你违反了规则,而这一次只是碰巧违反规则的结果是一个意想不到的结果。运行时不是必需将打破规则的结果变成异常。

如果您尝试执行相同的操作,例如在枚举列表时在 List<T> 上调用 Add,您会收到异常,因为有人在 List<T> 中编写了检测到这种情况的代码。

没有人写过“linq over an array”的代码,所以也不例外。 LINQ 的作者没有要求编写该代码。你被要求不要写你写的代码!你选择写了一个违反规则的坏程序,而且每次你​​写一个坏程序都不需要运行时来抓你。

似乎 LINQ 查询每次迭代都会重新运行

没错。查询是关于数据结构的问题。如果您更改该数据结构,则问题的答案可能会改变。枚举查询回答问题。

但是,这与您的问题标题中的问题完全不同。你真的有两个问题:

  • 为什么我可以在枚举时编辑枚举?

你可以做这种不好的做法,因为除了你的良好感觉之外,没有什么能阻止你编写一个糟糕的程序;编写不这样做的更好的程序!

  • 每次我枚举查询时都会从头开始重新执行它吗?

是的;查询是一个问题,而不是一个答案。查询的枚举就是一个答案,而答案会随着时间而改变。

【讨论】:

  • @Brosto:我建议你花时间回应fact的错误,而不是抱怨tone。不幸的是,清楚地陈述事实并清楚地说明其他答案何时错误被视为“侵略”,而不是善意的纠正。我不希望人们对 C# 有错误的信念;纠正他们的错误信念是一种善意。
  • @Brosto:我不是“分裂头发”;我正在取消他们错误信念的原始海报。认为“您不应该能够编辑正在迭代的列表”是错误的。正确的说法是“你不应该编辑你正在迭代的列表”,这些是非常不同的语句。一种暗示runtime要求,另一种暗示对程序作者的要求。当您尝试编写正确的程序时,正确区分这一点至关重要。
  • @Joosh1337:您的代码在您迭代数组而不是数据库时更改了数组中的对象,并问了两次——为什么我可以在编辑列表时编辑它?如果这不是您想要回答的问题,那不是您应该问的问题!这里没有“咆哮”的意思;你的问题暗示你有许多错误的信念。我希望你成为一名成功的 C# 程序员,而成功的 C# 程序员不会对这门语言抱有错误的信念!
  • @Servy:他们的错误并不是因为他们对延迟执行的存在不正确,而是暗示延迟执行解释了原始发布者的混乱;我认为一个包含真实陈述但误导性地暗示它解决问题的根源的答案是“错误答案”。现在,您可能会争辩说,从语气上说“不相关”或“不合理的推理”或“错误”以外的其他词会更好,但同样,这是对语气的回应。
  • @Servy:您的第二条评论很好地总结了我在这个答案中试图传达的内容;谢谢你的总结。 违反约定可能会导致意想不到的结果,但不能确定编写违反约定的程序的可能性
【解决方案2】:

对你第一个问题的解释,为什么你的LINQ query re-runs every time it's iterated over是因为Linqdeferred execution

这一行只是声明了 linq 表达式,并不执行它:

var linqLIST = aArray.Where(x => x == "a");

这是它被执行的地方:

foreach (var arrItem in aArray)

Console.WriteLine(linqList.Count());

显式调用ToList() 将立即运行Linq 表达式。像这样使用它:

var linqList = aArray.Where(x => x == "a").ToList();

关于已编辑的问题:

当然,Linq 表达式会在每次 foreach 迭代中进行评估。问题不在于Count(),而是每次调用 LINQ 表达式都会重新评估它。如上所述,将其枚举为 List 并遍历列表。

后期编辑:

关于 @Eric Lippert 的批评,我还将参考并详细了解 OP 的其余问题。

//为什么只打印出2个a和2个b,而不是4个b?

在第一次循环迭代i = 3,所以在aArray[3] = "b"; 之后,您的数组将如下所示:

{ "a", "a", "a", "b" }

在第二次循环迭代中,i(--) 现在的值为 2,在执行 aArray[i] = "b"; 之后,您的数组将是:

{ "a", "a", "b", "b" }

此时,您的数组中仍有a,但LINQ 查询返回IEnumerator.MoveNext() == false,因此循环达到退出条件,因为内部使用IEnumerator,现在到达第三个位置在数组的索引中,并且在重新评估 LINQ 时,它不再匹配 where x == "a" 条件。

为什么我可以在循环播放的同时更改循环播放的内容?

您可以这样做是因为Visual Studio 中的内置代码分析器未检测到您在循环中修改了集合。在运行时,数组被修改,改变了LINQ 查询的结果,但是在数组迭代器的实现中没有处理,所以没有抛出异常。 这种缺失的处理似乎是设计使然,因为数组的大小是固定的,与在运行时抛出此类异常的列表相对。

考虑以下示例代码,它应该与您的初始代码示例等效(编辑前):

using System;
using System.Linq;

namespace MyTest {
    class Program {
        static void Main () {
            var aArray = new string[] {
                "a", "a", "a", "a"
            };
            var iterationList = aArray.Where(x => x == "a").ToList();
            foreach (var item in iterationList)
            {
                var index = iterationList.IndexOf(item);
                iterationList.Remove(item);
                iterationList.Insert(index, "b");
            }
            foreach (var arrItem in aArray)
            {
                Console.WriteLine(arrItem);
            }
            Console.ReadKey();
        }
    }
}

此代码将编译并迭代循环一次,然后抛出带有消息的System.InvalidOperationException

Collection was modified; enumeration operation may not execute.

现在List实现在枚举它时抛出这个错误的原因是因为它遵循一个基本概念:ForForeach迭代控制流语句,需要确定性在运行时。此外,Foreach 语句是C# 特定于iterator pattern 的实现,它定义了一种算法,该算法意味着顺序遍历,因此它在执行过程中不会改变。因此,当您在枚举集合时修改集合时,List 实现会引发异常。

您找到了一种在迭代时修改循环并在每次迭代中重新评估它的方法。这是一个糟糕的设计选择,因为如果LINQ 表达式不断更改结果并且永远不会满足循环的退出条件,您可能会遇到无限循环。这会让调试变得困难,并且在阅读代码时不会很明显。

相比之下,while 控制流语句是一个条件构造,在运行时被定义为非确定性,具有预期会在执行时更改的特定退出条件。 根据您的示例考虑此重写:

using System;
using System.Linq;

namespace MyTest {
    class Program {
        static void Main () {
            var aArray = new string[] {
                "a", "a", "a", "a"
            };
            bool arrayHasACondition(string x) => x == "a";
            while (aArray.Any(arrayHasACondition))
            {
                var index = Array.FindIndex(aArray, arrayHasACondition);
                aArray[index] = "b";
            }
            foreach (var arrItem in aArray)
            {
                Console.WriteLine(arrItem); //Why does this only print out 2 a's and 2 b's, rather than 4 b's?
            }
            Console.ReadKey();
        }
    }
}

我希望这应该概述技术背景并解释您的错误期望。

【讨论】:

  • 不是在foreach循环开始的时候执行的吗?如果要一起删除 Count,则 foreach 循环在完成之前只有 2 次迭代。
  • 每次使用都会执行。执行一次不会将结果缓存/记忆到列表中(这就是 ToListToArray 的用途)。
  • 我相信这篇文章提供了更多的解释。 stackoverflow.com/questions/16946207/…
  • @Brosto 我的意思是不执行初始声明,当然 foreach 作为对 linq 表达式的任何其他调用都会导致评估。也许我表达有困难
  • @EricLippert 答案只是解释了代码的行为。你的说法是正确的并解释了他的困惑,我没有判断他的代码。有很多技巧可以让 C# 表现出意外。如果他会使用 List 并试图在迭代中操作集合,它会在编译之前被拦截,但正如你所说,有几个逻辑问题无法分析。不管怎样,他现在应该能够理解问题了
【解决方案3】:

Enumerable.Where 返回一个代表查询定义的实例。当它被枚举*时,查询被评估。 foreach 允许您在在查询找到时处理每个项目。查询被延迟,但也可以通过枚举机制暂停/恢复。

var aArray = new string[] { "a", "a", "a", "a" };
var i = 3;
var linqObj = aArray.Where(x => x == "a");
foreach (var item in linqObj )
{
  aArray[i] = "b";
  i--;
}
  • 在 foreach 循环中,会枚举 linqObj* 并启动查询。
  • 检查第一项并找到匹配项。查询已暂停。
  • 循环体发生:item="a", aArray[3]="b", i=2
  • 回到 foreach 循环,继续查询。
  • 检查第二项并找到匹配项。查询已暂停。
  • 循环体发生:item="a", aArray[2]="b", i=2
  • 回到 foreach 循环,继续查询。
  • 检查第三项,是“b”,不匹配。
  • 检查第四项,是“b”,不匹配。
  • 循环退出,查询结束。

注意: is enumerated* : 这意味着 GetEnumerator 和 MoveNext 被调用。这并不意味着查询已被完全评估并且结果保存在快照中。

要进一步了解,请阅读yield return 以及如何编写使用该语言功能的方法。如果你这样做,你就会明白你需要什么才能写Enumerable.Where

【讨论】:

    【解决方案4】:

    IEnumerable 在 c# 中是懒惰的。这意味着每当您强制它进行评估时,您都会得到结果。在您的情况下,Count() 强制 linqLIST 在您每次调用它时进行评估。顺便说一句,linqLIST 现在不是 列表

    【讨论】:

    • 我可以将.Count() 函数排除在等式之外,但我的列表更改仍然存在相同的潜在问题。更新了代码以反映这一点
    【解决方案5】:

    您可以使用以下扩展方法将«在枚举数组时避免副作用»建议升级为需求:

    private static IEnumerable<T> DontMessWithMe<T>(this T[] source)
    {
        var copy = source.ToArray();
        return source.Zip(copy, (x, y) =>
        {
            if (!EqualityComparer<T>.Default.Equals(x, y))
                throw new InvalidOperationException(
                "Array was modified; enumeration operation may not execute.");
            return x;
        });
    }
    

    现在将此方法链接到您的查询并观察会发生什么。 ?

    var linqObj = aArray.DontMessWithMe().Where(x => x == "a");
    

    当然,这是有代价的。现在每次枚举数组时,都会创建一个副本。这就是为什么我不希望有人会使用这个扩展!

    【讨论】:

    • 你得到我对(有趣的命名)解决方案的支持,因为你提到了开销
    猜你喜欢
    • 1970-01-01
    • 2019-01-09
    • 2019-02-01
    • 2020-01-27
    • 2013-03-30
    • 1970-01-01
    • 1970-01-01
    • 2012-06-04
    • 1970-01-01
    相关资源
    最近更新 更多