使用英语阅读

通过


使用语言集成查询 (LINQ)

介绍

本教程介绍 .NET Core 和 C# 语言中的功能。 你将学习如何:

  • 使用 LINQ 生成序列。
  • 编写可在 LINQ 查询中轻松使用的方法。
  • 区分及早计算和惰性计算。

你将通过生成应用程序来了解如何执行这些操作,应用程序体现了所有魔术师都具备的一项基本技能,即完美洗牌。 简而言之,完美洗牌这项技能是指,将一副纸牌分成两半,然后两手各拿一半交错洗牌(一张隔一张),以便重新生成原来的一副纸牌。

魔术师使用此技术,因为每个卡片在每次洗牌后都位于一个已知位置,并且顺序是重复模式。

考虑到你的目的,数据序列控制起来就会非常轻松。 生成的应用程序会构造一副纸牌,然后执行一系列洗牌操作,每次都会输出序列。 你还将将更新的订单与原始订单进行比较。

本教程包含多个步骤。 执行每个步骤后,可以运行应用程序并查看进度。 还可以在 dotnet/samples GitHub 存储库中看到 已完成的示例。 有关下载说明,请参阅 示例和教程

先决条件

创建应用程序

第一步是创建新应用程序。 打开命令提示符并为应用程序创建新目录。 使该目录成为当前目录。 在命令提示符处键入命令 dotnet new console。 这会为基本的“Hello World”应用程序创建入门文件。

如果以前从未使用过 C# ,本教程 说明 C# 程序的结构。 你可以阅读该内容,然后返回此处了解有关 LINQ 的详细信息。

创建数据集

在开始之前,请确保以下行位于 dotnet new console生成的 Program.cs 文件的顶部:

// Program.cs
using System;
using System.Collections.Generic;
using System.Linq;

如果这三行(using 指令)不在文件的顶部,则程序可能无法编译。

现在已具备所需的所有引用,接下来可以考虑一副扑克牌是由什么构成的。 通常一副扑克牌包含四种花色,每种花色包含 13 个值。 通常,您可能会一开始考虑创建一个 Card 类,并手动填充一个 Card 对象的集合。 相对于通常的方式,使用 LINQ 创建一副扑克牌更加简捷。 可以创建两个序列来分别表示花色和点数,而非创建 Card 类。 创建两个非常简单的迭代器方法,用于将级别和花色生成为 IEnumerable<T> 字符串:

// Program.cs
// The Main() method

static IEnumerable<string> Suits()
{
    yield return "clubs";
    yield return "diamonds";
    yield return "hearts";
    yield return "spades";
}

static IEnumerable<string> Ranks()
{
    yield return "two";
    yield return "three";
    yield return "four";
    yield return "five";
    yield return "six";
    yield return "seven";
    yield return "eight";
    yield return "nine";
    yield return "ten";
    yield return "jack";
    yield return "queen";
    yield return "king";
    yield return "ace";
}

将这些方法放在 Program.cs 文件中的 Main 方法下。 这两种方法都利用 yield return 语法在运行时生成序列。 编译器生成一个实现 IEnumerable<T> 的对象,并在请求字符串时生成字符串序列。

现在,使用这些迭代器方法创建一组卡片。 你需要在我们的 Main 方法中加入 LINQ 查询。 下面介绍一下:

// Program.cs
static void Main(string[] args)
{
    var startingDeck = from s in Suits()
                       from r in Ranks()
                       select new { Suit = s, Rank = r };

    // Display each card that we've generated and placed in startingDeck in the console
    foreach (var card in startingDeck)
    {
        Console.WriteLine(card);
    }
}

多个 from 子句生成 SelectMany,用于将第一个和第二个序列中的所有元素合并成一个序列。 顺序对于我们而言非常重要。 第一个源序列中的第一个元素(Suits)与第二个序列(Ranks)中的每个元素组合在一起。 这就生成了第一个花色的所有十三张纸牌。 对第一个序列(花色)中的每个元素重复此过程。 最后生成按花色排序(后跟值)的一副纸牌。

请务必记住,无论是选择在上述查询语法中编写 LINQ 还是改用方法语法,始终可以从一种语法形式转到另一种语法。 以查询语法编写的上述查询可以用方法语法编写为:

var startingDeck = Suits().SelectMany(suit => Ranks().Select(rank => new { Suit = suit, Rank = rank }));

编译器将使用查询语法编写的 LINQ 语句转换为等效的方法调用语法。 因此,无论选择哪种语法,查询的两个版本都会生成相同的结果。 选择最适合你的情况的语法:例如,如果你在一个团队中工作,其中一些成员对方法语法有困难,请尝试使用查询语法。

继续运行此时生成的示例。 它将显示牌堆中的所有 52 张纸牌。 你可能会发现,在调试器下运行此示例非常有用,以观察 Suits()Ranks() 方法的执行方式。 可以清楚地看到,每个序列中的每个字符串仅在需要时生成。

显示应用写出 52 张卡片的控制台窗口。

操作顺序

接下来重点介绍如何洗牌。 任何成功洗牌的第一步是将牌组分成两部分。 LINQ API 中的 TakeSkip 方法为你提供了该功能。 将它们置于 foreach 循环的下面:

// Program.cs
public static void Main(string[] args)
{
    var startingDeck = from s in Suits()
                       from r in Ranks()
                       select new { Suit = s, Rank = r };

    foreach (var c in startingDeck)
    {
        Console.WriteLine(c);
    }

    // 52 cards in a deck, so 52 / 2 = 26
    var top = startingDeck.Take(26);
    var bottom = startingDeck.Skip(26);
}

但是,标准库中没有可供使用的洗牌方法,所以你必须自行编写。 将要创建的洗牌方法体现了要对基于 LINQ 的程序执行的几种操作,因此我们将逐步介绍此过程的每个部分。

若要添加一些功能,以便与从 LINQ 查询返回的 IEnumerable<T> 进行交互,需要编写一些特殊类型的方法,这些方法称为 扩展方法。 简言之,扩展方法是一种特殊用途 静态方法,该方法可将新功能添加到已存在的类型,而无需修改要向其添加功能的原始类型。

向程序添加新的静态类文件(名称为 Extensions.cs),以用于存放扩展方法,然后开始生成第一个扩展方法:

// Extensions.cs
using System;
using System.Collections.Generic;
using System.Linq;

namespace LinqFaroShuffle
{
    public static class Extensions
    {
        public static IEnumerable<T> InterleaveSequenceWith<T>(this IEnumerable<T> first, IEnumerable<T> second)
        {
            // Your implementation will go here soon enough
        }
    }
}

仔细观察方法签名,尤其是参数:

public static IEnumerable<T> InterleaveSequenceWith<T> (this IEnumerable<T> first, IEnumerable<T> second)

可以在方法的第一个参数上看到添加 this 修饰符。 这意味着调用该方法,就像它是第一个参数类型的成员方法一样。 此方法声明还遵循一个标准惯例,其中输入和输出类型为 IEnumerable<T>。 这种做法使 LINQ 方法可以链接在一起来执行更复杂的查询。

正常情况下,将扑克牌分为两半后,需要将这两半合并在一起。 在代码中,这意味着一次性地枚举通过 TakeSkip 获得的两个序列,interleaving 元素,并创建一个序列:即现在洗牌后的扑克牌。 编写适用于两个序列的 LINQ 方法需要你了解 IEnumerable<T> 的工作原理。

IEnumerable<T> 接口有一个方法 (GetEnumerator)。 GetEnumerator 返回的对象具有移动到下一个元素的方法,以及检索序列中当前元素的属性。 你将使用这两个成员枚举集合并返回元素。 此 Interleave 方法将是迭代器方法,因此,你将使用上面所示的 yield return 语法,而不是生成集合并返回集合。

下面是该方法的实现:

public static IEnumerable<T> InterleaveSequenceWith<T>
    (this IEnumerable<T> first, IEnumerable<T> second)
{
    var firstIter = first.GetEnumerator();
    var secondIter = second.GetEnumerator();

    while (firstIter.MoveNext() && secondIter.MoveNext())
    {
        yield return firstIter.Current;
        yield return secondIter.Current;
    }
}

编写此方法后,请返回到 Main 方法并洗牌一次:

// Program.cs
public static void Main(string[] args)
{
    var startingDeck = from s in Suits()
                       from r in Ranks()
                       select new { Suit = s, Rank = r };

    foreach (var c in startingDeck)
    {
        Console.WriteLine(c);
    }

    var top = startingDeck.Take(26);
    var bottom = startingDeck.Skip(26);
    var shuffle = top.InterleaveSequenceWith(bottom);

    foreach (var c in shuffle)
    {
        Console.WriteLine(c);
    }
}

比较

洗多少次牌,才能让牌恢复到原来的顺序? 若要了解情况,需要编写一个确定两个序列是否相等的方法。 有了这个方法后,你需要将洗牌的代码放入循环中,并检查牌何时恢复原顺序。

编写方法以确定这两个序列是否相等应该很简单。 这是一个与你编写的洗牌方法类似的结构。 不同之处在于,这一次将比较每个序列的匹配元素,而不是 yield return 每个元素。 枚举整个序列后,如果每个元素都匹配,则序列相同:

public static bool SequenceEquals<T>
    (this IEnumerable<T> first, IEnumerable<T> second)
{
    var firstIter = first.GetEnumerator();
    var secondIter = second.GetEnumerator();

    while ((firstIter?.MoveNext() == true) && secondIter.MoveNext())
    {
        if ((firstIter.Current is not null) && !firstIter.Current.Equals(secondIter.Current))
        {
            return false;
        }
    }

    return true;
}

这反映了另一种 LINQ 惯用做法,即终端方法。 它们采用序列作为输入(在本例中为两个序列),并返回单个标量值。 使用终端方法时,它们始终是 LINQ 查询方法链中的最终方法,因此名称为“terminal”。

当你使用它来确定牌组何时恢复到原始顺序时,可以看到这种情况的发生。 将洗牌代码放入循环中,并在通过应用 SequenceEquals() 方法使序列恢复其原始顺序时停止。 可以看到它始终是任何查询中的最终方法,因为它返回单个值而不是序列:

// Program.cs
static void Main(string[] args)
{
    // Query for building the deck

    // Shuffling using InterleaveSequenceWith<T>();

    var times = 0;
    // We can re-use the shuffle variable from earlier, or you can make a new one
    shuffle = startingDeck;
    do
    {
        shuffle = shuffle.Take(26).InterleaveSequenceWith(shuffle.Skip(26));

        foreach (var card in shuffle)
        {
            Console.WriteLine(card);
        }
        Console.WriteLine();
        times++;

    } while (!startingDeck.SequenceEquals(shuffle));

    Console.WriteLine(times);
}

运行现有的代码,并记录每次洗牌时扑克牌的重写排列方式。 进行 8 次洗牌后(迭代 do-while 循环),扑克牌恢复从最初的 LINQ 查询首次创建它时的原始配置。

优化

到目前为止,你已生成的示例执行的是向外洗牌,即每次洗牌时第一张和最后一张纸牌保持不变。 让我们来做一点改变,改为使用向内洗牌,改变全部 52 张纸牌的位置。 向内洗牌是指,交错一副纸牌时,将后一半中的第一张纸牌变成一副纸牌中的第一张纸牌。 也就是说,上半部分中的最后一张纸牌变成一副纸牌中的最后一张纸牌。 这是对单一代码行的简单更改。 交换 TakeSkip 的位置,来更新当前洗牌查询。 这将更改甲板顶部和下半部分的顺序:

shuffle = shuffle.Skip(26).InterleaveSequenceWith(shuffle.Take(26));

再次运行程序,你将看到这副牌需要 52 次迭代才能重新排列。 随着程序继续运行,你还将开始注意到一些严重的性能下降。

原因有很多。 可以解决导致性能下降的主要原因之一:延迟计算的使用效率低下。

简单来说,延迟计算是指直至需要语句的值时才会执行语句计算。 LINQ 查询属于延迟计算的语句。 仅当请求元素时,才会生成序列。 通常,这是 LINQ 的主要优势。 但是,在此类程序的使用中,这会导致执行时间呈指数增长。

请记住,我们使用 LINQ 查询生成了原始甲板。 每次洗牌都是通过对上一副牌组执行三个 LINQ 查询生成的。 所有这些操作均采用惰性执行方式。 这也意味着每次请求该序列时,操作都会被再次执行。 到达第 52 次迭代时,你将多次反复重新生成最初的牌组。 让我们编写一个日志来演示此行为。 然后,你将对其进行修复。

Extensions.cs 文件中,键入或复制以下方法。 此扩展方法在项目目录中创建名为 debug.log 的新文件,并记录当前正在对日志文件执行的查询。 此扩展方法可以追加到任何查询,以标记该查询已被执行。

public static IEnumerable<T> LogQuery<T>
    (this IEnumerable<T> sequence, string tag)
{
    // File.AppendText creates a new file if the file doesn't exist.
    using (var writer = File.AppendText("debug.log"))
    {
        writer.WriteLine($"Executing Query {tag}");
    }

    return sequence;
}

你将在 File下看到一条红色波浪线,这意味着它不存在。 它不会编译,因为编译器不知道什么是 File。 若要解决此问题,请确保在 Extensions.cs的第一行下添加以下代码行:

using System.IO;

这应该可以解决问题,红色错误消失。

接下来,使用日志消息检测每个查询的定义:

// Program.cs
public static void Main(string[] args)
{
    var startingDeck = (from s in Suits().LogQuery("Suit Generation")
                        from r in Ranks().LogQuery("Rank Generation")
                        select new { Suit = s, Rank = r }).LogQuery("Starting Deck");

    foreach (var c in startingDeck)
    {
        Console.WriteLine(c);
    }

    Console.WriteLine();
    var times = 0;
    var shuffle = startingDeck;

    do
    {
        // Out shuffle
        /*
        shuffle = shuffle.Take(26)
            .LogQuery("Top Half")
            .InterleaveSequenceWith(shuffle.Skip(26)
            .LogQuery("Bottom Half"))
            .LogQuery("Shuffle");
        */

        // In shuffle
        shuffle = shuffle.Skip(26).LogQuery("Bottom Half")
                .InterleaveSequenceWith(shuffle.Take(26).LogQuery("Top Half"))
                .LogQuery("Shuffle");

        foreach (var c in shuffle)
        {
            Console.WriteLine(c);
        }

        times++;
        Console.WriteLine(times);
    } while (!startingDeck.SequenceEquals(shuffle));

    Console.WriteLine(times);
}

请注意,不是每次访问查询都会生成日志。 只有在创建原始查询时,才会生成日志。 程序仍需要很长时间才能运行,但现在可以看到原因。 如果对在启用日志记录的情况下运行向内洗牌失去了耐心,请切换回向外洗牌。 但仍会看到惰性计算效果。 在一次运行中,共执行 2592 次查询,包括生成所有值和花色。

可在此处提高代码的性能,以减少所执行的次数。 一个简单的解决方法是 缓存 构建卡片组的原始 LINQ 查询的结果。 目前,每当 do-while 循环进行迭代时,需要反复执行查询,每次都要重新构造扑克牌并进行洗牌。 若要缓存卡片组,可以利用 LINQ 方法 ToArrayToList;将它们追加到查询时,它们将执行你告诉查询的相同作,但现在它们会将结果存储在数组或列表中,具体取决于你选择调用的方法。 将 LINQ 方法 ToArray 追加到查询并再次运行程序:

public static void Main(string[] args)
{
    IEnumerable<Suit>? suits = Suits();
    IEnumerable<Rank>? ranks = Ranks();

    if ((suits is null) || (ranks is null))
        return;

    var startingDeck = (from s in suits.LogQuery("Suit Generation")
                        from r in ranks.LogQuery("Value Generation")
                        select new { Suit = s, Rank = r })
                        .LogQuery("Starting Deck")
                        .ToArray();

    foreach (var c in startingDeck)
    {
        Console.WriteLine(c);
    }

    Console.WriteLine();

    var times = 0;
    var shuffle = startingDeck;

    do
    {
        /*
        shuffle = shuffle.Take(26)
            .LogQuery("Top Half")
            .InterleaveSequenceWith(shuffle.Skip(26).LogQuery("Bottom Half"))
            .LogQuery("Shuffle")
            .ToArray();
        */

        shuffle = shuffle.Skip(26)
            .LogQuery("Bottom Half")
            .InterleaveSequenceWith(shuffle.Take(26).LogQuery("Top Half"))
            .LogQuery("Shuffle")
            .ToArray();

        foreach (var c in shuffle)
        {
            Console.WriteLine(c);
        }

        times++;
        Console.WriteLine(times);
    } while (!startingDeck.SequenceEquals(shuffle));

    Console.WriteLine(times);
}

现在向外洗牌下降到 30 次查询。 再次运行向内洗牌程序,改善情况类似:现在它执行 162 次查询。

请注意,此示例旨在突出显示延迟计算可能会导致性能下降的用例。 了解延迟计算会在何处影响代码性能至关重要,但了解并非所有查询应及早运行也同等重要。 不使用 ToArray 会对性能产生影响,因为每副牌的新排列都是基于以前的排列方式生成的。 使用惰性计算意味着一副纸牌的每个新配置都以原来的一副纸牌为依据,甚至在执行生成 startingDeck 的代码时,也不例外。 这会导致大量的额外工作。

在实践中,某些算法在急切求值的情况下运行良好,而另一些在惰性求值的情况下运行良好。 对于日常使用,当数据源是单独的进程(如数据库引擎)时,惰性求值通常是更好的选择。 对于数据库,使用延迟计算,更复杂的查询可以只对数据库进程执行一次往返,然后返回至剩余的代码。 无论选择使用延迟计算还是及早计算,LINQ 均可以灵活处理,因此请衡量自己的进程,然后选择可为你提供最佳性能的计算种类。

结论

在此项目中,你介绍了:

  • 使用 LINQ 查询将数据聚合为有意义的序列
  • 编写扩展方法以将自己的自定义功能添加到 LINQ 查询
  • 在代码中查找 LINQ 查询可能会遇到性能问题(如速度下降)的区域
  • 与 LINQ 查询相关的延迟计算和及早计算,以及它们对查询性能的影响

除 LINQ 外,还简单介绍了魔术师用于扑克牌魔术的一个技术。 魔术师使用法罗洗牌,因为他们可以控制每张牌在牌堆中的移动位置。 既然你已经知道了,就不要透露给其他人!

有关 LINQ 的详细信息,请参阅: