65.9K
CodeProject 正在变化。 阅读更多。
Home

LINQ 第 1 部分:深入了解 IEnumerable

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.92/5 (32投票s)

2018年3月29日

CPOL

12分钟阅读

viewsIcon

46985

downloadIcon

380

深入探讨 IEnumerable 接口,C# 语言如何支持它,如何避免其中的一些陷阱,以及介绍一些基本的 LINQ 概念。

引言

LINQ 有两种形式,基于它所扩展的关联接口:`IEnumerable` 或 `IQueryable`。我们将首先把讨论限制在 `System.Linq.Enumerable` 类中找到的 `IEnumerable` 接口的扩展方法上。如果你不熟悉扩展方法,本文末尾有该主题的摘要。

要很好地理解 LINQ,就必须很好地理解 `IEnumerable`。这个概念上简单的接口是 LINQ 的主力。它可以展现出一些令人惊讶的复杂行为。

在否定这个主题并进入下一个主题之前,请尝试正确地回答这五个简单的问题。如果成功,你可能已经足够理解了。如果不成功,花几分钟额外的时间阅读本文可能会值得。

  1. 该接口所需的一个且仅有的方法是什么?
  2. 如何在不使用 `foreach` 关键字的情况下实现 `foreach` 循环?
  3. 过早具体化的陷阱是什么?如何避免它们?
  4. 如何在不依赖 `yield` 关键字的情况下从方法中返回 `IEnumerable`?
  5. 一个 `IEnumerable` 中可以存在的最大项目数是多少?

要检查你的答案,请跳到文章末尾,但请不要作弊。

到本文结束时,你将完全理解所有这些概念,并开始理解它们与 LINQ 的关系。

背景

这是关于 LINQ 的一系列文章中的第一篇。本系列其他文章的链接如下:

万事万物

认识人生的方式是去热爱万事万物。——文森特·梵高

任何时候我们处理很多事情时,很可能都涉及到 `IEnumerable`。其核心上,`IEnumerable` 仅仅是零个或多个项目的序列。这是 .NET 框架中每个集合(数组、列表、字典、哈希集等)都实现的接口。

此外,.NET 框架的很大一部分使用此接口返回长(或昂贵)的项目序列,而无需集合,以下是一些快速示例:`DirectoryInfo.EnumerateFiles`、`DirectoryInfo.EnumerateDirectories` 和 `File.ReadLines`。

该接口有一个且仅有一个方法:`GetEnumerator`。如果你曾经使用过 `foreach` 关键字,你就已经使用过这个接口。那么,让我们考虑一个简单的 `foreach` 循环:

List<char> letters = new List<char> { 'a', 'b', 'c', 'd', 'e' };

foreach (char letter in letters)
  Console.Write(letter + " ");
Console.WriteLine();

在幕后,C# 会代表你执行以下操作:

using (IEnumerator<char> enumerator = letters.GetEnumerator())
  while(enumerator.MoveNext())
  {
    char letter = enumerator.Current;
    Console.Write(letter + " ");
  }
Console.WriteLine();

差不多了

伟大不仅是潜能。它是潜能的实现。——埃里克·伯恩斯

`IEnumerable` 的美妙之处在于它就放在那里,几乎不做任何事。在你调用 `GetEnumerator` 之前,它仍然是无形的,充满潜力的。你可以在变量之间复制它的值,将其作为参数传递,并用另一个 `IEnumerable` 包裹它。会发生什么?几乎什么都不会。

让我们考虑一个基于斐波那契数列的非常简单的 `IEnumerable`。这个序列是无限的。序列中的每个项目(除了前两个)都是前两个的和。这个序列如下:1、1、2、3、5、8、13、21、34、55……

让我们考虑以下返回此序列中数字的简单方法:

public static IEnumerable<long> EnumerateFibonacci()
{
  long current = 1;
  long previous = 0;

  while (true)
  {
    yield return current;

    long temp = previous;
    previous = current;
    current += temp;
  }
}

即使这个序列是无限的,我们仍然可以在应用程序中使用它。注意:当 `current` + `temp` 大于 `long.MaxValue` 时,此序列将开始返回无意义的值。

IEnumerable<long> mySequence = EnumerateFibonacci();

上面的赋值只是获取 `IEnumerable`。它**不**执行 `EnumerateFibonacci` 方法中的任何一行代码。我看到人们经常犯的一个错误是简单地在每个 `IEnumerable` 的末尾添加一个 `ToList` 调用,如下所示:

mySequence = mySequence.ToList();

为了填充列表,序列中的每个项目都必须被求值。在这种情况下,由于序列是无限的,操作将永远不会完成。最终,在所有内存被耗尽后,操作将失败。

虽然这是过早具体化陷阱的一个极端例子,但在现实世界中有很多例子。在大多数情况下,应用程序只会冻结,直到所有项目都被求值。它只能在列表构建完成后才能开始处理项目。

驯服无限

将无限握在掌中,将永恒置于一小时之内。——威廉·布莱克

再次以斐波那契为例,假设我们想显示前 10 个数字。不需要列表。以下简单的循环就足够了:

IEnumerable<long> mySequence = EnumerateFibonacci();
int index = 0;

foreach (long number in mySequence)
{
  Console.Write(number + " ");
  if (++index >= 10)
    break;
}
Console.WriteLine();

或者,为了更轻松,你可以使用 LINQ 来修剪列表:

mySequence = EnumerateFibonacci()
  .Take(10);

在这里,LINQ 只是将一个包装了原始序列的 `IEnumerable` 放在外面,该 `IEnumerable` 在第十个项目后停止。同样,`EnumerateFibonacci` 方法中的任何一行代码都不会被此赋值执行。

所以,总而言之,以下使得驯服无限变得容易:

foreach (long number in EnumerateFibonacci().Take(10))
  Console.Write(number + " ");
Console.WriteLine();

LINQ 的美妙之处在于大多数方法都采用了装饰器模式,只是将一个新的 `IEnumerable` 包装在序列周围。通过添加一系列方法调用,你可以形成一个生产线(或管道)。例如,要获取序列中第二组 10 个数字的 `IEnumerable`,以下赋值就足够了:

mySequence = EnumerateFibonacci()
  .Skip(10)
  .Take(10);

同样,`EnumerateFibonacci` 中的任何一行代码都不会因为这个赋值而被执行。

幕后

万物皆有美,但并非人人都能看到。——孔子

即使是微不足道的 LINQ,通过一系列简单的函数调用就能塑造一个管道,其能力看起来近乎神奇。然而,一旦你理解了最简单的 LINQ 方法的作用,理解起来就容易多了。

在这个层面上,我们真正拥有的只是一组作用于 `IEnumerable` 的扩展方法。让我们考虑一下我们如何实现自己的 `Take` 方法版本。

public static class MyEnumerable
{
  public static IEnumerable<TItem> MyTake<TItem>(this IEnumerable<TItem> items, int count)
  {
    using (IEnumerator<TItem> enumerator = items.GetEnumerator())
      for (int index = 0; index < count && enumerator.MoveNext(); index++)
        yield return enumerator.Current;
  }
}

有了这个方法,我们看起来就很像 LINQ 了:

mySequence = EnumerateFibonacci()
  .MyTake(10);

重活

如果我们做得正确,重活并不需要重花钱。——巴兹·奥尔德林

当你使用 `yield` 关键字时,C# 语言会在你的代码背后做大量的繁重工作。虽然我们不会完全复制生成的代码,但考虑一下如何在不使用 `yield` 关键字的情况下编写相同的代码仍然是有帮助的。

首先,考虑下面的代码。别担心,我们稍后会解释它。

public static class MyEnumerable2
{
  public static IEnumerable<TItem> MyTake2<TItem>(this IEnumerable<TItem> items, int count) =>
    new MyTakeEnumerable<TItem>(items, count);

  private class MyTakeEnumerable<TItem> : IEnumerable<TItem>
  {
    public MyTakeEnumerable(IEnumerable<TItem> items, int count)
    {
      this.items = items;
      this.count = count;
    }

    private IEnumerable<TItem> items;
    private int count;

    public IEnumerator<TItem> GetEnumerator() =>
      new MyTakeEnumerator(items.GetEnumerator(), count);

    IEnumerator IEnumerable.GetEnumerator() =>
      GetEnumerator();

    private class MyTakeEnumerator : IEnumerator<TItem>
    {
      public MyTakeEnumerator(IEnumerator<TItem> enumerator, int count)
      {
        this.enumerator = enumerator;
        this.count = count;
      }

      private IEnumerator<TItem> enumerator;
      private int count;
      private int index;

      public bool MoveNext() =>
        index++ >= count ? false : enumerator.MoveNext();

      public TItem Current => enumerator.Current;

      object IEnumerator.Current => enumerator.Current;

      public void Dispose() => enumerator.Dispose();

      public void Reset() => enumerator.Reset();
    }
  }
}

我敢打赌,你现在很高兴 `yield` 关键字为你做了所有这些工作。现在,让我们尝试解开这些有点复杂的代码。

基本上,我们在这里实现了三个类:

类名 描述
MyEnumerable2 这个类只是提供了 `MyTake2` 扩展方法和一个内部类。
MyTakeEnumerable 这个内部类只是包装了原始序列。它提供了一个新的 `GetEnumerator` 方法,该方法获取原始序列的枚举器,并将其包装在 `MyTakeEnumerator` 的实例中。
MyTakeEnumerator 这个内部类只是包装了原始序列的枚举器,并将其限制在请求的项目数内。

MyEnumerable2 类

这里重要的 `MyTake2` 方法相对容易理解。我们只是创建一个 `MyTakeEnumerable` 的实例来包装原始序列。

public static IEnumerable<TItem> MyTake2<TItem>(this IEnumerable<TItem> items, int count) =>
  new MyTakeEnumerable<TItem>(items, count);

MyTakeEnumerable 类

这里,我们再次有一个相对直接的方法。我们只是创建一个 `MyTakeEnumerator` 的实例来包装原始序列的枚举器。

public IEnumerator<TItem> GetEnumerator() =>
  new MyTakeEnumerator(items.GetEnumerator(), count);

MyTakeEnumerator 类

这里,事情看起来更复杂,但实际上并非如此。这只是 `IEnumerator` 的一个实现,它包装了原始序列的枚举器。大多数方法只不过是调用底层枚举器的等效方法。

只有一个例外:`MoveNext` 方法。我们对此略作修改。如果我们返回的项目数少于请求的数量,我们只是调用底层的 `MoveNext` 方法。否则,我们返回 `false`。

public bool MoveNext() =>
  index++ >= count ? false : enumerator.MoveNext();

总结

总结的总结的总结:人是个问题。——道格拉斯·亚当斯

当 C# 在方法中遇到 `yield` 关键字时,它会执行一点魔法。你的方法被更改,以便它为你创建 `IEnumerable`/`IEnumerator` 类。然后返回这个新类的实例。

那么你的代码去了哪里?它(基本上)被移到了 `IEnumerator` 的 `MoveNext` 方法中。

这就是为什么当你调用原始方法时,你的代码实际上并没有被执行。你的实际代码现在驻留在 `MoveNext` 中。直到你调用 `GetEnumerator` 然后调用 `MoveNext`,它才会被执行。

标准 LINQ 方法

标准 LINQ 方法实现为 `IEnumerable` 接口的扩展方法。它们位于 `System.Linq.Enumerable` 类中。

这些方法分为三种基本类型:返回原始顺序序列的方法,返回不同顺序序列的方法,以及返回单个值的方法。

原始顺序序列

一些方法返回一个新的序列,其中包含原始序列的全部或一部分,并保持原始顺序。这些方法不需要具体化序列即可开始返回项目。这些方法包括:`Append`、`AsEnumerable`、`Cast`、`Concat`、`Empty`、`Except`、`OfType`、`Prepend`、`Range`、`Repeat`、`Select`、`SelectMany`、`Skip`、`SkipWhile`、`Take`、`TakeWhile`、`Where` 和 `Zip`。

新顺序序列

一些方法返回一个新的序列,其中包含原始序列的全部或一部分,但顺序不同。虽然执行仍然被推迟到你开始消耗序列为止,但这可能具有欺骗性。为了返回序列的第一个项目,这些方法必须首先求值(并具体化)序列的一部分或全部。这些方法包括:`Distinct`、`GroupBy`、`GroupJoin`、`Intersect`、`Join`、`OrderBy`、`OrderByDescending`、`Reverse`、`ThenBy`、`ThenByDescending` 和 `Union`。

单例

单例方法强制至少一部分序列立即具体化。这些方法包括:`Aggregate`、`All`、`Any`、`Average`、`Contains`、`Count`、`DefaultIfEmpty`、`ElementAt`、`ElementAtOrDefault`、`First`、`FirstOrDefault`、`Last`、`LastOrDefault`、`LongCount`、`Max`、`Min`、`SequenceEqual`、`Single`、`SingleOrDefault`、`Sum`、`ToArray`、`ToDictionary`、`ToList` 和 `ToLookup`。

避免具体化

如果你坚持使用返回与原始序列相同顺序的新修改序列的标准 LINQ 方法,你就可以避免具体化。虽然这并非总是可能,但它比我们的一些同事可能意识到的要常见得多。

一个常见的抱怨是“我需要计数、总和、最小值、最大值或平均值”。这可能是真的。但是,你**何时**需要这些信息?如果你只是在报告的末尾显示这些信息,那么它可以在处理过程中轻松计算出来(而不会强制提前具体化)。

另一个常见的抱怨是“我首先需要知道序列是否为空”。这也是一个很容易解决的问题。在演示项目中,你会找到扩展方法 `PeekableEnumerable.NullIfEmpty`。它可以这样使用:

mySequence = mySequence.NullIfEmpty();
if (mySequence == null)
{
  Console.WriteLine("empty");
  return;
}

有时,你可能会遇到需要向前查看序列的情况。这对于 `System.IO.StreamReader` 的使用者来说很熟悉,它提供了一个 `Peek` 方法专门用于此目的。这也是一个很容易解决的问题。在演示项目中,你会找到扩展方法 `PeekableEnumerable.AsPeekableEnumerable`。它可以这样使用:

using (var peekable = mySequence.AsPeekableEnumerable())
{
  foreach(var current in peekable)
  {
    Console.Write($"current={current}");
    if (peekable.Peek(out long next))
      Console.Write($", next={next}");
    Console.WriteLine();
  }
}

总而言之,缺乏创造力可能是过早具体化最常见的原因。即便如此,有时具体化是必要且不可避免的。在简单地将 `ToList` 拍在序列上之前,花些额外的时间仔细考虑你的具体情况。

扩展方法

扩展方法最初是在 C# 3.0(与 LINQ 一起)中引入的。从语法上看,这些方法似乎扩展了预先存在的类或接口的行为。

所以,为了进行比较,让我们先来看一个普通的方法:

public static string TrimMyStringNormal(string value) =>
  value.Trim();

这个方法如下调用:

string value = TrimMyStringNormal(" Trim It! ");

扩展方法通过在方法的第一个参数前加上 `this` 关键字来声明。同一个逻辑的扩展方法可能看起来像这样:

public static class SimpleExtension
{
  public static string TrimMyStringExtended(this string value) =>
    value.Trim();
}

当我们调用这个扩展方法时,从语法上看,它似乎扩展了 `string` 类:

string value = " Trim It! ".TrimMyStringExtended();

实际上,在幕后,编译器会将此调用转换为以下调用:

string value = SimpleExtension.TrimMyStringExtended(" Trim It!  ");

答案

在本文的引言中,我们提出了一些简单的问题,下面是答案,所有答案都在文章中(详细)解释了:

  1. 该接口所需的一个且仅有的方法是什么?

    该接口所需的一个且仅有的方法是 `GetEnumerator`。作为后续,如果你从未编写过 `IEnumerator` 实现,你应该这样做。

  2. 如何在不使用 `foreach` 关键字的情况下实现 `foreach` 循环?

    你调用 `GetEnumerator` 来获取枚举器,使用 `MoveNext` / `Current` 遍历序列,然后调用 `Dispose` 来释放与枚举器相关的资源(如果有)。通常,通过 `using` 语句来完成处理。

  3. 过早具体化的陷阱是什么?如何避免它们?

    当你强制 `IEnumerable` 提供其序列中的成员时,就会发生具体化。LINQ 的 `Count` 和 `ToList` 等方法会强制具体化整个序列。这可能会延迟对单个成员的处理,直到所有成员都被求值。它还可能导致不必要的内存消耗。最后,对于无限序列(例如斐波那契数列),它会创建一个操作永远不会完成的场景。

  4. 如何在不依赖 `yield` 关键字的情况下从方法中返回 `IEnumerable`?

    你需要自己去做 `yield` 所做的事情。将代码包装在 `IEnumerator` 实现中,通过 `GetEnumerator` 将 `IEnumerator` 包装在 `IEnumerable` 实例中,然后返回 `IEnumerable` 实例。`yield` 关键字确实很方便。

  5. 一个 `IEnumerable` 中可以存在的最大项目数是多少?

    没有限制。`IEnumerable` 可以字面意义上返回一个无限数量的元素序列。此行为的一个实际示例在本文的示例代码中提供,其中提供了斐波那契数的 `IEnumerable`。

其他阅读

以下是微软参考资料的链接集合,涵盖了本文中的一些概念:

Enumerable 类
https://msdn.microsoft.com/zh-cn/library/system.linq.enumerable.aspx

扩展方法 (C# 编程指南)
https://docs.microsoft.com/en-us/dotnet/csharp/programming-guide/classes-and-structs/extension-methods

C# 中 LINQ 入门
https://docs.microsoft.com/en-us/dotnet/csharp/programming-guide/concepts/linq/getting-started-with-linq

标准查询运算符概述 (C#)
https://docs.microsoft.com/en-us/dotnet/csharp/programming-guide/concepts/linq/standard-query-operators-overview

yield (C# 参考)
https://docs.microsoft.com/en-us/dotnet/csharp/language-reference/keywords/yield

历史

  • 2018 年 3 月 28 日 - 上传了原始版本
  • 2018 年 3 月 28 日 - 原始上传有错误...已修复并重新上传
  • 2018 年 4 月 20 日 - 添加了本系列第二篇文章的链接
  • 2018 年 4 月 21 日 - 添加了本系列第三篇文章的链接
  • 2018年4月25日 - 添加了本系列第四篇文章的链接
LINQ 第一部分:深入探讨 IEnumerable - CodeProject - 代码之家
© . All rights reserved.