LINQ投影(选择)返回奇怪的结果

9
考虑以下代码。
namespace ConsoleApp1
{
    using System;
    using System.Collections.Generic;
    using System.Linq;

    public class Program
    {
        public static void Main(string[] args)
        {
            int count = default(int);

            IEnumerable<int> values1 = Enumerable.Range(1, 200)
                .OrderBy(o => Guid.NewGuid())
                .Take(100);

            IEnumerable<int> values2 = values1
                .OrderBy(o => Guid.NewGuid())
                .Take(50)
                .Select(o => { count++; return o; });

            Console.Read();
        }
    }
}

复现步骤

  1. Console.Read(); 上设置断点
  2. 运行到断点处
  3. 检查 count++ (应该显示为0)
  4. 检查 values2 并填充“结果视图”
  5. 检查 count++ (应该显示为100)

问题

考虑到我只从 values1 中取了50个项目,我期望 count++ 显示50。为什么它显示100?

请注意,如果这很困惑,请尝试运行此代码,因为它会产生相同的结果...

namespace ConsoleApp1
{
    using System;
    using System.Collections.Generic;
    using System.Linq;

    public class Program
    {
        public static void Main(string[] args)
        {
            int count = default(int);

            IEnumerable<int> values1 = Enumerable.Range(1, 100)
                .OrderBy(o => Guid.NewGuid())
                .Take(50);

            IEnumerable<int> values2 = values1
                .OrderBy(o => Guid.NewGuid())
                .Take(50)
                .Select(o => { count++; return o; });

            Console.Read();
        }
    }
}

示例

检查count ++

enter image description here

检查values2(填充结果视图)

enter image description here

检查count ++

enter image description here

请解释这里正在发生什么,并说明如何修复它。

注意

许多给出的答案都暗示了延迟执行。我知道linq使用延迟执行,所以除非我错过了什么,否则这不是问题。

我的观点是,当断点被击中时,CLR已经为values2创建了一个状态机。然后在调试器中对其进行迭代,count立即增加到100,这似乎只有1次迭代。这似乎有点奇怪!

此外,我知道value2的结果视图的后续填充会导致count递增,因为这会导致状态机进一步迭代。


1
搜索的关键字是“延迟执行”。调试器需要每次检查时评估表达式/查询。 - Tim Schmelter
@TimSchmelter 请查看问题中的注释。 - Matthew Layton
1
原因是调试器会对此查询进行两次评估。如果您在空的 foreach 之后输出它,您将得到预期的 50:foreach (int i in values2); Console.WriteLine(count); - Tim Schmelter
补充一下@TimSchmelter的最后评论,如果你在Locals/Watch/Quick Watch窗口展开查询,那么查询似乎会被三重评估,导致count为150 :) - Ivan Stoev
2个回答

17
每次您检查values2时,表达式会重新评估--如果您在监视窗口中检查它,则每次看起来都会评估两次(不要问我为什么; 问编写监视窗口代码的人)。我得到了count == 300。每次有东西评估它时,它都会将50添加到count;这就是代码所做的,可以自己看一下。并且每次您在监视窗口中展开它时,count都会增加100。因此,监视窗口对其进行了两次评估。
您只看到其中一次,那又怎样呢?在VS代码内部发生了很多事情,它不需要向您显示所有内容。GUI不是程序内部的窗口;它只是一堆被某些代码有意着色的屏幕像素。我可以编写一个监视窗口,评估表达式19次并向您显示Pokemon。更合理的解释是:您从未见过的某些代码正在执行一些未在GUI中显示的操作,还是有时计算机无法进行加法?
看一下values2的运行时类型:System.Linq.Enumerable.WhereSelectEnumerableIterator<int,int>。那不是集合,而是等待执行的东西。
让我们在该表达式的末尾添加ToList()。那将评估它一次并存储结果。然后,您可以检查结果一整天,而不再执行任何LINQ表达式。
int count = default(int);

IEnumerable<int> values1 = Enumerable.Range(1, 200)
    .OrderBy(o => Guid.NewGuid())
    .Take(100);

IEnumerable<int> values2 = values1
    .OrderBy(o => Guid.NewGuid())
    .Take(50)
    .Select(o => { count++; return o; })
    .ToList();

现在 count == 50,因为表达式只被评估了一次,并且结果被存储在一个 List<T> 中。

故事寓意:

屏幕上的点是一种幻觉,将惰性评估与副作用相结合就如同在星巴克放一只猴子和一把机关枪。我不是说这样做是错误的,只是不是每个人都会喜欢。


1
我理解这一点,但是扩展结果视图会导致状态机的迭代。这很明显,因为由于OrderBy(o => Guid.NewGuid())的行为不同,结果总是不同的 - 我想知道为什么计数只有1次迭代就达到了100? - Matthew Layton
如果 count == 100,那不仅仅是一次迭代。哪个更可靠:你(或者我!)对于一个 Linq 表达式被观察窗口(我完全不知道其内部)评估了多少次的直觉,还是计算机进行算术运算?尝试添加 ToList(),以保证只有一次迭代。当我这样做时,我看到 count == 50 - 15ee8f99-57ff-4f92-890c-b56153
我了解.ToList()保证只有一次迭代,然而,我想知道为什么在检查“看起来是第一个也是唯一的”迭代时,计数器会增加到100? - Matthew Layton
1
@series0ne: 顺便说一下,你可以简化你的示例:即使在调试器中,这个查询也会返回100作为计数:IEnumerable<int> values1 = Enumerable.Range(1, 50).Select(o => { count++; return o; });。所以,OrderByTake或第二个查询都与此无关。只有调试器做了比你预期的更多的事实。如果你在foreach (int i in values1); Console.WriteLine(count);之后输出计数,你将得到50的结果。这是调试器的作用。 - Tim Schmelter
1
@series0ne 这与 OrderBy(o => Guid.NewGuid()) 无关。只需将其注释掉并进行测试,结果仍然相同。 - Adil Mammadov
2
@series0ne 你唯一准确了解有关评估次数的信息是 count 的值,它明确地告诉你有两个评估。你知道自己对 count 做了什么,这是绝对确定的事情。监视窗口内部是一个猜测。是的,对你来说,它看起来并不像是枚举了那个序列两次;你没有看到监视窗口这样做;但为什么它必须在你能看到的地方这样做呢?这是代码。它可以做各种各样的事情,UI 只会显示给你它想让你看到的。 - 15ee8f99-57ff-4f92-890c-b56153

3

这是因为 Linq 是延迟执行的,只有在您显式调用 ToList(),或者遍历结果时,委托才会被调用。

当您在快速查看中查看 Projection 的结果时,此时将调用委托来填充结果,正如 @Ed 也提到的那样。


是和不是。不是因为在第一次枚举后,您应该仍然会看到50,但显然并非如此。即使使用此查询:var x=Enumerable.Range(1,50).Select(i=>count++),您始终会得到100。因此,在调试器快速监视窗口中对其进行评估时,您已经执行了两次。是的,因为延迟执行仍然负责此问题。 - Tim Schmelter
同意@TimSchmelter,这是一个委托,可以被调用多次。 - Ehsan Sajjad

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