介绍
本教程介绍 .NET 与 C# 的相关功能。你将学习如何:
- 使用 LINQ 生成序列。
- 编写可在 LINQ 查询中轻松使用的方法。
- 区分及早计算和惰性计算。
你将开发一个演示法罗洗牌的应用,学习这些技术。法罗洗牌会把牌组精确分成两半,再将两半的牌逐张交错排列,重新组成一副牌。
魔术师采用这种技术,是因为每次洗牌后各张牌的位置都可预测,而且排列顺序会周期性重复。
本教程用轻松的例子展示如何处理数据序列。程序会构建牌组、反复洗牌、输出每次排列,并将当前顺序与初始顺序比较。
本教程分为多个步骤。每一步完成后,都可以运行程序观察进展。完整示例保存在 dotnet/samples 仓库,下载方法见 示例与教程。
先决条件
- 最新的 .NET SDK
- Visual Studio Code 编辑器
- C# 开发套件
创建应用程序
创建新的应用程序。 打开命令提示符并为应用程序创建新目录。 使该目录成为当前目录。 在命令提示符处键入命令 dotnet new console -o LinqFaroShuffle。 此命令为基本的“Hello World”应用程序创建初始文件。
如果以前从未使用过 C# ,本教程 说明 C# 程序的结构。 你可以阅读该内容,然后返回此处了解有关 LINQ 的详细信息。
创建数据集
小窍门
可以将代码放在 LinqFaroShuffle 命名空间中,与示例保持一致,也可以使用默认的全局命名空间。如果选择命名空间,请让所有类和方法保持一致,或按需添加 using 语句。 一副扑克牌有四种花色,每种花色有13个点数。常见做法是创建 Card 类,再手动填充对象集合。使用 LINQ 则可以更简洁:先用两个字符串序列表示花色与点数,再组合它们。以下迭代器方法生成这两个序列:
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 的 Console.WriteLine 语句后面。它们都用 yield return 在运行时按需生成元素。编译器会生成实现 IEnumerable<T> 的对象,让调用者按需枚举字符串。
接下来,用这两个迭代器方法构建牌组。在 Program.cs 顶部添加以下 LINQ 查询:
var startingDeck = from s in Suits()
from r in Ranks()
select (Suit: s, Rank: r);
// Display each card that's generated and placed in startingDeck
foreach (var card in startingDeck)
{
Console.WriteLine(card);
}
多个 from 子句对应 SelectMany,将花色与点数的全部组合展平成一个序列。顺序在这里很重要:先将第一种花色与所有13个点数组合,再对其他花色重复。结果先按花色排列,再按点数排列。
LINQ 查询语法和方法语法可以互相转换。上面的查询也可以写成:
var startingDeck = Suits().SelectMany(suit => Ranks().Select(rank => (Suit: suit, Rank: rank )));
编译器会把查询语法转换为等效的方法调用,所以两个版本产生相同结果。选择适合自己和团队的形式;如果团队成员不熟悉方法语法,可以采用查询语法。 运行程序后,会显示全部52张牌。也可以在调试器中观察 Suits() 与 Ranks() 的执行过程,确认每个字符串都只在需要时生成。
调整顺序
洗牌的第一步是将牌组分成两半。LINQ 的 Take 与 Skip 可以完成这个操作。将下列代码放在 foreach 循环后:
var top = startingDeck.Take(26);
var bottom = startingDeck.Skip(26);
标准库没有本例所需的交错洗牌方法,因此需要自己实现。下面逐步介绍实现中涉及的 LINQ 技术。
扩展方法可以为 LINQ 查询返回的 IEnumerable<T> 添加功能,无需修改原类型。它是一种具有特殊用途的静态方法。 新建 Extensions.cs 文件,用静态类保存扩展方法,并开始编写第一个方法:
public static class CardExtensions
{
extension<T>(IEnumerable<T> sequence)
{
public IEnumerable<T> InterleaveSequenceWith(IEnumerable<T> second)
{
// Your implementation goes here
return default;
}
}
}
注释 如果使用 Visual Studio Code 等编辑器,可能需要在 Program.cs 顶部添加 using LinqFaroShuffle; 才能访问扩展方法。Visual Studio 会自动添加该语句,其他编辑器未必如此。
extension 块指定要扩展的类型及其接收器参数,块中的成员共享这个接收器。本例扩展 IEnumerable<T>,接收器参数名为 sequence。
扩展成员声明看起来就像是接收器类型的成员:
public IEnumerable<T> InterleaveSequenceWith(IEnumerable<T> second)
调用该方法,就像它是扩展类型的成员方法一样。 此方法声明还遵循一个标准惯例,其中输入和输出类型为 IEnumerable<T>。 这种做法使 LINQ 方法可以链接在一起来执行更复杂的查询。 现在需要同时枚举 Take 与 Skip 得到的两个序列,把元素交错排列为新的洗牌结果。为此,先了解 IEnumerable<T> 的工作方式。
IEnumerable<T> 的 GetEnumerator 返回枚举器。枚举器有一个移动到下一元素的方法,以及一个读取当前元素的属性。交错方法本身也是迭代器,因此用 yield return 按需返回元素,而不是先构建整个集合。 下面是该方法的实现:
public IEnumerable<T> InterleaveSequenceWith(IEnumerable<T> second)
{
var firstIter = sequence.GetEnumerator();
var secondIter = second.GetEnumerator();
while (firstIter.MoveNext() && secondIter.MoveNext())
{
yield return firstIter.Current;
yield return secondIter.Current;
}
}
实现后,返回程序主体,执行一次洗牌:
var shuffledDeck = top.InterleaveSequenceWith(bottom);
foreach (var c in shuffledDeck)
{
Console.WriteLine(c);
}
比较
接下来,确定多少次洗牌能让牌组恢复初始顺序。先编写比较两个序列的方法,再循环洗牌,直到比较结果相等。 比较方法与交错方法结构相近,但不再逐个返回元素,而是比较对应位置的元素。若枚举后所有元素匹配,则两个序列相同:
public bool SequenceEquals(IEnumerable<T> second)
{
var firstIter = sequence.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 的另一种常见模式:终结方法。它接收序列,返回单个标量值,因此会作为查询方法链的最后一步。 把洗牌代码放入循环,在 SequenceEquals() 确认恢复初始顺序后停止。比较方法返回的是单个值,因此始终位于该查询的末尾:
var startingDeck = from s in Suits()
from r in Ranks()
select (Suit: s, Rank: r);
// Display each card generated and placed in startingDeck in the console
foreach (var card in startingDeck)
{
Console.WriteLine(card);
}
var top = startingDeck.Take(26);
var bottom = startingDeck.Skip(26);
var shuffledDeck = top.InterleaveSequenceWith(bottom);
var times = 0;
// Re-use the shuffle variable from earlier, or you can make a new one
shuffledDeck = startingDeck;
do
{
shuffledDeck = shuffledDeck.Take(26).InterleaveSequenceWith(shuffledDeck.Skip(26));
foreach (var card in shuffledDeck)
{
Console.WriteLine(card);
}
Console.WriteLine();
times++;
} while (!startingDeck.SequenceEquals(shuffledDeck));
Console.WriteLine(times);
原文说明:执行8次外洗牌,也就是8次 do-while 循环后,牌组会恢复到最初由 LINQ 查询构建的顺序。
优化
目前的外洗牌会让牌组最上方与最下方的牌保持不变。现在改为内洗牌,让全部52张牌改变位置:先取后半组的第一张牌,再与前半组交错。原前半组的最后一张牌于是成为整副牌的最后一张。只需交换 Take 和 Skip 的位置:
shuffledDeck = shuffledDeck.Skip(26).InterleaveSequenceWith(shuffledDeck.Take(26));
原文说明:内洗牌需要52次迭代才能恢复初始顺序。程序长时间运行时,也会表现出明显的性能下降。
性能下降有多个原因,其中一个主要原因是没有高效地使用延迟求值。 延迟求值是指直到需要某个值时,才计算它。LINQ 查询会在请求元素时生成序列,这通常是优势,但本例中的查询依赖会让执行时间呈指数增长。
原始牌组由 LINQ 查询生成,每次洗牌又对前一次结果执行三个查询。这些查询都延迟执行;每次再次请求序列时,它们也会重新执行。到第52次迭代时,原始牌组已经被生成了许多次。可以加入日志,观察这一现象,再据此优化。 在 Extensions.cs 中添加以下方法。它会在项目目录中创建 debug.log,记录调用时标记的查询。把它接在查询后面,可以标记查询的构建位置。
public IEnumerable<T> LogQuery(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;
}
接下来,在每个查询的定义中加入日志:
var startingDeck = (from s in Suits().LogQuery("Suit Generation")
from r in Ranks().LogQuery("Rank Generation")
select (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);
注意,日志不会在每次枚举查询时都写入,只在创建查询时写入。程序仍然可能运行很久。如果内洗牌等待时间过长,可以切回外洗牌观察延迟求值的影响。原文报告:一次运行涉及2,592次查询,包括花色与点数的生成;这不是本次实测。 可以通过缓存查询结果,减少重复工作。ToArray 或 ToList 会立即执行查询,将结果存入数组或列表。这样,就不必在每次循环中重复构建原始牌组。给查询追加 ToArray,再运行程序:
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次。这些数字为原文示例结果,并非本次运行测量。
这个例子展示了延迟求值可能带来的性能问题。需要了解它何时影响性能,也要知道并非所有查询都应提前执行。每副新牌组都依赖前一次排列;如果一直延迟计算,就会沿依赖链反复生成此前的牌组,甚至重新执行 startingDeck 的构建,产生大量额外工作。
有些算法适合提前求值,另一些适合延迟求值。如果数据源位于独立进程,例如数据库,延迟求值通常更合适:复杂查询可以合并为一次数据库往返,再把结果交给其余代码。LINQ 支持两种方式,应测量实际过程,选择性能更好的方案。
结论
在这个项目中,你学习了:
- 使用 LINQ 查询将数据聚合为有意义的序列。
- 编写扩展方法以向 LINQ 查询添加自定义功能。
- 在代码中查找 LINQ 查询可能会遇到性能问题(如速度下降)的区域。
- LINQ 查询中的延迟和提前求值及其对查询性能的影响。
除了 LINQ,你也了解了魔术师如何借助法罗洗牌控制每张牌的位置。既然知道了这个秘密,就别告诉别人!
有关 LINQ 的详细信息,请参阅:
来源:Microsoft 文档团队,使用语言集成查询(LINQ)。2026-10-03 依据官方中文正文整理,调整中文误译、排版和链接;全部实质章节和14个代码块均保留,代码未执行。内容采用 CC BY 4.0,内容许可证;示例代码另见 MIT 代码许可证。保留原文示例图入口,未制作或声明本次运行截图。原文按现状提供,不含保证。











暂无评论内容