在foreach循环中,无法通过Enumerable.Where访问C#对象属性

3

发现了一个让人头疼的C#问题。在foreach循环中,直接使用parent.Id属性作为Enumerable.Where的参数是不起作用的。但是如果先将其放入变量中,则可以正常工作。在Select语句中直接访问parent.Id没有任何问题。

    List<Person> people = new List<Person>() { 
        new Person() { Id = 1, name = "John", parentId = null },
        new Person() { Id = 2, name = "Sarah", parentId = null },
        new Person() { Id = 3, name = "Daniel", parentId = 1 },
        new Person() { Id = 4, name = "Peter", parentId = 1 }
    };

    List<object> peopleTree = new List<object>();

    var parents = people.Where(p => !p.parentId.HasValue);
    foreach (Person parent in parents)
    {
        int parentId = parent.Id;
        var children = people
            //.Where(p => p.parentId.Equals(parentId)) //This works, is able to find the children
            .Where(p => p.parentId.Equals(parent.Id)) //This does not work, no children for John
            .Select(p => new { Id = p.Id, Name = p.name, pId = parent.Id }); //pId set correctly

        peopleTree.Add(new
        {
            Id = parent.Id,
            Name = parent.name,
            Children = children
        });
    }

另外,如果我使用for循环并首先将parent放入变量中,我可以直接在Where语句中访问parent.Id属性。

var parents = people.Where(p => !p.parentId.HasValue).ToArray();
for (int idx = 0; idx < parents.Count(); idx++)
{
    var parent = parents[idx];
...

我找不到为什么会出现这种行为的答案。有人能解释一下吗?


在第二个例子中,你调用了 'ToArray'。这样做可能也会使你的第一个例子正常工作。 - Rufus L
你使用的框架版本是哪个?也许我没有理解你的问题,因为它对我来说运行良好。 - danish
@danish 框架版本是不相关的,编译器版本才是关键。该行为在 C# 5.0 中得到了改变。 - Servy
3个回答

4
这个问题是由于children的延迟执行所创建的。本质上,children被评估时parent的值是不同的。对此的极客说法是“访问修改的闭包”。
你可以像之前一样引入一个临时变量来解决它,或者在当前迭代中foreach循环仍然存在时强制执行评估。
var children = people
    .Where(p => p.parentId.Equals(parent.Id))
    .Select(p => new { Id = p.Id, Name = p.name, pId = parent.Id })
    .ToList();

3
可以使用C# 5.0编译器编译代码,而不是早期版本的编译器。 - Servy
@Servy 也是 :) 我认为 Eric Lippert 在他的博客中解释了这个变化的理由,但我找不到这篇文章。 - Sergey Kalinichenko
1
@dasblinkenlight 它在这里:https://dev59.com/hWoy5IYBdhLWcg3wScJB#8649429 它也链接到我的答案。 - Joel Coehoorn

2
这是由于linq查询的惰性本质引起的。为了避免做可能不必要的工作,Linq查询将尽可能晚地“实现”。
"children" 是一个未实现的 "IEnumerable",它不会被填充元素。在你的两个 ".Where()" 调用中使用的 "parent" 和 "parentId" 之间有重大差异。 "parent" 只被声明一次,但 "parentId" 在循环内限定作用域,所以实际上被多次声明。当 "children" 最终被实现时,“parent”已经更改了值。它将指向 "parents" 中的最后一个元素,这不是你想要的。
你可以像这样强制执行急切评估。
    var children = people
        .Where(p => p.parentId.Equals(parent.Id)) 
        .Select(p => new { Id = p.Id, Name = p.name, pId = parent.Id })
        .ToArray();  <---- this forces materialization

1
不能说IEnumerable本身总是会被“实体化”为元素。列表或数组可能会被实体化,但您也可以拥有迭代器块可枚举,它们一次只处理一个项目,并且由Select()/Where()/等扩展方法返回的对象实际上是在原始集合上操作的状态机...即使您在foreach中使用它们,它们本身也永远不会被实体化。 - Joel Coehoorn
@JoelCoehorn:你说得对。我编辑了我的答案以反映这一点。 - recursive

1
问题出在以下这句话的陈述上:
var children = people ...

这个语句不会生成一个实际存储值的集合...它生成一个IEnumerable对象,该对象知道如何迭代集合。此对象使用的指令碰巧引用循环中的parent变量。该变量被捕获到一个称为closure的东西中,以供Enumerable使用。稍后,当您实际使用Enumerable对象访问项时,它会回顾那个parent变量。
重点在于:每次通过原始循环迭代时都会有一个父级变量变异。在循环结束时,您parents集合中的所有项都使用相同的parent对象。在循环内部将parent.Id复制到变量中可以解决问题,因为现在您正在处理每次循环迭代的新变量闭包。
您也可以通过在前面提到的语句末尾使用.ToList()调用来解决此问题,以在循环内部评估Enumerable对象。但是,如果您从不需要同时展开所有这些Children,则我更喜欢您现有的解决方案,因为它更节省内存。

好消息是C# 5已经修复了这个问题


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