C-10-编程指南-五-

110 阅读1小时+

C#10 编程指南(五)

原文:zh.annas-archive.org/md5/f6bf98ae10aa686be15d58fe9358e0e2

译者:飞龙

协议:CC BY-NC-SA 4.0

第十章:LINQ

语言集成查询(LINQ)是一组强大的 C# 语言功能,用于处理信息集合。它在任何需要处理多个数据片段的应用程序中都很有用(即几乎所有应用程序)。尽管其最初的目标之一是提供对关系数据库的简单访问,但 LINQ 适用于许多类型的信息。例如,它还可以与内存中的对象模型、基于 HTTP 的信息服务、JSON 和 XML 文档一起使用。正如我们将在 第十一章 中看到的那样,它还可以与实时数据流一起使用。

LINQ 不是单一的功能。它依赖于多个语言元素共同工作。与 LINQ 相关的最显著的语言特性是查询表达式,这是一种类似数据库查询的表达形式,但可以用来对任何支持的源执行查询,包括普通的旧对象。正如你将看到的那样,查询表达式在很大程度上依赖于一些其他语言特性,例如 lambda 表达式、扩展方法和表达式对象模型。

语言支持只是故事的一半。LINQ 需要类库来实现一组查询原语,称为LINQ 操作符。每种不同类型的数据都需要其自己的实现,而某种类型信息的一组操作符被称为LINQ 提供者。(顺便说一句,这些也可以从 Visual Basic 和 F# 中使用,因为这些语言也支持 LINQ。)Microsoft 提供了几个提供者,其中一些内置于运行库中,一些作为独立的 NuGet 包提供。例如,Entity Framework Core 就提供了一个 LINQ 提供者,这是一个用于与数据库交互的对象/关系映射系统。Cosmos DB 云数据库(Microsoft Azure 的一个特性)也提供了一个 LINQ 提供者。还有在 .NET 反应式扩展 中描述的反应式扩展,提供了对数据实时流的 LINQ 支持。简而言之,LINQ 是.NET 中广泛支持的习语,并且它是可扩展的,因此你也会发现开源和其他第三方提供者。

本章大部分示例使用 LINQ to Objects。这部分是因为它避免了示例中的额外细节,例如数据库或服务连接,但还有一个更重要的原因。LINQ 的引入在 2007 年显著改变了我编写 C# 代码的方式,这完全是由于 LINQ to Objects。尽管 LINQ 的查询语法使它看起来主要是一种数据访问技术,但我发现它远比那更有价值。在任何对象集合上都可以使用 LINQ 的服务使其在代码的每个部分中都非常有用。

查询表达式

LINQ 最显著的特点是查询表达式语法。这并非最重要——我们稍后会看到,完全可以在不编写查询表达式的情况下有效地使用 LINQ。但是,对于许多类型的查询来说,这是一种非常自然的语法。

乍一看,查询表达式大致类似于关系数据库查询,但语法适用于任何 LINQ 提供程序。示例 10-1 展示了一个使用 LINQ to Objects 搜索特定CultureInfo对象的查询表达式。(CultureInfo对象提供了一组特定于文化的信息,如本地货币使用的符号、使用的语言等。有些系统称其为区域设置。)这个特定的查询查看了表示英文中所谓的小数点的字符。许多国家实际上使用逗号而不是句点,而在这些国家中,100,000 意味着将数字 100 写成三位小数;在英语文化中,我们通常将其写作 100.000. 查询表达式搜索系统中所有已知的文化,并返回那些使用逗号作为小数分隔符的文化。

示例 10-1. LINQ 查询表达式
`IEnumerable``<``CultureInfo``>` `commaCultures` `=`
    `from` `culture` `in` `CultureInfo``.``GetCultures``(``CultureTypes``.``AllCultures``)`
    `where` `culture``.``NumberFormat``.``NumberDecimalSeparator` `=``=` `","`
    `select` `culture``;`

foreach (CultureInfo culture in commaCultures)
{
    Console.WriteLine(culture.Name);
}

在这个例子中,foreach循环显示了查询的结果。在我的系统上,这列出了 354 个文化的名称,表明大约 813 个可用文化中有一半使用逗号而不是小数点。当然,我完全可以不使用 LINQ 来轻松实现这一点。示例 10-2 将产生相同的结果。

示例 10-2. 非 LINQ 等效方法
CultureInfo[] allCultures = CultureInfo.GetCultures(CultureTypes.AllCultures);
foreach (CultureInfo culture in allCultures)
{
    if (culture.NumberFormat.NumberDecimalSeparator == ",")
    {
        Console.WriteLine(culture.Name);
    }
}

两个示例都有八行非空代码,尽管如果忽略只包含括号的行,示例 10-2 只包含四行,比示例 10-1 少两行。另一方面,如果我们计算语句,LINQ 示例只有三个,而循环示例有四个。因此很难有说服力地论证哪种方法比另一种更简单。

然而,示例 10-1 有一个显著的优势:决定选择哪些项的代码与决定如何处理这些项的代码有很好的分离。示例 10-2 把这两个问题搞混了:选择对象的代码一半在循环外部,一半在循环内部。

另一个区别是,示例 10-1 具有更声明性的风格:它关注我们想要的内容,而不是如何获得它。查询表达式描述了我们想要的项,而不强制规定以任何特定方式实现。对于这个非常简单的例子,这并不重要,但对于更复杂的例子,特别是在使用 LINQ 提供程序进行数据库访问时,允许提供程序自由决定如何执行查询非常有用。示例 10-2 的方法遍历foreach循环中的所有内容并选择它想要的项,如果我们在与数据库交互,这种做法通常是不好的——通常希望服务器来处理这种过滤工作。

示例 10-1 中的查询有三个部分。所有查询表达式都必须以 from 子句开头,该子句指定查询的源。在本例中,源是由 CultureInfo 类的 GetCultures 方法返回的 CultureInfo[] 类型的数组。除了为查询定义源外,from 子句还包含一个名称,在这里是 culture。这称为范围变量,我们可以在查询的其余部分中使用它来代表源中的单个项目。子句可以多次运行——在 示例 10-1 中的 where 子句会针对集合中的每个项运行一次,因此范围变量每次都会有不同的值。这与 foreach 循环中的迭代变量类似。事实上,from 子句的整体结构相似——我们有一个将代表集合中的项目的变量,然后是 in 关键字,然后是该变量将代表单个项的源。正如 foreach 循环中的迭代变量仅在循环内部有效一样,范围变量 culture 仅在此查询表达式内部有效。

注意

尽管与 foreach 的类比有助于理解 LINQ 查询的意图,但不应该过于字面化。例如,并非所有的提供程序直接执行查询中的表达式。一些 LINQ 提供程序将查询表达式转换为数据库查询,在这种情况下,查询中各种表达式中的 C# 代码并不以传统意义上的方式运行。因此,虽然可以说范围变量表示源中的单个值,但并不总是可以说各个子句会针对其处理的每个项执行一次,并且范围值取该项的值。这在 示例 10-1 中是正确的,因为它使用的是 LINQ to Objects,但并非所有提供程序都是如此。

查询的第二部分在 示例 10-1 中是一个 where 子句。此子句是可选的,或者您也可以在一个查询中有多个。where 子句用于过滤结果,在本示例中说明,我只希望具有指示小数分隔符为逗号的 NumberFormatCultureInfo 对象。

查询的最后部分是 select 子句。所有查询表达式都以 select 子句或 group 子句结尾。这确定查询的最终输出。这个示例表明,我们希望保留未被查询过滤掉的每个 CultureInfo 对象。在 示例 10-1 中展示查询结果的 foreach 循环仅使用 Name 属性,因此我可以编写一个仅提取该属性的查询。如 示例 10-3 所示,如果我这样做,还需要更改循环,因为生成的查询现在会生成字符串而不是 CultureInfo 对象。

示例 10-3. 在查询中提取单个属性
IEnumerable<string> commaCultures =
    from culture in CultureInfo.GetCultures(CultureTypes.AllCultures)
    where culture.NumberFormat.NumberDecimalSeparator == ","
    `select` `culture``.``Name``;`

foreach (string cultureName in commaCultures)
{
    Console.WriteLine(cultureName);
}

这引出了一个问题:一般来说,查询表达式有什么类型?在示例 10-1 中,commaCulturesIEnumerable<CultureInfo>;在示例 10-3 中,它是IEnumerable<string>。输出项的类型由查询的最后一个子句确定——select或者在某些情况下是group子句。然而,并非所有的查询表达式都会得到IEnumerable<T>。这取决于你使用的 LINQ 提供程序——我最终得到了IEnumerable<T>,因为我使用的是 LINQ to Objects。

注意

在声明用于保存 LINQ 查询的变量时,通常使用var关键字是非常常见的。如果select子句生成匿名类型的实例,则这是必需的,因为没有办法编写结果查询类型的名称。即使不涉及匿名类型,var仍然被广泛使用,有两个原因。一个原因是一致性问题:一些人认为,因为你必须对某些 LINQ 查询使用var,所以应该对所有 LINQ 查询都使用它。另一个论点是,LINQ 查询类型通常具有冗长和丑陋的名称,而var可以使代码更清晰。在书籍布局的严格限制环境中,这可能是一个特别迫切的问题,因此在本章的许多示例中,我离开了我通常偏爱显式类型的偏好,而是使用了var来使事物更协调。

C#如何知道我想要使用 LINQ to Objects?这是因为我在from子句中使用了数组作为源。更普遍地说,当你指定任何IEnumerable<T>作为源时,LINQ to Objects 将被使用,除非存在更专门的提供程序。然而,这并不能真正解释 C#如何首先发现提供程序的存在以及如何在它们之间进行选择。要理解这一点,你需要知道编译器如何处理查询表达式。

查询表达式的扩展方式

编译器将所有的查询表达式转换成一个或多个方法调用。一旦这样做了,LINQ 提供程序通过与 C#用于任何其他方法调用相同的机制来选择。编译器没有任何内置的概念来定义什么是 LINQ 提供程序。它仅仅依赖于约定。示例 10-4 展示了编译器如何处理示例 10-3 中的查询表达式。

示例 10-4. 查询表达式的影响
IEnumerable<string> commaCultures =
    CultureInfo.GetCultures(CultureTypes.AllCultures)
    .Where(culture => culture.NumberFormat.NumberDecimalSeparator == ",")
    .Select(culture => culture.Name);

WhereSelect方法是 LINQ 操作符的示例。LINQ 操作符实际上就是符合标准模式的方法。我稍后将在“标准 LINQ 操作符”中描述这些模式。

示例 10-4 中的代码都是一个语句,并且我正在链接方法调用——我在 GetCultures 的返回值上调用 Where 方法,并在 Where 的返回值上调用 Select 方法。格式看起来有点奇怪,但它太长了,无法一行展示;尽管这不是特别优雅,但我更喜欢在多行上拆分链式调用时在每一行的开头放置 .,因为这样更容易看出每一行是从上一行的哪里继续的。把句点留在前一行的末尾看起来更整洁,但也更容易误读代码。

编译器已将 whereselect 子句的表达式转换为 lambda 表达式。注意,范围变量最终成为每个 lambda 的参数。这是一个例子,说明为什么不应过于字面地将查询表达式与 foreach 循环类比。与 foreach 迭代变量不同,范围变量并不存在作为单个常规变量。在查询中,它只是一个表示源中项的标识符,在扩展查询为方法调用时,C# 可能会为单个范围变量创建多个实际变量,就像在这里为两个分离的 lambda 的参数创建的那样。

所有查询表达式归结为这种形式——带有 lambda 的链式方法调用。(这就是为什么我们不严格需要查询表达式语法——你可以使用方法调用来写任何查询。)有些比其他的更复杂。尽管看起来几乎相同,示例 10-1 中的表达式最终具有更简单的结构。示例 10-5 展示了它是如何展开的。原来,当查询的 select 子句直接传递范围变量时,编译器解释为我们希望直接传递前一个子句的结果,而无需进一步处理,因此不会添加 Select 调用。(这有一个例外:如果编写一个查询表达式,其中只包含 fromselect 子句,它将生成一个 Select 调用,即使 select 子句是微不足道的。)

示例 10-5. select 子句如何展开
IEnumerable<CultureInfo> commaCultures =
    CultureInfo.GetCultures(CultureTypes.AllCultures)
    .Where(culture => culture.NumberFormat.NumberDecimalSeparator == ",");

如果在查询的范围内引入多个变量,编译器将需要更多工作。可以使用 let 子句来实现这一点。示例 10-6 执行与 示例 10-3 相同的作业,但我引入了一个名为 numFormat 的新变量来引用数字格式。这使得我的 where 子句更短更易读,在需要多次引用该格式对象的更复杂查询中,这种技术可以减少很多混乱。

示例 10-6. 带有 let 子句的查询
IEnumerable<string> commaCultures =
    from culture in CultureInfo.GetCultures(CultureTypes.AllCultures)
    `let` `numFormat` `=` `culture``.``NumberFormat`
    where numFormat.NumberDecimalSeparator == ","
    select culture.Name;

当你编写引入额外变量的查询时,编译器会自动生成一个隐藏类,为每个变量创建一个字段,以便在每个阶段都能使它们可用。为了在普通的方法调用中达到同样的效果,我们需要做类似的事情,引入一个匿名类型来包含它们,就像 示例 10-7 中展示的那样。

示例 10-7. 多变量查询表达式如何扩展(近似)
IEnumerable<string> commaCultures =
    CultureInfo.GetCultures(CultureTypes.AllCultures)
    .Select(culture => new { culture, numFormat = culture.NumberFormat })
    .Where(vars => vars.numFormat.NumberDecimalSeparator == ",")
    .Select(vars => vars.culture.Name);

无论查询表达式多么简单或复杂,它们都不过是方法调用的一种特殊语法。这表明了我们编写自定义查询表达式源的方法。

支持查询表达式

因为 C# 编译器只是将查询表达式的各个子句转换为方法调用,所以我们可以编写一个类型来参与这些表达式,定义一些合适的方法。为了说明 C# 编译器实际上并不关心这些方法做了什么,示例 10-8 展示了一个完全没有意义的类,但在从查询表达式中使用时,仍然能让 C# 保持愉快。编译器只是机械地将查询表达式转换为一系列方法调用,因此如果存在合适的方法,代码将成功编译。

示例 10-8. 毫无意义的 WhereSelect
public class SillyLinqProvider
{
    public SillyLinqProvider Where(Func<string, int> pred)
    {
        Console.WriteLine("Where invoked");
        return this;
    }

    public string Select<T>(Func<DateTime, T> map)
    {
        Console.WriteLine($"Select invoked, with type argument {typeof(T)}");
        return "This operator makes no sense";
    }
}

我可以使用这个类的实例作为查询表达式的源。这太疯狂了,因为这个类根本不代表数据的集合,但编译器不关心。它只需要某些方法存在,所以如果我在 示例 10-9 中编写代码,尽管代码毫无意义,编译器仍会完全满意。

示例 10-9. 一个无意义的查询
var q = from x in new SillyLinqProvider()
        where int.Parse(x)
        select x.Hour;

编译器将这些内容转换为方法调用的方式与示例 10-1 中更合理的查询完全相同。示例 10-10 展示了结果。如果你注意到了,你会发现我的范围变量在中间实际上改变了类型——我的 Where 方法需要一个接受字符串的委托,所以在第一个 Lambda 中,x 的类型是 string。但是我的 Select 方法要求它的委托接受一个 DateTime,所以在那个 Lambda 中,x 的类型就是 DateTime。 (而这些都不重要,因为我的 WhereSelect 方法甚至根本不使用这些 Lambda。)再次强调,这是无意义的,但它展示了 C# 编译器如何机械地将查询转换为方法调用。

示例 10-10. 编译器如何转换这个无意义的查询
var q = new SillyLinqProvider().Where(x => int.Parse(x)).Select(x => x.Hour);

显然,编写毫无意义的代码是没有用的。我展示这个的原因是为了演示查询表达式语法不了解语义——编译器对其调用的任何方法都没有特定的期望。它所要求的只是它们接受 lambda 作为参数并返回非void

明显,真正的工作是在别处进行的。正是 LINQ 提供程序自己使事情发生。现在我将概述,如果没有 LINQ to Objects,我们需要编写什么来使我在前面的几个示例中显示的查询工作。

你已经看到了 LINQ 查询如何转换为像示例 10-4 中显示的代码,但这并不是全部故事。where 子句变成了对 Where 方法的调用,但我们是在 CultureInfo[] 类型的数组上调用它,这种类型实际上没有 Where 方法。这只能工作,因为 LINQ to Objects 定义了一个适当的扩展方法。就像我在第 3 章中展示的那样,可以向现有类型添加新方法,LINQ to Objects 就是为 IEnumerable<T> 定义这些方法的。(由于大多数集合实现了 IEnumerable<T>,这意味着几乎可以在任何类型的集合上使用 LINQ to Objects。)要使用这些扩展方法,您需要为 System.Linq 命名空间添加一个 using 指令;在 .NET 6.0 中,新创建的项目启用了隐式全局 using功能(在“命名空间”中描述),它会自动生成适合的全局 using 指令,因此,除非您禁用了该功能,或者您的项目是在 .NET 6.0 之前创建的并且之后未启用该设置,否则您不需要自己编写指令。(顺便说一下,所有这些扩展方法都由该命名空间中名为 Enumerable 的静态类定义。)如果您尝试在没有该指令的情况下使用 LINQ,编译器会为 示例 10-1 或 示例 10-3 的查询表达式生成以下错误:

error CS1935: Could not find an implementation of the query pattern for source
type 'CultureInfo[]'.  'Where' not found.  Are you missing required assembly
references or a using directive for 'System.Linq'?

一般来说,该错误消息的建议可能很有帮助,但在这种情况下,我想编写自己的 LINQ 实现。示例 10-11 就是这样做的,我展示了整个源文件,因为扩展方法对命名空间和using指令的使用很敏感。(如果你下载这些示例,你会发现我没有为这个特定项目启用隐式全局using,这样就完全清楚发生了什么。)Main方法的内容应该看起来很熟悉——这类似于示例 10-3,但这一次,它将使用我的CustomLinqProvider类的扩展方法,而不是使用 LINQ 到对象提供程序。(通常情况下,你可以通过using指令使扩展方法可用,但由于CustomLinqProviderProgram类在同一个命名空间中,它的所有扩展方法都会自动对Main可用。)

警告

虽然示例 10-11 表现如预期,但你不应将其视为 LINQ 提供程序通常执行其查询的示例。这确实展示了 LINQ 提供程序如何置身事外,但正如我稍后将展示的那样,这段代码在执行查询时存在一些问题。而且,它相当简约——LINQ 不仅仅是WhereSelect,大多数真实的提供程序提供的不仅仅是这两个操作符。

示例 10-11. 一个用于CultureInfo[]的自定义 LINQ 提供程序
using System;
using System.Globalization;

namespace CustomLinqExample;

public static class CustomLinqProvider
{
    public static CultureInfo[] Where(this CultureInfo[] cultures,
                                        Predicate<CultureInfo> filter)
    {
        return Array.FindAll(cultures, filter);
    }

    public static T[] Select<T>(this CultureInfo[] cultures,
                                Func<CultureInfo, T> map)
    {
        var result = new T[cultures.Length];
        for (int i = 0; i < cultures.Length; ++i)
        {
            result[i] = map(cultures[i]);
        }
        return result;
    }
}

class Program
{
    static void Main(string[] args)
    {
        var commaCultures =
            from culture in CultureInfo.GetCultures(CultureTypes.AllCultures)
            where culture.NumberFormat.NumberDecimalSeparator == ","
            select culture.Name;

        foreach (string cultureName in commaCultures)
        {
            Console.WriteLine(cultureName);
        }
    }
}

正如你现在很清楚的那样,在Main中的查询表达式将首先在源上调用Where,然后在Where的返回值上调用Select。与之前一样,源是GetCultures的返回值,它是一个CultureInfo[]类型的数组。这是CustomLinqProvider定义的扩展方法的类型,因此这将调用CustomLinqProvider.Where。它使用Array类的FindAll方法来查找源数组中与谓词匹配的所有元素。Where方法将自己的参数直接传递给FindAll作为谓词,正如你所知道的,当 C#编译器调用Where时,它会传递一个基于 LINQ 查询中where子句表达式的 lambda 表达式。该谓词将匹配使用逗号作为其小数分隔符的区域设置,因此Where子句返回一个仅包含这些区域设置的CultureInfo[]类型的数组。

接下来,编译器为查询创建的代码将在Where返回的CultureInfo[]数组上调用Select。数组没有Select方法,因此将使用CustomLinqProvider中的扩展方法。我的Select方法是泛型的,因此编译器将需要推断类型参数,它可以从select子句中的表达式中推断出来。

首先,编译器将其转换为一个 lambda 表达式:culture => culture.Name。因为这成为 Select 的第二个参数,编译器知道我们需要一个 Func<CultureInfo, T>,因此它知道 culture 参数必须是 CultureInfo 类型。这使它能够推断 T 必须是 string,因为 lambda 返回 culture.Name,而 Name 属性的类型是 string。所以编译器知道它正在调用 CustomLinqProvider.Select<string>。(顺便说一句,我刚刚描述的推断并不特定于查询表达式。类型推断发生在查询被转换为方法调用之后。如果我们从 Example 10-4 中的代码开始,编译器将经历完全相同的过程。)

现在,Select 方法将生成一个 string[] 类型的数组(因为这里的 Tstring)。它通过迭代传入的 CultureInfo[] 中的元素,将每个 CultureInfo 作为参数传递给提取 Name 属性的 lambda 表达式来填充该数组。因此,我们最终得到一个包含每个使用逗号作为其十进制分隔符的文化的名称的字符串数组。

这个例子比我的 SillyLinqProvider 稍微现实一些,因为它现在提供了预期的行为。然而,虽然查询产生了与使用真正的 LINQ to Objects 提供程序时相同的字符串,但它所采用的机制有些不同。我的 CustomLinqProvider 立即执行了每个操作——WhereSelect 方法都返回完全填充的数组。LINQ to Objects 做了完全不同的事情。实际上,大多数 LINQ 提供程序也是如此。

延迟评估

如果 LINQ to Objects 的工作方式与我在 Example 10-11 中的自定义提供程序相同,它将无法很好地处理 Example 10-12。这有一个 Fibonacci 方法,返回一个永无止境的序列——只要代码继续请求,它将继续提供斐波那契数列中的数字。我已经使用这个方法返回的 IEnumerable<BigInteger> 作为查询表达式的源。由于我们在开头附近放置了 System.Linqusing 指令,我现在回到使用 LINQ to Objects。 (在可下载的示例中,我已禁用了该项目的隐式全局 using 指令,以清楚地了解使用了哪些命名空间。)

Example 10-12. 具有无限源序列的查询
using System;
using System.Collections.Generic;
using System.Linq;
using System.Numerics;

static IEnumerable<BigInteger> Fibonacci()
{
    BigInteger n1 = 1;
    BigInteger n2 = 1;
    yield return n1;
    while (true)
    {
        yield return n2;
        BigInteger t = n1 + n2;
        n1 = n2;
        n2 = t;
    }
}

var evenFib = from n in Fibonacci()
              where n % 2 == 0
              select n;

foreach (BigInteger n in evenFib)
{
    Console.WriteLine(n);
}

这里将使用 LINQ to Objects 为 IEnumerable<T> 提供的 Where 扩展方法。如果它的工作方式与我在 示例 10-11 中为 CultureInfo[] 编写的 CustomLinqExtension 类的 Where 方法相同,那么这个程序将永远无法显示单个数字。我的 Where 方法在未过滤其整个输入并生成完全填充的数组作为输出之前不会返回。如果 LINQ to Objects 的 Where 方法尝试对我的无限斐波那契枚举器进行相同的操作,它将永远无法完成。

实际上,示例 10-12 运行完美——它产生了一系列由 2 整除的斐波那契数输出。这意味着在我们调用 Where 时它不会尝试执行所有的过滤工作。相反,它的 Where 方法返回一个 IEnumerable<T>,按需过滤项目。它不会尝试从输入序列获取任何内容,直到某些东西要求一个值,此时它将从源中一个接一个地检索值,直到过滤委托说找到匹配项。然后它会生成该匹配项,并在被要求下一个项目之前不会再从源中检索任何内容。示例 10-13 展示了如何利用 C# 的 yield return 特性实现这种行为。

示例 10-13. 自定义延迟 Where 操作符
public static class CustomDeferredLinqProvider
{
    public static IEnumerable<T> Where<T>(this IEnumerable<T> src,
                                          Func<T, bool> filter)
    {
        foreach (T item in src)
        {
            if (filter(item))
            {
                yield return item;
            }
        }
    }
}

真正的 LINQ to Objects 的 Where 实现要复杂一些。它检测到某些特殊情况,比如数组和列表,并以略微比通用实现更高效的方式处理它们。然而,对于 Where 和所有其他操作符来说,原则都是一样的:这些方法不执行指定的工作。相反,它们返回将按需执行工作的对象。只有在尝试检索查询结果时才会真正发生任何事情。这被称为延迟评估,有时也称为惰性评估

延迟评估的好处是在需要时才执行工作,并且可以处理无限序列。然而,它也有缺点。您可能需要小心避免多次评估查询。示例 10-14 犯了这个错误,导致执行比必要做的工作多得多。这段代码循环遍历几个不同的数字,并使用每个使用逗号作为小数分隔符的文化的货币格式写出每一个数字。

注意

如果您在 Windows 上运行此代码,则可能会发现大多数行显示的是包含?字符的行,表示控制台无法显示大多数货币符号。实际上,它可以——只是需要权限。默认情况下,Windows 控制台出于向后兼容性的原因使用 8 位代码页。如果您从命令提示符运行命令chcp 65001,它将将该控制台窗口切换到 UTF-8 代码页,从而使其能够显示您选择的控制台字体支持的任何 Unicode 字符。您可能希望配置控制台使用具有对不常见字符全面支持的字体——例如 Consolas 或 Lucida Console,以充分利用它。

示例 10-14. 延迟查询的意外重新评估
var commaCultures =
    from culture in CultureInfo.GetCultures(CultureTypes.AllCultures)
    where culture.NumberFormat.NumberDecimalSeparator == ","
    select culture;

object[] numbers = { 1, 100, 100.2, 10000.2 };

foreach (object number in numbers)
{
    foreach (CultureInfo culture in commaCultures)
    {
        Console.WriteLine(string.Format(culture, "{0}: {1:c}",
                          culture.Name, number));
    }
}

此代码的问题在于,即使commaCultures变量在数字循环外部初始化,我们也会为每个数字迭代它。由于 LINQ to Objects 使用延迟评估,这意味着每次外部循环周围都会重新执行查询的实际工作。因此,而不是为每种文化(在我的系统上为 813 次)评估一次where子句,它最终为每种文化执行四次(3,252 次),因为整个查询针对numbers数组的四个项之一每次都会评估。这并不是灾难性的——代码仍然能正常工作。但是,如果您在运行在负载较重的服务器上的程序中执行此操作,它将损害您的吞吐量。

如果您知道您将需要多次迭代查询的结果,请考虑使用 LINQ to Objects 提供的ToListToArray扩展方法之一。这些方法立即评估整个查询一次,分别生成IList<T>T[]数组(因此显然不应在无限序列上使用这些方法)。然后,您可以随意多次迭代它,而不会产生进一步的成本(除了读取数组或列表元素所固有的最小成本)。但在只迭代一次查询的情况下,通常最好不要使用这些方法,因为它们将比必要的消耗更多内存。

LINQ、泛型和 IQueryable

大多数 LINQ 提供程序使用泛型类型。虽然没有强制要求这样做,但这种做法非常普遍。LINQ to Objects 使用IEnumerable<T>。几个数据库提供程序使用称为IQueryable<T>的类型。更广泛地说,模式是具有某种泛型类型*Source*<T>,其中*Source*表示某些项目的来源,而T是单个项目的类型。具有 LINQ 支持的源类型使得操作方法在*Source*<T>上对任何T都可用,并且这些操作符通常还返回*Source*<TResult>,其中TResult可能与T不同。

IQueryable<T>很有趣,因为它设计用于多个提供程序使用。在示例 10-15 中显示了这个接口、它的基本IQueryable和相关的IQueryProvider

示例 10-15. IQueryableIQueryable<T>
public interface IQueryable : IEnumerable
{
    Type ElementType { get; }
    Expression Expression { get; }
    IQueryProvider Provider { get; }
}

public interface IQueryable<out T> : IEnumerable<T>, IQueryable
{
}

public interface IQueryProvider
{
    IQueryable CreateQuery(Expression expression);
    IQueryable<TElement> CreateQuery<TElement>(Expression expression);
    object? Execute(Expression expression);
    TResult Execute<TResult>(Expression expression);
}

IQueryable<T> 最明显的特点是,它不向其基类添加任何成员。这是因为它完全通过扩展方法来使用。Sys⁠tem.​Li⁠nq 命名空间定义了所有标准 LINQ 操作符,这些操作符是由 Queryable 类提供的扩展方法,适用于 IQueryable<T>。然而,所有这些操作符都简单地推迟到由 IQueryable 基类定义的 Provider 属性。因此,与 LINQ to Objects 不同,在那里,IEnumerable<T> 上的扩展方法定义了行为,IQueryable<T> 的实现能够决定如何处理查询,因为它可以提供执行实际工作的 IQueryProvider

然而,所有基于 IQueryable<T> 的 LINQ 提供程序有一个共同点:它们将 lambda 解释为表达式对象,而不是委托。Example 10-16 展示了为 IEnumerable<T>IQueryable<T> 定义的 Where 扩展方法的声明。比较 predicate 参数。

示例 10-16. Enumerable versus Queryable
public static class Enumerable
{
    public static IEnumerable<TSource> Where<TSource>(
        this IEnumerable<TSource> source,
        `Func``<``TSource``,` `bool``>` `predicate``)`
    ...
}

public static class Queryable
{
    public static IQueryable<TSource> Where<TSource>(
        this IQueryable<TSource> source,
        `Expression``<``Func``<``TSource``,` `bool``>``>` `predicate``)`
    ...
}

IEnumerable<T>Where 扩展方法(LINQ to Objects)接受 Func<TSource, bool>,如您在 Chapter 9 中所见,这是一种委托类型。但是 IQueryable<T>Where 扩展方法(许多 LINQ 提供程序使用)接受 Exp⁠res⁠sion​<Fu⁠nc<T⁠Sou⁠rce,⁠ bool>>,正如您在 Chapter 9 中也看到的,这会导致编译器构建表达式的对象模型并将其作为参数传递。

如果 LINQ 提供程序需要这些表达式树,通常会使用 IQueryable<T>。这通常是因为它将检查您的查询并将其转换为其他形式,例如 SQL 查询。

在 LINQ 中还有一些其他常见的泛型类型。一些 LINQ 特性保证按特定顺序生成项目,而另一些则不保证。更微妙的是,一些运算符生成的项目顺序取决于其输入的顺序。这可以反映在定义运算符的类型和它们返回的类型中。LINQ to Objects 定义了 IOrderedEnumerable<T> 来表示有序数据,而对于基于 IQueryable<T> 的提供程序,则有相应的 IOrderedQueryable<T> 类型。(使用自己类型的提供程序往往会做类似的事情——例如,Parallel LINQ 在 Chapter 16 中定义了 Ord⁠ered​Par⁠all⁠elQ⁠uery<T>。)这些接口从它们的无序对应接口派生,如 IEnumerable<T>IQueryable<T>,因此所有常见的运算符都是可用的,但它们使得定义需要考虑其输入顺序的运算符或其他方法成为可能。例如,在 “Ordering” 中,我将展示一个称为 ThenBy 的 LINQ 运算符,该运算符仅在已经有序的源上可用。

在查看 LINQ to Objects 时,有序/无序的区分可能看起来是不必要的,因为 IEnumerable<T> 总是以某种顺序生成项目。但某些提供程序并不一定以任何特定的顺序进行操作,也许是因为它们并行执行查询,或者因为它们让数据库为它们执行查询,并且在某些情况下,数据库保留在启用更有效地工作时干预顺序的权利。

标准 LINQ 操作符

在本节中,我将描述 LINQ 提供程序可以提供的标准操作符。在适用的情况下,我还将描述查询表达式的等效形式,尽管许多操作符没有相应的查询表达式形式。一些 LINQ 功能只能通过显式方法调用来使用。即使在某些可以在查询表达式中使用的操作符中也是如此,因为大多数操作符都是重载的,而查询表达式无法使用一些更高级的重载形式。

注意

LINQ 操作符不是通常 C# 中的符号运算符,它们不是如 +&& 的符号。LINQ 有自己的术语,对于本章而言,操作符是 LINQ 提供程序提供的查询功能。在 C# 中,它看起来像是一个方法。

所有这些操作符都有一个共同点:它们都设计用于支持组合。这意味着您几乎可以以任何方式组合它们,从而能够从简单元素构建复杂查询。为了实现这一点,操作符不仅接受某种类型的项目集合(例如 IEnumerable<T>)作为它们的输入,而且大多数操作符还返回某种代表项目集合的结果。如前所述,项目类型并不总是相同的——一个操作符可能会以某种 IEnumerable<T> 作为输入,并生成 IEnumerable<TResult> 作为输出,其中 TResult 不必与 T 相同。尽管如此,您仍然可以以任意数量的方式将它们链接在一起。这样做的部分原因是 LINQ 操作符类似于数学函数,它们不会修改它们的输入;相反,它们会基于它们的操作数产生一个新的结果。 (函数式编程语言通常具有相同的特征。)这意味着不仅您可以自由地以任意组合方式连接操作符,而且您还可以自由地将同一源用作多个查询的输入,因为没有任何 LINQ 查询会修改其输入。每个操作符都返回基于其输入的新查询。

没有任何东西强制执行这种函数式风格。就像您在我的 SillyLinqProvider 中看到的那样,编译器并不关心表示 LINQ 操作符的方法做了什么。但是,约定是操作符应该是函数式的,以支持组合。内置的 LINQ 提供程序都是这样工作的。

并非所有提供商都全面支持所有操作符。微软提供的主要支持包括 LINQ to Objects 或 Entity Framework Core 和 Rx 中的 LINQ 支持,尽可能全面,但某些情况下,某些操作符可能无意义。

为了演示操作符的作用,我需要一些源数据。以下部分的许多示例将使用 示例 10-17 中的代码。

示例 10-17. LINQ 查询的示例输入数据
public record Course(
    string Title,
    string Category,
    int Number,
    DateOnly PublicationDate,
    TimeSpan Duration)
{
    public static readonly Course[] Catalog =
    {
            new Course(
                Title: "Elements of Geometry",
                Category: "MAT", Number: 101, Duration: TimeSpan.FromHours(3),
                PublicationDate: new DateOnly(2009, 5, 20)),
            new Course(
                Title: "Squaring the Circle",
                Category: "MAT", Number: 102, Duration: TimeSpan.FromHours(7),
                PublicationDate: new DateOnly(2009, 4, 1)),
            new Course(
                Title: "Recreational Organ Transplantation",
                Category: "BIO", Number: 305, Duration: TimeSpan.FromHours(4),
                PublicationDate: new DateOnly(2002, 7, 19)),
            new Course(
                Title: "Hyperbolic Geometry",
                Category: "MAT", Number: 207, Duration: TimeSpan.FromHours(5),
                PublicationDate: new DateOnly(2007, 10, 5)),
            new Course(
                Title: "Oversimplified Data Structures for Demos",
                Category: "CSE", Number: 104, Duration: TimeSpan.FromHours(2),
                PublicationDate: new DateOnly(2021, 11, 8)),
            new Course(
                Title: "Introduction to Human Anatomy and Physiology",
                Category: "BIO", Number: 201, Duration: TimeSpan.FromHours(12),
                PublicationDate: new DateOnly(2001, 4, 11)),
        };
}

过滤器

最简单的操作符之一是 Where,它用于过滤其输入。你提供一个谓词,即一个接受单个项目并返回 bool 的函数。Where 返回一个表示输入中谓词为 true 的项目的对象。(从概念上讲,这与 List<T> 和数组类型上可用的 FindAll 方法非常相似,但使用延迟执行。)

正如你已经看到的,查询表达式使用 where 子句表示这一点。然而,Where 操作符有一种重载,提供了一个从查询表达式中无法访问的额外功能。你可以编写一个过滤器 lambda,它接受两个参数:输入中的一个项目和表示该项目在源中位置的索引。示例 10-18 使用此形式从输入中移除每隔一个数字,并且还移除长度小于三小时的课程。

示例 10-18. 带索引的 Where 操作符
IEnumerable<Course> q = Course.Catalog.Where(
    (course, index) => (index % 2 == 0) && course.Duration.TotalHours >= 3);

带索引的过滤只对有序数据有意义。它在 LINQ to Objects 中总是有效,因为它使用的是 IEnumerable<T>,会一个接一个地生成项目,但并非所有 LINQ 提供程序都按顺序处理项目。例如,使用 Entity Framework Core (EF Core),你在 C# 中编写的 LINQ 查询将在数据库上处理。除非查询明确要求特定顺序,否则数据库通常可以自由地按照它认为合适的顺序处理项目,甚至可能并行处理。在某些情况下,数据库可能有优化策略,使其能够使用与原始查询极为不同的过程生成查询所需的结果。因此,说“由 WHERE 子句处理的第 14 个项目”可能并无意义。因此,如果你像 示例 10-18 类似的查询在 EF Core 中执行,将会引发异常,指出索引的 Where 操作符不适用。如果你想知道为什么提供程序不支持却还存在重载,因为 EF Core 使用 IQueryable<T>,所以所有标准操作符在编译时都是可用的;选择使用 IQueryable<T> 的提供程序只能在运行时报告操作符的不可用性。

注意

实现部分或全部查询逻辑在服务器端的 LINQ 提供程序通常限制您可以在查询的 Lambda 中执行的操作。相反,LINQ 到对象在进程中运行查询,因此允许您在过滤 Lambda 中调用任何方法—如果您想在谓词中调用Console.WriteLine或从文件中读取数据,LINQ 到对象无法阻止您。但是数据库提供程序仅提供非常有限的方法选择。这些提供程序需要能够将您的 Lambda 表达式转换为服务器可以处理的内容,并且会拒绝尝试调用没有服务器端等效方法的表达式。

尽管如此,您可能期望异常在调用Where时出现,而不是在尝试执行查询时(即当您首次尝试检索一个或多个项目时)。然而,将 LINQ 查询转换为其他形式(例如 SQL 查询)的提供程序通常会推迟所有验证直到您执行查询。这是因为某些操作符可能仅在特定情况下有效,这意味着提供程序可能不知道任何特定操作符是否有效,直到您完成整个查询的构建。如果由于不可行查询而导致的错误有时在构建查询时出现,有时在执行时出现,这将是不一致的,因此即使在提供程序可以较早确定特定操作符将失败的情况下,通常也会等到执行查询时才告知您。

您提供给Where操作符的过滤 Lambda 必须接受项目类型的参数(例如IEnumerable<T>中的T),并且必须返回bool类型。您可能还记得第九章中运行时库定义了一个称为Predicate<T>的适当委托类型,但我在该章节中还提到 LINQ 避免使用它,现在我们可以看到原因了。Where操作符的索引版本不能使用Predicate<T>,因为有额外的参数,因此该重载使用Func<T, int, bool>。没有什么阻止非索引形式的Where使用Predicate<T>,但 LINQ 提供程序倾向于全面使用Func以确保具有类似意义的操作符具有类似的签名。因此,大多数提供程序使用Func<T, bool>,以与索引版本保持一致。(C#不在乎您使用哪个—如果提供程序使用Predicate<T>,查询表达式仍然有效,如我在示例 10-11 中展示的自定义Where操作符,但微软的提供程序没有这样做。)

警告

C# 编译器的空值分析并不理解 LINQ 运算符。如果你有一个 IEnumerable<string?>,你可以写 xs.Where(s => s is not null) 来移除任何空值,但是 Where 仍然会返回一个 IEnumerable<string?>。编译器对 Where 的行为没有预期,因此它不理解输出实际上是一个 IEnumerable<string>。可以说让编译器做这样的推断可能是一个错误:就像 示例 10-8 中展示的那样,可以提供一个违反预期的 Where

LINQ 定义了另一个过滤运算符:OfType<T>。如果您的源包含不同类型的项目混合——可能源是 IEnumerable<object>,您想将其过滤为仅为 string 类型的项目。 示例 10-19 展示了 OfType<T> 运算符如何实现这一点。

示例 10-19. OfType<T> 运算符
static void ShowAllStrings(IEnumerable<object> src)
{
    foreach (string s in src.OfType<string>())
    {
        Console.WriteLine(s);
    }
}

当你使用 OfType<T> 运算符与引用类型一起使用时,它将过滤掉任何 null 值。如果你启用了可空引用类型,OfType 避免了 Where(s => s is not null) 遇到的问题:如果你在 IEnumerable<string?> 上调用 OfType<string>,结果类型将是 IEnumerable<string>。但这并不是因为 OfType 被设计时考虑了可空引用类型。相反,它在使用引用类型作为类型参数时有效地忽略了空值。它之所以在这种情况下做我们想要的事情,是因为它总是寻找积极的匹配(它实际上执行与 o is string 类似的测试)。令人惊讶的推论是 OfType<string?> 也会过滤掉 null 项,稍微奇怪的是,它返回一个 IEnumerable<string?>,但永远不会产生 null

如果源中没有任何对象符合要求,WhereOfType<T> 都会产生空序列。这不被视为错误——在 LINQ 中,空序列非常正常。许多运算符可以产生它们作为输出,大多数运算符可以处理它们作为输入。

Select

在编写查询时,我们可能只想从源项目中提取特定的数据片段。大多数查询末尾的 select 子句允许我们提供一个 lambda 表达式,用于生成最终的输出项,我们可能希望使我们的 select 子句不仅仅是直接传递每个项。我们可能只想从每个项中挑选一个特定的信息片段,或者我们可能希望将其转换为完全不同的东西。

您已经看到了几个 select 子句,并且我在 Example 10-3 中展示了编译器如何将它们转换为对 Select 的调用。然而,与许多 LINQ 操作符一样,通过查询表达式访问的版本并不是唯一的选择。还有另一种重载形式,不仅提供用于生成输出项的输入项,还提供该项的索引。Example 10-20 使用这一点生成了一个课程标题的编号列表。

示例 10-20. 带有索引的 Select 操作符
IEnumerable<string> nonIntro = Course.Catalog.Select((course, index) =>
      $"Course {index}: {course.Title}");

请注意,传入 lambda 表达式的从零开始的索引将基于进入 Select 操作符的内容,并且不一定代表底层数据源中项的原始位置。这可能不会产生您在诸如 Example 10-21 中编写的代码中所期望的结果。

示例 10-21. Where 操作符下游的索引化 Select
IEnumerable<string> nonIntro = Course.Catalog
    .Where(c => c.Number >= 200)
    .Select((course, index) => $"Course {index}: {course.Title}");

此代码将选择在Course.Catalog数组中分别位于索引 2、3 和 5 的课程,因为这些课程的Number属性满足Where表达式。然而,此查询将会将这三门课程编号为 0、1 和 2,因为Select操作符只能看到Where子句允许通过的项目。就它而言,只有三个项目,因为Select子句从未访问过原始来源。如果您希望相对于原始集合提取索引,您需要在Where子句上游进行提取,就像 Example 10-22 中展示的那样。

示例 10-22. Where 操作符上游的索引化 Select
IEnumerable<string> nonIntro = Course.Catalog
    `.``Select``(``(``course``,` `index``)` `=``>` `new` `{` `course``,` `index` `}``)`
    .Where(vars => vars.course.Number >= 200)
    .Select(vars => $"Course {vars.index}: {vars.course.Title}");

你可能会想为什么我在这里使用了匿名类型而不是元组。我可以用(course, index)替换new { course, index },代码同样可以工作。 (甚至可能更有效,因为元组是值类型,而匿名类型是引用类型。元组在这里会给 GC 带来更少的工作)。然而,一般来说,在 LINQ 中元组并不总是有效的。轻量级元组语法是在 C# 7.0 引入的,因此在 C# 3.0 引入表达式树时它们并不存在。表达式对象模型尚未更新以支持此语言特性,因此如果您尝试在基于IQueryable<T>的 LINQ 提供程序中使用元组,您将收到编译器错误 CS8143,提示An expression tree may not contain a tuple literal。¹ 因此,在这一章中我倾向于使用匿名类型,因为它们与基于查询的提供程序兼容。但是,如果您使用的是纯本地 LINQ 提供程序(例如 Rx 或 LINQ 到对象),请随意使用元组。

索引化的 Select 操作符与索引化的 Where 操作符类似。因此,正如您可能期望的那样,并非所有的 LINQ 提供程序都在所有情况下都支持它。

数据塑形和匿名类型

如果你正在使用 LINQ 提供程序来访问数据库,Select 运算符可以提供一个减少获取数据量的机会,这可能会减少服务器的负载。当你使用像 EF Core 这样的数据访问技术来执行返回表示持久化实体集合的查询时,存在着一种在一开始做过多工作和需要执行大量额外延迟工作之间的权衡。这些框架是否应完全填充与各个数据库表中列对应的对象属性?它们是否还应加载相关对象?通常来说,不获取你不会使用的数据更有效率,而未在一开始获取的数据随时可以后续按需加载。然而,如果你在初始请求中过于节约,最终可能会导致需要大量额外请求来填补空白,这可能会抵消避免不必要工作带来的任何好处。

当涉及到相关实体时,EF Core 允许你配置哪些相关实体应预取,哪些应按需加载,但对于获取的任何特定实体,通常会完全填充与列相关的所有属性。这意味着请求整个实体的查询最终会获取它们所触及的任何行的所有列。

如果你只需要使用一两列,获取它们所有都是相对昂贵的。示例 10-23 使用了这种效率较低的方法。它展示了一个相当典型的 EF Core 查询。

示例 10-23. 获取比所需更多的数据
var pq = from product in dbCtx.Product
         where product.ListPrice > 3000
         select product;
foreach (var prod in pq)
{
    Console.WriteLine($"{prod.Name} ({prod.Size}): {prod.ListPrice}");
}

这个 LINQ 提供程序将 where 子句翻译为一个高效的 SQL 等价物。然而,SQL SELECT 子句从表中检索所有列。与 示例 10-24 对比一下。这只修改了查询的一部分:LINQ select 子句现在返回一个匿名类型的实例,该实例仅包含我们需要的那些属性。(随后的循环仍然可以保持不变。它使用 var 作为其迭代变量,这对于匿名类型来说可以正常工作,因为匿名类型提供了循环所需的三个属性。)

示例 10-24. 匿名类型的 select 子句
var pq = from product in dbCtx.Product
         where product.ListPrice > 3000
         `select` `new` `{` `product``.``Name``,` `product``.``ListPrice``,` `product``.``Size` `}``;`

代码产生了完全相同的结果,但生成了一个更加紧凑的 SQL 查询,仅请求NameListPriceSize 列。如果你正在使用具有许多列的表,这将产生一个显著较小的响应,因为它不再被我们不需要的数据所主导。这减少了与数据库服务器的网络连接负载,并且由于数据到达时间更短,还会导致更快的处理。这种技术称为数据整形

这种方法并不总是一种改进。首先,这意味着你直接在数据库中使用数据,而不是使用实体对象。这可能意味着你在抽象级别上工作的比使用实体类型时更低,这可能会增加开发成本。另外,在某些环境中,数据库管理员不允许使用即席查询,强制你使用存储过程,在这种情况下,你将无法使用这种技术来获得灵活性。

将查询结果投影到匿名类型并不限于数据库查询。你可以在任何 LINQ 提供程序(如 LINQ to Objects)中自由使用这个功能。有时这是一种在不需要专门定义类的情况下从查询中获取结构化信息的有用方式。(正如我在 第三章 中提到的,匿名类型可以在 LINQ 之外使用,但这是它们设计的主要场景之一。按复合键分组是另一个场景,我将在 “分组” 中描述。)

投影和映射

Select 操作符有时被称为投影,它与许多语言称为映射的操作相同,提供了一种略有不同的看待 Select 操作符的方式。到目前为止,我已经介绍了 Select 作为选择查询结果的一种方式,但你也可以把它看作是将变换应用到源中的每个项的一种方式。示例 10-25 使用 Select 生成修改后的数字列表。它分别将数字加倍、求平方,并将它们转换为字符串。

示例 10-25. 使用 Select 转换数字
int[] numbers = { 0, 1, 2, 3, 4, 5 };

IEnumerable<int> doubled = numbers.Select(x => 2 * x);
IEnumerable<int> squared = numbers.Select(x => x * x);
IEnumerable<string> numberText = numbers.Select(x => x.ToString());

SelectMany

SelectMany LINQ 操作符用于具有多个 from 子句的查询表达式。它之所以被称为 SelectMany,是因为它不是为每个输入项选择单个输出项,而是为每个输入项提供一个生成整个集合的 lambda 表达式。生成的查询将来自所有这些集合的对象,就好像你的 lambda 返回的所有集合被合并成一个一样。(这不会移除重复项。在 LINQ 中,序列可以包含重复项。你可以使用 “集合操作” 中描述的 Distinct 操作符来移除它们。)有几种思考这个操作符的方式。一种是它提供了将两个层次结构(集合的集合)展平为单一级别的方法。另一种看待它的方式是作为笛卡尔积——即从一些输入集合中生成每一种可能的组合的方法。

示例 10-26 展示了如何在查询表达式中使用此运算符。此代码突出显示了类似于笛卡尔积的行为。它显示了字母 A、B 和 C 与数字 1 到 5 的每个组合,即 A1、B1、C1、A2、B2、C2 等(如果您对这两个输入序列的表现不兼容感到疑惑,此查询的select子句依赖于一个事实,即如果您使用+运算符将一个string和某种其他类型相加,C#会为您生成调用非 string 操作数的ToString的代码)。

示例 10-26. 使用查询表达式中的SelectMany
int[] numbers = { 1, 2, 3, 4, 5 };
string[] letters = { "A", "B", "C" };

IEnumerable<string> combined = from number in numbers
                               from letter in letters
                               select letter + number;
foreach (string s in combined)
{
    Console.WriteLine(s);
}

示例 10-27 展示了如何直接调用运算符。这相当于示例 10-26 中的查询表达式。

示例 10-27. SelectMany运算符
IEnumerable<string> combined = numbers.SelectMany(
    number => letters,
    (number, letter) => letter + number);

示例 10-26 使用了两个固定集合——第二个from子句每次返回相同的letters集合。然而,您可以使第二个from子句中的表达式基于第一个from子句的当前项返回一个值。您可以在示例 10-27 中看到,SelectMany的第一个 Lambda 表达式(实际上对应第二个from子句的最终表达式)通过其number参数接收第一个集合的当前项,因此您可以用它来选择每个第一个集合项的不同集合。我可以利用这一点来利用SelectMany的展平行为。

我已经从示例 5-16 在第 5 章中复制了一个嵌套数组到示例 10-28,然后使用包含两个from子句的查询处理它。请注意,第二个from子句中的表达式现在是row,即第一个from子句的范围变量。

示例 10-28. 展平嵌套数组
int[][] arrays =
{
    new[] { 1, 2 },
    new[] { 1, 2, 3, 4, 5, 6 },
    new[] { 1, 2, 4 },
    new[] { 1 },
    new[] { 1, 2, 3, 4, 5 }
};

IEnumerable<int> flattened = from row in arrays
                             from number in row
                             select number;

第一个from子句要求迭代顶层数组中的每个项。这些项中的每一个也是一个数组,第二个from子句要求迭代每个嵌套数组。这个嵌套数组的类型是int[],因此第二个from子句的范围变量number表示来自该嵌套数组的一个intselect子句只是返回每个这些int值。

结果序列依次提供数组中的每个数字。它将嵌套数组展平为简单的线性数字序列。这种行为在概念上类似于编写一个嵌套的循环对,一个循环遍历外部int[][]数组,另一个内部循环遍历每个单独的int[]数组的内容。

编译器对于 示例 10-28 和 示例 10-27 使用相同的 SelectMany 重载,但在这种情况下存在另一种选择。在 示例 10-28 中,最终的 select 子句更简单—它仅传递来自第二个集合的项目,这意味着 示例 10-29 中显示的更简单的重载同样能胜任。使用这种重载,我们只需提供一个 lambda,它选择 SelectMany 将为输入集合中的每个项目扩展的集合。

示例 10-29. 没有项目投影的 SelectMany
var flattened = arrays.SelectMany(row => row);

这是一段略显简洁的代码,因此如果不太清楚它如何最终展平数组,示例 10-30 展示了如果必须自己编写的话,您可能如何为 IEnumerable<T> 实现 SelectMany

示例 10-30. SelectMany 的一个实现
static IEnumerable<T2> MySelectMany<T, T2>(
    this IEnumerable<T> src, Func<T, IEnumerable<T2>> getInner)
{
    foreach (T itemFromOuterCollection in src)
    {
        IEnumerable<T2> innerCollection = getInner(itemFromOuterCollection);
        foreach (T2 itemFromInnerCollection in innerCollection)
        {
            yield return itemFromInnerCollection;
        }
    }
}

编译器为什么不使用在 示例 10-29 中展示的更简单的选项?C# 语言规范定义了查询表达式如何转换为方法调用,并且仅提到了 示例 10-26 中显示的重载。也许规范之所以没有提及更简单的重载,是为了减少 C# 对想要支持这种双 from 查询形式的类型的要求—您只需编写一个方法即可启用此语法。然而,.NET 的各种 LINQ 提供程序更为慷慨,为选择直接使用运算符的开发人员提供了这种更简单的重载。事实上,一些提供程序定义了另外两个重载版本:迄今为止我们看到的 SelectMany 的这两种形式也会将项目索引传递给第一个 lambda。当然,对于索引运算符,通常的警告仍然适用。

虽然 示例 10-30 给出了 LINQ to Objects 在 SelectMany 中的一个合理想法,但这并不是确切的实现。对于特殊情况,存在优化。此外,其他提供程序可能使用非常不同的策略。数据库通常内置支持笛卡尔积,因此某些提供程序可能会基于此实现 SelectMany

Chunking

SelectMany 将多个序列展平为一个,LINQ 的 Chunk 操作符(在 .NET 6.0 中添加)则朝相反方向工作,将单个序列转换为一系列固定大小的序列。在涉及到 I/O 的情况下,这可能更有效,因为写入数据到磁盘或通过网络发送数据通常具有固定的最低成本,这往往意味着写入或发送单个记录的成本仅比写入或发送 10 条记录稍微低一点。

示例 10-31 使用 Range 方法(稍后在 “序列生成” 中描述)创建了一个从 1 到 50 的数字序列,然后要求 Chunk 将其分成每个包含 15 个数字的块。虽然 Range 生成了一个 IEnumerable<int>—一系列 int 值—Chunk 返回了一个 数组 序列,类型为 int[]

示例 10-31. 使用 Chunk 将序列分成批次
IEnumerable<int> lotsOfNumbers = Enumerable.Range(1, 50);

IEnumerable<int[]> chunked = lotsOfNumbers.Chunk(15);
foreach(int[] chunk in chunked)
{
    Console.WriteLine(
        $"Chunk (length {chunk.Length}): {String.Join(", ", chunk)}");
}

查看 示例 10-31 的输出,我们可以看到 Chunk 将所有数字按顺序分割成了块:

Chunk (length 15): 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15
Chunk (length 15): 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30
Chunk (length 15): 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45
Chunk (length 5): 46, 47, 48, 49, 50

在本例中,源序列的长度不是块大小的精确倍数。Chunk 通过使最后一个块更小来处理这个问题。

注意

一些 LINQ 提供程序使用不同的名称来表示此操作符:Buffer。在约 10 年前,Rx 库在介绍这种类型的操作符时选择了这个名称。.NET 6.0 选择了 Chunk 这个名称,但在此之前编写的库通常遵循了 Rx 的先例,将其版本的这个操作符称为 Buffer

排序

一般来说,LINQ 查询不保证按任何特定顺序生成项目,除非您明确定义所需的顺序。您可以在查询表达式中使用 orderby 子句来实现这一点。正如 示例 10-32 所示,您可以指定定义项目顺序及其方向的表达式—因此,这将生成按发布日期升序排列的课程集合。恰巧的是,默认情况下是 ascending,因此您可以省略该限定词而不改变含义。正如您可能已经猜到的那样,您可以指定 descending 来反转顺序。

示例 10-32. 带有 orderby 子句的查询表达式
var q = from course in Course.Catalog
        orderby course.PublicationDate ascending
        select course;

编译器将 示例 10-32 中的 orderby 子句转换为对 OrderBy 方法的调用,如果指定了 descending 排序顺序,它将使用 OrderByDescending。对于那些区分有序和无序集合的源类型,这些操作符返回有序类型(例如,LINQ to Objects 返回 IOrderedEnumerable<T>,而基于 IQueryable<T> 的提供程序返回 IOrderedQueryable<T>)。

警告

对于 LINQ to Objects,这些操作符必须从输入中检索每个元素,然后才能生成任何输出元素。升序的 OrderBy 只有在找到最低项之后才能确定要返回哪个项,并且直到看到所有项之后才能确定最低项。它仍然使用延迟评估—直到您请求第一个项目时才会执行任何操作。但是一旦您请求了某些内容,它必须立即完成所有工作。某些提供程序可能对数据具有额外的知识,从而能够实现更有效的策略(例如,数据库可以使用索引按照所需的顺序返回值)。

LINQ to Objects 的OrderByOrderByDescending运算符各有两个重载,其中只有一个可以从查询表达式中使用。如果直接调用这些方法,您可以提供一个额外的IComparer<TKey>类型的参数,其中TKey是正在排序的表达式的类型。如果您基于string属性进行排序,这可能很重要,因为文本有几种不同的排序方式,您可能需要根据应用程序的区域设置选择其中一种,或者您可能希望指定一个与文化无关的排序方式,以确保在所有环境中保持一致性。

示例 10-32 中确定顺序的表达式非常简单—它只是从源项目中检索PublicationDate属性。如果您愿意,可以编写更复杂的表达式。如果您使用将 LINQ 查询转换为其他内容的提供程序,可能会有一些限制。如果查询在数据库上运行,您可能可以引用其他表—提供程序可能能够将诸如product.ProductCategory.Name之类的表达式转换为适当的连接。但是,您无法在该表达式中运行任何旧代码,因为它必须是数据库可以执行的内容。但是,LINQ to Objects 只是为每个对象调用一次表达式,因此您确实可以在其中放入任何代码。

您可能希望按多个标准进行排序。您不应该通过编写多个orderby子句来实现这一点。示例 10-33 犯了这个错误。

示例 10-33. 如何不应用多个排序标准
var q = from course in Course.Catalog
        orderby course.PublicationDate ascending
        orderby course.Duration descending // BAD! Could discard previous order
        select course;

此代码按发布日期和持续时间对项目进行排序,但是将其作为两个独立且不相关的步骤进行。第二个orderby子句仅保证结果将按照该子句中指定的顺序排列,并不保证保留有关元素原始顺序的任何信息。如果您实际上想要项目按发布日期顺序排列,并且具有相同发布日期的项目按持续时间降序排列,您需要编写示例 10-34 中的查询。

示例 10-34. 查询表达式中的多个排序标准
var q = from course in Course.Catalog
        orderby course.PublicationDate ascending, course.Duration descending
        select course;

LINQ 为这种多级排序定义了单独的运算符:ThenByThe⁠nBy​Des⁠cen⁠ding。示例 10-35 展示了如何通过直接调用 LINQ 运算符来实现与示例 10-34 中查询表达式相同的效果。对于那些类型区分有序和无序集合的 LINQ 提供程序,ThenByThenByDescending运算符仅在有序形式上可用,例如IOrderedQueryable<T>IOrderedEnumerable<T>。如果您尝试直接在Course.Catalog上调用ThenBy,编译器将报告错误。

示例 10-35. 使用 LINQ 操作符进行多重排序标准
var q = Course.Catalog
    .OrderBy(course => course.PublicationDate)
    .ThenByDescending(course => course.Duration);

您会发现,即使您没有要求,一些 LINQ 操作符也会保留某些排序方面。例如,LINQ to Objects 通常会按照它们在输入中出现的顺序生成项目,除非您编写了一个引起顺序改变的查询。但这只是 LINQ to Objects 工作方式的副产品,您不应该普遍依赖它。事实上,即使在使用该特定 LINQ 提供程序时,您也应该查看文档,以了解您得到的顺序是有保证的还是仅仅是实现的偶然。在大多数情况下,如果您关心顺序,应该编写一个明确表达这一点的查询。

包含性测试

LINQ 定义了各种用于发现集合内容的标准操作符。一些提供程序可能能够实现这些操作符而无需检查每个项目。(例如,基于数据库的提供程序可能使用 WHERE 子句,数据库可以使用索引来评估,而无需查看每个元素。)但没有限制 - 您可以按自己的喜好使用这些操作符,以及提供程序发现是否可以利用快捷方式。

注意

不同于大多数 LINQ 操作符,在大多数提供程序中,它们既不返回集合也不返回输入中的项目。它们通常只返回 truefalse,或者在某些情况下返回一个计数。Rx 是一个显著的例外:它这些操作符的实现将 boolint 包装在产生结果的单元素 IObservable<T> 中。它这样做是为了保持 Rx 中处理的响应式特性。

最简单的操作符是 Contains。您传递一个项目,一些提供程序(包括 LINQ to Objects)提供了一个重载,还接受一个 IEqualityComparer<T>,以便您可以自定义操作符如何确定源中的项目与指定项目是否相同。Contains 如果源包含指定的项目则返回 true,如果不包含则返回 false。如果您使用具有 ICollection<T> 实现的集合的单参数版本(其中包括所有 IList<T>ISet<T> 实现),LINQ to Objects 将检测到这一点,并且其 Contains 的实现只是将其延迟到集合。如果您使用非 ICollection<T> 集合,或者提供自定义的相等性比较器,它将不得不检查集合中的每个项目。

如果你不是在寻找特定的值,而是想知道一个集合是否包含任何满足某些特定条件的值,你可以使用Any运算符。它接受一个谓词,并且在源中至少有一个项满足谓词时返回true。如果你想知道有多少项满足某些条件,你可以使用Count运算符。它也接受一个谓词,而不是返回bool,而是返回一个int。如果你正在处理非常大的集合,int的范围可能不够,这时你可以使用LongCount运算符,它返回一个 64 位的计数。(在大多数 LINQ to Objects 应用程序中,这可能过于复杂,但在集合存在于数据库中时可能很重要。)

AnyCountLongCount运算符有些重载不接受任何参数。对于Any来说,这告诉你源序列是否至少包含一个元素,而对于CountLongCount,这些重载告诉你源序列包含多少元素。

你应该警惕像if (q.Count() > 0)这样的代码。计算确切的计数可能需要评估整个源查询(在这种情况下是q),而且通常比简单地回答“它是空的吗?”更耗费功夫。如果q是 LINQ 查询,写成if (q.Any())可能更有效率。话虽如此,在 LINQ 之外,对于类似列表的集合来说,并不是这样,那里获取元素计数是廉价的,而且实际上可能比Any运算符更有效率。

有些情况下,你可能只希望在能够高效计算时才使用计数。(例如,用户界面可能希望在可以轻松确定的情况下显示可用项的总数,但在计算成本太高时可能选择不显示。)对于这些情况,.NET 6.0 添加了一个新的TryGetNonEnumeratedCount方法。它将在可以在不必迭代整个集合的情况下确定计数时返回true,否则返回false。当它返回true时,通过out int类型的单个参数将计数传递回去。

Any运算符密切相关的是All运算符。这个运算符没有重载——它接受一个谓词,并且仅当源序列不包含任何不匹配谓词的项时返回true。在上述句子中我使用了尴尬的双重否定是有原因的:All应用于空序列时返回true,因为空序列显然不包含任何元素无法匹配谓词,简单来说,它根本没有任何元素。

这可能看起来像一种顽固的逻辑形式。这让人想起了一个孩子的情形,当问到“你吃了你的蔬菜吗?”时,他无助地回答道:“我吃了我放在盘子上的所有蔬菜”,却忽略了他根本没把任何蔬菜放在盘子上这一事实。从技术上讲,这并不是不真实的,但它未能提供父母寻找的信息。尽管如此,这些运算符之所以工作如此,是有其原因的:它们对应一些标准的数学逻辑运算符。Any存在量词,通常写作倒立的 E (∃),读作“存在”,而All全称量词,通常写作倒置的 A (∀),读作“对于所有”。数学家们很久以前就对适用于空集的全称量词陈述达成了一致的约定。例如,定义𝕍为所有蔬菜的集合,我可以断言 ∀{v : (v ∈ 𝕍) ∧ putOnPlateByMe(v)} eatenByMe(v),或者用英语说,“对于我放在盘子上的每一个蔬菜,我吃了那个蔬菜。” 如果我放在盘子上的蔬菜集合是空的,这个陈述被认为是真实的。(也许数学家也不喜欢蔬菜。)令人愉悦的是,这种陈述的正式术语是空真

特定项目和子范围

编写仅产生单个项目的查询可能很有用。也许你正在寻找满足某些条件的列表中的第一个对象,或者你想通过特定键标识的数据库获取信息。LINQ 定义了几个可以实现此目的的运算符,以及一些处理查询可能返回的子范围的相关运算符。

使用Single运算符时,你认为应该只生成一个结果的查询。示例 10-36 展示了这样的一个查询—它通过类别和编号查找课程,在我的样本数据中,这唯一确定了一个课程。

示例 10-36. 将Single运算符应用于查询
var q = from course in Course.Catalog
        where course.Category == "MAT" && course.Number == 101
        select course;

Course geometry = q.Single();

因为 LINQ 查询是通过链接操作符构建的,我们可以取出由查询表达式构建的查询,然后添加另一个运算符—在这种情况下是Single运算符。虽然大多数运算符会返回代表另一个查询的对象(这里是IEnumerable<T>,因为我们使用 LINQ 来处理对象),但Single不同。像ToArrayToList一样,Single运算符立即评估查询,然后返回查询产生的唯一对象。如果查询未能产生正好一个对象—可能没有生成任何项,或者生成了两个—这将引发InvalidOperationException。(由于这是另一个立即产生结果的运算符,一些提供程序提供了SingleAsync,如侧边栏“即时评估和异步”中所述。)

Single 操作符还有一个带有谓词的重载。正如 示例 10-37 所示,这使我们能够更紧凑地表达与 示例 10-36 整体相同的逻辑。(与 Where 操作符一样,本节中所有基于谓词的操作符都使用 Func<T, bool>,而不是 Predicate<T>。)

示例 10-37. 带有谓词的 Single 操作符
Course geometry = Course.Catalog.Single(
    course => course.Category == "MAT" && course.Number == 101);

Single 操作符是严格的:如果你的查询没有精确返回一个项,它会抛出异常。还有一个略微更灵活的变体叫做 SingleOrDefault,允许查询返回一个或零个项。如果查询没有结果,这个方法会返回该项类型的默认值(比如引用类型返回 null,数值类型返回 0 等)。多个匹配项仍会引发异常。和 Single 一样,它们有两个重载:一个不带参数,用于你认为源中不会有多个对象的情况;另一个带有谓词 lambda。

LINQ 定义了两个相关的操作符 FirstFirstOrDefault,它们分别提供了不带参数或带有谓词的重载。对于包含零个或一个匹配项的序列,它们的行为与 SingleSingleOrDefault 完全相同:如果存在一个项,则返回该项;如果没有,First 会抛出异常,而 FirstOrDefault 会返回 null 或等效值。然而,当存在多个结果时,这些操作符的响应不同——它们会选择第一个结果并返回,忽略其余结果。如果你想从列表中找出最昂贵的物品,这可能会很有用——你可以按价格降序排序查询,然后选择第一个结果。示例 10-38 使用了类似的技术来从我的示例数据中选择最长的课程。

示例 10-38. 使用 First 选择最长的课程
var q = from course in Course.Catalog
        orderby course.Duration descending
        select course;
Course longest = q.First();

如果你的查询结果没有特定的顺序保证,这些操作符会任意选择一个项。

提示

不要使用 FirstFirstOrDefault,除非你期望有多个匹配项并且只想处理其中一个。有些开发者在期望只有一个匹配项时也使用这些操作符。当然,这些操作符可以工作,但 SingleSingleOrDefault 操作符更准确地表达了你的期望。它们会在有多个匹配项时抛出异常,让你知道你的期望是错误的。如果你的代码存在错误的假设,通常最好是知道而不是无视它们继续执行。

FirstFirstOrDefault 的存在引出了一个明显的问题:我能选出最后一项吗?答案是肯定的;还有 LastLastOrDefault 操作符,同样,每个都提供两个重载——一个不带参数,一个带有谓词。

.NET 6.0 对SingleOrDefaultFirstOrDefaultLastOrDefault进行了优化。这些方法新增了重载,使你能够提供一个返回默认值的值,而不是通常的零值。如果你有一个包含整数元素的集合,其中零是有效值,这可能非常有用。示例 10-39 展示了如何使用新的SingleOrDefault重载,在列表为空时获取一个值为-1 的结果。这样可以区分空列表和只包含单个零值的列表。当然,如果你的应用程序中所有可能的整数值都是有效的,这就不起作用了,你需要用其他方式检测空集合。但是,在你可以指定一些特殊值来表示“不在这里”的情况时(例如,在这种情况下是-1),这些新的重载是一个有用的补充。

示例 10-39. 使用显式默认值的SingleOrDefault
int valueOrNegative = numbers.SingleOrDefault(-1);

下一个显而易见的问题是:如果我想要一个既不是第一个也不是最后一个的特定元素怎么办?在这种情况下,LINQ 的指令非常实用,因为它提供了ElementAtElementAtOrDefault操作符,两者都只接受一个索引。这提供了一种通过索引访问任何IEnumerable<T>元素的方式。你可以指定索引为一个int。另外,.NET 6.0 添加了使用Index的重载,正如你可能从“使用索引和范围语法访问元素”了解到的那样,它允许使用相对末尾的位置。例如,²表示倒数第二个元素。(奇怪的是,ElementAtOrDefault没有新增重载来指定默认值,不像上一段讨论的三个操作符。)

你需要小心使用ElementAtElementAtOrDefault,因为它们可能会出乎意料地昂贵。如果你要求第 10,000 个元素,这些操作符可能需要请求并丢弃前 9,999 个元素才能到达那里。如果你通过写source.ElementAt(⁵⁰⁰)来指定一个相对末尾的位置,操作符可能需要读取每一个元素才能找到最后一个元素,并且对于这个特定的示例,它还可能需要保持已经看到的最后 500 个元素,因为直到到达末尾时,它才知道最终需要返回哪个元素。

正如情况所示,LINQ to Objects 会检测源对象是否实现了IList<T>接口,如果是,则直接使用索引器来直接获取元素,而不是绕一个慢速的方式。但并不是所有的IEnumerable<T>实现都支持随机访问,因此这些操作符可能会非常慢。特别是,即使你的源实现了IList<T>,一旦你对其应用了一个或多个 LINQ 操作符,这些操作符的输出通常就不支持索引访问了。因此,在像示例 10-40 中展示的循环中使用ElementAt将会特别灾难性。

示例 10-40. 不正确使用ElementAt的例子
var mathsCourses = Course.Catalog.Where(c => c.Category == "MAT");
for (int i = 0; i < mathsCourses.Count(); ++i)
{
    // Never do this!
    Course c = mathsCourses.ElementAt(i);
    Console.WriteLine(c.Title);
}

尽管Course.Catalog是一个数组,我已经用Where运算符过滤了它的内容,返回了一个类型为IEnumerable<Course>的查询,该类型不实现IList<Course>接口。第一次迭代不会太糟糕——我将ElementAt的索引设为0,因此它只返回第一个匹配项,在我的样本数据中,Where检查的第一个项目将匹配。但是在循环的第二次迭代中,我们再次调用ElementAtmathsCourses引用的查询并不跟踪我们在上一个循环中的位置——它是一个IEnumerable<T>,而不是IEnumerator<T>——因此这将重新开始。ElementAt会要求该查询返回第一个项目,它会立即丢弃它,然后请求下一个项目,这将成为返回值。因此,Where查询现在已经执行了两次——第一次,ElementAt只要求它返回一个项目,然后第二次它要求它返回两个项目,因此它现在已经处理了第一个课程两次。第三次循环(也是最后一次),我们再次重复这一过程,但这次,ElementAt将丢弃前两个匹配项,并返回第三个匹配项,因此现在它已经查看了第一个课程三次,第二个课程两次,第三和第四个课程各一次。(在我的样本数据中,第三个课程不属于MAT类别,因此当要求第三个项目时,Where查询会跳过它。)因此,为了检索三个项目,我已经评估了Where查询三次,导致它评估我的过滤 lambda 函数七次。

事实上,情况比这更糟,因为for循环每次还会调用Count方法,而对于像Where返回的非可索引源,Count必须评估整个序列——Where运算符告诉你有多少项匹配的唯一方法就是查看所有这些项。因此,这段代码除了ElementAt进行的三次部分评估外,还完全评估了Where返回的查询三次。在这里我们得以侥幸,因为集合很小,但如果我有一个包含 1,000 个元素的数组,所有元素都匹配过滤器,我们将完全评估Where查询 1,000 次,并进行另外 1,000 次部分评估。每次完全评估都会调用过滤器谓词 1,000 次,而这里的部分评估平均会这样做 500 次,因此代码最终会执行 1,500,000 次过滤。通过foreach循环迭代Where查询只会评估一次查询,执行 1,000 次过滤表达式,结果将会是一样的。

因此,在使用CountElementAt时要小心。如果你在迭代调用它们的集合的循环中使用它们,结果代码的复杂度将会是 O(n²)(即,运行代码的成本与项目数量的平方成正比增长)。

所有我刚刚描述的操作符都从源中返回单个项目。还有四个操作符也会有选择地使用项目,但可以返回多个项目:SkipTakeSkipLastTakeLast。这些操作符每个接受一个 int 参数。顾名思义,Skip 丢弃序列开头指定数量的元素,然后返回源中的所有其他元素。Take 从序列开头返回指定数量的元素,然后丢弃其余部分(因此类似于 SQL 中的 TOP)。SkipLastTakeLast 作用于序列末尾,例如,您可以使用 TakeLast 获取源中的最后 5 个项目,或者使用 SkipLast 跳过最后 5 个项目。

.NET 6.0 添加了一个重载的 Take 方法,接受一个 Range,使得可以使用在“使用索引和范围语法访问元素”中描述的范围语法。例如,source.Take(10..¹⁰) 跳过了前 10 个和最后 10 个项目(因此等效于 source.Skip(10).SkipLast(10))。由于范围语法允许您在范围的起始和结束位置使用起始或结束相对索引,我们可以使用这个 Take 的重载来表示其他组合。例如,source.Take(10..20) 的效果与 source.Skip(10).Take(10) 相同;source.Take(¹⁰..²) 相当于 source.TakeLast(10).SkipLast(2)

还有基于条件的版本,SkipWhileTakeWhileSkipWhile 将丢弃序列中的项目,直到找到与谓词匹配的项目,此时它将返回该项目及其后续项目直至序列结束(无论剩余项目是否匹配谓词)。相反,TakeWhile 将返回项目,直到遇到第一个不匹配谓词的项目,此时它将丢弃该项目及其后续序列。

虽然 SkipTakeSkipLastTakeLastSkipWhileTakeWhile 显然都是有序敏感的,但它们并不限于仅限于有序类型,比如 IOr⁠der⁠ed​Enu⁠mer⁠abl⁠e<T>。它们也适用于 IEnumerable<T>,这是合理的,因为即使没有特定的顺序保证,IEnumerable<T> 总是以某种顺序产生元素。(你可以从 IEnumerable<T> 中逐个提取项,因此总会有一种顺序,即使是任意的。每次枚举项时可能不会相同,但对于单个评估,项必须以某种顺序出现。)此外,IOrderedEnumerable<T> 在 LINQ 之外并没有广泛实现,因此通常有些不了解 LINQ 的对象,虽然它们以已知的顺序产生项目,但仅实现了 IEnumerable<T>。这些运算符在这些场景中非常有用,因此限制得以放宽。更令人惊讶的是,IQueryable<T> 也支持这些操作,但这与许多数据库支持对无序查询应用 TOP(大致相当于 Take)是一致的。正如以往一样,单个提供程序可能选择不支持某些操作,因此在没有这些运算符合理解释的情况下,它们将引发异常。

聚合

SumAverage 运算符将所有源项的值相加。Sum 返回总和,Average 返回总和除以项数。通常支持这些运算符的 LINQ 提供程序会使它们适用于这些数值类型的项目集合:decimaldoublefloatintlong。还有一些重载版本,与 lambda 表达式一起工作,该 lambda 接受一个项目并返回其中一个这些数值类型,这使得我们可以编写像 示例 10-41 这样的代码,它处理 Course 对象集合,并计算从对象中提取的特定值的平均值:课程时长(以小时计算)。

示例 10-41. 带有投影的 Average 运算符
Console.WriteLine("Average course length in hours: {0}",
    Course.Catalog.Average(course => course.Duration.TotalHours));

LINQ 还定义了 MinMax 运算符。你可以将它们应用于任何类型的序列,尽管不能保证一定成功——你使用的特定提供程序可能在不知道如何比较你使用的类型时报告错误。例如,LINQ to Objects 要求序列中的对象实现 IComparable

MinMax 都有重载版本,接受一个从源项目获取值的 lambda 表达式。示例 10-42 使用这一特性来找出最近发布的课程的日期。

示例 10-42. Max 与投影
DateOnly m = mathsCourses.Max(c => c.PublicationDate);

注意,此方法并不返回最近发布日期的课程;它返回的是该课程的发布日期。如果您想选择某个属性具有最大值的对象,可以使用MaxBy。示例 10-43 将找到具有最高PublicationDate的课程,但与示例 10-42 不同,它返回相关课程而不是日期。(正如您所预料的那样,还有一个MinBy。)

示例 10-43. 用于标准的投影Max但不用于结果
Course? mostRecentlyPublished = mathsCourses.MaxBy(c => c.PublicationDate);

您可能已经在示例中注意到了?,表示MaxBy可能返回一个null结果。在输入集合为空且输出类型是引用类型或其支持的支持的数值类型的可空形式(例如int?double?)的情况下,MaxMaxBy会发生这种情况。当输出是非空的结构(例如DateOnly,如示例 10-42)时,这些运算符无法返回null,并且会抛出InvalidOperationException。如果您使用的是引用类型,并且希望像值类型输出那样在输入为空时引发异常,唯一的方法是自行检查是否存在null结果并抛出异常。示例 10-44 展示了一种实现方式。

示例 10-44. 用于标准的投影Max但不用于结果,输入为空时出错
Course mostRecentlyPublished = mathsCourses.MaxBy(c => c.PublicationDate)
    ?? throw new InvalidOperationException("Collection must not be empty");

LINQ to Objects 为返回与SumAverage处理相同数值类型的特定序列的MinMax定义了专用重载(即decimaldoublefloatintlong及其可空形式)。它还为使用 lambda 表达式的形式定义了类似的专用化。这些重载存在是为了通过避免装箱来提高性能。通用形式依赖于IComparable,并且获取一个值的接口类型引用总是涉及装箱该值。对于大集合,装箱每个值会对 GC 造成相当大的额外压力。

LINQ 定义了一个称为Aggregate的运算符,它泛化了MinMaxSumAverage所使用的模式,即使用涉及考虑每个源项的过程来生成单个结果。可以通过Aggregate来实现这四个运算符(及其...By对应运算符)。示例 10-45 使用Sum运算符计算所有课程的总持续时间,然后展示如何使用Aggregate运算符执行完全相同的计算。

示例 10-45. SumAggregate 的等效形式
double t1 = Course.Catalog.Sum(course => course.Duration.TotalHours);
double t2 = Course.Catalog.Aggregate(
    0.0, (hours, course) => hours + course.Duration.TotalHours);

聚合通过建立一个值来表示到目前为止检查过的所有项目的知识,称为累加器。我们使用的类型取决于我们要累积的知识。在这里,我只是将所有数字相加,所以我使用了一个double(因为TimeSpan类型的TotalHours属性也是一个double)。

最初我们没有知识,因为我们还没有查看任何项目。我们需要提供一个累加器值来表示这个起始点,因此Aggregate运算符的第一个参数是seed,累加器的初始值。在 Example 10-45 中,累加器只是一个运行总数,因此种子是0.0

第二个参数是一个 lambda 表达式,描述如何更新累加器以包含单个项目的信息。由于我这里的目标只是计算总时间,所以我只是将当前课程的持续时间添加到运行总数中。

一旦Aggregate查看了每个项目,这个特定的重载将直接返回累加器。在这种情况下,它将是所有课程中的总小时数。如果我们使用不同的累积策略,我们可以实现Max。而不是维护一个运行总数,表示到目前为止关于数据的所有知识的值只是看到的最高值。Example 10-46 显示了与 Example 10-42 的大致等价物。(它不完全相同,因为 Example 10-46 没有尝试检测空源。如果此源为空,Max会抛出异常,但这只会返回日期 0/0/0000。)

Example 10-46. 使用Aggregate实现Max
DateOnly m = mathsCourses.Aggregate(
    new DateOnly(),
    (date, c) => date > c.PublicationDate ? date : c.PublicationDate);

这说明了Aggregate并不对累积知识的值强加任何单一含义——你使用它的方式取决于你要做什么。一些操作需要一个稍微有结构的累加器。Example 10-47 使用Aggregate计算了平均课程持续时间。

Example 10-47. 使用Aggregate实现Average
double average = Course.Catalog.Aggregate(
    new { TotalHours = 0.0, Count = 0 },
    (totals, course) => new
    {
        TotalHours = totals.TotalHours + course.Duration.TotalHours,
        Count = totals.Count + 1
    },
    totals => totals.Count > 0
        ? totals.TotalHours / totals.Count
        : throw new InvalidOperationException("Sequence was empty"));

平均持续时间要求我们知道两件事:总持续时间和项目数。因此,在这个例子中,我的累加器使用了一个可以包含两个值的类型,一个用来保存总和,一个用来保存项目计数。我使用了匿名类型,因为正如前面提到的,在 LINQ 中有时这是唯一的选择,并且我想展示最一般的情况。然而,值得一提的是,在这种特定情况下,元组可能更好。它会起作用,因为这是 LINQ 到对象,而轻量级元组是值类型,而匿名类型是引用类型,元组会减少被分配的对象数量。

注意

示例 10-47 基于同一组件中的两个独立方法创建两个结构相同的匿名类型实例时的事实,编译器会生成一个用于两者的单一类型。种子生成了一个由TotalHoursdouble)和Countint)组成的匿名类型实例。累加 lambda 也返回了一个具有相同成员名称和类型的匿名类型实例,并且顺序也相同。C# 编译器认为这些将是相同的类型,这很重要,因为Aggregate要求 lambda 接受并返回累加器类型的实例。

示例 10-47 使用了与先前示例不同的重载。它采用了额外的 lambda 函数,用于从累加器中提取返回值—累加器积累了我需要生成结果所需的信息,但在这个示例中,累加器本身不是结果。

当然,如果你只想计算总和、最大值或平均值,你不会使用Aggregate—你会使用专门设计用于执行这些任务的操作符。它们不仅更简单,而且通常更高效。 (例如,数据库的 LINQ 提供程序可能能够生成一个查询,使用数据库的内置功能计算最小或最大值。)我只是想展示灵活性,使用易于理解的例子。但现在我已经做到了,示例 10-48 展示了一个特别简洁的Aggregate示例,它不对应任何其他内置操作符。它接受一个矩形集合并返回包含所有这些矩形的边界框。

示例 10-48. 聚合边界框
public static Rect GetBounds(IEnumerable<Rect> rects) =>
    rects.Aggregate(Rect.Union);

本示例中的Rect结构来自System.Windows命名空间。这是 WPF 的一部分,它是一个非常简单的数据结构,只包含四个数字—XYWidthHeight—因此,即使你喜欢,你也可以在非 WPF 应用中使用它。² 示例 10-48 使用了Rect类型的静态Union方法,它接受两个Rect参数并返回一个包含两个输入矩形的边界框的单个Rect(即包含两个输入矩形的最小矩形)。

我在这里使用Aggregate的最简单重载。它与我在示例 10-45 中使用的方法相同,但它不需要我提供一个种子——它只使用列表中的第一项。示例 10-49 相当于示例 10-48,但使步骤更明确。我已经提供了序列中第一个Rect作为显式种子值,使用Skip来聚合除了第一个元素之外的所有内容。我还编写了一个 lambda 来调用该方法,而不是直接传递方法本身。如果你使用这种 lambda,它只是将其参数直接传递给 LINQ 到对象的现有方法,你可以直接传递方法名称,它将直接调用目标方法,而不经过你的 lambda。(你不能在基于表达式的提供程序中这样做,因为它们要求一个 lambda。)

直接使用该方法更为简洁和略微更有效,但也会导致代码略显晦涩,这就是为什么我在示例 10-49 中详细解释它的原因。

示例 10-49. 更详细和不那么晦涩的边界框聚合
public static Rect GetBounds(IEnumerable<Rect> rects)
{
    IEnumerable<Rect> theRest = rects.Skip(1);
    return theRest.Aggregate(rects.First(), (r1, r2) => Rect.Union(r1, r2));
}

这两个示例的工作方式相同。它们以第一个矩形作为种子。对于列表中的下一个项,Aggregate将调用Rect.Union,传递种子和第二个矩形。结果——前两个矩形的边界框——成为新的累加器值。然后将其与第三个矩形一起传递给Union,依此类推。示例 10-50 展示了在四个Rect值的集合上执行此Aggregate操作的效果。(我在这里表示四个值为r1r2r3r4。要将它们传递给Aggregate,它们需要在像数组这样的集合内。)

示例 10-50. Aggregate的效果
Rect bounds = Rect.Union(Rect.Union(Rect.Union(r1, r2), r3), r4);

Aggregate是 LINQ 中对其他一些语言称为reduce的操作的称呼。有时你也会看到它被称为fold。LINQ 选择使用Aggregate这个名字的原因与其将投影运算符称为Select而不是map(函数式编程语言中更常见的名称)相同:LINQ 的术语更多受到 SQL 的影响,而不是函数式编程语言。

集合操作

LINQ 定义了三个运算符,使用一些常见的集合操作来合并两个源。Intersect生成一个结果,其中包含仅存在于两个输入源中的项。Except包含仅来自第一个输入源中不在第二个输入源中的项。Union³的输出包含存在于任一(或两者)输入源中的项。

虽然 LINQ 定义了这些集合操作,大多数 LINQ 源类型并不直接对应集合的抽象。在数学集合中,任何特定项都要么属于集合,要么不属于,没有固有的顺序概念或特定项在集合中出现的次数。IEnumerable<T> 不是这样的——它是一系列项,因此可能存在重复项,IQueryable<T> 也是如此。这并不一定是问题,因为有些集合永远不会处于包含重复项的情况中,而且在某些情况下,重复项的存在也不会导致问题。但有时,将包含重复项的集合转换为不包含重复项可能很有用。为此,LINQ 定义了 Distinct 操作符,用于删除重复项。示例 10-51 包含一个查询,从所有课程中提取类别名称,并将其传递给 Distinct 操作符,以确保每个唯一的类别名称只出现一次。

示例 10-51. 使用 Distinct 删除重复项
var categories = Course.Catalog.Select(c => c.Category).Distinct();

所有这些集合操作符都有两种形式可用,因为你可以选择向其中任何一个传递一个 IEqualityComparer<T>。这允许你定制操作符如何决定两个项是否相同。

.NET 6.0 添加了 IntersectByExceptByUnionByDistinctBy 操作符。它们的基本目的与 IntersectExceptUnionDistinct 相同,但用于确定等效性的机制不同。你可以提供一个 lambda,它接受源集合中的一个元素作为输入,并产生任何你想要的输出。如果这个 lambda 对两个项产生相同的结果,则认为它们是相同的。(例如,你可以编写 courses.DistinctBy(c => c.Title),如果两个课程具有相同的 Title,则它们被视为相同。)你也可以通过编写自定义的 IEq⁠ual⁠ity​Com⁠par⁠er⁠<T> 来实现相同的效果,但使用投影通常更简单。(这四种方法的所有重载还接受一个 IEqualityComparer<T>。如果你的投影产生一个字符串,并且你想指定字符串比较机制,这可能很有用。)

整个序列、保持顺序的操作

LINQ 定义了一些操作符,它们的输出包括源中的每个项,并保留或者反转顺序。并非所有集合都一定有顺序,因此这些操作符并不总是被支持。不过,LINQ 对对象支持它们全部。最简单的是 Reverse,它反转了元素的顺序。

Concat操作符组合两个序列。它返回一个序列,该序列产生第一个序列中所有元素(以该序列返回它们的任何顺序),然后是第二个序列中所有元素(再次保持顺序)。在需要仅将单个元素添加到第一个序列末尾的情况下,可以使用Append。还有Prepend,它在开头添加单个项目。Repeat操作符有效地连接源的指定数量的副本。

DefaultIfEmpty操作符返回其源的所有元素。但是,如果源为空,它将返回单个元素。这个方法有两个重载版本:您可以指定源为空时返回的默认值,或者如果不传递参数,则使用元素类型的默认值,类似于零。

Zip操作符也可以组合两个序列,但不是依次返回每个元素,它是逐对元素进行操作。因此,它返回的第一个项目将基于第一个序列和第二个序列的第一个项目。zipped 序列中的第二个项目将基于每个序列的第二个项目,依此类推。名称Zip旨在让人联想到服装上拉链的作用,将两个物品完美对齐在一起。(这并不是一个精确的类比。当拉链将两部分连接时,两半部分的齿会交错连接。但Zip操作符不会像物理拉链的齿那样交错处理其输入。它将两个源的项目成对组合在一起。)

我们需要告诉Zip如何组合项目。它接受一个带有两个参数的 lambda 函数,将来自两个源的项目对作为这些参数传递,并生成你的 lambda 函数返回的输出项目。示例 10-52 使用一个选择器,通过字符串连接组合每对项目。

示例 10-52. 使用Zip组合列表
string[] firstNames = { "Elisenda", "Jessica", "Liam" };
string[] lastNames = { "Gascon", "Hill", "Mooney" };
`IEnumerable``<``string``>` `fullNames` `=` `firstNames``.``Zip``(``lastNames``,`
    `(``first``,` `last``)` `=``>` `first` `+` `" "` `+` `last``)``;`
foreach (string name in fullNames)
{
    Console.WriteLine(name);
}

此示例中Zip组合在一起的两个列表包含名字和姓氏。输出如下:

Elisenda Gascon
Jessica Hill
Liam Mooney

如果输入源包含不同数量的项目,Zip将在达到较短集合的末尾时停止,并且不会尝试从较长的集合中获取更多项目。它不会将不匹配的长度视为错误。

Zip还有一些不需要 lambda 函数的重载。这些重载只返回一个元组序列。有两个版本:一个是组合一对序列,产生 2 元组的版本,另一个是接受三个序列,将它们组合成 3 元组的版本。(没有对应的三个输入 lambda-based Zip。)

SequenceEqual 运算符与 Zip 类似,它对两个序列进行操作,并处理两个序列中在相同位置上找到的项目对。但是,SequenceEqual 只是比较每对项目是否相等,而不是将它们传递给 lambda 表达式进行组合。如果比较过程发现两个源包含相同数量的项,并且对于每一对,两个项目都相等,则返回 true。如果源长度不同,或者仅有一对项目不相等,则返回 falseSequenceEqual 有两个重载,一个只接受用于与源比较的列表,另一个还接受一个 IEqualityComparer<T> 以自定义相等的含义。

分组

有时候,你会想要将具有共同特点的所有项目作为一组进行处理。示例 10-53 使用查询按类别对课程进行分组,在列出该类别下的所有课程之前写出每个类别的标题。

示例 10-53. 分组查询表达式
var subjectGroups = from course in Course.Catalog
                    group course by course.Category;

foreach (var group in subjectGroups)
{
    Console.WriteLine("Category: " + group.Key);
    Console.WriteLine();

    foreach (var course in group)
    {
        Console.WriteLine(course.Title);
    }
    Console.WriteLine();
}

group 子句接受一个表达式,用于确定组成员资格——在本例中,任何返回相同值的 Category 属性的课程都将被视为同一组的成员。 group 子句生成一个集合,其中每个项实现表示组的类型。由于我正在使用 LINQ 对象,且按类别字符串进行分组,在 示例 10-53 中 subjectGroup 变量的类型将为 IEnumerable<IGrouping<string, Course>>。此特定示例生成了三个组对象,如 图 10-1 所示。

每个 IGrouping<string, Course> 项都有一个 Key 属性,由于查询通过课程的 Category 属性对项进行分组,每个键包含来自该属性的字符串值。在 示例 10-17 中的示例数据中有三个不同的类别名称:MATBIOCSE,因此这些是三个组的 Key 值。

IGrouping<TKey, TItem> 接口派生自 IEnumerable<TItem>,因此可以枚举每个组对象以查找它包含的项。因此,在 示例 10-53 中,外部 foreach 循环遍历查询返回的三个组,然后内部 foreach 循环遍历每个组中的 Course 对象。

图 10-1. 评估分组查询的结果

查询表达式变成了 示例 10-54 中的代码。

示例 10-54. 扩展简单分组查询
var subjectGroups = Course.Catalog.GroupBy(course => course.Category);

查询表达式在分组主题上提供了一些变体。通过对原始查询进行轻微修改,我们可以安排每个组中的项目不再是原始的Course对象。在示例 10-55 中,我已将group关键字后面的表达式从course改为了course.Title

示例 10-55. 使用项目投影的分组查询
var subjectGroups = from course in Course.Catalog
                    group course.Title by course.Category;

这仍然具有相同的分组表达式course.Category,因此仍然会生成三个组,但现在它的类型是IGrouping<string, string>。如果您迭代其中一个组的内容,您会发现每个组提供了一个字符串序列,其中包含课程名称。正如示例 10-56 所示,编译器会将此查询扩展为GroupBy操作符的另一个重载。

示例 10-56. 使用项目投影扩展的分组查询
var subjectGroups = Course.Catalog
    .GroupBy(course => course.Category, course => course.Title);

查询表达式要求其最后一个子句必须是selectgroup之一。然而,如果一个查询包含group子句,它不必是最后一个子句。在示例 10-55 中,我修改了查询如何表示每个组内的每个项目(即图 10-1 右侧的框),但我也可以自定义表示每个组的对象(左侧的项目)。默认情况下,我会得到IGrouping<TKey, TItem>对象(或者对于查询使用的任何 LINQ 提供程序的等效对象),但我可以更改这一点。示例 10-57 在其group子句中使用了可选的into关键字。这引入了一个新的范围变量,可以遍历组对象,我可以继续在查询的其余部分使用它。我可以跟随其他子句类型,比如orderbywhere,但在这种情况下,我选择使用了一个select子句。

示例 10-57. 使用组投影的分组查询
var subjectGroups =
    from course in Course.Catalog
    group course by course.Category into category
    select $"Category '{category.Key}' contains {category.Count()} courses";

此查询的结果是一个IEnumerable<string>,如果显示它生成的所有字符串,会得到以下内容:

Category 'MAT' contains 3 courses
Category 'BIO' contains 2 courses
Category 'CSE' contains 1 courses

如示例 10-58 所示,这会扩展为调用与示例 10-54 相同的GroupBy重载,然后在最后一个子句中使用普通的Select操作符。

示例 10-58. 扩展的组投影分组查询
IEnumerable<string> subjectGroups = Course.Catalog
    .GroupBy(course => course.Category)
    .Select(category =>
        $"Category '{category.Key}' contains {category.Count()} courses");

LINQ to Objects 定义了一些更多的GroupBy操作符重载,这些重载不能从查询语法中访问。示例 10-59 展示了一个提供稍微更直接等效于示例 10-57 的重载。

示例 10-59. 使用键和组投影的GroupBy
IEnumerable<string> subjectGroups = Course.Catalog.GroupBy(
    course => course.Category,
    (category, courses) =>
        $"Category '{category}' contains {courses.Count()} courses");

此重载采用两个 lambda 表达式。第一个是用于分组项目的表达式。第二个用于生成每个组对象。与之前的示例不同,这不使用IGrouping<TKey, TItem>接口。相反,最后一个 lambda 接收关键字作为一个参数,然后作为第二个参数接收组中项目的集合。这与IGrouping<TKey, TItem>封装的信息完全相同,但因为此操作符的此形式可以将它们作为单独的参数传递,所以它消除了需要创建用于表示组的对象的必要性。

在示例 10-60 中还展示了该操作符的另一个版本。它结合了所有其他变体的功能。

示例 10-60. 带有键、项目和组投影的GroupBy操作符
IEnumerable<string> subjectGroups = Course.Catalog.GroupBy(
    course => course.Category,
    course => course.Title,
    (category, titles) =>
         $"Category '{category}' contains {titles.Count()} courses: " +
             string.Join(", ", titles));

此重载采用三个 lambda 表达式。第一个是用于分组项目的表达式。第二个确定如何表示组内各个项目——这次我选择提取课程标题。第三个 lambda 用于生成每个组对象,就像示例 10-59 一样,最后一个 lambda 将关键字作为一个参数传递,并将其它参数作为组项目传递,由第二个 lambda 转换。因此,第二个参数不再是原始的Course项目,而是包含课程标题的IEnumerable<string>,因为这是本示例中第二个 lambda 请求的内容。GroupBy操作符的结果再次是一个字符串集合,但现在看起来像这样:

Category 'MAT' contains 3 courses: Elements of Geometry, Squaring the Circle, Hy
perbolic Geometry
Category 'BIO' contains 2 courses: Recreational Organ Transplantation, Introduct
ion to Human Anatomy and Physiology
Category 'CSE' contains 1 courses: Oversimplified Data Structures for Demos

我展示了GroupBy操作符的四个版本。所有四个版本都接受一个 lambda,用于选择用于分组的键,而最简单的重载仅接受键本身。其他版本让您控制组内各个项目的表示形式,或者每个组的表示形式,或者两者兼而有之。这个操作符还有另外四个版本,它们提供了与我已展示的四个版本完全相同的功能,但还接受一个IEqualityComparer<T>,让您可以自定义用于分组目的的逻辑来确定两个键是否被视为相同。

有时按多个值分组很有用。例如,假设您想按类别和出版年份分组课程。您可以链接操作符,首先按类别分组,然后按类别内的年份分组(或反之)。但您可能不希望这种嵌套水平——您可能希望将课程分组到每个唯一的Category和出版年份组合下。做法很简单,只需将两个值放入键中,可以通过使用匿名类型实现,如示例 10-61 所示。

示例 10-61. 复合组键
var bySubjectAndYear =
    from course in Course.Catalog
    group course by new { course.Category, course.PublicationDate.Year };
foreach (var group in bySubjectAndYear)
{
    Console.WriteLine($"{group.Key.Category} ({group.Key.Year})");
    foreach (var course in group)
    {
        Console.WriteLine(course.Title);
    }
}

这利用了匿名类型为我们实现了 EqualsGetHashCode 的事实。它适用于所有形式的 GroupBy 操作符。对于不将它们的 lambda 表达式视为表达式的 LINQ 提供程序(例如 LINQ to Objects),您可以改用元组,这样会更加简洁,但效果相同。

还有另一个分组输出的运算符称为 GroupJoin,但它作为联接操作的一部分执行,我们将先看一些更简单的联接。

联接操作

LINQ 定义了一个 Join 操作符,使得查询可以使用来自其他源的相关数据,就像数据库查询可以将一张表中的信息与另一张表中的数据联接一样。假设我们的应用程序存储了哪些学生报名了哪些课程的列表。如果将该信息存储在文件中,您不希望将课程或学生的完整详细信息复制到每一行中,而是只希望有足够的信息来识别学生和特定的课程。在我的示例数据中,课程通过类别和编号的组合唯一标识。为了跟踪谁报名了什么课程,我们需要记录包含三个信息:课程类别、课程编号以及用于识别学生的某些信息。在 示例 10-62 中的记录类型显示了我们可以如何在内存中表示这种关联。

Example 10-62. 记录类型关联学生和课程
public record CourseChoice(int StudentId, string Category, int Number);

一旦我们的应用程序将这些信息加载到内存中,我们可能希望访问 Course 对象,而不仅仅是识别课程的信息。我们可以通过 join 子句实现这一点,如 示例 10-63 所示(它还使用 CourseChoice 类提供了一些额外的示例数据,以便查询有可用的数据)。

Example 10-63. 使用 join 子句查询
CourseChoice[] choices =
{
    new CourseChoice(StudentId: 1, Category: "MAT", Number: 101),
    new CourseChoice(StudentId: 1, Category: "MAT", Number: 102),
    new CourseChoice(StudentId: 1, Category: "MAT", Number: 207),
    new CourseChoice(StudentId: 2, Category: "MAT", Number: 101),
    new CourseChoice(StudentId: 2, Category: "BIO", Number: 201),
};

var studentsAndCourses = from choice in choices
                         `join` `course` `in` `Course``.``Catalog`
                           `on` `new` `{` `choice``.``Category``,` `choice``.``Number` `}`
                           `equals` `new` `{` `course``.``Category``,` `course``.``Number` `}`
                         select new { choice.StudentId, Course = course };

foreach (var item in studentsAndCourses)
{
    Console.WriteLine(
        $"Student {item.StudentId} will attend {item.Course.Title}");
}

这显示了 choices 数组中每个条目的一行。它显示了每门课程的标题,因为尽管在输入集合中未提供该信息,但 join 子句定位了课程目录中的相关条目。示例 10-64 展示了编译器如何将 示例 10-63 中的查询转换。

Example 10-64. 直接使用 Join 操作符
var studentsAndCourses = choices.Join(
    Course.Catalog,
    choice => new { choice.Category, choice.Number },
    course => new { course.Category, course.Number },
    (choice, course) => new { choice.StudentId, Course = course });

Join操作符的作用是查找第二个序列中与第一个项目对应的项目。这种对应关系由前两个 lambda 表达式决定;如果这两个 lambda 返回的值相等,则来自两个源的项目将被视为相互对应。本示例使用匿名类型,并依赖于同一程序集中两个结构上相同的匿名类型实例具有相同类型的事实。换句话说,这两个 lambda 都生成具有相同类型的对象。编译器为任何匿名类型生成一个Equals方法,逐个比较每个成员,因此该代码的效果是,如果它们的CategoryNumber属性相等,则认为两行相对应。(再次强调,对于基于IQueryable<T>的提供程序,我们必须使用匿名类型,而不是元组,因为这些 lambda 将转换为表达式树。但由于此示例使用非表达式的提供程序,LINQ 到对象,您可以稍微简化此代码,改用元组。)

我已经设置了这个示例,以便只能有一个匹配项,但如果课程类别和编号由于某种原因不能唯一标识课程会发生什么?如果任何单个输入行有多个匹配项,Join操作符将为每个匹配项生成一个输出项,因此在这种情况下,输出项数量将超过choices数组中的条目数。相反,如果第一个源中的项目在第二个集合中没有对应的项目,Join将不会为该项目生成任何输出项——它实际上会忽略该输入项。

LINQ 提供了一种替代的连接类型,用于处理具有零个或多个相应行的输入行,其方式与Join操作符不同。示例 10-65 展示了修改后的查询表达式。(区别在于join子句末尾添加了into courses,最终的select子句引用该子句而不是course范围变量。)这会以不同的形式生成输出,因此我还修改了编写结果的代码。

示例 10-65. 分组连接
var studentsAndCourses =
    from choice in choices
    join course in Course.Catalog
      on new { choice.Category, choice.Number }
      equals new { course.Category, course.Number }
      `into` `courses`
    select new { choice.StudentId, Courses = courses };

foreach (var item in studentsAndCourses)
{
    Console.WriteLine($"Student {item.StudentId} will attend " +
        string.Join(",", item.Courses.Select(course => course.Title)));
}

如示例 10-66 所示,这导致编译器生成对GroupJoin操作符的调用,而不是Join

示例 10-66. GroupJoin操作符
var studentsAndCourses = choices.GroupJoin(
    Course.Catalog,
    choice => new { choice.Category, choice.Number },
    course => new { course.Category, course.Number },
    (choice, courses) => new { choice.StudentId, Courses = courses });

这种连接形式通过调用最终的 lambda 为输入集合中的每个项目生成一个结果。它的第一个参数是输入项,第二个参数将是来自第二个集合的所有相应对象的集合。(与Join相比,后者为每个匹配项调用最终的 lambda 一次,逐个传递相应的项目。)这提供了一种表示第二个集合中没有相应项目的输入项的方法:操作符可以简单地传递一个空集合。

JoinGroupJoin还有重载,接受IEqualityComparer<T>以便您可以为前两个 lambda 返回的值定义自定义的相等性意义。

转换

有时您需要将一个类型的查询转换为另一种类型。例如,您可能已经得到一个集合,其中类型参数指定了某个基本类型(例如object),但您有充分理由相信该集合实际上包含某些更具体类型的项目(例如Course)。在处理单个对象时,您可以使用 C#的转型语法将引用转换为您认为正在处理的类型。不幸的是,这对于诸如IEnumerable<T>IQueryable<T>之类的类型并不适用。

虽然协变意味着IEnumerable<Course>可以隐式转换为IEnumerable<object>,但即使使用显式向下转换也不能反向转换。如果您有一个类型为IEnumerable<object>的引用,试图将其转换为IEnumerable<Course>将仅在对象实现IEnumerable<Course>时成功。很可能最终得到一个完全由Course对象组成但不实现IEnumerable<Course>的序列。注意,示例 10-67 创建了这样的序列,当试图转换为IEnumerable<Course>时将抛出异常。

示例 10-67. 如何避免对序列进行强制转换
IEnumerable<object> sequence = Course.Catalog.Select(c => (object) c);
var courseSequence = (IEnumerable<Course>) sequence; // InvalidCastException

当然,这是一个刻意设计的示例。我通过将Select lambda 的返回类型强制转换为object来强制创建一个IEnumerable<object>。然而,在稍微复杂的情况下,很容易陷入这种情况。幸运的是,有一个简单的解决方案。您可以使用Cast<T>运算符,如示例 10-68 所示。

示例 10-68. 如何对序列进行强制转换
var courseSequence = sequence.Cast<Course>();

这返回一个查询,按顺序产生其来源中的每个项目,但在执行此操作时将每个项目转换为指定的目标类型。这意味着尽管最初的Cast<T>可能成功,但在稍后尝试从序列中提取值时可能会得到InvalidCastException。毕竟,通常来说,Cast<T>运算符能够验证您提供的序列确实只产生类型为T的值的唯一方法是提取所有这些值并尝试转换它们。它无法预先评估整个序列,因为您可能提供了一个无限序列。如果您的序列首次产生的十亿个项目是正确类型的,但之后返回一个不兼容类型的项目,那么Cast<T>发现这一点的唯一方法就是逐个尝试转换项目。

小贴士

Cast<T>OfType<T> 看起来相似,有时开发人员在应该使用另一个时使用了一个(通常是因为他们不知道两者都存在)。OfType<T> 几乎与 Cast<T> 做的事情一样,但它会静默地过滤掉任何错误类型的项目,而不是抛出异常。如果您期望并希望忽略错误类型的项目,请使用 OfType<T>。如果您不期望错误类型的项目出现,请使用 Cast<T>,因为如果您错了,它会通过抛出异常来告诉您,减少隐藏潜在 bug 的风险。

LINQ to Objects 定义了一个 AsEnumerable<T> 运算符。它只是返回源而没有任何修改——在运行时没有任何效果。其目的是即使处理可能由不同的 LINQ 提供程序处理的内容,也强制使用 LINQ to Objects。例如,假设您有一个实现了 IQueryable<T> 的东西。该接口从 IEnumerable<T> 派生,但是适用于 IQueryable<T> 的扩展方法将优先于 LINQ to Objects。如果您的意图是在数据库上执行特定查询,然后使用 LINQ to Objects 进行进一步的客户端处理结果,您可以使用 AsEnumerable<T> 来划定界限,表示“这是我们将事务移到客户端的地方”。

相反,还有 AsQueryable<T>。它设计用于具有静态类型 IEnumerable<T> 变量的场景,您认为该变量可能包含对也实现 IQueryable<T> 的对象的引用,并且您希望任何创建的查询都使用该引用而不是 LINQ to Objects。如果您在一个实际上不实现 IQueryable<T> 的源上使用此运算符,则返回一个实现 IQueryable<T> 的包装器,但在内部使用 LINQ to Objects。

另一个选择不同 LINQ 口味的运算符是 AsParallel。它返回 ParallelQuery<T>,允许您构建由并行 LINQ 执行的查询,这是一个能够利用多个 CPU 核心并行执行某些操作以提高性能的 LINQ 提供程序。

有一些运算符可以将查询转换为其他类型,同时也会立即执行查询,而不是在先前查询的基础上构建新的查询链。ToArrayToListToHashSet 分别返回包含执行输入查询完整结果的数组、列表或哈希集合。ToDictionaryToLookup 同样如此,但它们不是产生简单的项目列表,而是生成支持关联查找的结果。ToDictionary 返回 Dictionary<TKey, TValue>,因此适用于键对应于唯一值的场景。ToLookup 设计用于键可能关联多个值的场景,因此返回不同类型 ILookup<TKey, TValue>

我在 Chapter 5 中没有提及此查找接口,因为它特定于 LINQ。它与只读字典接口本质上相同,只是索引器返回 IEnumerable<TValue> 而不是单个 TValue

数组和列表转换不需要参数,但字典和查找转换需要告诉每个源项要使用的键值。正如 Example 10-69 所示,通过传递 lambda 来告诉它们。这里使用课程的 Category 属性作为键。

Example 10-69. 创建查找
ILookup<string, Course> categoryLookup =
    Course.Catalog.ToLookup(course => course.Category);
foreach (Course c in categoryLookup["MAT"])
{
    Console.WriteLine(c.Title);
}

ToDictionary 操作符提供了一个重载,参数与 ToLookup 相同,但返回字典而不是查找表。如果你像在 Example 10-69 中调用 ToLookup 一样调用它,会抛出异常,因为多个课程对象共享类别,它们会映射到相同的键。ToDictionary 要求每个对象具有唯一的键。要从课程目录生成字典,你需要首先按类别分组数据,并使每个字典条目引用整个组,或者需要一个 lambda 返回基于课程类别和编号的复合键,因为该组合对于课程是唯一的。

这两个操作符还提供了一个重载,接受一对 lambda 表达式——一个提取键,另一个选择用作相应值的内容(您无需使用源项作为值)。最后,还有接受 IEqualityComparer<T> 的重载。

现在您已经看到所有标准 LINQ 操作符,但由于这占据了相当多的页面,您可能会发现具有简洁摘要很有用。Table 10-1 列出了这些操作符,并简要描述了每个操作符的用途。

Table 10-1. LINQ 操作符摘要

操作符目的
Aggregate通过用户提供的函数组合所有项以产生单个结果。
All如果对所有项条件均不满足,返回true
Any如果对至少一个项满足条件,返回true
Append返回具有所有输入序列中的项及末尾添加的一项的序列。
AsEnumerable将序列作为 IEnumerable<T> 返回。(强制使用 LINQ to Objects 很有用。)
AsParallel返回用于并行查询执行的ParallelQuery<T>
AsQueryable确保在可用时使用 IQueryable<T> 处理。
Average计算项的算术平均值。
Cast将序列中的每个项转换为指定类型。
Chunk将序列分割成相等大小的批次。
Concat通过连接两个序列形成一个新序列。
Contains如果序列中包含指定的项,则返回true
Count, LongCount返回序列中的项数。
DefaultIfEmpty生成源序列的元素,除非没有元素,此时生成一个具有默认值的单个元素。
Distinct删除重复值。
DistinctBy删除投影生成重复值的值。
ElementAt返回指定位置的元素(如果超出范围则抛出异常)。
ElementAtOrDefault返回指定位置的元素(如果超出范围则生成元素类型的默认值)。
Except过滤掉在另一个提供的集合中的项目。
First返回第一个项目,如果没有项目则抛出异常。
FirstOrDefault如果没有项目,则返回第一个项目或默认值。
GroupBy将项目分组。
GroupJoin根据它们与输入序列中项目的关系,对另一个序列中的项目进行分组。
Intersect过滤掉不在另一个提供的集合中的项目。
IntersectBy使用投影进行比较的Intersect
Join对两个输入序列中每个匹配对的项目生成一个项目。
Last返回最后一个项目,如果没有项目则抛出异常。
LastOrDefault如果没有项目,则返回最后一个项目或默认值。
Max返回最高值。
MaxBy返回投影产生最高值的项目。
Min返回最低值。
MinBy返回投影产生最低值的项目。
OfType过滤掉不是指定类型的项目。
OrderBy以升序生成项目。
OrderByDescending以降序生成项目。
Prepend返回以指定单个项目开始,后跟其输入序列中所有项目的序列。
Reverse以与输入相反的顺序生成项目。
Select通过函数对每个项目进行投影。
SelectMany将多个集合合并为一个。
SequenceEqual仅当所有项目与另一个提供的序列中的项目相等时返回true
Single返回唯一项目,如果没有项目或多于一个项目则抛出异常。
SingleOrDefault返回唯一项目或默认值(如果没有项目则抛出异常);如果存在多个项目则抛出异常。
Skip从开头过滤指定数量的项目。
SkipLast从末尾过滤掉指定数量的项目。
SkipWhile从开头开始过滤项目,直到项目不匹配为止。
Sum返回所有项目相加的结果。
Take生成指定数量或范围的项目,丢弃其余项目。
TakeLast从输入的末尾生成指定数量的项目(丢弃之前的所有项目)。
TakeWhile只要项目匹配谓词,就生成项目;一旦有一个项目不匹配,就丢弃其余序列。
ToArray返回包含所有项目的数组。
ToDictionary返回包含所有项目的字典。
ToHashSet返回包含所有项目的HashSet<T>
ToList返回包含所有项目的List<T>
ToLookup返回包含所有项目的多值关联查找。
Union生成位于任一输入中或两者中的所有项目。
UnionByUnion相同,但使用投影进行比较。
Where过滤掉不符合提供的谓词的项目。
Zip将来自两个或三个输入的相同位置的项目组合在一起。

序列生成

Enumerable类定义了扩展方法,用于IEnumerable<T>,构成了 LINQ to Objects。它还提供了一些额外的(非扩展)静态方法,用于创建新的序列。Enumerable.Range接受两个int参数,并返回一个IEnumerable<int>,产生从第一个参数值开始的连续递增数字系列,包含第二个参数指定的数字个数。例如,Enumerable.Range(15, 10)生成包含数字 15 到 24(包括)的序列。

Enumerable.Repeat<T>接受类型为T的值和计数。它返回一个序列,该序列将产生指定次数的该值。

Enumerable.Empty<T>返回一个不包含任何元素的IEnumerable<T>。这听起来可能不是很有用,因为有一个更简洁的替代方案。你可以写new T[0],它创建一个不包含任何元素的数组(类型为T的数组实现了IEnumerable<T>)。然而,Enumerable.Empty<T>的优点在于,对于任何给定的T,它每次返回相同的实例。这意味着如果由于任何原因你需要在执行许多迭代的循环中重复使用空序列,Enumerable.Empty<T>更高效,因为它对 GC 的压力较小。

其他 LINQ 实现

本章中大多数我展示的示例都使用了 LINQ to Objects,除了少数几个引用了 EF Core。在这最后一节中,我将快速描述一些其他基于 LINQ 的技术。这并不是一个详尽的列表,因为任何人都可以编写 LINQ 提供程序。

实体框架核心

我展示的数据库示例使用了 Entity Framework Core(EF Core)的 LINQ 提供程序。EF Core 是一种数据访问技术,以 NuGet 包Microsoft.EntityFrameworkCore的形式提供。(EF Core 的前身,Entity Framework,仍内置于.NET Framework 中,但不包括在较新版本的.NET 中。)EF Core 可以在数据库和对象层之间进行映射。它支持多个数据库供应商。

EF Core 依赖于 IQueryable<T>。对于数据模型中的每个持久化实体类型,EF 可以提供一个实现 IQueryable<T> 的对象,作为构建检索该类型和相关类型实体查询的起点。由于 IQueryable<T> 不仅仅适用于 EF,您将使用 System.Linq 命名空间中提供的标准扩展方法集,但该机制设计用于允许每个提供程序插入其自己的行为。

因为 IQueryable<T> 将 LINQ 操作符定义为接受 Expression<T> 参数的方法,而不是普通的委托类型,所以您在查询表达式或作为底层操作符方法的 lambda 参数中编写的任何表达式都将转换为由编译器生成的代码,创建表示表达式结构的对象树。EF 依赖于此能力来生成检索所需数据的数据库查询。这意味着您必须使用 lambda;与 LINQ to Objects 不同,您不能在 EF 查询中使用匿名方法或委托。

警告

因为 IQueryable<T> 派生自 IEnumerable<T>,所以可以在任何 EF 源上使用 LINQ to Objects 操作符。您可以通过 AsEnumerable<T> 操作符明确地执行此操作,但如果使用的重载支持 LINQ to Objects 而不支持 IQueryable<T>,也可能会发生意外情况。例如,如果尝试使用委托而不是 lambda 作为 Where 操作符的谓词,这将回退到 LINQ to Objects。这里的要点是,EF 最终会下载整个表的内容,然后在客户端上评估 Where 操作符。这不太可能是一个好主意。

并行 LINQ(PLINQ)

并行 LINQ 与 LINQ to Objects 相似,因为它基于对象和委托,而不是表达式树和查询翻译。但是,当您开始从查询请求结果时,它将尽可能使用多线程评估,利用线程池来有效地使用可用的 CPU 资源。第十六章 将展示多线程操作的实际效果。

XML 的 LINQ

LINQ to XML 不是一个 LINQ 提供程序。我在这里提到它,因为它的名称听起来像一个。它真正是一个用于创建和解析 XML 文档的 API。它被称为LINQ to XML,因为它旨在通过 .NET 对象模型轻松执行对 XML 文档的 LINQ 查询,但它通过 .NET 对象模型来呈现 XML 文档来实现这一点。运行库提供了两个单独的 API 来实现这一点:除了 LINQ to XML 外,它还提供了 XML 文档对象模型(DOM)。DOM 基于一个平台无关的标准,因此与 .NET 习惯用法不太匹配,并且与大多数运行库相比感觉不必要地古怪。LINQ to XML 纯粹是为 .NET 设计的,因此它与普通的 C# 技术集成得更好。这包括与 LINQ 良好地配合工作,它通过提供从文档中提取特性的方法来推迟到 LINQ to Objects 来定义和执行查询。

IAsyncEnumerable<T>

正如第五章所述,.NET 定义了IAsyncEnumerable<T>接口,这是IEnumerable<T>的异步等价物。第十七章将描述语言特性,使您能够使用这个接口。虽然 .NET 运行库中没有内置完整的 LINQ 操作符集,但它们在一个名为System.Linq.Async的 NuGet 包中可用。

响应式扩展

.NET 的响应式扩展(或简称为 Rx)是下一章的主题,因此我不会在这里过多介绍它们,但它们很好地说明了 LINQ 操作符如何在各种类型上工作。Rx 反转了本章展示的模型,我们可以在准备好并且需要数据时调用一个 Rx 源,而不是编写一个foreach循环来迭代查询,或者调用诸如ToArraySingleOrDefault等评估查询的运算符。

尽管如此,Rx 有一个 LINQ 提供程序,支持大多数标准的 LINQ 操作符。

总结

在本章中,我展示了支持一些最常用的 LINQ 特性的查询语法。这使我们能够在 C# 中编写类似于数据库查询的查询,但可以查询任何 LINQ 提供程序,包括 LINQ to Objects,使我们能够针对我们的对象模型运行查询。我展示了用于查询的标准 LINQ 操作符,所有这些操作符都可以在 LINQ to Objects 中使用,大多数可以在数据库提供程序中使用。我还提供了一些常见的 .NET 应用程序的 LINQ 提供程序的快速概述。

我提到的最后一个提供程序是 Rx。但在我们查看 Rx 的 LINQ 提供程序之前,下一章将从如何使用 Rx 本身开始。

¹ 当我写这篇文章时,.NET 7.0 的初步功能集包括修复这个问题,因此有一些希望这可能会得到改善。

² 如果你这样做,请注意不要将其与另一种 WPF 类型Rectangle混淆。那是一个更为复杂的实体,支持动画、样式、布局、用户输入、数据绑定以及其他各种 WPF 功能。请勿在 WPF 应用程序之外尝试使用Rectangle

³ 这与在前面示例中使用的Rect.Union方法无关。

第十一章:反应式扩展

.NET 反应式扩展(通常简称为Rx)专为处理异步和基于事件的信息源而设计。Rx 提供了帮助您编排和同步代码对这些类型数据反应的服务。我们已经看到如何在 第九章 中定义和订阅事件,但 Rx 提供的远不止这些基本功能。它提供了一个比事件更陡峭的事件源抽象,但却配备了一组强大的操作符,使得组合和管理多个事件流比使用委托和 .NET 事件提供的自由组合更加容易。微软还推出了一个名为 Reaqtor 的相关库,它基于 Rx 的基础提供了一个可靠、有状态、分布式、可扩展、高性能的事件处理服务框架。

Rx 的基本抽象是 IObservable<T>,它表示一个项的序列,其操作符定义为此接口的扩展方法。这听起来很像 LINQ to Objects,它们确实有相似之处 —— 不仅 IObservable<T>IEnumerable<T> 有很多共同之处,而且 Rx 也支持几乎所有标准的 LINQ 操作符。如果您熟悉 LINQ to Objects,那么您在 Rx 中也会感到如鱼得水。区别在于,在 Rx 中,序列不那么被动。与 IEnumerable<T> 不同,Rx 源不等待请求其项,消费者也不能要求提供下一个项。相反,Rx 使用一种推送模型,在此模型中,源在项可用时通知其接收者。

举例来说,如果您正在编写一个处理实时金融信息(例如股市价格数据)的应用程序,IObservable<T> 模型比 IEnumerable<T> 更为自然。因为 Rx 实现了标准的 LINQ 操作符,您可以对实时数据源编写查询 —— 您可以通过 where 子句筛选事件流,或者按股票代码分组。Rx 不仅限于标准的 LINQ,它还添加了自己的操作符,考虑了实时事件源的时间性质。例如,您可以编写一个查询,仅提供更频繁变动价格的股票数据。

Rx 的推送式方法使其比IEnumerable<T>更适合类似事件的源。但为什么不直接使用事件,甚至普通的委托呢?Rx 解决了这些替代方案的四个缺点。首先,它定义了源报告错误的标准方式。其次,在涉及多个源的多线程场景中,它能够以明确定义的顺序传递项。第三,Rx 提供了清晰的方法来信号化没有更多项的时候。第四,因为传统事件是特殊类型的成员,而不是正常的对象,所以对于事件的使用有显著的限制——你不能将.NET 事件作为参数传递给方法,存储在字段中或在属性中提供。你可以使用委托来处理事件,但这并不相同——委托可以处理事件,但不能表示它们的源。没有办法编写一个订阅某个.NET 事件的方法,并将其作为参数传递,因为你不能传递实际的事件本身。Rx 通过将事件源表示为对象而不是类型系统中不像其他任何东西的特殊的独特元素来修复了这一点。

当然,在IEnumerable<T>的世界中,我们可以免费获得这四个特性。集合在枚举其内容时可能会抛出异常,但使用回调时,何时何地传递异常就不那么明显了。IEnumerable<T>让消费者逐个检索项,所以排序是明确的,但使用普通事件和委托时,并没有强制执行这一点。而IEnumerable<T>告诉消费者集合已经结束时,使用简单回调时,并不一定清楚何时发出了最后一次调用。IObservable<T>处理了所有这些情况,将我们在IEnumerable<T>中可以理所当然的事情带入了事件的世界。

通过提供一个统一的抽象来解决这些问题,Rx 能够将 LINQ 的所有优势带入事件驱动的场景中。如果 Rx 能够替代事件的话,我就不会在第九章中专门提到它们了。事实上,Rx 可以与事件集成。它可以在其自身的抽象和其他几种抽象之间架起桥梁,不仅仅是普通事件,还有IEnumerable<T>和各种异步编程模型。远非淘汰事件,Rx 将它们的功能提升到了一个新的水平。理解 Rx 要比理解事件难得多,但一旦理解了,它提供的能力就远超过后者。

Rx 的核心是两个接口。通过这个模型展示项的源实现了IObservable<T>。订阅者需要提供一个实现了IObserver<T>的对象。这两个接口内置于.NET 中。Rx 的其他部分包含在System.Reactive NuGet 包中。

基本接口

Rx 中最重要的两种类型是 IObservable<T>IObserver<T> 接口。它们足够重要,以至于位于 System 命名空间中。示例 11-1 显示了它们的定义。

示例 11-1. IObservable<T>IObserver<T>
public interface IObservable<out T>
{
    IDisposable Subscribe(IObserver<T> observer);
}

public interface IObserver<in T>
{
    void OnCompleted();
    void OnError(Exception error);
    void OnNext(T value);
}

Rx 中的基本抽象 IObservable<T> 由事件源实现。它模拟事件作为项目序列,而不是使用 event 关键字。IObservable<T> 根据准备好提供项目时为订阅者提供项目。

正如您所看到的,IObservable<T> 的类型参数是协变的,这意味着如果您有一个类型 Base 是另一个类型 Derived 的基类型,那么就像您可以将 Derived 传递给任何期望 Base 的方法一样,您可以将 IObservable<Derived> 传递给任何期望 IObservable<Base> 的东西。直观地看,这里使用 out 关键字是有道理的,因为像 IEnumerable<T> 一样,这是信息的来源——项目从中出来。相反,项目进入订阅者的 IObserver<T> 实现,因此它具有 in 关键字,表示逆变性——您可以将 IObserver<Base> 传递给任何期望 IObserver<Derived> 的东西。(我在 第六章 中描述了变体。)

我们可以通过将 IObserver<T> 的实现传递给 Subscribe 方法来订阅源。当源希望报告事件时,它将调用 OnNext,并且可以调用 OnCompleted 来指示不再有进一步的活动。如果源希望报告错误,它可以调用 OnErrorOnCompletedOnError 都表示流的结束——在此之后,观察者上的任何方法都不应再调用。

警告

如果您违反此规则,不一定会立即收到异常。在某些情况下会收到异常——如果您使用 NuGet 的 System.Reactive 库来帮助实现和消费这些接口,则某些情况下它可以检测到此类错误。但通常情况下,调用这些方法的代码负责遵守这个规则。

表示 Rx 活动的视觉约定有一个视觉约定。有时称为 弹珠图,因为它主要由看起来有点像弹珠的小圆圈组成。图 11-1 使用这种约定来表示两个事件序列。水平线表示对源的订阅,左侧的竖线表示订阅开始,水平位置表示事件发生的时间(从左到右的经过时间)。圆圈表示对 OnNext 的调用(即源报告的事件)。右端的箭头表示订阅在图表表示的时间结束时仍然活动。右侧的竖线表示订阅结束——由于调用 OnErrorOnCompleted 或订阅者取消订阅。

图 11-1. 简单的弹珠图

当您在可观察对象上调用 Subscribe 时,它会返回一个实现 IDisposable 接口的对象,提供取消订阅的方法。如果调用 Dispose,可观察对象将不再向您的观察者发送任何通知。这比取消事件的机制更方便;要取消事件,您必须传入与用于订阅的委托相等的委托。如果您使用匿名方法,这可能会让人感到令人惊讶的笨拙,因为通常唯一的方法是保留对原始委托的引用。使用 Rx,对源的任何订阅都表示为 IDisposable,使其更容易以统一的方式处理。事实上,通常您根本不需要取消订阅 —— 这只有在希望在源完成之前停止接收通知时才是必需的(这是 .NET 中相对不常见的事情的一个示例:可选的可释放性)。

IObserver

如您所见,在实践中,我们通常不直接调用源的 Subscribe 方法,也不通常需要自己实现 IObserver<T>。相反,通常使用 Rx 提供的基于委托的扩展方法,该方法附加了一个 Rx 提供的实现。然而,这些扩展方法不是 Rx 的基本类型的一部分,所以现在我将展示如果这些接口是您唯一拥有的内容,您需要编写什么。示例 11-2 展示了一个简单但完整的观察者。

示例 11-2. 简单的 IObserver<T> 实现
class MySubscriber<T> : IObserver<T>
{
    public void OnNext(T value) => Console.WriteLine("Received: " + value);
    public void OnCompleted() => Console.WriteLine("Complete");
    public void OnError(Exception ex) => Console.WriteLine("Error: " + ex);
}

Rx 源(即 IObservable<T> 的实现)必须对如何调用观察者方法作出某些保证。调用发生在特定顺序中:对于源提供的每个项目,都会调用 OnNext 方法,我已经提到,一旦调用 OnCompletedOnError 中的任何一个,观察者就知道不会再调用这三种方法中的任何一个。这两种方法中的任何一种信号序列的结束。

另外,不允许调用重叠 —— 当可观察源调用我们观察者的方法之一时,必须等待该方法返回后再调用。多线程可观察源必须小心协调其调用,即使在单线程世界中,递归的可能性也可能需要源检测和防止重入调用。

这让观察者的生活变得简单。因为 Rx 将事件作为一个序列提供,我的代码不需要处理并发调用的可能性。调用方法的正确顺序取决于源。因此,尽管 IObservable<T> 界面看起来更简单,只有一个方法,但实际上更难以实现。稍后您会看到,让 Rx 库为您实现这一点通常是最简单的,但了解可观察源如何工作仍然很重要,因此我将从头开始手动实现它。

IObservable

Rx 区分可观察源。热可观察源会在有趣的事情发生时产生每个值,并且如果此时没有订阅者附加,那么该值将丢失。热可观察源通常代表实时事件,例如鼠标输入、按键或传感器报告的数据,因此它们生成的值独立于附加的订阅者数量。热源通常具有类似广播的行为—它们将每个项目发送给所有订阅者。这些可能是更复杂的源的实现方式,因此我将先讨论冷源。

实现冷源

热源根据自己的意愿报告项目,而冷可观察源的工作方式有所不同。它们在观察者订阅时开始推送值,并且将值分别提供给每个订阅者,而不是广播。这意味着订阅者不会因为太迟而错过任何内容,因为源在你订阅时开始提供项目。示例 11-3 展示了一个非常简单的冷源。

示例 11-3. 一个简单的冷可观察源
public class SimpleColdSource : IObservable<string>
{
    public IDisposable Subscribe(IObserver<string> observer)
    {
        observer.OnNext("Hello,");
        observer.OnNext("World!");
        observer.OnCompleted();
        return NullDisposable.Instance;
    }

    private class NullDisposable : IDisposable
    {
        public readonly static NullDisposable Instance = new();
        public void Dispose() { }
    }
}

一旦观察者订阅,这个源将提供两个值,字符串 "Hello,""World!",然后通过调用 OnCompleted 表示序列结束。它在 Subscribe 中完成所有这些操作,所以这实际上看起来不像是一个订阅—在 Subscribe 返回时序列已经结束,所以支持取消订阅没有任何实际意义。这就是为什么这返回一个 IDisposable 的微不足道的实现。(我选择了一个极其简单的示例来展示基础知识。真实的源会更复杂。)

要展示这个过程,我们需要创建一个 SimpleColdSource 的实例,还需要从 示例 11-2 中创建一个我的观察者类的实例,并使用它订阅源,就像 示例 11-4 所做的那样。

示例 11-4. 将观察者附加到可观察源
var source = new SimpleColdSource();
var sub = new MySubscriber<string>();
source.Subscribe(sub);

预计,这将产生以下输出:

Received: Hello,
Received: World!
Complete

一般来说,冷观察者将可以访问某些底层信息源,它可以按需推送给订阅者。在 示例 11-3 中,那个“源”只是两个硬编码的值。示例 11-5 展示了一个稍微有趣的冷可观察源,它读取文件中的行并将它们提供给订阅者。

示例 11-5. 一个表示文件内容的冷可观察源
public class FilePusher : IObservable<string>
{
    private readonly string _path;
    public FilePusher(string path)
    {
        _path = path;
    }

    public IDisposable Subscribe(IObserver<string> observer)
    {
        using (var sr = new StreamReader(_path))
        {
            while (!sr.EndOfStream)
            {
                string? line = sr.ReadLine();
                if (line is not null)
                {
                    observer.OnNext(line);
                }
            }
        }
        observer.OnCompleted();
        return NullDisposable.Instance;
    }

    private class NullDisposable : IDisposable
    {
        public static NullDisposable Instance = new();
        public void Dispose() { }
    }
}

与之前一样,这并不表示事件的实时源,它只有在有订阅发生时才会启动,但比示例 11-3 更加有趣。每当从文件中检索到每一行时,它会调用观察者,因此虽然它开始工作的时间由订阅者确定,但这个源控制它提供值的速率。就像示例 11-3 一样,在Subscribe调用内部,它将所有项传递给调用者线程上的观察者,但从示例 11-5 到一个从文件读取数据时运行在单独线程或使用异步技术(例如第十七章中描述的)的概念跨度可能相对较小,从而使Subscribe在工作完成之前返回(在这种情况下,您需要编写一个更有趣的IDisposable实现来允许调用者取消订阅)。这仍然是一个冷源,因为它代表一些基础数据集,可以从开始为每个订阅者的利益枚举。

示例 11-5 还不完整——它未能处理从文件读取时发生的错误。我们需要捕获这些错误并调用观察者的OnError方法。不幸的是,简单地将整个循环放在try块中并不那么简单,因为这也会捕获来自观察者的OnNext方法的异常。如果OnNext抛出异常,我们应该允许它继续向上堆栈传播——我们应该只处理我们代码中预期的位置出现的异常。不幸的是,这使代码变得相当复杂。示例 11-6 将使用FileStream的所有代码放在try块内,但将允许观察者抛出的任何异常向上传播,因为我们无权处理这些异常。

示例 11-6. 处理文件系统错误但不处理观察者错误
public IDisposable Subscribe(IObserver<string> observer)
{
    StreamReader? sr = null;
    string? line = null;
    bool failed = false;

    try
    {
        while (true)
        {
            try
            {
                if (sr == null)
                {
                    sr = new StreamReader(_path);
                }
                if (sr.EndOfStream)
                {
                    break;
                }
                line = sr.ReadLine();
            }
            catch (IOException x)
            {
                observer.OnError(x);
                failed = true;
                break;
            }

            if (line is not null)
            {
                observer.OnNext(line);
            }
            else
            {
                break;
            }
        }
    }
    finally
    {
        if (sr != null)
        {
            sr.Dispose();
        }
    }
    if (!failed)
    {
        observer.OnCompleted();
    }
    return NullDisposable.Instance;
}

如果在从文件读取时发生 I/O 异常,则会报告给观察者的OnError方法——因此,此源使用IObserver<T>的所有三个方法。

实现热源

热源在数值可用时通知所有当前订阅者。这意味着任何热 observable 必须跟踪当前已订阅的观察者。在热源中,订阅和通知被分开处理,这种方式通常在冷源中是不会出现的。

示例 11-7 是一个可观察源,每次按键报告一个项目,作为热源,它非常简单。它是单线程的,因此不需要采取任何特殊措施来避免重叠调用。它不报告错误,因此从不需要调用观察者的OnError方法。而且它永不停止,因此也不需要调用OnCompleted。即便如此,它也相当复杂。(一旦我介绍 Rx 库支持,情况将会简单得多——目前,我只坚持使用两个基本接口,所以这个示例相对复杂。)

示例 11-7. 用于监控按键的IObservable<T>
public class KeyWatcher : IObservable<char>
{
    private readonly List<Subscription> _subscriptions = new();

    public IDisposable Subscribe(IObserver<char> observer)
    {
        var sub = new Subscription(this, observer);
        _subscriptions.Add(sub);
        return sub;
    }

    public void Run()
    {
        while (true)
        {
            // Passing true here stops the console from showing the character
            char c = Console.ReadKey(true).KeyChar;

            // ToArray duplicates the list, enabling us to iterate over a
            // snapshot of our subscribers. This handles the case where an
            // observer unsubscribes from inside its OnNext method.
            foreach (Subscription sub in _subscriptions.ToArray())
            {
                sub.Observer.OnNext(c);
            }
        }
    }

    private void RemoveSubscription(Subscription sub)
    {
        _subscriptions.Remove(sub);
    }

    private class Subscription : IDisposable
    {
        private KeyWatcher? _parent;
        public Subscription(KeyWatcher parent, IObserver<char> observer)
        {
            _parent = parent;
            Observer = observer;
        }

        public IObserver<char> Observer { get; }

        public void Dispose()
        {
            if (_parent is not null)
            {
                _parent.RemoveSubscription(this);
                _parent = null;
            }
        }
    }
}

这定义了一个名为Subscription的嵌套类,用于跟踪每个订阅的观察者,并提供了我们的Subscribe方法需要返回的IDisposable的实现。Observable 在Subscribe期间创建此嵌套类的新实例,并将其添加到当前订阅者列表中;如果调用了Dispose,则从该列表中移除自身。

作为.NET 的一般规则,在使用完代表您的资源分配的任何IDisposable资源时,应调用Dispose。但在 Rx 中,通常不会处理表示订阅的对象的释放,因此,如果您实现了这样的对象,则不应指望其被处理。这通常是不必要的,因为 Rx 可以为您清理。与普通的.NET 事件或委托不同,可观察对象可以明确地结束,此时分配给订阅者的任何资源都可以释放。(某些会无限期运行,但在这种情况下,订阅通常会保持活动状态直到程序生命周期结束。)承认,到目前为止我展示的例子并没有自动清理,因为我提供了自己的实现,这些实现足够简单,不需要这样做,但是如果使用 Rx 库的源和订阅者实现,Rx 库会这样做。在 Rx 中,通常只有在您希望在源完成之前取消订阅时,才会处理订阅。

注意

订阅者无需确保通过Subscribe返回的object仍然可访问。如果您不需要早期取消订阅的能力,则可以忽略它,并且如果垃圾收集器释放了对象,则不会有任何影响,因为 Rx 提供的代表订阅的IDisposable实现中没有任何终结器。(虽然通常不会自己实现这些——我在这里只是为了说明它是如何工作的——如果您决定编写自己的实现,请采用相同的方法:不要在代表订阅的类上实现终结器。)

在 示例 11-7 中,KeyWatcher 类有一个 Run 方法。这不是标准的 Rx 特性;它只是一个循环,坐等键盘输入——这个可观察对象实际上不会产生任何通知,除非有东西调用该方法。每次这个循环接收到一个键时,它会在每个当前订阅的观察者上调用 OnNext 方法。请注意,我正在构建订阅者列表的副本(通过调用 ToArray —— 这是让 List<T> 复制其内容的简单方法),因为有可能订阅者在调用 OnNext 过程中选择取消订阅。如果我直接将订阅者列表传递给 foreach,在这种情况下会抛出异常,因为列表不允许在迭代过程中添加和删除项目。

警告

这个例子仅防止在同一线程上重新进入调用;处理多线程取消订阅将会更加复杂。事实上,甚至构建一个副本也不够谨慎。我确实应该检查我的快照中的每个观察者在调用其 OnNext 之前当前是否仍在订阅,因为有可能一个观察者可能选择取消其他观察者的订阅。这也不尝试处理来自另一个线程的取消订阅。稍后,我将用 Rx 库中更加健壮的实现来替换所有这些。

在使用中,这个热源与我的冷源非常相似。我们需要创建一个 KeyWatcher 类的实例,并且还需要另一个观察者类的实例(这次使用 char 类型参数,因为这个源产生的是字符而不是字符串)。因为这个源在其监控循环运行之前不会生成项目,所以我需要调用 Run 来启动它,就像 示例 11-8 那样。

示例 11-8. 将观察者附加到可观察对象
var source = new KeyWatcher();
var sub = new MySubscriber<char>();
source.Subscribe(sub);
source.Run();

运行该代码时,应用程序将等待键盘输入,如果您按下,比如说,m 键,观察者(示例 11-2)将显示消息 Received: m。(由于我的源永不停息,Run 方法将永远不会返回。)

您可能需要处理混合的热和冷可观察对象。此外,一些冷源具有某些热特性。例如,您可以想象一个表示警报消息的源,可能有意义的是以这样一种方式实现它,即存储警报,以确保您不会错过在创建源和附加订阅者之间发生的任何事件。因此,它将是一个冷源——任何新的订阅者都会获得到目前为止的所有事件——但是一旦订阅者赶上了,持续的行为看起来更像是一个热源,因为任何新事件都将被广播给所有当前的订阅者。正如您将看到的,Rx 库提供了各种方法来混合和适应这两种类型的源。

虽然了解观察者和可观察对象需要做什么很有用,但如果让 Rx 来处理这些繁重的工作会更高效。现在我将展示如果你使用 System.Reactive NuGet 库而不仅仅是两个基本接口,你将如何编写源和订阅者。

使用委托发布和订阅

如果你使用 System.Reactive NuGet 包,就不需要直接实现 IObservable<T>IObserver<T>。该库提供了多种实现。其中一些是适配器,用于在 Rx 和其他异步生成序列表示之间桥接。有些是包装现有的可观察流。但这些助手不仅仅用于适配现有内容。它们还可以帮助你编写生成新项或作为最终目标的代码。其中最简单的助手提供了基于委托的 API 来创建和消费可观察流。

使用委托创建可观察源

正如你在之前的一些示例中看到的那样,虽然 IObservable<T> 是一个简单的接口,但实现它的源可能需要做相当多的工作来跟踪订阅者。而且我们还没有看到全部的故事。正如你将在 “Schedulers” 中看到的那样,源经常需要采取额外的措施来确保它与 Rx 的线程机制良好集成。幸运的是,Rx 库可以为我们完成部分工作。示例 11-9 展示了如何使用 Observable 类的静态 Create 方法来实现一个冷源。(每次调用 GetFilePusher 都会创建一个新的源,因此这实际上是一个工厂方法。)

示例 11-9. 基于委托的可观察源
public static IObservable<string> GetFilePusher(string path)
{
    return Observable.Create<string>(observer =>
    {
        using (var sr = new StreamReader(path))
        {
            while (!sr.EndOfStream)
            {
                string? line = sr.ReadLine();
                if (line is not null)
                {
                    observer.OnNext(line);
                }
                else
                {
                    break;
                }
            }
        }
        observer.OnCompleted();
        return () => { };
    });
}

这与 例子 11-5 的目的相同——它提供了一个可观察源,逐行向订阅者提供文件中的每一行。(与 例子 11-5 一样,出于清晰起见,我省略了错误处理。在实践中,你需要像 例子 11-6 那样报告错误。)代码的核心部分是相同的,但我只需要编写一个方法而不是整个类,因为现在 Rx 提供了 IObservable<T> 的实现。每当观察者订阅该可观察对象时,Rx 就会调用我传递给 Create 的回调函数。因此,我所需要做的就是编写提供这些项的代码。除了不需要外部实现 IObservable<T> 的类之外,我还能够省略实现 IDisposable 的嵌套类——Create 方法允许我们返回一个 Action 委托而不是对象,并且如果订阅者选择取消订阅,它将调用该委托。因为我的方法在生成项目后才会返回,所以我只是返回了一个空方法。

我写的代码比示例 11-5 少得多,但是除了简化我的实现外,Observable.Create 对我们还做了两件稍微微妙的事情,这些事情并不立即从代码中显现出来。

首先,如果订阅者提前取消订阅,这段代码现在会正确停止发送项目给它,尽管我没有编写处理这种情况的代码。当观察者订阅这种类型的源时,Rx 不会直接将 IObserver<T> 传递给我们的回调。示例 11-9 中嵌套方法中的 observer 参数指的是一个由 Rx 提供的包装器。如果底层观察者取消订阅,该包装器会自动停止转发通知。我的循环会在订阅者停止监听后继续运行文件,这是浪费的,但至少订阅者在要求停止后不再收到项目。 (也许你会想知道,订阅者如何有机会取消订阅,因为我的代码直到完成才返回。它可以在其 OnNext 方法中执行此操作。)

你可以结合 C# 的异步语言特性(具体来说,是asyncawait关键字)使用 Rx 来实现示例 11-9 的一个版本,这不仅可以更有效地处理取消订阅,还可以异步地从文件中读取数据,意味着订阅不需要阻塞。这显著提升了效率,但代码几乎没有改变。我不会在第十七章介绍异步语言特性,所以这可能现在还不完全明白,但如果你感兴趣,示例 11-10 展示了其实现方式。修改的行已用粗体标出。(再次强调,这是没有错误处理的版本。异步方法可以像同步方法一样处理异常,所以你可以用与示例 11-6 相同的方式处理错误。)

示例 11-10. 异步源
public static IObservable<string> GetFilePusher(string path)
{
    `return` `Observable``.``Create``<``string``>``(``async` `(``observer``,` `cancel``)` `=``>`
    {
        using (var sr = new StreamReader(path))
        {
            `while` `(``!``sr``.``EndOfStream` `&``&` `!``cancel``.``IsCancellationRequested``)`
            {
                `string?` `line` `=` `await` `sr``.``ReadLineAsync``(``)``;`
                if (line is not null)
                {
                    observer.OnNext(line);
                }
                else
                {
                    break;
                }
            }
        }
        observer.OnCompleted();
    });
}

Observable.Create 在幕后为我们做的第二件事,在某些情况下,它将使用 Rx 的调度系统通过工作队列调用我们的代码,而不是直接调用它。这样做可以避免在链式多个 observable 的情况下可能出现的死锁。我将在本章稍后描述调度器。

这种技术适用于冷源,比如示例 11-9。热源的工作方式不同,它将实时事件广播给所有订阅者,Observable.Create 不直接支持它们,因为它每个订阅者只调用一次你传递的委托。不过,Rx 库仍然可以提供帮助。

Rx 为任何 IObservable<T> 提供了一个 Publish 扩展方法,由 System.Reactive.Linq 命名空间中的 Observable 类定义。该方法旨在包装一个仅支持一次运行的订阅方法的源(即您传递给 Observa⁠ble​.Create 的委托),但您希望附加多个订阅者—它为您处理多播逻辑。严格来说,仅支持单个订阅的源是退化的,但只要您将其隐藏在 Publish 后面,这并不重要,您可以将其用作实现热源的方法。Example 11-11 展示了如何创建一个提供与 Example 11-7 中的 KeyWatcher 相同功能的源。我还连接了两个订阅者,仅仅是为了说明这支持多个订阅者的点。

Example 11-11. 基于委托的热源
IObservable<char> singularHotSource = Observable.Create(
    (Func<IObserver<char>, IDisposable>) (obs =>
    {
        while (true)
        {
            obs.OnNext(Console.ReadKey(true).KeyChar);
        }
    }));

IConnectableObservable<char> keySource = singularHotSource.Publish();

keySource.Subscribe(new MySubscriber<char>());
keySource.Subscribe(new MySubscriber<char>());

keySource.Connect();

Publish 方法不会立即在源上调用 Subscribe。当您首次将订阅器附加到返回的源时,也不会立即调用。我必须告诉已发布的源何时启动。请注意,Publish 返回一个 IConnectableObservable<T>。这从 IObservable<T> 派生,并添加了一个额外的方法 Connect。这个接口表示一个在被告知之前不会启动的源,设计用于让您在设置其运行之前连接所有需要的订阅器。在由 Publish 返回的源上调用 Connect 导致它订阅我的原始源,调用我传递给 Observable.Create 的订阅回调并运行我的循环。这使得 Connect 方法具有与在我的原始 Example 11-7 上调用 Run 相同的效果。

Connect 返回一个 IDisposable。这提供了一种在稍后断开连接的方式—即从底层源取消订阅。(如果您不调用此方法,则由 Publish 返回的可连接的可观察对象将保持订阅到您的源,即使您每个单独的下游订阅都 Dispose。)在这个特定的例子中,对 Connect 的调用将永远不会返回,因为我传递给 Observable.Create 的代码也永远不会返回。大多数可观察源不会这样做。通常,它们通过使用异步或基于调度程序的技术来避免这种情况,我将在本章后面展示。

基于委托的 Observable.Create 结合 Publish 提供的多播功能,使我能够丢弃 Example 11-7 中除了实际生成项的循环之外的所有内容,甚至这个循环也变得更简单了。能够删除大约 80% 的代码并不是全部故事。这将工作得更好—Publish 让 Rx 处理我的订阅者,这些订阅者将正确处理在通知期间取消订阅的尴尬情况。

当然,Rx 库不仅有助于实现数据源,还可以简化订阅者。

使用委托订阅可观察源

就像你不必实现IObservable<T>一样,也不必提供IObserver<T>的实现。你并不总是关心这三种方法中的全部——示例 11-7 中的KeyWatcher可观察对象甚至从未调用OnCompletedOnError方法,因为它运行时间无限,并且没有错误检测。即使你需要提供所有三种方法,你也不一定想要编写一个完全独立的类型来提供它们。因此,Rx 库提供了扩展方法来简化订阅,由System命名空间中的ObservableExtensions类定义。大多数 C# 源文件包含using System;指令,或者在一个隐式全局using指令的项目中,对System的引用通常都是可用的,因此它提供的扩展方法也通常可用于任何IObservable<T>。示例 11-12 使用其中一个。

示例 11-12. 在不实现IObserver<T>的情况下订阅
var source = new KeyWatcher();
`source``.``Subscribe``(``value` `=``>` `Console``.``WriteLine``(``"Received: "` `+` `value``)``)``;`
source.Run();

这个示例与示例 11-8 具有相同的效果。然而,通过使用这种方法,我们不再需要像示例 11-2 那样编写一个完整实现IObserver<T>的类。使用这个Subscribe扩展方法,Rx 为我们提供了IObserver<T>的实现,我们只需为我们想要的通知提供方法。

示例 11-12 使用的Subscribe重载接受一个Action<T>,其中TIObservable<T>的项类型,在本例中为char。我的源代码不提供错误通知,也不使用OnCompleted来指示项目结束,但许多源会这样做,因此有三个Subscribe重载来处理这种情况。其中一个接受一个额外的Action<Exception>委托来处理错误。另一个接受一个类型为Action(即不带参数的委托)的第二个委托来处理完成通知。第三个重载接受三个委托——与所有项相关的回调相同,然后是一个异常处理程序和一个完成处理程序。

注意

如果在使用基于委托的订阅时没有提供异常处理程序,但源调用 OnError,Rx 提供的 IObserver<T> 将抛出异常以防止错误被忽略。例子 11-5 在处理 I/O 异常的 catch 块中调用 OnError,如果使用 例子 11-12 中的技术订阅,你会发现调用 OnError 会将 IOException 再次抛出——相同的异常连续抛出两次,一次是由 StreamReader 抛出,然后再由 Rx 提供的 IObserver<T> 实现抛出。由于这时我们已经在 例子 11-5 的 catch 块中(而不是 try 块),这第二次抛出会导致异常从 Subscribe 方法中出现,要么被更高层次处理,要么导致应用程序崩溃。

Subscribe 扩展方法还有一个不带参数的重载。这会订阅一个源,然后对接收到的项不做任何处理。(它会将任何错误抛回给源,就像那些不带错误回调的其他重载一样。)如果你有一个源在订阅时执行了一些重要的副作用,这会很有用,尽管最好避免必须这样设计。

序列生成器

Rx 定义了几种方法,可以从头开始创建新的序列,而无需自定义类型或回调。这些设计用于某些简单的场景,例如单元素序列、空序列或特定模式。这些都是由 Observable 类定义的静态方法。

Observable.Empty<T> 方法类似于 LINQ 到对象中的 Enumerable.Empty<T> 方法,我在第十章中展示过它:它生成一个空序列。(当然,不同之处在于它实现了 IObservable<T> 而不是 IEnumera⁠ble​<T>。)与 LINQ 到对象方法一样,当你需要与要求可观察源的 API 一起工作但没有要提供的项目时,这是非常有用的。

任何订阅 Observable.Empty<T> 序列的观察者都会立即调用其 OnCompleted 方法。

从不

Observable.Never<T> 方法生成一个永不执行任何操作的序列——它不生成任何项目,并且不像空序列那样甚至不会完成。(Rx 团队考虑将其称为 Infinite<T>,以强调除了永不生成任何内容外,它也永不结束。)在 LINQ to Objects 中没有对应物。如果要编写 NeverIEnumerable<T> 等效版本,它将在首次尝试检索项目时无限期地阻塞。在基于拉取的 LINQ to Objects 世界中,这将毫不有用——它将导致调用线程在进程的生命周期内冻结。(IAsyncEnumerable<T> 等效版本将从首次调用 MoveNextAsync 开始返回一个永不完成的 ValueTask<bool>。这不需要阻塞线程,但你仍然会得到一个永远不会完成的逻辑操作。)但在 Rx 的响应式世界中,源不会因为它们处于当前不生成项目的状态而阻塞进度,因此 Never 是一个不那么灾难性的想法。它对我后面将展示的一些运算符可能有所帮助,这些运算符可以使用 IObservable<T> 表示持续时间。Never 可以表示你希望无限期运行的活动。

Return

Observable.Return<T> 方法接受一个单一参数,并返回一个 observable 序列,立即产生该值,然后完成。就像 Empty 在需要序列但没有项目时很有用一样,当需要序列且只有一个项目时,这也很有用。这是一个冷源——你可以订阅任意次数,每个订阅者都会收到相同的值。在 LINQ to Objects 中没有确切的等效物,尽管 Rx 团队提供了一个名为交互扩展(Interactive Extensions for .NET,或简称 Ix,在 System.Interactive NuGet 包中可用)的库,其中包括本章描述的此类和其他几个运算符的 IEnumerable<T> 版本,这些运算符在 Rx 中有但在 LINQ to Objects 中没有。

Throw

Observable.Throw<T> 方法接受一个 Exception 类型的单一参数,并返回一个 observable 序列,立即将该异常传递给任何订阅者的 OnError。与 Return 类似,这也是一个冷源,可以订阅任意次数,并且每个订阅者都将执行相同的操作。

Range

Observable.Range 方法生成一个数字序列。(它总是返回一个 IObservable<int>,这就是为什么它不需要类型参数。)类似于 Enumerable.Range 方法,它接受一个起始数字和一个计数。这是一个冷源,每个订阅者都将产生整个范围。

Repeat

Observable.Repeat<T> 方法接受一个输入并产生一个重复产生该输入的序列。输入可以是单个值,但也可以是另一个可观察序列,在这种情况下,它将转发项目直到输入完成,然后重新订阅以重复整个序列。(这意味着只有在传递一个冷可观察序列时,数据才会真正重复。)

如果你没有传递其他参数,生成的序列将无限产生值,唯一停止的方法是取消订阅。你还可以传递一个计数,表示你希望输入重复多少次。

生成

Observable.Generate<TState, TResult> 方法可以生成比我刚刚描述的其他方法更复杂的序列。你提供给 Generate 一个表示生成器初始状态的对象或值。这可以是任何你喜欢的类型——它是方法的泛型类型参数之一。你还必须提供三个函数:一个检查当前状态以决定序列是否已经完成的函数,一个在准备产生下一个项目时推进状态的函数,以及一个确定当前状态下要产生的值的函数。示例 11-13 使用这些函数创建一个源,该源生成随机数,直到所有生成的数字的总和超过 10,000。

示例 11-13. 生成项目
IObservable<int> src = Observable.Generate(
    (Current: 0, Total: 0, Random: new Random()),
    state => state.Total <= 10000,
    state =>
    {
        int value = state.Random.Next(1000);
        return (value, state.Total + value, state.Random);
    },
    state => state.Current);

这总是作为第一个项目产生0,说明 Generate 在首次调用用于确定当前值的函数(在示例 11-13 中的最后一个 lambda 表达式)之前,会调用用于迭代状态的函数。

你可以通过使用 Observable.Create 和一个循环来实现与这个示例相同的效果。但是,Generate 反转了控制流:你的代码不再在循环中告诉 Rx 何时产生下一个项目,而是 Rx 要求你的函数提供下一个项目。这使得 Rx 在调度工作时具有更大的灵活性。例如,它使 Generate 能够提供带有定时功能的重载版本。示例 11-14 以类似的方式产生项目,但是通过传递一个额外的函数作为最后一个参数告诉 Rx 延迟每个项目的传递。

示例 11-14. 生成定时项目
IObservable<int> src = Observable.Generate(
    (Current: 0, Total: 0, Random: new Random()),
    state => state.Total < 10000,
    state =>
    {
        int value = state.Random.Next(1000);
        return (value, state.Total + value, state.Random);
    },
    state => state.Current,
    state => TimeSpan.FromMilliseconds(state.Random.Next(1000)));

为了使这个方法工作,Rx 需要能够安排未来某个时间点发生的工作。我将在“调度器”中解释这是如何工作的。

LINQ 查询

使用 Rx 的最大好处之一是它有一个 LINQ 实现,使你能够编写查询来处理诸如事件之类的异步项目流。示例 11-15 说明了这一点。它首先生成一个表示来自 UI 元素的 MouseMove 事件的可观察源。我将在 “适应” 中更详细地讨论这种技术,但现在知道 Rx 可以将任何 .NET 事件包装为可观察源就足够了。每个事件产生一个项目,其中包含两个属性,这些属性包含通常作为参数传递给事件处理程序的值(即发送者和事件参数)。

示例 11-15. 使用 LINQ 查询过滤项目
IObservable<EventPattern<MouseEventArgs>> mouseMoves =
    Observable.FromEventPattern<MouseEventArgs>(
        background, nameof(background.MouseMove));

`IObservable``<``Point``>` `dragPositions` `=`
    `from` `move` `in` `mouseMoves`
    `where` `Mouse``.``Captured` `=``=` `background`
    `select` `move``.``EventArgs``.``GetPosition``(``background``)``;`

dragPositions.Subscribe(point => { line.Points.Add(point); });

LINQ 查询中的 where 子句过滤事件,以便我们只处理在特定 UI 元素(background)捕获鼠标时引发的事件。这个特定示例基于 WPF,但一般来说,希望支持拖动的 Windows 桌面应用程序在鼠标按钮按下时捕获鼠标,并在之后释放它。这确保捕获元素在拖动进行时接收鼠标移动事件,即使鼠标移动到其他 UI 元素上也是如此。通常,当鼠标位于 UI 元素上时,即使它们没有捕获鼠标,它们也会接收鼠标移动事件。因此,我需要在 示例 11-15 中的 where 子句中忽略那些事件,只留下在拖动进行时发生的鼠标移动。因此,为了使 示例 11-15 中的代码工作,你需要将事件处理程序附加到相关元素的 MouseDownMouseUp 事件,就像 示例 11-16 中的那样。

示例 11-16. 捕获鼠标
private void OnBackgroundMouseDown(object sender, MouseButtonEventArgs e)
{
    background.CaptureMouse();
}

private void OnBackgroundMouseUp(object sender, MouseButtonEventArgs e)
{
    if (Mouse.Captured == background)
    {
        background.ReleaseMouseCapture();
    }
}

在 示例 11-15 中的 select 子句在 Rx 中的工作方式与 LINQ to Objects 中的工作方式相同,或者与任何其他 LINQ 提供程序一样。它允许我们从源项目中提取信息以用作输出。在这种情况下,mouseMoves 是一个 EventPattern<MouseEventArgs> 对象的可观察序列,但我真正想要的是一个鼠标位置的可观察序列。因此,在 示例 11-15 中的 select 子句要求相对于特定 UI 元素的位置。

这个查询的要点是,dragPositions 指的是一个 Point 值的可观察序列,它将报告每次发生鼠标位置变化的情况,而这发生在我的应用程序中某个特定 UI 元素捕获鼠标时。这是一个热源,因为它代表着正在实时发生的事情:鼠标输入。LINQ 的过滤和投影操作符不会改变源的性质,因此如果你将它们应用于一个热源,得到的查询结果也将是热的,如果源是冷的,过滤后的结果也将是冷的。

警告

运算符不会检测源的热度。WhereSelect运算符只是直接传递这个方面。每当你订阅由Select运算符生成的最终查询时,它将订阅它的输入。在本例中,输入是由Where运算符返回的可观察对象,它将依次订阅由适应鼠标移动事件产生的源。如果你第二次订阅,你将得到第二个订阅链。热事件源将把每个事件广播到这两个链,因此每个项目将通过过滤和投影过程两次。因此,请注意,将多个订阅者附加到热源的复杂查询可能会工作,但可能会带来不必要的开销。如果需要这样做,最好在查询上调用Publish,正如你所看到的,它可以对其输入进行单一订阅,然后将每个项目广播给所有订阅者。

示例 11-15 的最后一行订阅了过滤和投影后的源,并将其生成的每个Point值添加到另一个名为line的 UI 元素的Points集合中。这是一个Polyline元素,这里没有显示,¹这样做的结果是你可以在应用程序窗口上用鼠标涂鸦。(如果你长时间进行过 Windows 开发,你可能还记得 Scribble 示例,这里的效果大致相同。)

Rx 提供了大部分在第 10 章中描述的标准查询运算符。²这些运算符在 Rx 中的工作方式与其他 LINQ 实现完全相同。然而,有些运算符的工作方式可能乍一看会稍有些令人惊讶,我将在接下来的几节中描述。

分组运算符

标准的分组运算符GroupBy生成一个序列的序列。在 LINQ to Objects 中,它返回IEnumerable<IGrouping<TKey, TSource>>,正如你在第 10 章中看到的,IGrouping<TKey, TSource>本身从IEnumerable<T>派生而来。GroupJoin在概念上类似:虽然它返回一个普通的IEnumerable<T>,但T是一个投影函数的结果,该函数将序列作为输入。因此,在任一情况下,你得到的都是逻辑上的序列的序列。

在 Rx 的世界中,分组会生成一个可观察序列的可观察序列。这是完全一致的,但可能会有些令人惊讶,因为 Rx 引入了时间方面:表示所有组的可观察源在发现每个新组时生成一个新项目(一个新的可观察源)。示例 11-17 通过监听文件系统中的变化并根据每个发生的文件夹形成组来说明这一点。对于每个组,我们得到一个IGroupedObservable<TKey, TSource>,这是IGrouping<TKey, TSource>的 Rx 等效物。

示例 11-17. 事件分组
string path = Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments);
var w = new FileSystemWatcher(path);
IObservable<EventPattern<FileSystemEventArgs>> changes =
    Observable.FromEventPattern<FileSystemEventHandler, FileSystemEventArgs>(
        h => w.Changed += h, h => w.Changed -= h);
w.IncludeSubdirectories = true;
w.EnableRaisingEvents = true;

IObservable<IGroupedObservable<string, string>> folders =
    from change in changes
    group Path.GetFileName(change.EventArgs.FullPath)
       by Path.GetDirectoryName(change.EventArgs.FullPath);

folders.Subscribe(f =>
{
    Console.WriteLine("New folder ({0})", f.Key);
    f.Subscribe(file =>
        Console.WriteLine("File changed in folder {0}, {1}", f.Key, file));
});

订阅到分组源 folders 的 lambda 订阅到源产生的每个组。事件可能来自的文件夹数量是无限的,因为在程序运行时可以添加新的文件夹。因此,当它检测到以前未见过的文件夹发生变化时,folders observable 将产生一个新的 observable 源,正如 图 11-2 所示。

生产新组并不意味着任何先前的组现在已完成,这与我们通常在 LINQ to Objects 中消费组的方式不同。当你在 IEnumerable<T> 上运行分组查询时,它会产生每个组,你可以在移动到下一个组之前完全枚举其内容。但在 Rx 中你做不到这一点,因为每个组被表示为一个 observable,而 observables 直到它们告诉你它们完成之前都不算完成——相反,每个组的订阅保持活动状态。在 示例 11-17 中,一个已经开始的组对应的文件夹可能会在其他文件夹活动时长时间处于休眠状态,直到稍后重新启动。而且更一般地说,Rx 的分组操作符必须准备好处理任何源中发生这种情况的情况。

Rx 分组操作符

图 11-2. 将 IObservable<T> 拆分为组

连接操作符

Rx 提供了标准的 JoinGroupJoin 操作符。然而,它们与 LINQ to Objects 或大多数数据库 LINQ 提供者处理连接的方式略有不同。在这些世界中,两个输入集的项目通常基于具有一些共同值进行连接。

在数据库中,当连接两个表时,一个非常常见的示例是连接具有相同值的一个表中行的外键列和另一个表中行的主键列。然而,Rx 并不是基于值进行连接。相反,如果它们的持续时间重叠,那么项目会被连接。

不过稍等一下。一个项目的持续时间究竟是什么?Rx 处理瞬时事件;生成一个项目,报告一个错误以及完成一个流,都是发生在特定时刻的事情。因此,连接操作符使用一个约定:对于每个源项目,你可以提供一个返回 IObservable<T> 的函数。

该源项目的持续时间从其生成时开始,并在相应的 IObservable<T> 第一次响应时结束(即它完成或生成一个项目或错误)。图 11-3 阐明了这个想法。顶部是一个 observable 源,在其下是一系列定义每个项目持续时间的源。底部展示了每个项目 observables 为其源项目建立的持续时间。

图 11-3. 为每个源项目使用 IObservable<T> 定义持续时间

虽然您可以为每个源项目使用不同的 IObservable<T>,就像 Figure 11-3 展示的那样,但您并不需要这样做——每次使用相同的源也是有效的。例如,如果您将组操作应用于代表MouseDown事件流的IObservable<T>,然后再使用另一个代表MouseUp事件流的IObservable<T>来定义每个项目的持续时间,这将导致 Rx 将每个MouseDown事件的“持续时间”视为持续到下一个MouseUp事件。图 11-4 描述了这种安排,您可以看到在底部显示的每个MouseDown事件的有效持续时间由MouseDownMouseUp事件对界定。

图 11-4. 使用一对事件流定义持续时间

源甚至可以定义自己的持续时间。例如,如果您提供一个表示MouseDown事件的可观察源,您可能希望每个项目的持续时间在下一个项目开始时结束。这意味着项目具有连续的持续时间——在第一个项目到达后,总是有一个当前项目,它是最后发生的项目。图 11-5 阐明了这一点。

图 11-5. 相邻项目持续时间

项目的持续时间可以重叠。如果您愿意,您可以提供一个定义持续时间的IObservable<T>,表明输入项目的持续时间在下一个项目开始后一段时间结束。

现在我们知道 Rx 如何决定一个项目的持续时间以进行连接,那么它如何使用这些信息呢?请记住,连接运算符结合了两个输入。(定义持续时间的源不算是输入。它们提供有关其中一个输入的额外信息。)Rx 认为来自两个输入流的项目对是相关的,如果它们的持续时间重叠。它展示相关项目的方式取决于您是使用Join还是GroupJoin运算符。Join运算符的输出是一个包含每对相关项目的流。(您提供一个投影函数,该函数将传递每对项目,并由您决定如何处理它们。这个函数决定连接流的输出项目类型。)Figure 11-6 展示了基于事件MouseDownMouseMove的两个输入流(分别由MouseUpMouseMove定义持续时间)。这与图示中的源类似于图 11-4 和 11-5,但我添加了字母和数字,以便更容易引用每个流中的每个项目。在图的底部是Join运算符将为这两个流产生的可观察对象。

图 11-6. Join 运算符

正如您所看到的,任何两个输入流项目的持续时间重叠的地方,我们都会得到一个结合两个输入的输出项目。如果重叠的项目在不同的时间开始(这通常是情况),则输出项目将在两个输入中后开始的时间产生。MouseDown事件AMouseMove事件1之前开始,因此结果输出A1发生在重叠开始时(即MouseMove事件1发生时)。但事件3在事件B之前发生,因此连接的输出B3发生在B开始时。

事件5的持续时间不与任何MouseDown项的持续时间重叠,因此在输出流中看不到任何该项。相反,MouseMove事件可能会出现在多个输出项目中(就像每个MouseDown事件一样)。如果没有3事件,事件2的持续时间会从A内部开始,并在B内部完成,因此除了图 11-6 中显示的A2外,还会在B开始时出现B2事件。

Example 11-18 显示了执行图 11-6 中所示的连接的代码,使用查询表达式。正如您在第 10 章中看到的,编译器会将查询表达式转换为一系列方法调用,而 Example 11-19 显示了与 Example 11-18 中查询的基于方法的等效形式。

Example 11-18. 使用连接进行查询表达式
IObservable<EventPattern<MouseEventArgs>> downs =
    Observable.FromEventPattern<MouseEventArgs>(
        background, nameof(background.MouseDown));
IObservable<EventPattern<MouseEventArgs>> ups =
    Observable.FromEventPattern<MouseEventArgs>(
        background, nameof(background.MouseUp));
IObservable<EventPattern<MouseEventArgs>> allMoves =
    Observable.FromEventPattern<MouseEventArgs>(
        background, nameof(background.MouseMove));

IObservable<Point> dragPositions =
    from down in downs
    join move in allMoves
      on ups equals allMoves
    select move.EventArgs.GetPosition(background);
Example 11-19. 加入代码
IObservable<Point> dragPositions = downs.Join(
    allMoves,
    down => ups,
    move => allMoves,
    (down, move) => move.EventArgs.GetPosition(background));

我们可以使用任何这些示例生成的dragPositions可观察源来替换 Example 11-15 中的源。我们不再需要基于background元素是否捕获鼠标来进行过滤,因为现在 Rx 仅为我们提供了持续时间与鼠标按下事件重叠的移动事件。发生在鼠标按下之间的任何移动都将被忽略,或者如果它们是最后一次在鼠标按下之前发生的移动,则在鼠标按钮按下的瞬间我们将接收到该位置。

GroupJoin以类似的方式组合项目,但不是生成单个可观察输出,而是生成一个可观察的可观察对象。对于当前的示例,这意味着其输出会为每个MouseDown输入生成一个新的可观察源。这将包含包含该输入的所有配对,并且它的持续时间与该输入相同。图 11-7 显示了此运算符与与图 11-6 相同的输入事件的运行情况。我在输出序列的端点放置了竖线,以澄清它们何时调用其观察者的OnComplete方法。这些可观察对象的起始和结束与相应输入的持续时间完全对齐,因此它们产生其最终输出项目和完成时间之间通常存在显著差异。

图 11-7. GroupJoin运算符

一般来说,使用 LINQ,GroupJoin运算符能够产生空组,因此与Join运算符不同,即使第二个流中没有相应的项目,每个来自第一个输入的项目也会产生一个输出。Rx 的GroupJoin也是这样工作的,增加了时间方面的考虑。每个输出组从相应的输入事件发生时开始(在本例中为MouseDown),并在该事件被认为已经结束时结束(这里是下一个MouseUp);如果在此期间没有移动,该可观察对象将不会生成任何项目。因为这里的移动事件持续连续,这只能在接收到第一个移动之前发生。但在第二个输入项目具有不连续持续时间的连接中,空组更有可能发生。

在允许用户用鼠标在窗口中涂鸦的示例应用程序背景下,这种分组输出非常有用,因为它将每个单独的拖动呈现为一个独立的对象。这意味着我可以为每次拖动创建一条新线,而不是将点添加到越来越长的同一线上。使用示例 11-15 中的代码,每次新的拖动操作将从上一个拖动结束的地方到新位置画一条线,这样就无法绘制出分离的形状。但是分组输出使得分离变得容易。示例 11-20 订阅分组输出,并为每个新组(代表一个新的拖动操作)创建一个新的Polyline来渲染涂鸦,然后订阅组中的项目以填充该单独的线条。

示例 11-20. 为每次拖动操作添加新线
var dragPointSets = from mouseDown in downs
                    join move in allMoves
                      on ups equals allMoves into m
                    select m.Select(e => e.EventArgs.GetPosition(background));

dragPointSets.Subscribe(dragPoints =>
{
    var currentLine = new Polyline { Stroke = Brushes.Black, StrokeThickness = 2 };
    background.Children.Add(currentLine);

    dragPoints.Subscribe(point =>
    {
        currentLine.Points.Add(point);
    });
});

仅需明确,所有这些即使在连接运算符下也可以实时运行 - 这些都是热源。在示例 11-20 中由GroupJoin返回的IObservable<IObservable<Point>>会在按下鼠标按钮时立即产生一个新组。该组中的IObservable<Point>将会为每个MouseMove事件立即产生一个新的Point。总之,当用户拖动鼠标时,用户会立即看到直线出现并增长。

SelectMany运算符

正如你在第十章中所看到的,SelectMany 操作符有效地将集合的集合展平为单个集合。当查询表达式具有多个from子句时,将使用此操作符,在 LINQ to Objects 中,其操作类似于嵌套的foreach循环。在 Rx 中,它仍然具有这种展平效果 - 它允许你获取一个可观察的源,其中每个生成的项目也是一个可观察的源(或者可以用来生成一个),SelectMany 操作符的结果将是一个包含所有子源中所有项目的单个可观察序列。然而,与分组一样,在 LINQ to Objects 中,情况可能不那么有序。Rx 的推送驱动特性,以及其潜在的异步操作,使得所有涉及的可观察源都有可能同时推送新项目,包括用作嵌套源的原始源。(该操作符仍然确保一次只传递一个事件 - 当调用OnNext时,它会等待返回后再进行下一个调用。混乱的可能性仅限于事件传递的顺序混乱。)

当使用 LINQ to Objects 遍历嵌套数组时,一切都按照直观的顺序进行。它会检索第一个嵌套数组,然后遍历该数组中的所有元素,然后转到下一个嵌套数组,并遍历该数组,依此类推。但是,这种有序的展平仅因为使用IEnumerable<T>时,项目的消费者可以控制何时检索哪些项目。使用 Rx 时,订阅者在源提供项目时接收它们。

尽管有这种自由度,行为还是足够直接的:由SelectMany产生的输出流只是在源提供它们时提供项目。

聚合和其他单值操作符

几个标准的 LINQ 操作符将整个值序列减少为单个值。这些包括聚合操作符,如MinSumAggregate;量词符AnyAll;以及Count操作符。它还包括选择性操作符,如ElementAt。这些在 Rx 中也是可用的,但与大多数 LINQ 实现不同,Rx 实现不会返回普通的单个值。它们都返回一个IObservable<T>,就像产生序列输出的操作符一样。

注意

FirstLastFirstOrDefaultLastOrDefaultSingleSingleOrDefault 操作符应该都是以相同的方式工作,但出于历史原因,它们并非如此。在 Rx 的 v1 中引入,它们返回的单个值并不包装在IObserva⁠ble​<T>中,这意味着它们会阻塞直到源提供所需的内容。这与推送模型不太匹配,并且可能导致死锁,因此这些操作符现在已被弃用,并且有了新的异步版本,工作方式与 Rx 中的其他单值操作符相同。所有这些操作符的新版本只需在原始操作符名称后附加Async即可(例如FirstAsyncLastAsync等)。

这些操作符每个仍然产生单个值,但它们都将该值呈现为可观察源。原因是与 LINQ to Objects 不同,Rx 不能枚举其输入以计算聚合值或查找所选值。源控制着流程,因此这些操作符的 Rx 版本必须等待源提供其值——就像所有操作符一样,单值操作符必须是被动反应的,而不是主动的。诸如Average这样需要看到每个值的操作符,在源表示已完成之前,不能生成其结果。即使像FirstAsyncElementAt这样不需要等待输入的末尾的操作符,也要等到源决定提供操作符正在等待的值之前才能执行任何操作。一旦单值操作符能够提供值,它就会这样做,然后完成。

ToArrayToListToDictionaryToLookup 操作符的工作方式类似。虽然它们都生成源的全部内容,但作为单个输出对象,被包装为单项可观察源。

如果你真的想等待任何这些项的值,可以使用Wait操作符,这是 Rx 中的一个非标准操作符,适用于任何IObserva⁠ble​<T>。这个阻塞操作符等待源完成,然后返回最终元素,因此弃用的FirstLast等操作符的“等待”行为仍然可用;只是不再是默认行为了。或者,你可以使用 C# 的异步语言特性——将await关键字用于可观察源。从逻辑上讲,它与Wait做的事情是一样的,但它是通过高效的非阻塞异步等待实现的,这种等待方式在 第十七章 中有描述。

Concat 操作符

Rx 的 Concat 操作符与其他 LINQ 实现共享相同的概念:它将两个输入序列组合起来,生成一个序列,该序列首先生成其第一个输入的每个项,然后生成其第二个输入的每个项。 (事实上,Rx 比一些 LINQ 提供程序更进一步,可以接受一组输入,并将它们全部连接起来。)只有在第一个流最终完成时,这才有用 — 当然,在 LINQ to Objects 中也是如此,但在 Rx 中无限源更为常见。此外,请注意,此操作符在第一个流完成之前不会订阅第二个流。这是因为冷流通常在订阅时开始生成项,并且 Concat 操作符不希望在等待第一个流完成时缓冲第二个源的项。这意味着在与热源一起使用时,Concat 可能会产生非确定性结果。(如果你想要一个包含来自两个热源的所有项的可观察源,请使用 Merge,我马上会描述。)

Rx 并不仅仅满足于提供标准的 LINQ 操作符。它定义了更多自己的操作符。

Rx 查询操作符

Rx 的主要目标之一是简化与多个潜在独立的异步产生项的可观察源的工作。Rx 的设计者有时候会提到“编排和同步”,意思是你的系统可能同时进行许多事情,但你需要确保应用程序对事件的反应是某种程度上协调一致的。Rx 的许多操作符都是基于这个目标设计的。

注意

并非本节的所有内容都是由 Rx 的独特要求驱动的。Rx 的一些非标准操作符(例如 Scan)在其他 LINQ 提供程序中也是非常合理的。并且这些操作的许多版本在 .NET 的交互扩展(Ix)中也有提供,可以在 System.Interactive NuGet 包中找到,正如前面提到的。

Rx 拥有如此丰富的操作符库,以至于要对它们全都公正地介绍将会使本章的长度大致增加四倍,而本章已经偏长了。由于这不是一本关于 Rx 的书籍,并且因为一些操作符非常专业化,我只会挑选一些最有用的来介绍。我建议浏览 Rx 的文档或者 源代码 来探索它所提供的完整和非常全面的操作符集合。

合并

Merge 操作符将两个或多个可观察序列中的所有元素合并为一个单一的可观察序列。我可以使用它来解决在示例 11-15、11-18 和 11-20 中出现的问题。这些示例都处理鼠标输入,如果你在 Windows UI 编程中做了很多工作,你会知道并不一定会得到与按下和释放鼠标按钮对应的鼠标移动通知。这些按钮事件的通知包含鼠标位置信息,因此 Windows 没有必要发送单独的鼠标移动消息来提供这些位置,因为这只会将相同的信息发送两次。这是完全合理的,但也相当恼人。³ 在这些示例中,起始和结束位置不在表示鼠标位置的可观察源中。我可以通过合并所有三个事件的位置来解决这个问题。示例 11-21 展示了如何修复 示例 11-15。

示例 11-21. 合并可观察对象
IObservable<EventPattern<MouseEventArgs>> downs =
    Observable.FromEventPattern<MouseEventArgs>(
        background, nameof(background.MouseDown));
IObservable<EventPattern<MouseEventArgs>> ups =
    Observable.FromEventPattern<MouseEventArgs>(
        background, nameof(background.MouseUp));
IObservable<EventPattern<MouseEventArgs>> allMoves =
    Observable.FromEventPattern<MouseEventArgs>(
        background, nameof(background.MouseMove));

IObservable<EventPattern<MouseEventArgs>> dragMoves =
    from move in allMoves
    where Mouse.Captured == background
    select move;

`IObservable``<``EventPattern``<``MouseEventArgs``>``>` `allDragPositionEvents` `=`
    `Observable``.``Merge``(``downs``,` `ups``,` `dragMoves``)``;`

IObservable<Point> dragPositions =
    from move in allDragPositionEvents
    select move.EventArgs.GetPosition(background);

我已经创建了三个可观察对象来表示三个相关事件:MouseDownMouseUpMouseMove。因为这三个事件都需要共享同一个投影(select 子句),但只有一个需要过滤事件,所以我稍微重构了一下。只有鼠标移动需要过滤,所以我为此编写了单独的查询。然后我使用了 Observable.Merge 方法将所有三个事件流合并为一个。

注意

Merge 既可以作为扩展方法使用,也可以作为非扩展的 static 方法使用。如果你在单个可观察对象上使用可用的扩展方法,那么唯一可用的 Merge 重载是将其与单个其他源合并(可选指定调度程序)。在这种情况下,我有三个源,所以我使用了非扩展方法形式。然而,如果你有一个表达式,它是一个可观察源的可枚举或一个可观察源的可观察源,你会发现这些情况下也有 Merge 扩展方法。因此,我本可以写成 new[] { downs, ups, dragMoves }.Merge()

我的 allDragPositionEvents 变量指的是一个单一的可观察流,将报告我所需的所有鼠标移动。最后,我通过投影运行这些数据,以提取每个项目的鼠标位置。同样,结果是一个热流。与之前一样,只要 background 元素捕获了鼠标,它就会在鼠标移动时生成一个位置,但同时也会在 MouseDownMouseUp 事件发生时生成一个位置。我可以使用与 示例 11-15 最后一行显示的相同调用订阅此事件,这次我不会错过起始和结束位置。

在我刚刚展示的例子中,所有源都是无限的,但并非总是如此。当其中一个输入停止时,合并的可观察应该怎么做?如果其中一个因错误而停止,该错误将通过合并的可观察传递,此时它将变为完成状态—在报告错误后,可观察的对象不允许继续生成项。然而,虽然输入可以单方面地通过错误终止输出,但如果输入正常完成,直到所有输入都完成,合并的可观察才会完成。

窗口操作符

Rx 定义了两个操作符,BufferWindow,它们都生成一个可观察的输出,其中每个项基于源中的多个相邻项。(顺便说一句,Window 的名称与 UI 无关。)Figure 11-8 展示了使用 Buffer 操作符的三种方式。我已经标记了代表输入项的圆圈,并在其下方展示了代表由 Buffer 产生的可观察源的项的形状和数字,其中线条和数字表示与每个输出项相关联的输入项。很快你会看到,Window 的工作方式非常相似。

图 11-8. 使用Buffer操作符的滑动窗口

在第一个案例中,我传递了 (2, 2) 的参数,表明我希望每个输出项对应于两个输入项,并且我希望在每第二个输入项上启动一个新的缓冲区。这听起来像是用两种不同的方式说同一件事情,直到你看到 Figure 11-8 中的第二个例子,在这个例子中,(3, 2) 的参数表明每个输出项对应于输入的三个项,但我仍然希望在每第二个输入上开始缓冲。这意味着每个窗口—用于构建输出项的输入项集合—与其邻居重叠。当第二个参数,跳过,小于窗口大小时,这种情况将发生。第一个输出项的窗口包含第一个、第二和第三个输入。第二个输出项的窗口包含第三、第四和第五个,因此第三个项出现在两者中。

图中的最后一个示例显示了窗口大小为三,但这次我要求跳过一个大小为一的间隔—因此,在这种情况下,窗口每次只移动一个输入项,但每次都包含源中的三个项。我也可以指定一个大于窗口的跳过大小,在这种情况下,落在窗口之间的输入项将被简单地忽略。

BufferWindow 操作符往往会引入一定的延迟。在第二和第三种情况下,窗口大小为三意味着输入可观测对象需要生成其第三个值,然后才能为输出项提供整个窗口。对于 Buffer 来说,这总是意味着窗口大小的延迟,但正如你将看到的那样,使用 Window 操作符时,每个窗口在完全填充之前就可以开始处理。

注意

Buffer 还提供了一个重载,接受一个数字,其效果与两次传递相同数字相同。 (例如,而不是 Buffer(2, 2),你可以简单地写成 Buffer(2)。)这在逻辑上等同于 LINQ to Objects 的 Chunk 操作符。正如 第十章 中讨论的那样,Rx 没有使用相同的名称的主要原因是,Rx 在大约十年前发明了 Buffer,而 LINQ to Objects 添加了 Chunk

BufferWindow 操作符之间的区别在于它们呈现窗口化项的方式。Buffer 是最直接的。它提供一个 IObservable<IList<T>>,其中 T 是输入项的类型。换句话说,如果你订阅 Buffer 的输出,对于每个生成的窗口,订阅者将收到一个包含窗口中所有项的列表。 示例 11-22 使用此方法生成了来自 示例 11-15 的鼠标位置的平滑版本。

示例 11-22. 使用 Buffer 对输入进行平滑处理
IObservable<Point> smoothed = from points in dragPositions.Buffer(5, 2)
                              let x = points.Average(p => p.X)
                              let y = points.Average(p => p.Y)
                              select new Point(x, y);

此查询的第一行指定我要查看五个连续鼠标位置的组,并且我希望每隔一个输入生成一个组。查询的其余部分计算窗口内的平均鼠标位置,并将其作为输出项。图 11-9 显示了效果。顶部线条是使用原始鼠标位置的结果。紧接着它下面的线条使用了相同输入产生的 示例 11-22 中的平滑点。正如你所看到的,顶部线条有些崎岖不平,而底部线条则平滑了许多突起。

图 11-9. 平滑效果展示

示例 11-22 使用了 LINQ to Objects 和 Rx 的混合实现。查询表达式本身使用了 Rx,但是范围变量 points 的类型是 IList<Point>(因为在这个示例中 Buffer 返回一个 IObservable<IList<Point>>)。因此,对 points 调用 Average 操作符的嵌套查询将得到 LINQ to Objects 的实现。

如果Buffer运算符的输入是热的,则它将产生一个热可观测对象作为结果。因此,您可以订阅示例 11-22smoothed变量中的可观测对象,类似于示例 11-15的最后一行代码,它将在您拖动鼠标时实时显示平滑的线条。正如讨论的那样,当然会有一些延迟 —— 代码指定了跳过两个项目,因此它仅对每两次鼠标事件更新一次屏幕。对最后五个点进行平均处理也会增加鼠标指针与线条末端之间的差距。在这些参数下,差异很小,不会太分散注意力,但如果使用更激进的平滑处理,可能会变得令人不快。

Window运算符与Buffer运算符非常相似,但不同之处在于它不将每个窗口呈现为IList<T>,而是提供一个IObservable<T>。如果您在示例 11-22中对dragPositions使用Window,结果将是IObservable<IObservable<Point>>图 11-10展示了Window运算符在图 11-8中的最后一种情景中的工作方式,正如您所见,它可以更早地开始每个窗口。它不必等到窗口中的所有项目都可用才开始;它提供的每个输出项目都是一个IObservable<T>,该对象将根据项目的可用性逐个产生窗口的项目。Window生成的每个可观测对象在提供最后一个项目后立即完成(即与Buffer提供整个窗口的时刻相同)。因此,如果您的处理依赖于整个窗口的可用性,Window无法更快地将其提供给您,因为它最终受到输入项到达速率的限制,但它将更早地开始提供值。

Window在这个例子中生成的可观测对象的一个潜在令人惊讶的特征是它们的起始时间。尽管它们在生成最后一个项目后立即结束,但它们在生成第一个项目之前并不立即开始。代表第一个窗口的可观测对象立即开始 —— 一旦您订阅运算符返回的可观测对象的可观测对象,您将立即收到该可观测对象。因此,第一个窗口将立即可用,即使Window运算符的输入尚未执行任何操作。然后,每个新窗口在接收到需要跳过的所有输入项后立即开始。在这个例子中,我使用的是一个跳过计数为一,因此第二个窗口在输入产生一个项目后开始,第三个在产生两个项目后开始,依此类推。

正如您稍后在本节中将看到的,以及在“定时操作”中也看到的,WindowBuffer支持一些其他定义窗口何时开始和停止的方式。一般模式是,一旦Window操作符到达一个点,源中的新项将进入新窗口,操作符就创建该窗口,预期窗口的第一个项,而不是等待它(见图 11-10)。

图 11-10. Window操作符
注意

如果输入完成,所有当前打开的窗口也会完成。这意味着可能会看到空窗口。(事实上,如果跳过大小为一,如果源完成,你保证会得到一个空窗口。)在图 11-10 中,底部的一个窗口已经开始但尚未产生任何项。如果输入在不再产生任何项的情况下完成,仍在进行中的三个可观测源也会完成,包括那个尚未产生任何内容的最后一个。

因为Window操作符会在源提供项时立即将项投放到窗口中,这可能会使您能够更早地开始处理,比使用Buffer更能提高整体响应性。Window的缺点是它往往更复杂——您的订阅者将在相应的输入窗口的所有项都可用之前开始接收输出值。而Buffer提供您一个列表,您可以随时检查,而使用Window,您将需要继续在 Rx 的序列世界中工作,只有当它们准备好时才会产生项。要执行与示例 11-22 相同的平滑操作,使用Window需要在示例 11-23 中的代码。

示例 11-23. 使用Window进行平滑操作
IObservable<Point> smoothed =
    from points in dragPositions.Window(5, 2)
    from totals in points.Aggregate(
      new { X = 0.0, Y = 0.0, Count = 0 },
      (acc, point) => new
          { X = acc.X + point.X, Y = acc.Y + point.Y, Count = acc.Count + 1 })
    where totals.Count > 0
    select new Point(totals.X / totals.Count, totals.Y / totals.Count);

这有点复杂,因为我无法使用Average操作符,由于需要应对空窗口的可能性。(严格来说,在我有一个不断变长的Polyline的情况下,这并不重要。但是,如果我像示例 11-20 那样按拖动操作分组点,每个单独的可观测点源将在拖动结束时完成,迫使我处理任何空窗口。)如果你向Average操作符提供一个空序列,它会产生错误,所以我改用了Aggregate操作符,它让我添加一个where子句来过滤掉空窗口而不是崩溃。但这不是更复杂的唯一方面。

正如我之前提到的,Rx 的所有聚合操作符——AggregateMinMax 等——与大多数 LINQ 提供程序的操作方式不同。LINQ 要求这些操作符将流减少为单个值,因此它们通常返回单个值。例如,如果我使用示例 11-23 中显示的参数调用 LINQ to Objects 版本的 Aggregate,它将返回我用作累加器的匿名类型的单个值。但在 Rx 中,返回类型是 IObservable<T>(在这种情况下 T 是累加器类型)。它仍然生成单个值,但通过可观测源呈现该值。与 LINQ to Objects 不同,它可以枚举其输入以计算平均值,Rx 操作符必须等待源提供其值,因此它不能在源说它已完成之前产生这些值的聚合。

因为 Aggregate 操作符返回一个 IObservable<T>,我不得不使用第二个 from 子句。这将源传递给 SelectMany 操作符,该操作符提取所有值并使它们出现在最终流中——在本例中,每个窗口只有一个值,因此 SelectMany 实际上是从其单一项流中展开平均点。

示例 11-23 中的代码比 示例 11-22 复杂一些,我认为理解其工作原理要困难得多。更糟糕的是,它甚至没有提供任何好处。Aggregate 操作符在可用输入时就开始工作,但代码在看到窗口中的每个点之前无法生成最终结果——平均值。如果我必须等到窗口结束才能更新 UI,那么我可能还是坚持使用 Buffer。因此,在这种特殊情况下,Window 是为了没有好处而做了更多的工作。然而,如果在窗口中处理的项目不那么琐碎,或者涉及的数据量非常大,以至于不希望在开始处理整个窗口之前缓冲整个窗口,那么额外的复杂性可能值得,因为可以开始聚合过程而无需等待整个输入窗口可用。

使用可观测对象划分窗口

WindowBuffer 操作符提供了一些定义窗口何时开始和结束的其他方式。就像连接操作符可以用可观测对象指定持续时间一样,你可以提供一个返回每个窗口定义持续时间的函数。示例 11-24 使用此方法将键盘输入分解为单词。本示例中的 keySource 变量来自 示例 11-11。它是一个生成每次按键的可观测序列。

示例 11-24. 使用窗口将文本分解为单词
IObservable<IObservable<char>> wordWindows = keySource.Window(
    () => keySource.FirstAsync(char.IsWhiteSpace));

IObservable<string> words = from wordWindow in wordWindows
                            from chars in wordWindow.ToArray()
                            select new string(chars).Trim();

words.Subscribe(word => Console.WriteLine("Word: " + word));

Window运算符将立即在此示例中创建一个新窗口,并且还将调用我提供的 lambda 来确定窗口的结束时间。它会保持窗口处于打开状态,直到我提供的可观察源的 lambda 返回一个值或完成。当发生这种情况时,Window会立即打开下一个窗口,并再次调用我的 lambda 以获取另一个可观察对象来确定第二个窗口的长度,依此类推。此处的 lambda 会从键盘产生下一个空格字符,因此窗口将在下一个空格处关闭。换句话说,这将输入序列分割成一系列窗口,其中每个窗口包含零个或多个非空格字符,后跟一个空格字符。

Window运算符返回的可观察序列将每个窗口呈现为IObservable<char>。Example 11-24 中的第二个语句是一个查询,将每个窗口转换为一个字符串。(如果输入包含多个相邻的空格字符,这将产生空字符串。这与string类型的Split方法的行为一致,该方法执行了与此分区相对应的拉取导向的操作。如果你不喜欢这种行为,你可以通过where子句来过滤掉空白字符。)

由于 Example 11-24 使用了Window,它将会在用户键入每个单词时立即使字符可用。但由于我的查询在窗口上调用了ToArray,它将等待窗口完成之后才会产生任何内容。这意味着如果使用Buffer同样有效,而且更简单。正如 Example 11-25 所示,如果使用Buffer,我不需要第二个from子句来收集完成的窗口,因为它仅在窗口完成后提供窗口。

Example 11-25. 使用Buffer进行单词拆分
IObservable<IList<char>> wordWindows = keySource.Buffer(
    () => keySource.FirstAsync(char.IsWhiteSpace));

IObservable<string> words = from wordWindow in wordWindows
                            select new string(wordWindow.ToArray()).Trim();

Scan 运算符

Scan运算符与标准的Aggregate运算符非常相似,只有一个区别。它不会在源完成后产生单个结果,而是产生一个序列,其中依次包含每个累加器的值。为了说明这一点,我将首先介绍一个记录类型,它将作为一个非常简单的股票交易模型。这种类型在 Example 11-26 中显示,并定义了一个静态方法,用于提供测试目的的随机生成交易流。

Example 11-26. 使用测试流进行简单股票交易
public record Trade(string StockName, decimal UnitPrice, int Number)
{
    public static IObservable<Trade> TestStream()
    {
        return Observable.Create<Trade>(obs =>
        {
            string[] names = { "MSFT", "GOOGL", "AAPL" };
            var r = new Random(0);
            for (int i = 0; i < 100; ++i)
            {
                var t = new Trade(
                    StockName: names[r.Next(names.Length)],
                    UnitPrice: r.Next(1, 100),
                    Number: r.Next(10, 1000));
                obs.OnNext(t);
            }
            obs.OnCompleted();
            return Disposable.Empty;
        });
    }
}

Example 11-27 展示了使用普通Aggregate运算符计算所有交易股票的总数,方法是将每个交易的Number属性相加。(当然,你通常会直接使用Sum运算符,但为了与Scan进行比较,我这里展示了这种方法。)

Example 11-27. 使用Aggregate进行求和
IObservable<Trade> trades = Trade.TestStream();

IObservable<long> tradeVolume = trades.Aggregate(
    0L, (total, trade) => total + trade.Number);
tradeVolume.Subscribe(Console.WriteLine);

这将显示一个单一的数字,因为由 Aggregate 生成的可观测对象只提供一个单一的值。示例 11-28 几乎完全展示了相同的代码,但是使用了 Scan 替代。

示例 11-28. 使用 Scan 运行总和
IObservable<Trade> trades = Trade.TestStream();

IObservable<long> tradeVolume = trades.Scan(
    0L, (total, trade) => total + trade.Number);
tradeVolume.Subscribe(Console.WriteLine);

这不是产生单一输出值,而是为每个输入产生一个输出项目,这是源迄今为止所有项目的累计总和。如果你需要在无限流中实现类似聚合的行为(例如基于事件源),Scan 就特别有用。在这种情况下,Aggregate 无法使用,因为如果其输入永远不完成,它将不会产生任何内容。

Amb 操作符

Rx 定义了一个名为 Amb 的操作符,其名称有些神秘。(请参见下一个侧边栏,“Why Amb?”)它接受任意数量的可观测序列,并等待看哪一个先执行操作。(文档讨论了输入中哪一个“首先反应”。这意味着它调用了任意三个 IObserver<T> 方法中的任何一个。)首先行动的输入有效成为 Amb 操作符的输出——它立即取消订阅其他流并转发所选择流的所有内容。(如果任何其他流在第一个流之后但操作符尚未取消订阅之前产生元素,这些元素将被忽略。)

使用这个操作符可以通过向服务器池中的多台机器发送请求并使用最先响应的结果来优化系统的响应时间。(当然,这种技术存在一些风险,其中最大的风险之一是可能会显著增加系统的总负载,导致整体速度变慢,而不是加快任何事情的速度。然而,在某些场景中,谨慎应用这种技术可以取得成功。)

DistinctUntilChanged

我要在本节中描述的最后一个操作符非常简单但相当有用。DistinctUntilChanged 操作符删除相邻的重复项。假设你有一个可观测源,它定期产生项目,但往往连续多次产生相同的值。你可能只在出现不同值时需要采取行动。DistinctUntilChanged 正好适用于这种场景——当其输入产生一个项目时,只有在与上一个项目不同(或者是第一个项目)时才会传递该项目。

我还没有展示我想介绍的所有 Rx 操作符。然而,剩下的那些我将在“Timed Operations”中讨论,它们都是时间敏感的。在我展示它们之前,我需要描述 Rx 如何处理时间。

调度器

Rx 通过 调度器 执行特定的工作。调度器是提供三项服务的对象。首先是决定何时执行特定的工作。例如,当观察者订阅冷源时,应立即将源的项目传递给订阅者,还是应该推迟该工作?第二项服务是在特定上下文中运行工作。例如,调度器可能决定始终在特定线程上执行工作。第三项工作是跟踪时间。某些 Rx 操作是时间相关的;为了确保可预测的行为并启用测试,调度器为时间提供了虚拟化模型,因此 Rx 代码不必依赖 .NET 的 DateTimeOffset 类报告的当前时间。

调度器的前两个角色有时是相互依赖的。例如,Rx 为 UI 应用程序提供了几个调度器。Windows Store 应用程序有一个 CoreDispatcherScheduler,WPF 应用程序有一个 DispatcherScheduler,Windows Forms 程序有一个 Control​Sched⁠uler,还有一个更通用的称为 SynchronizationContextScheduler,它将在所有 .NET UI 框架中工作,尽管对比特定于框架的调度器,它的细节控制稍逊一筹。所有这些调度器都有一个共同的特点:它们确保工作在适合访问 UI 对象的上下文中执行,通常意味着在特定线程上运行工作。如果调度工作的代码运行在其他线程上,则调度程序可能别无选择,只能推迟工作,因为它无法在 UI 框架准备好之前运行工作。这可能意味着等待特定线程完成其正在执行的任务。在这种情况下,正确上下文中运行工作也必然会影响工作的执行时间。

尽管如此,并非总是如此。Rx 提供了两个使用当前线程的调度器。其中一个是 ImmediateScheduler,非常简单:它在调度时立即运行工作。当您给这个调度器一些工作时,它不会返回,直到工作完成为止。另一个是 CurrentThreadScheduler,它维护一个工作队列,这使它在排序上具有一定的灵活性。例如,如果在执行某个其他工作的中间调度了一些工作,它可以允许正在进行的工作项完成后再开始下一个。如果没有排队或正在进行的工作项,CurrentThreadScheduler 就像 Immediate​Sched⁠uler 一样立即运行工作。当它调用完成一个工作项时,Current​Th⁠read​Sched⁠uler 检查队列,并在队列不为空时调用下一个工作项。因此,它试图尽快完成所有工作项,但与 ImmediateScheduler 不同的是,它不会在前一个工作项完成之前开始处理新的工作项。

指定调度器

Rx 操作通常不经过调度程序。许多可观察源直接调用其订阅者的方法。通常可以生成大量项目的源是一个例外。例如,用于创建序列的 RangeRepeat 方法使用调度程序来控制它们向新订阅者提供项目的速率。您可以传递显式调度程序或让它们选择默认调度程序。即使使用不接受调度程序作为参数的源,您也可以显式地涉及调度程序。

ObserveOn

指定调度程序的常见方式是使用 System.Reactive.Linq 命名空间中各种静态类定义的 ObserveOn 扩展方法。⁴ 即使事件可能来自其他地方,这在想要在特定上下文(如 UI 线程)处理事件时非常有用。

您可以在任何 IObservable<T> 上调用 ObserveOn,传递一个 IScheduler,它会返回另一个 IObservable<T>。如果订阅返回的可观察对象,则您的观察者的 OnNextOnCompletedOnError 方法将通过您指定的调度程序调用。 Example 11-29 使用此功能确保在项目处理程序回调中更新 UI 是安全的。

示例 11-29. ObserveOn 特定调度程序
IObservable<Trade> trades = GetTradeStream();
IObservable<Trade> tradesInUiContext =
    `trades``.``ObserveOn``(``DispatcherScheduler``.``Current``)``;`
tradesInUiContext.Subscribe(t =>
{
    tradeInfoTextBox.AppendText(
        $"{t.StockName}: {t.Number} at {t.UnitPrice}\r\n");
});

在此示例中,我使用了 DispatcherScheduler 类的静态 Current 属性,该属性返回通过当前线程的 Dispatcher 执行工作的调度程序。 (Dispatcher 是在 WPF 应用程序中管理 UI 消息循环的类。) Rx 的 DispatcherObservable 类定义了各种提供 WPF 特定重载的扩展方法,而不是传递调度程序,我可以调用 ObserveOn 只传递一个 Dispatcher 对象。我可以在代码中使用此方法,例如在 Example 11-30 中。

示例 11-30. ObserveOn WPF Dispatcher
IObservable<Trade> tradesInUiContext = trades.ObserveOn(this.Dispatcher);

这种重载的优点在于,在调用 ObserveOn 的时候我不需要处于 UI 线程上。在 Example 11-29 中使用的 Current 属性只有在您所需的调度程序的线程上时才有效。如果我已经在该线程上,可以更简单地设置它。我可以使用 ObserveOnDispatcher 扩展方法,该方法获取当前线程的调度程序的 DispatcherScheduler,如 Example 11-31 所示。

示例 11-31. 在当前调度程序上观察
IObservable<Trade> tradesInUiContext = trades.ObserveOnDispatcher();

SubscribeOn

大多数各种ObserveOn扩展方法都有相应的SubscribeOn方法。(还有SubscribeOnDispatcher,它是ObserveOnDispatcher的对应物。)SubscribeOn不是为了通过调度程序安排每次对观察者方法的调用,而是通过调度程序执行源可观察对象的Subscribe方法的调用。如果通过调用Dispose取消订阅,那也会通过调度程序传递。对于冷源来说,这可能很重要,因为很多在其Subscribe方法中执行重要工作,有些甚至会立即传递所有项目。

注意

一般来说,订阅源的上下文与生成的项目将传递给订阅者的上下文之间没有任何对应关系的保证。某些源会在其订阅上下文中通知您,但很多则不会。如果您需要在特定上下文中接收通知,那么除非源提供某种方式来指定调度程序,否则使用ObserveOn

明确传递调度程序

一些操作接受调度程序作为参数。您通常会在可以生成多个项目的操作中找到这些调度程序。生成数字序列的Observable.Range方法可以选择在最后一个参数中接受一个调度程序,以控制生成这些数字的上下文。这也适用于适应其他源(例如IEnumerable<T>)到可观察源的 API,如“适配”中所述。

另一个通常可以提供调度程序的场景是使用合并输入的可观察对象。前面提到过,Merge操作符可以合并多个序列的输出。您可以提供一个调度程序来告诉操作符从特定上下文订阅源。

最后,所有定时操作都依赖于调度程序。我将在“定时操作”中展示其中一些。

内置调度程序

我已经描述了四个面向 UI 的调度程序,DispatcherScheduler(用于 WPF)、CoreDispatcherScheduler(用于 Windows Store 应用)、ControlScheduler(用于 Windows Forms)和SynchronizationContextScheduler,以及两个在当前线程上运行工作的调度程序,CurrentThreadSchedulerImmediateScheduler。但还有一些其他值得注意的调度程序。

EventLoopScheduler在特定线程上运行所有工作项。它可以为您创建一个新线程,或者您可以为其提供一个回调方法,在需要您创建线程时它会调用该方法。您可以在 UI 应用程序中使用它来处理传入数据。它允许您将工作从 UI 线程移出,以保持应用程序的响应性,但确保所有处理都在单个线程上进行,这可以简化并发问题。

NewThreadScheduler 为每个顶级工作项创建一个新线程。(如果该工作项生成更多工作项,则这些工作项将在同一线程上运行,而不是创建新线程。)只有在每个项需要大量工作时才适用,因为在 Windows 中线程的启动和关闭成本相对较高。如果需要并发处理工作项,通常最好使用线程池。

TaskPoolScheduler 使用任务并行库(TPL)的线程池。TPL 在第十六章中描述,提供了一个高效的线程池,可以重用单个线程来处理多个工作项,从而分摊了创建线程的启动成本。

ThreadPoolScheduler 使用 CLR 的线程池来运行工作。这在概念上类似于 TPL 线程池,但技术上稍显陈旧。(TPL 是在.NET 4.0 中引入的,但 CLR 线程池自 v1.0 起就存在。)在某些场景下效率较低。Rx 引入了这个调度器,因为早期的 Rx 版本支持没有 TPL 的旧版.NET。出于向后兼容的原因保留了它。

HistoricalScheduler 在你需要测试依赖于时间的代码,但又不想实时执行测试时非常有用。所有调度器都提供时间服务,但HistoricalScheduler让你可以决定调度器以多快的速度模拟时间流逝。因此,如果你需要测试等待 30 秒后会发生什么,你可以告诉HistoricalScheduler模拟 30 秒已过去,而无需真正等待。

Subjects

Rx 定义了各种subjects,这些类实现了IObserver<T>IObservable<T>接口。如果需要 Rx 提供这些接口的强大实现,但通常的Observable.CreateSubscribe方法不方便时,这些类有时会很有用。例如,也许你需要提供一个可观察的源,而你的代码中有几个不同的地方需要为该源提供值来生成。这很难适配到Create方法的订阅回调模型中,使用 subject 会更容易处理。某些 subject 类型提供额外的行为,但我将从最简单的Subject<T>开始。

Subject<T>

Subject<T> 类的 IObserver<T> 实现只是通过其 IObservable<T> 接口转发给所有已订阅的观察者。因此,如果你订阅了一个或多个 Subject<T>,然后调用 OnNext,该主题将调用其所有订阅者的 OnNext 方法。其他方法 OnCompletedOnError 也是如此。这种多播转发与我在 示例 11-11 中使用的 Publish 操作符⁵ 提供的功能非常相似,因此这为我从 KeyWatcher 源中移除所有跟踪订阅者代码提供了另一种选择,结果如 示例 11-32 中所示。这比 示例 11-7 中的原始方法简单得多,尽管不像 示例 11-11 中的基于委托的版本那样简单。

示例 11-32. 使用 Subject<T> 实现 IObservable<T>
public class KeyWatcher : IObservable<char>
{
    private readonly Subject<char> _subject = new();

    public IDisposable Subscribe(IObserver<char> observer)
    {
        return _subject.Subscribe(observer);
    }

    public void Run()
    {
        while (true)
        {
            _subject.OnNext(Console.ReadKey(true).KeyChar);
        }
    }
}

在其 Subscribe 方法中,它转发到了一个 Subject<char>,所以试图订阅这个 KeyWatcher 的一切最终都会订阅到该主题。然后,我的循环只需调用主题的 OnNext 方法,它会负责将其广播给所有订阅者。

实际上,我可以通过将可观察对象公开为单独的属性来进一步简化事情,而不是使整个类型都成为可观察的,正如 示例 11-33 所示。这不仅使代码稍微简单了些,而且意味着我的 KeyWatcher 现在可以提供多个源。

示例 11-33. 提供一个 IObservable<T> 作为属性
public class KeyWatcher
{
    private readonly Subject<char> _subject = new();

    public IObservable<char> Keys => _subject;

    public void Run()
    {
        while (true)
        {
            _subject.OnNext(Console.ReadKey(true).KeyChar);
        }
    }
}

这仍然不像我在 示例 11-11 中使用的 Observable.CreatePublish 操作符的组合那样简单,但它确实提供了两个优点。首先,现在更容易看到生成键盘按键通知的循环何时运行。在 示例 11-11 中我控制了这个过程,但对于不太熟悉 Publish 工作原理的人来说,这可能并不明显。我觉得 示例 11-33 较少神秘。其次,如果我愿意,我可以从 KeyWatcher 类的任何地方使用这个主题,而在 示例 11-11 中,我只能在由 Observable.Create 调用的回调函数内部提供一个项。在这个例子中,我并不需要这种灵活性,但在需要时,Subject<T> 很可能比回调方法更好。

BehaviorSubject

BehaviorSubject<T> 看起来几乎和 Subject<T> 一模一样,除了一点:当任何观察者第一次订阅时,只要你还没有通过调用 OnComplete 完成主题,它就保证会立即接收一个值。(如果你已经完成了主题,它将立即在任何进一步的订阅者上调用 OnComplete。)它会记住它传递的最后一项,并将其提供给新的订阅者。当你构造一个 BehaviorSubject<T> 时,你必须提供一个初始值,它将提供给新的订阅者,直到第一次调用 OnNext

有一种方法可以将此主题视为 Rx 版本的变量。它是一种具有可以随时检索的值的东西,其值也可以随时间改变。但是由于是响应式的,你必须订阅它才能检索其值,并且在你取消订阅之前,观察者将被通知任何进一步的更改。

此主题具有热和冷特性的混合。它将立即向任何订阅者提供一个值,使其看起来像一个冷源,但一旦发生这种情况,它会像热源一样向所有订阅者广播新值。还有另一个主题具有类似的混合特性,但更进一步地采用了冷侧。

ReplaySubject<T>

ReplaySubject<T> 可以记录它从任何你订阅的源接收到的每个值。(或者,如果你直接调用其方法,它会记住你通过 OnNext 提供的每个值。)对此主题的每个新订阅者将接收到 ReplaySubject<T> 到目前为止所看到的每个项目。因此,这更像是一个普通的冷主题——与从 BehaviorSubject<T> 获取最近值不同,你会得到一个完整的项目集。然而,一旦 ReplaySubject<T> 向特定的订阅者提供了所有已记录的项目,对于该订阅者,它会转变为更像热主题的行为,因为它将继续提供新接收到的项目。

因此,长期以来,对 ReplaySubject<T> 的每个订阅者默认情况下都会看到它从其源接收到的每个项目,无论该订阅者是何时订阅的主题。

在其默认配置中,ReplaySubject<T> 将消耗越来越多的内存,只要它订阅到一个源。无法告诉它不会再有新的订阅者,并且现在可以丢弃已经分发给所有现有订阅者的旧项。因此,你不应该无限期地将其订阅到一个无限源。但是,你可以限制 ReplaySubject<T> 缓冲的数量。它提供了各种构造函数重载,其中一些让你可以指定回放的项目数量的上限或者持续保留项目的时间的上限。显然,如果你这样做,新的订阅者将无法依赖于获取到以前接收到的所有项目。

AsyncSubject<T>

AsyncSubject<T>只会记住其来源的一个值,但与BehaviorSubject<T>不同,后者记住最近的值,AsyncSubject<T>会等待其来源完成。然后,它将产生最终项作为其输出。如果来源在不提供任何值的情况下完成,AsyncSubject<T>将立即完成所有新的订阅者。

如果在其来源完成之前订阅了AsyncSubject<T>,则AsyncSubject<T>不会对观察者执行任何操作。但一旦来源完成,AsyncSubject<T>将作为冷源,提供单个值,除非来源在不提供值的情况下完成,否则此主题将立即完成所有新的订阅者。

适应

尽管 Rx 很有趣且强大,但如果它存在于真空中,则几乎没有用处。如果您正在处理异步通知,可能会有 API 提供它们,但不支持 Rx。虽然IObservable<T>IObserver<T>已经存在很长时间(自 2010 年发布的.NET 4.0 以来),但并非每个支持这些接口的 API 都能够支持。此外,由于 Rx 的基本抽象是一系列项目,因此很可能在某个时候需要在 Rx 的推送式IObservable<T>和拉取式等效的IEnumerable<T>IAsyncEnumerable<T>之间进行转换。Rx 提供了将这些和其他类型的来源适应为IObservable<T>的方法,并且在某些情况下,可以在两者之间进行适应。

IEnumerable和 IAsyncEnumerable

任何IEnumerable<T>都可以轻松地进入 Rx 的世界,这要归功于ToObservable扩展方法。这些方法由System.Reactive.Linq命名空间中的Observable静态类定义。示例 11-34 展示了最简单的形式,不带任何参数。

示例 11-34. 将IEnumerable<T>转换为IObservable<T>
public static void ShowAll(IEnumerable<string> source)
{
    `IObservable``<``string``>` `observableSource` `=` `source``.``ToObservable``(``)``;`
    observableSource.Subscribe(Console.WriteLine);
}

ToObservable方法本身不会枚举其输入——它只返回一个实现IObservable<T>的包装器。此包装器是一个冷源,每当您向其订阅观察者时,它才会迭代输入,将每个项目传递给观察者的OnNext方法,并在最后调用OnCompleted。如果源引发异常,此适配器将调用OnError。示例 11-35 展示了如果不需要使用调度程序,ToObservable可能的工作方式。

示例 11-35. 没有调度程序支持时ToObservable的实现
public static IObservable<T> MyToObservable<T>(this IEnumerable<T> input)
{
    return Observable.Create((IObserver<T> observer) =>
        {
            bool inObserver = false;
            try
            {
                foreach (T item in input)
                {
                    inObserver = true;
                    observer.OnNext(item);
                    inObserver = false;
                }
                inObserver = true;
                observer.OnCompleted();
            }
            catch (Exception x)
            {
                if (inObserver)
                {
                    throw;
                }
                observer.OnError(x);
            }
            return () => { };
        });
}

这并不是它真正的工作方式,因为示例 11-35 无法使用调度程序。(完整的实现将会更难阅读,从而违背了示例的目的,即显示ToObservable背后的基本思想。)真实的方法使用调度程序来管理迭代过程,如果需要,允许异步订阅。它还支持在观察者的订阅被提前取消时停止工作。有一个以IScheduler类型的单一参数作为重载,允许您告诉它使用特定的调度程序;如果您不提供一个,它将使用CurrentThreadScheduler

当需要进行相反方向的操作时,即当您有一个IObservable<T>但希望将其视为IEnumerable<T>时,您可以调用由Observable类提供的ToEnumerable扩展方法。示例 11-36 将IObservable<string>封装为IEnumerable<string>,以便可以使用普通的foreach循环迭代源中的项。

示例 11-36. 将IObservable<T>用作IEnumerable<T>
public static void ShowAll(IObservable<string> source)
{
    foreach (string s in source.ToEnumerable())
    {
        Console.WriteLine(s);
    }
}

包装器代表您订阅了源。如果源提供的项比您迭代它们的速度更快,包装器将把这些项存储在队列中,以便您可以随时检索它们。如果源提供的项不如您检索它们的速度快,包装器将等待直到有可用的项。

接口IAsyncEnumerable<T>提供了与IEnumerable<T>相同的模型,但通过使用第十七章中讨论的技术,以一种能够进行有效异步操作的方式实现。Rx 提供了ToObservable扩展方法,以及IObservable<T>ToAsyncEnumerable方法扩展方法。这两者都来自AsyncEnumerable类,如果要使用它们,您将需要引用一个名为System.Linq.Async的单独 NuGet 包。

.NET 事件

Rx 可以使用Observable类的静态FromEventPattern方法将.NET 事件包装为IObservable<T>。此前在示例 11-17 中,我使用了FileSystemWatcher,这是System.IO命名空间中的一个类,在特定文件夹中添加、删除、重命名或以其他方式修改文件时触发各种事件。示例 11-37 复制了该示例的第一部分,这是上次我略过的部分。此代码使用Observable.FromEventPattern静态方法生成一个表示监视器Created事件的可观察源。(如果要处理静态事件,您可以将Type对象作为第一个参数传递。第十三章描述了Type类。)

示例 11-37. 将事件包装为IObservable<T>
string path = Environment.GetFolderPath(Environment.SpecialFolder.MyPictures);
var watcher = new FileSystemWatcher(path);
watcher.EnableRaisingEvents = true;

IObservable<EventPattern<FileSystemEventArgs>> changes =
    Observable.FromEventPattern<FileSystemEventArgs>(
        watcher, nameof(watcher.Created));
changes.Subscribe(evt => Console.WriteLine(evt.EventArgs.FullPath));

表面上看,这似乎比在 第九章 中显示的正常订阅事件要复杂得多,而且没有明显的优势。在这种特定示例中,使用传统方法会更好。但是,使用 Rx 的一个好处是,如果您正在编写 UI 应用程序,您可以使用适当的调度程序与 ObserveOn 一起确保您的处理程序始终在正确的线程上调用,无论哪个线程引发事件。当然,另一个好处 —— 通常的原因 —— 是您可以使用 Rx 的任何查询操作符来处理事件。(这就是为什么原始的 Example 11-17 这样做的原因。)

Example 11-37 产生的可观察源的元素类型是 Event​Pat⁠tern<FileSystemEventArgs>。泛型 EventPattern<T> 是 Rx 特别为表示事件触发而定义的类型,其中事件的委托类型符合 第九章 中描述的标准模式(即接受两个参数,第一个是 object 类型,表示引发事件的对象,第二个是从 EventArgs 派生的某种类型,包含有关事件的信息)。EventPattern<T> 有两个属性,SenderEventArgs,对应于事件处理程序将接收的两个参数。实际上,这是一个表示通常会对事件处理程序进行方法调用的对象。

Example 11-37 的一个令人惊讶的特性是,FromEvent​Pat⁠tern 的第二个参数是一个包含事件名称的字符串。Rx 在运行时将其解析为真实的事件成员。这在某些方面不太理想,原因有几个。首先,这意味着如果您输入错误的名称,编译器不会注意到(尽管使用 nameof 操作符可以缓解这个问题)。其次,这意味着编译器无法帮助您确定类型 —— 如果直接使用 lambda 表达式处理 .NET 事件,编译器可以从事件定义推断参数类型,但在这里,因为我们将事件名称作为字符串传递,编译器不知道我使用的是哪个事件(甚至不知道我是否使用了事件),因此我必须明确地为方法指定泛型类型参数。而且,如果我搞错了,编译器不会察觉 —— 这将在运行时检查。

这种基于字符串的方法源于事件的缺陷:您不能将事件作为参数传递。事实上,事件是非常有限的成员。您不能从定义它的类外部对事件执行任何操作,除了添加或移除处理程序。这是 Rx 在事件方面改进的一种方式——一旦进入 Rx 的世界,事件源和订阅者都表示为对象(分别实现 IObservable<T>IObserver<T>),使得将它们作为参数传递到方法中变得简单。但这并不帮助我们处理尚未进入 Rx 的事件的情况。

Rx 确实提供了一个重载,不需要使用字符串——您可以传递用于 Rx 添加和移除处理程序的委托,如 示例 11-38 所示。

示例 11-38. 基于委托的事件包装
IObservable<EventPattern<FileSystemEventArgs>> changes =
    Observable.FromEventPattern<FileSystemEventHandler, FileSystemEventArgs>(
    h => watcher.Created += h, h => watcher.Created -= h);

这相对冗长一些,因为它需要一个泛型类型参数来指定处理程序委托类型以及事件参数类型。基于字符串的版本在运行时自行发现处理程序类型,但因为使用 示例 11-38 的一般原因是为了获得编译时类型检查,编译器需要知道你使用的是哪些类型,而该示例中的 Lambda 表达式并没有为编译器提供足够的信息来自动推断所有类型参数。

除了将事件作为可观察源进行包装之外,还可以反过来进行。Rx 定义了一个名为 ToEventPattern<T>IObservable<EventPattern<T>> 操作符。(请注意,这不适用于任何旧的可观察源——它必须是 EventPattern<T> 的可观察序列。)如果调用此方法,它将返回一个实现了 IEventPatternSource<T> 接口的对象。它定义了一个名为 OnNext 的单一事件,类型为 EventHandler<T>,允许您以普通的 .NET 方式将事件处理程序连接到可观察源。

异步 API

.NET 支持各种异步模式,在第十六章和第十七章中会详细描述。最早在 .NET 中引入的是异步编程模型(APM)。然而,这种模式并不直接被新的 C# 异步语言特性支持,所以现在大多数 .NET API 使用 TPL,对于旧的 API,TPL 提供了适配器,可以为基于 APM 的 API 提供基于任务的包装器。Rx 可以将任何 TPL 任务表示为可观察源。

所有.NET 异步模式的基本模型是,你启动一些工作,最终会完成,可选地生成一个结果。因此,将其翻译成 Rx 可能看起来有些奇怪,因为 Rx 的基本抽象是一个项目序列,而不是单个结果。事实上,理解 Rx 和 TPL 之间的区别的一种有用方法是,IObservable<T>类似于IEnumerable<T>,而Task<T>类似于类型为T的属性。与使用IEnumerable<T>和属性时,调用者决定何时从源获取信息不同,使用IObservable<T>Task<T>时,源在准备好时提供信息。决定何时提供信息的选择与信息是单个还是项目序列是分开的。因此,将单个异步 API 映射到IObservable<T>似乎有些不匹配。但是我们在非异步世界中也可以跨越类似的界限——正如你在第十章中看到的,LINQ 定义了各种标准运算符,用于从序列生成单个项目,如FirstLast。Rx 支持这些运算符,但它还支持另一种方式:将单个异步源带入类似流的世界。其结果是一个IObservable<T>源,只产生一个项目(或者如果操作失败,则报告错误)。在非异步世界中的类比可以是将单个值包装在数组中,以便您可以将其传递给需要IEnumerable<T>的 API。

示例 11-39 使用此功能生成一个IObservable<string>,它将生成包含从特定 URL 下载的文本的单个值,或在下载失败时报告故障。

示例 11-39. 将Task<T>包装为IObservable<T>
public static IObservable<string> GetWebPageAsObservable(
    Uri pageUrl, IHttpClientFactory cf)
{
    HttpClient web = cf.CreateClient();
    Task<string> getPageTask = web.GetStringAsync(pageUrl);
    `return` `getPageTask``.``ToObservable``(``)``;`
}

在这个示例中使用的ToObservable方法是 Rx 为Task定义的扩展方法。为了使其可用,你需要确保System.Reactive.Threading.Tasks命名空间在作用域内。

示例 11-39 的一个潜在的不满意特性是,它只会尝试下载一次,无论多少观察者订阅源。根据您的需求,这可能没问题,但在某些情况下,每次尝试下载新副本可能更合理。如果您需要这样做,更好的方法是使用Observable.FromAsync方法,因为您可以将一个 lambda 传递给它,每当新的观察者订阅时它都会调用这个 lambda。您的 lambda 返回一个任务,然后将其作为可观察源进行包装。示例 11-40 使用这种方法为每个订阅者启动一个新的下载。

示例 11-40. 为每个订阅者创建一个新任务
public static IObservable<string> GetWebPageAsObservable(
    Uri pageUrl, IHttpClientFactory cf)
{
    return Observable.FromAsync(() =>
        {
            HttpClient web = cf.CreateClient();
            return web.GetStringAsync(pageUrl);
        });
}

如果你有多个订阅者,这可能不是最佳选择。另一方面,当没有任何尝试订阅时,这更有效率。示例 11-39 立即开始异步工作,甚至不等待任何订阅者。这可能是一件好事——如果流肯定会有订阅者,那么在等待第一个订阅者之前开始缓慢的工作将减少整体延迟。然而,如果你正在编写一个库中呈现多个可观察源的类,可能最好推迟工作直到第一次订阅。

定时操作

由于 Rx 可以处理实时信息流,你可能需要以时间敏感的方式处理项目。例如,项目到达的速率可能很重要,或者你可能希望根据提供的时间对项目进行分组。在最后的这一节中,我将描述 Rx 提供的一些基于时间的操作符。

间隔

Observable.Interval 方法返回一个序列,该序列定期以 TimeSpan 类型的参数指定的间隔产生值。示例 11-41 创建并订阅了一个每秒产生一个值的源。

示例 11-41. 使用 Interval 进行定期项目处理
IObservable<long> src = Observable.Interval(TimeSpan.FromSeconds(1));
src.Subscribe(i => Console.WriteLine($"Event {i} at {DateTime.Now:T}"));

Interval 产生的项目的类型是 long。它生成值为零、一、二等。

Interval 独立处理每个订阅者(即它是一个冷源)。为了演示这一点,在 示例 11-41 的代码之后添加 示例 11-42 中的代码,稍等一会儿,然后创建第二个订阅。

示例 11-42. 对 Interval 源的第二个订阅者
Thread.Sleep(2500);
src.Subscribe(i => Console.WriteLine(
    $"Event {i} at {DateTime.Now:T} (2nd subscriber)"));

第二个订阅者在第一个订阅者之后订阅了两秒半,所以这将产生以下输出:

Event 0 at 09:46:58
Event 1 at 09:46:59
Event 2 at 09:47:00
Event 0 at 09:47:00 (2nd subscriber)
Event 3 at 09:47:01
Event 1 at 09:47:01 (2nd subscriber)
Event 4 at 09:47:02
Event 2 at 09:47:02 (2nd subscriber)
Event 5 at 09:47:03
Event 3 at 09:47:03 (2nd subscriber)

你可以看到第二个订阅者的值从零开始,这是因为它有自己的序列。如果你希望这些定时项目的单个集合供多个订阅者使用,可以使用前面描述的 Publish 操作符。

你可以将 Interval 源与组连接一起作为一种根据到达时间分割项目的方法。(这不是唯一的方法——BufferWindow 有多种重载可以做同样的事情。)示例 11-43 将定时器与代表用户输入的可观察序列结合起来。(第二个序列在 words 变量中,来自 示例 11-25。)

示例 11-43. 计算每分钟的字数
IObservable<long> ticks = Observable.Interval(TimeSpan.FromSeconds(6));
IObservable<int> wordGroupCounts = from tick in ticks
                                   join word in words
                                     on ticks equals words into wordsInTick
                                   from count in wordsInTick.Count()
                                   select count * 10;

wordGroupCounts.Subscribe(c => Console.WriteLine($"Words per minute: {c}"));

将单词根据来自Interval源的事件分组后,此查询继续计算每个组中的项目数。由于组在时间上均匀间隔,这可以用来计算用户输入单词的近似速率。我每 6 秒形成一组,所以我们可以将组中的单词数乘以 10 来估算每分钟的单词数。

结果并不完全准确,因为 Rx 将会合并两个项目,如果它们的持续时间重叠。这将导致单词在此处被多次计数。在一个间隔的末尾的最后一个单词也将成为下一个间隔开始时的第一个单词。在这种情况下,测量结果相当粗略,所以我不太担心,但如果您希望得到更精确的结果,您需要考虑重叠如何影响这种操作。WindowBuffer可能提供更好的解决方案。

定时器

Observable.Timer方法可以创建一个产生确切一个项目的序列。它在产生该项目之前等待由TimeSpan参数指定的持续时间。它看起来非常类似于Observable.Interval,因为它不仅接受相同的参数,而且甚至返回相同类型的序列:IObservable<long>。因此,我可以几乎以与间隔序列完全相同的方式订阅此类源,正如示例 11-44 所示。

示例 11-44. 使用Timer的单个项目
IObservable<long> src = Observable.Timer(TimeSpan.FromSeconds(1));
src.Subscribe(i => Console.WriteLine($"Event {i} at {DateTime.Now:T}"));

效果与在产生其第一个项目后停止的Interval相同,因此您将始终获得零值。还有接受额外TimeSpan的重载,它将像Interval一样重复生成值。实际上,Interval在内部使用Timer——它只是一个提供更简单 API 的包装器。

时间戳

在前两节中,我在写出消息时使用DateTime.Now来指示源产生项目的时间。这样做的一个潜在问题是它告诉我们处理程序处理消息的时间,这不总是准确反映消息接收的时间。例如,如果您使用ObserveOn确保处理程序始终在 UI 线程上运行,那么在项目生成和您的代码处理它之间可能会有显著延迟,因为 UI 线程可能在执行其他任务。您可以使用Timestamp运算符来减轻这一问题,该运算符适用于任何IObservable<T>。示例 11-45 使用这种方式作为显示Interval生成其项目的时间的替代方法。

示例 11-45. 带时间戳的项目
IObservable<Timestamped<long>> src =
    Observable.Interval(TimeSpan.FromSeconds(1)).Timestamp();
src.Subscribe(i => Console.WriteLine(
    $"Event {i.Value} at {i.Timestamp.ToLocalTime():T}"));

如果源可观察对象的项目类型为某种类型T,此运算符将生成一个Timestamped<T>项目的可观察对象。这定义了一个包含来自源可观察对象的原始值的Value属性,以及一个指示值何时通过Timestamp运算符的Timestamp属性。

注意

Timestamp属性是一个DateTimeOffset,并选择了零时区偏移(即它在 UTC 时间)。这通过移除程序运行时进出夏令时的可能性,为定时提供了稳定的基础。然而,如果你想向最终用户显示时间戳,你可能需要调整它,这就是为什么示例 11-45 在其上调用了 ToLocalTime 的原因。

你应该直接将这个操作符应用到你想要加时间戳的可观察对象上,而不是将它留在链条的后面。写成 src.ObserveOn(sched).Timestamp() 会失去意义,因为你会在调度程序传递给 ObserveOn 后对项进行计时。你应该写成 src.Timestamp().ObserveOn(sched),以确保在将项馈送到可能引入延迟的处理链之前获取时间戳。

TimeInterval

Timestamp记录产生项时的当前时间,它的相对对应物TimeInterval记录连续项之间的时间。示例 11-46 在由Observable.Interval产生的可观察序列上使用了这个操作符,所以我们期望这些项间隔相对均匀。

示例 11-46. 测量间隔
IObservable<long> ticks = Observable.Interval(TimeSpan.FromSeconds(0.75));
IObservable<TimeInterval<long>> timed = ticks.TimeInterval();
timed.Subscribe(x => Console.WriteLine(
    $"Event {x.Value} took {x.Interval.TotalSeconds:F3}"));

Timestamp操作符生成的Timestamped<T>项提供一个Timestamp属性,而由TimeInterval操作符生成的TimeInterval<T>项则定义了一个Interval属性。这是一个TimeSpan,而不是DateTimeOffset。我选择显示每个项之间的秒数,保留三位小数。当我在我的计算机上运行时,这是我看到的一些内容:

Event 0 took 0.760
Event 1 took 0.757
Event 2 took 0.743
Event 3 took 0.751
Event 4 took 0.749
Event 5 took 0.750

这显示的间隔可能比我要求的要差 10 毫秒,但这是相当典型的。Windows 不是一个实时操作系统。

Throttle

Throttle操作符允许你限制处理项的速率。你传递一个TimeSpan,指定任意两个项之间的最小时间间隔。如果底层源产生的项比这更快,Throttle会直接丢弃它们。如果源比指定速率慢,Throttle会直接传递所有内容。

惊讶的是(或者至少我发现这很惊讶),一旦源超过指定速率,Throttle会将所有东西都丢弃,直到速率再次降到指定水平以下。因此,如果你指定每秒处理 10 个项目,而源每秒产生 100 个项目,它不会简单地返回每第 10 个项目,而是直到源减速才会返回任何东西。

Sample

Sample操作符根据其TimeSpan参数指定的间隔从其输入中产生项,而不管输入可观察对象生成项的速率如何。如果底层源产生的项比所选速率快,Sample会丢弃项以限制速率。然而,如果源运行较慢,Sample操作符会重复最后一个值,以确保持续供应通知。

Timeout

Timeout 操作符从其源可观察对象中传递所有内容,除非源在订阅时间和第一个项目之间或两次调用观察者之间留下了太大的间隙。你可以用 TimeSpan 参数指定最小可接受的间隙。如果在该时间内没有任何活动发生,Timeout 操作符将通过向 OnError 报告 TimeoutException 来完成。

窗口操作符

我之前描述了 BufferWindow 操作符,但没有展示它们基于时间的重载。除了能够指定窗口大小和跳过计数,或者使用辅助的可观察源标记窗口边界外,你还可以指定基于时间的窗口。

如果只传递一个 TimeSpan,这两个操作符都会在指定的间隔时间内将输入拆分为相邻窗口。这提供了一种比 示例 11-43 更简单的估算每分钟单词数的方法。示例 11-47 展示了如何使用定时窗口的 Buffer 操作符实现相同效果。

示例 11-47. 使用 Buffer 的定时窗口
IObservable<int> wordGroupCounts =
    from wordGroup in words.Buffer(TimeSpan.FromSeconds(6))
    select wordGroup.Count * 10;
wordGroupCounts.Subscribe(c => Console.WriteLine("Words per minute: " + c));

还有接受 TimeSpanint 两个参数的重载,允许你在指定的间隔时间内关闭当前窗口(从而启动下一个窗口),或者在项数超过阈值时关闭窗口。此外,还有接受两个 TimeSpan 参数的重载。这些重载支持窗口大小和跳过计数的时间等效组合。第一个 TimeSpan 参数指定窗口持续时间,而第二个参数指定开始新窗口的间隔。这意味着窗口不需要严格相邻,它们可以有间隙,或者可以重叠。示例 11-48 使用这种方法提供更频繁的单词速率估算,同时仍然使用六秒窗口。

示例 11-48. 重叠的定时窗口
IObservable<int> wordGroupCounts =
    from wordGroup in words.Buffer(TimeSpan.FromSeconds(6),
                                   TimeSpan.FromSeconds(1))
    select wordGroup.Count * 10;

与我在 示例 11-43 中展示的基于连接的分块不同,WindowBuffer 不会因为它们不基于重叠持续时间的概念而重复计数项。它们将项目到达视为瞬时事件,要么在给定窗口内,要么在外部。因此,我刚刚展示的示例将提供稍微更准确的速率测量。

延迟

Delay 操作符允许你对可观察的源进行时间偏移。你可以传递一个 TimeSpan,在这种情况下,操作符将延迟所有内容指定的时间量,或者你可以传递一个 DateTimeOffset,指示你希望它开始重新播放其输入的特定时间。另外,你也可以传递一个可观察对象,当该可观察对象首次产生值或完成时,Delay 操作符将开始产生它存储的值。

不论时间偏移持续时间如何确定,在所有情况下,Delay 操作符都试图保持输入之间的相同间隔。因此,如果底层源立即生成一个项目,然后三秒后生成另一个项目,再过一分钟生成第三个项目,Delay 生成的可观察对象将按照相同的时间间隔生成项目。

显然,如果你的源开始以惊人的速度生成项目——也许是每秒万个项目 — Delay 要复制项目的确切时间安排的保真度存在限制,但它会尽力而为。准确性的限制不是固定的。它们将由你使用的调度器的性质和机器上的可用 CPU 容量确定。例如,如果你使用其中一个基于 UI 的调度器,它将受制于 UI 线程的可用性以及其能够分派工作的速率。(与所有基于时间的运算符一样,Delay 将为你选择一个默认调度器,但它提供了可以传递调度器的重载。)

DelaySubscription

DelaySubscription 操作符提供了一组与 Delay 操作符类似的重载,但它尝试实现延迟的方式不同。当你订阅由 Delay 生成的可观察源时,它将立即订阅底层源并开始缓冲项目,在所需的延迟时间过去后才转发每个项目。DelaySubscription 采用的策略只是延迟对底层源的订阅,然后立即转发每个项目。

对于冷源,DelaySubscription 通常会满足你的需求,因为延迟冷源的工作开始通常会使整个过程发生时间偏移。但对于热源,DelaySubscription 会导致你错过延迟期间发生的任何事件,并且之后,你将开始获取没有时间偏移的事件。

Delay 操作符更可靠——通过单独对每个项目进行时间偏移,它适用于热源和冷源。然而,它需要更多的工作——它需要为延迟持续时间内接收到的所有内容进行缓冲。对于繁忙的源或长时间的延迟,这可能会消耗大量内存。并且,试图通过时间偏移来复制原始时间安排比直接传递项目要复杂得多。因此,在适用的场景中,DelaySubscription 更有效率。

Reaqtor — Rx 作为服务

在 2021 年 5 月,微软开源了 Reaqtor,这是一组组件,使得能够在服务中托管长时间运行的 Rx 查询成为可能。微软在其多种在线服务中内部使用了这些组件,包括必应搜索引擎和 Office 的在线版本,为其提供事件驱动功能。例如,它使得设置提醒成为可能,告诉你根据当前交通情况需要何时离开以准时赴约。Reaqtor 已经被证明能够维护数百万活跃查询的记录。使这一切成为可能的核心库的代码托管在 Reaqtor 源代码仓库,你可以在 文档和支持信息 找到相关内容。

Reaqtor 借用了 Rx 的编程模型——可观察序列、主题和操作符,并利用了 .NET 的表达式树功能(详见 第九章)来实现可以存储或发送网络传输的查询。它还提供了标准 LINQ 操作符的版本,能够持久化它们的状态,使得带有状态操作符的查询(如 AggregateDistinctUntilChanged 或其他需要记住已见数据的操作符)可以在任何单个进程生命周期之外继续存在。这使得一个应用程序能够定义一个 LINQ 查询到某个数据源的可观察对象,并设置一个订阅该查询的服务器池,其寿命可以任意长。Reaqtor 的设计旨在提供与数据库相同的耐久性,因此微软的一些应用程序中有 Rx 查询已经连续运行数年未中断。

Rx 和 Reaqtor 之间的关系与 LINQ to Objects 和 Entity Framework (EF) Core 之间的关系非常相似。正如您在 第十章 中看到的那样,LINQ to Objects 是建立在 IEnumerable<T> 上的,并且完全在内存中工作,没有持久性或跨进程能力。EF Core 使用相同的基本概念,并提供大多数相同的操作符,但是通过基于表达树的 IQueryable<T> 来构建,EF Core 能够将应用程序的查询表示发送到数据库服务器,以便远程执行这些查询。EF Core 将 LINQ 带入了持久性耐久性和分布式执行的世界。类似地,Rx 是建立在 IObservable<T> 上的,并且完全在内存中运行,而 Reaqtor 则使用基于表达树的接口 IQbservable<T>。(请注意,Q 代替了 O,表示其与 IQueryable<T> 在概念上的相似性。)IQbservable<T> 看起来与 IObservable<T> 非常相似,并提供所有相同的操作符,但是因为它在表达树的世界中工作,Reaqtor 能够将查询转换为可以发送到服务器群的形式,然后服务器群内部能够重新构建可运行版本的这些查询。它利用可序列化性来存储查询,使得这些查询能够在服务器群内的不同机器之间迁移,提供了在单个服务器故障面前的持久性和耐久性。Reaqtor 将 Rx 带入了持久性耐久性和分布式执行的世界。

在撰写本文时,还没有现成的免费可托管的 Reaqtor 版本,因此从 Reaqtor 库构建真实应用需要相当多的工作。但是我已经在雇主那里基于此构建了几个应用程序,所以我可以自信地说,这绝对是可行的。

摘要

正如你现在所见,Reactive Extensions for .NET 提供了大量功能。Rx 的基本概念是一种对项序列的良好抽象,其中源决定何时提供每个项,以及一个相关的表示订阅者的抽象。通过将这两个概念表示为对象,事件源和订阅者都成为一流实体,这意味着你可以将它们作为参数传递,存储在字段中,并且通常可以像处理其他任何数据类型一样处理它们。虽然你也可以使用委托完成所有这些操作,但.NET 事件不是一流的。此外,Rx 提供了一种明确定义的机制来通知订阅者错误,而委托和事件都不擅长处理这一点。除了定义事件源的一流表示外,Rx 还定义了一个全面的 LINQ 实现,这就是为什么有时将 Rx 描述为 LINQ 到事件的原因。事实上,它远远超出了标准 LINQ 运算符的范围,添加了许多运算符,这些运算符利用和帮助管理事件驱动系统所处的实时和潜在时间敏感的世界。Rx 还提供各种服务,用于在其基本抽象和其他世界(包括标准的.NET 事件、IEnumerable<T> 和各种异步模型)之间进行桥接。

¹ 你可以下载完整的 WPF 示例,作为本书示例的一部分。

² 在推送式的世界中,缺少了OrderByThenBy操作符,因为在看到所有输入项之前,它们不能生成任何项。

³ 像一些开发者。

⁴ 这些重载分布在多个类中,因为一些扩展方法是特定于技术的。例如,WPF 获取与其Dispatcher类直接配合而不是ISchedulerObserveOn重载。

⁵ 实际上,Publish在当前版本的 Rx 中内部使用Subject<T>