使用 Language-Integrated Query (LINQ)
簡介
此教學課程會教導您 .NET Core 和 C# 語言中的功能。 您將了解如何:
- 使用 LINQ 來產生序列。
- 撰寫可輕鬆用於 LINQ 查詢的方法。
- 區分積極與消極評估。
您將建置一個應用程式來學習這些技術,其中將示範任何魔術師都會的基礎技巧:完美洗牌 (英文)。 簡單地說,完美洗牌是將牌組確實分成兩半,然後互相交錯每一張紙牌來重建原始牌堆的技術。
魔術師使用這項技術的原因,是因為在每次洗牌後,每張紙牌都會在已知的位置,其順序會遵循重複性的模式。
基於您的目的,這是以較輕鬆的方式來了解對資料序列的操作。 您將建置的應用程式會建構牌堆,然後執行一連串的洗牌,而且每次洗牌都會寫出序列。 您也會比較原始的順序與更新過的順序。
本教學課程有多個步驟。 在每個步驟之後,您可以執行應用程式並查看進度。 您也可以在 dotnet/samples GitHub 存放機制中查看完整範例。 如需下載指示,請參閱範例和教學課程。
必要條件
您將必須設定電腦以執行 .NET Core。 您可以在 .NET Core 下載頁面上找到安裝指示。 您可以在 Windows、Ubuntu Linux、OS X 或 Docker 容器中執行此應用程式。 您將必須安裝慣用的程式碼編輯器。 下面的描述使用 Visual Studio Code,這是開放原始碼的跨平台編輯器。 不過,您可以使用您熟悉的任何工具。
建立應用程式
第一個步驟是建立新的應用程式。 請開啟命令提示字元,然後為您的應用程式建立新目錄。 使該目錄成為目前的目錄。 在命令提示字元處輸入命令 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
陳述式) 不在檔案最上方,我們的程式將不會編譯。
現在已具備您所需的所有參考,請考慮構成一副撲克牌的內容。 通常,一副撲克牌有四種花色,而每種花色有十三個值。 一般來說,您可以考慮立即建立 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,這會將第一個序列與第二個序列中的每個元素相互結合,來建立單一序列。 其順序對我們來說很重要。 第一個來源序列 (花色) 中的第一個元素,會與第二個序列 (順位) 中的每個元素結合。 這樣會產生第一個花色的十三張紙牌。 該程序會針對第一個序列 (花色) 中的每個元素重複執行。 最終結果是一疊先依花色,並接著依值排序的牌堆。
請務必牢記在心,無論您選擇要在上述使用的查詢語法中撰寫 LINQ,還是改用方法語法,一律可從某種語法形式移至另一種語法形式。 上述以查詢語法撰寫的查詢可利用方法語法撰寫為:
var startingDeck = Suits().SelectMany(suit => Ranks().Select(rank => new { Suit = suit, Rank = rank }));
編譯器會將以查詢語法撰寫的 LINQ 陳述式轉譯為相等的方法呼叫語法。 因此,不論您的語法選擇為何,這兩個查詢版本都會產生相同的結果。 選擇最適合您情況的語法:例如,如果您所在的小組中有部分成員對於使用方法語法有困難,請嘗試使用查詢語法。
現在,請執行您目前所建置的範例。 它會顯示牌堆中全部 52 張紙牌。 您會發現以偵錯工具執行此範例,對於觀察 Suits()
和 Ranks()
方法的執行方式非常有用。 您可以清楚地看見每個序列中的每個字串都只會在需要時產生。
操作順序
接著,專注於您將如何在這副牌中洗牌。 所有好好洗牌步驟中的第一個步驟是將這副牌一分為二。 包含於 LINQ API 的Take 和 Skip 方法可為您提供該功能。 將它們放在 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 方法鏈結在一起,以執行更複雜的查詢。
當然,因為您將這副牌分成數堆,所以必須將這幾堆聯結在一起。 在程式碼中,這表示您將同時列舉您透過 Take 和 Skip 取得的序列、interleaving
元素,然後建立一個序列:您現在已完成對這副牌的洗牌。 若要撰寫可搭配兩個序列使用的 LINQ 方法,您必須先了解 IEnumerable<T> 的運作方式。
IEnumerable<T> 介面具有單一方法:GetEnumerator。 GetEnumerator 傳回的物件具有可移動到下一個元素的方法,以及可擷取目前序列中元素的屬性。 您將會使用那兩個成員來列舉集合並傳回元素。 此交錯方法將會是迭代器方法,因此您將使用上述的 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 查詢之方法鏈結中的最終方法,因此有「終端」的名稱。
當您使用它來判斷牌堆何時回到原始順序時,便可以看到其作用。 將洗牌程式碼放到迴圈中,並透過套用 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 張牌的位置。 對於內部洗牌而言,您要交錯牌堆,讓下半部的第一張紙牌變為牌堆的第一張紙牌。 這表示上半部的最後一張紙牌會變為底部的排。 這是對單行程式碼的簡單變更。 藉由切換 Take 和 Skip 的位置來更新目前的洗牌查詢。 這將會變更這副牌上下半部的順序:
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 方法 ToArray 和 ToList;當您將它們附加至查詢時,它們將執行您已告訴它們的相同動作,但現在它們會根據您選擇呼叫的方法,將結果儲存在陣列或清單中。 將 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 的詳細資訊,請參閱: