【问题标题】:Does IEnumerable<T> store a function to be called later?IEnumerable<T> 是否存储稍后调用的函数?
【发布时间】:2018-10-15 21:38:30
【问题描述】:

我最近遇到了一些不符合我预期的代码。

1: int[] numbers = { 1, 2, 3, 4, 5, 6, 7, 8 };
2: IEnumerable<int> result = numbers.Select(n => n % 2 == 0 ? n : 0);
3: 
4: int a = result.ElementAt(0);
5: numbers[0] = 10;
6: int b = result.ElementAt(0);

当我使用 Visual Studio 单步执行这段代码时,我惊讶地发现黄色突出显示从第 4 行跳回到第 2 行的 lambda 表达式,然后又从第 6 行跳到第 2 行的 lambda。

另外,运行这段代码后a的值为0,b的值为10。

让我意识到这可能/将会发生的原始代码涉及Select() 中的方法调用,并且访问 IEnumerable 的任何属性或特定元素会导致Select() 中的方法被一次又一次地调用。

// The following code prints out:
// Doing something... 1
// Doing something... 5
// Doing something... 1
// Doing something... 2
// Doing something... 3
// Doing something... 4
// Doing something... 5

using System;
using System.Linq;
using System.Collections.Generic;

class Program
{
    static void Main(string[] args)
    {
        int[] numbers = { 1, 2, 3, 4, 5 };
        IEnumerable<int> result = numbers.Select(DoSomething);

        int a = result.ElementAt(0);
        int b = result.ElementAt(4);
        int c = result.Count();
    }

    static int DoSomething(int x)
    {
        Console.WriteLine("Doing something... " + x);
        return x;
    }
}

我觉得我现在了解了代码的行为方式(我在网上发现了其他问题,这些问题是这种行为的结果)。然而,究竟是什么导致Select() 中的代码从后面的行中被调用?

【问题讨论】:

  • 我觉得标记为重复的问题非常广泛,我已经编辑了这个问题以试图明确我正在寻找更具体或幕后的东西
  • 注意:我重新打开了这个,因为重复的内容实在是太宽泛了,并且没有以可以轻松翻译成这段代码的方式回答问题
  • 注意:将 numbers.Select(DoSomething) 更改为 numbers.Select(DoSomething).ToList() 以缓冲结果,这样它就不会再次重新计算 Linq 表达式。
  • 是的,linq 会延迟执行。它是一个聪明的功能,可能会产生令人惊讶的副作用。查看源代码选择referencesource.microsoft.com/#System.Core/System/Linq/…

标签: c# linq select ienumerable func


【解决方案1】:

您有一个对 LINQ 查询的引用,该查询的计算次数与您迭代它们的次数一样多。

From the docs(可以看到这叫做延迟执行):

如前所述,查询变量本身只存储查询命令。查询的实际执行被推迟,直到您在 foreach 语句中迭代查询变量。这个概念被称为延迟执行

...

因为查询变量本身从不保存查询结果,所以您可以随意执行它。例如,您可能有一个由单独的应用程序不断更新的数据库。在您的应用程序中,您可以创建一个检索最新数据的查询,并且您可以每隔一段时间重复执行它,以每次检索不同的结果。

所以,当你有

IEnumerable<int> result = numbers.Select(DoSomething);

您有一个查询引用,该查询会将numbers 中的每个元素转换为DoSomething 的结果。
所以,你可以这样说:

int a = result.ElementAt(0);

迭代result 直到第一个元素。 ElementAt(4) 也是如此,但这次它会迭代到第五个元素。请注意,您只看到打印的Doing something... 5,因为.Current 被评估了一次。 如果此时查询无法生成 5 个项目,则调用将失败。
.Count 调用再次迭代 result 查询并返回当时的元素数量。

如果您没有保留对查询的引用,而是保留了对结果的引用,即:

IEnumerable<int> result = numbers.Select(DoSomething).ToArray();
// or
IEnumerable<int> result = numbers.Select(DoSomething).ToList();

你只会看到这个输出:

// Doing something... 1
// Doing something... 2
// Doing something... 3
// Doing something... 4
// Doing something... 5

【讨论】:

  • 这很有趣。当您说“ElementAt(4) 也是如此,但这次它迭代到第五个元素”时,这是否表明 Element(4) 调用应该打印出所有数字?它只打印数字 5
  • @elmer007 不,抱歉,这是一个心理问题。它应该只打印 5。问题是 Current 只被评估一次
【解决方案2】:

让我们逐段分解,直到你理解为止。相信我;花点时间阅读这篇文章,这将是您理解Enumerable 类型并回答您的问题的一个启示。

查看IEnumerable 接口,它是IEnumerable&lt;T&gt; 的基础。它包含一种方法; IEnumerator GetEnumerator();.

Enumerables 是一头棘手的野兽,因为它们可以为所欲为。真正重要的是在foreach 循环中自动调用GetEnumerator();或者您可以手动完成。

GetEnumerator() 是做什么的?它返回另一个接口IEnumerator

这就是魔法。 IEnumerator 有 1 个属性和 2 个方法。

object Current { get; }
bool MoveNext();
void Reset();

让我们分解魔法吧。

首先让我解释一下它们通常是什么,我之所以这么说是因为就像我提到的那样,它可能是一个棘手的野兽。您可以根据自己的选择来实现它...有些类型不符合标准。

object Current { get; } 很明显。它获取IEnumerator中的当前对象;默认情况下,这可能为 null。

bool MoveNext(); 如果IEnumerator 中有另一个对象,则返回true,它应该将Current 值设置为该新对象。

void Reset(); 告诉类型从头开始。

现在让我们来实现它。请花时间查看此IEnumerator 类型,以便您理解它。意识到当您引用 IEnumerable 类型时,您甚至没有引用 IEnumerator (this);但是,您引用的类型通过 GetEnumerator() 返回此 IEnumerator

注意: 注意不要混淆名称。 IEnumeratorIEnumerable 不同。

IEnumerator

public class MyEnumerator : IEnumerator
{
    private string First => nameof(First);
    private string Second => nameof(Second);
    private string Third => nameof(Third);
    private int counter = 0;

    public object Current { get; private set; }

    public bool MoveNext()
    {
        if (counter > 2) return false;

        counter++;
        switch (counter)
        {
            case 1:
                Current = First;
                break;
            case 2:
                Current = Second;
                break;
            case 3:
                Current = Third;
                break;                    
        }
        return true;
    }

    public void Reset()
    {
        counter = 0;
    }
}

现在,让我们创建一个IEnumerable 类型并使用这个IEnumerator

IEnumerable

public class MyEnumerable : IEnumerable
{
    public IEnumerator GetEnumerator() => new MyEnumerator();
}

这是值得一试的...当您拨打numbers.Select(n =&gt; n % 2 == 0 ? n : 0) 之类的电话时,您并没有迭代任何项目...您返回的类型与上述类型非常相似。 .Select(…) 返回IEnumerable&lt;int&gt;。上面看起来不错……IEnumerable 只不过是一个调用GetEnumerator() 的接口。每当您进入循环情况或可以手动完成时,都会发生这种情况。因此,考虑到这一点,您已经可以看到迭代永远不会开始,直到您调用 GetEnumerator(),即使这样它也永远不会开始,直到您调用 GetEnumerator() 的结果的 MoveNext() 方法,这是 IEnumerator 类型。

所以...

换句话说,您在通话中只引用了IEnumerable&lt;T&gt;,仅此而已。没有发生任何迭代。这就是代码在您的代码中跳回的原因,因为它最终确实在 ElementAt 方法中进行了迭代,然后它正在查看 Lamba 表达式。和我在一起,稍后我将更新一个示例以完整地完成本课程,但现在让我们继续我们的简单示例:

现在让我们制作一个简单的控制台应用程序来测试我们的新类型。

控制台应用

class Program
{
    static void Main(string[] args)
    {
        var myEnumerable = new MyEnumerable();

        foreach (var item in myEnumerable)
            Console.WriteLine(item);

        Console.ReadKey();
    }

    // OUTPUT
    // First
    // Second
    // Third
}

现在让我们做同样的事情,但让它通用。我不会写那么多,但会密切关注代码的变化,你会明白的。

我将把它全部复制并粘贴到一个文件中。

整个控制台应用

using System;
using System.Collections;
using System.Collections.Generic;

namespace Question_Answer_Console_App
{
    class Program
    {
        static void Main(string[] args)
        {
            var myEnumerable = new MyEnumerable<Person>();

            foreach (var person in myEnumerable)
                Console.WriteLine(person.Name);

            Console.ReadKey();
        }

        // OUTPUT
        // Test 0
        // Test 1
        // Test 2
    }

    public class Person
    {
        static int personCounter = 0;
        public string Name { get; } = "Test " + personCounter++;
    }

    public class MyEnumerator<T> : IEnumerator<T>
    {
        private T First { get; set; }
        private T Second { get; set; }
        private T Third { get; set; }
        private int counter = 0;

        object IEnumerator.Current => (IEnumerator<T>)Current;
        public T Current { get; private set; }

        public bool MoveNext()
        {
            if (counter > 2) return false;

            counter++;
            switch (counter)
            {
                case 1:
                    First = Activator.CreateInstance<T>();
                    Current = First;
                    break;
                case 2:
                    Second = Activator.CreateInstance<T>();
                    Current = Second;
                    break;
                case 3:
                    Third = Activator.CreateInstance<T>();
                    Current = Third;
                    break;
            }
            return true;
        }

        public void Reset()
        {
            counter = 0;
            First = default;
            Second = default;
            Third = default;
        }

        public void Dispose() => Reset();
    }

    public class MyEnumerable<T> : IEnumerable<T>
    {
        IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
        public IEnumerator<T> GetEnumerator() => new MyEnumerator<T>();
    }
}

让我们回顾一下...IEnumerable&lt;T&gt; 是一种类型,它有一个返回IEnumerator&lt;T&gt; 类型的方法。 IEnumerator&lt;T&gt; 类型具有 T Current { get; } 属性以及 IEnumerator 方法。

让我们在代码中再分解一次并手动调出各个部分,以便您可以更清楚地看到它。这将只是应用程序的控制台部分,因为其他一切都保持不变。

控制台应用

class Program
{
    static void Main(string[] args)
    {
        IEnumerable<Person> enumerable = new MyEnumerable<Person>();
        IEnumerator<Person> enumerator = enumerable.GetEnumerator();

        while (enumerator.MoveNext())
            Console.WriteLine(enumerator.Current.Name);

        Console.ReadKey();
    }
    // OUTPUT
    // Test 0
    // Test 1
    // Test 2
}

仅供参考: 需要指出的是,在上面的答案中有两个版本的 Linq。 EF 或 Linq-to-SQL 中的 Linq 包含与典型 linq 不同的扩展方法。主要区别在于 Linq 中的查询表达式(当引用数据库时)将返回 IQueryable&lt;T&gt;,它实现了 IQueryable 接口,该接口创建了运行和迭代的 SQL 表达式。换句话说......像.Where(…) 子句这样的东西不会查询整个数据库然后对其进行迭代。它将该表达式转换为 SQL 表达式。这就是为什么像 .Equals() 这样的东西在那些特定的 Lambda 表达式中不起作用的原因。

【讨论】:

  • 这很好,但我认为您需要包含一些关于延迟执行的内容。
  • @Logan 我想我已经解释了幕后延迟执行的确切含义。它只不过是持有类型引用并且在需要之前从不使用它。还有一个很长的课程来完整地捆绑延迟执行,当您开始使用 Linq with Data(EF、Linq-to-SQL、ETC)时,情况会有所不同。我确实包含了一个关于此的 FYI。我同意你的担忧,但如果你打破了我写下的内容,这是所有这些的基础,逻辑应该坚持。我会用一个关于延迟执行的花絮来更新它,但理解它比给它贴上 IMO 的标签要好。
  • 对,我说错了。您解释了延迟执行而没有说延迟执行 :) 我同意理解比贴标签更好,但标签也很重要,因此人们知道您在说什么。
  • @Logan 不能争辩。
【解决方案3】:

IEnumerable&lt;T&gt; 是否存储了稍后调用的函数?

是的。 IEnumerable 正是它所说的那样。这是可以在未来某个时候枚举出来的东西。您可以将其视为设置操作管道。

直到实际枚举(即调用foreach.ElementAt()ToList() 等),这些操作中的任何一个才会被实际调用。这称为deferred execution

究竟是什么导致 Select() 中的代码从后面的行中被调用?

当您调用SomeEnumerable.Select(SomeOperation) 时,结果是一个 IEnumerable,它是一个代表您设置的“管道”的对象。该 IEnumerable 的实现确实存储了您传递给它的函数。这个(对于.net核心)的实际来源是here。您可以看到SelectEnumerableIteratorSelectListIteratorSelectArrayIterator 都有一个Func&lt;TSource, TResult&gt; 作为私有字段。这是它存储您指定供以后使用的函数的地方。如果您知道自己正在迭代有限集合,则数组和列表迭代器只是提供了一些快捷方式。

【讨论】:

    猜你喜欢
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 2015-02-12
    • 2015-10-08
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    相关资源
    最近更新 更多