簡介
這個教學會教你 .NET 和 C# 語言的功能。 您將學習如何:
- 使用 LINQ 產生序列。
- 寫出你可以輕鬆在 LINQ 查詢中使用的方法。
- 區分急切和懶惰的評估。
你透過打造一個展示魔術師基本技能之一的應用程式來學習這些技巧: 法羅洗牌。 法羅洗牌是一種將一副牌正好分成兩半的技巧,然後洗牌時每張來自兩半的牌交錯排列,以重建原本的牌組。
魔術師會使用這項技術,因為每個卡片在每次洗牌后都位於已知位置,而且順序是重複的模式。
這個教學輕鬆地介紹了如何操作資料序列。 應用程式會建立一副牌組,執行一連串洗牌,每次都寫出該順序。 同時也將更新後的訂單與原始訂單做比較。
本教學課程有多個步驟。 在每個步驟之後,您可以執行應用程式並查看進度。 您也可以在 dotnet/samples GitHub 存放庫中看到 已完成的範例。 如需下載指示,請參閱 範例和教學課程。
先決條件
- 最新 .NET SDK
- Visual Studio Code 編輯器
- C# 開發套件
建立應用程式
建立一個新的應用程式。 開啟命令提示字元,併為您的應用程式建立新的目錄。 讓該目錄成為目前的目錄。 在命令提示字元中輸入命令 dotnet new console -o LinqFaroShuffle。 此指令建立基本「Hello World」應用程式的起始檔案。
如果您之前從未使用過 C#,本教學課程 說明 C# 程序的結構。 您可以閱讀該內容,然後返回這裡以深入瞭解LINQ。
建立資料集
小提示
在本教學課程中,您可以將程式代碼組織在名為 LinqFaroShuffle 的命名空間中,以符合範例程序代碼,也可以使用預設的全域命名空間。 如果您選擇使用命名空間,請確定所有類別和方法都一致地位於相同的命名空間內,或視需要新增適當的 using 語句。
想想什麼構成一副撲克牌。 一副撲克牌有四個花色,每個花色有13個點數。 通常,你可以考慮直接建立 Card 一個類別,然後手動填充一組 Card 物件。 使用 LINQ,你可以比傳統建立卡牌組的方式更簡潔。 與其建立一個 Card 類別,不如建立兩個序列來代表花色和等級。 建立一對迭代方法,生成「秩」與「花色」的IEnumerable<T>字串:
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";
}
將這些方法放在Console.WriteLine語句下方,在你的Program.cs檔案中。 這兩種方法都使用yield return語法在運行時產生序列。 編譯器會建立一個物件,根據請求實作 IEnumerable<T> 並產生字串序列。
現在,使用這些反覆運算器方法來建立牌組。 將 LINQ 查詢放在檔案的開頭 Program.cs。 其看起來會像下面這樣:
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,這將建立一個單一序列,將第一個序列中的每個元素與第二個序列中的每個元素逐一結合形成。 這個例子的順序很重要。 第一個來源序列(Suits)中的第一個元素會與第二個序列(Ranks)中的每個元素結合。 此過程產生所有13張第一花色的牌。 流程會在第一個序列(Suits)的每個元素上重複執行。 最終結果是一副按照花色排序的牌,再以數值排序。
請記住,無論你是用前述範例中使用的查詢語法來寫 LINQ,還是用方法語法,從一種語法轉換到另一種語法都是有可能的。 前述以查詢語法撰寫的查詢,可以用方法語法寫成:
var startingDeck = Suits().SelectMany(suit => Ranks().Select(rank => (Suit: suit, Rank: rank )));
編譯程式會將以查詢語法撰寫的LINQ語句轉譯為對等的方法呼叫語法。 因此,無論您的語法選擇為何,查詢的兩個版本都會產生相同的結果。 選擇最適合你情況的語法。 舉例來說,如果你在一個團隊裡,有些成員對方法的語法感到困難,建議嘗試偏好使用查詢語法。
執行此時你所建立的範例。 它會顯示牌組中全部 52 張牌。 你可以將這個範例在除錯器下執行,來觀察 Suits() 和 Ranks() 方法的執行過程。 你可以清楚看到每個序列中的每個字串都是在需要時才產生的。
操控順序
接著,專注於你如何洗牌。 任何一個好的洗牌的第一步是將牌堆分成兩部分。
Take LINQ API 中的 和 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 以外的編輯器(例如 Visual Studio Code),您可能需要將 新增 using LinqFaroShuffle; 至 Program.cs 檔案頂端,才能存取擴充方法。 Visual Studio 會自動新增這個 using 語句,但其他編輯器可能不會。
extension容器會指定被延伸的類型。 節點宣告所有容器內成員的接收參數型別與名稱。 在這個例子中,你正在擴展 IEnumerable<T>,且參數命名 sequence為 。
擴展成員聲明看起來就像是接收者類型的成員:
public IEnumerable<T> InterleaveSequenceWith(IEnumerable<T> second)
你將該方法稱為擴充型態的成員方法。 這個方法宣告也會遵循標準慣用法,其中輸入和輸出類型為 IEnumerable<T>。 這種做法可讓 LINQ 方法鏈結在一起,以執行更複雜的查詢。
因為你把牌組分成兩半,你需要把這兩半合併在一起。 在程式碼中,這意味著你會同時處理通過Take和Skip取得的兩個序列,並將元素交錯編排,形成一個新的序列,即你現在打亂的牌組。 撰寫使用兩個序列的 LINQ 方法,需要您瞭解 IEnumerable<T> 的運作方式。
IEnumerable<T> 介面有一種方法:GetEnumerator。 回傳 GetEnumerator 的物件有一個移動到下一個元素的方法,以及一個能檢索序列中當前元素的屬性。 你用這兩個成員來枚舉集合並回傳元素。 這個 Interleave 方法是一種迭代方法,所以你不是建立一個集合再回傳集合,而是使用 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;
}
}
既然你已經寫好這個方法,回頭再洗 Main 方法的牌一次:
var shuffledDeck = top.InterleaveSequenceWith(bottom);
foreach (var c in shuffledDeck)
{
Console.WriteLine(c);
}
比較
計算需要多少次洗牌,牌組才能回到原本的順序。 要找出答案,可以寫一個方法來判斷兩個序列是否相等。 有了這個方法後,把洗牌的程式碼放進一個循環,然後檢查牌組什麼時候恢復正常。
撰寫一個方法來判斷這兩個序列是否相等應該是非常簡單的。 這的結構類似於你撰寫來洗牌牌組的方法。 不過這次,不再對每個元素使用 yield return,而是比較每個序列中匹配的元素。 當整個序列被列舉時,如果每個元素都匹配,則序列是相同的:
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 慣用語:終端法。 它們會接收一個序列作為輸入(或在此情況下是兩個序列),並回傳一個純量值。 當你使用終端方法時,它們總是 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次洗牌(do-while 循環的迭代)後,牌組會返回到從起始LINQ查詢建立時的原始組態。
優化措施
你目前建立的樣本執行外洗牌,每次執行時最上面與最下面的牌保持不變。 我們來做一個改變:改用內洗牌的方式,在這裡所有52張牌的位置都會改變。 對於洗牌,您會交錯牌牌,讓下半場的第一張卡片成為牌組的第一張牌。 這表示上半部的最後一張卡片會變成最底下的卡片。 這項變更只需一行程式碼。 透過對調 Take 和 Skip的位置更新目前的隨機查詢。 此變更會調換牌組上下半部的順序:
shuffledDeck = shuffledDeck.Skip(26).InterleaveSequenceWith(shuffledDeck.Take(26));
再跑一次程式,你會發現牌組需要 52 次迭代才能重新排序。 隨著程式持續執行,你也會注意到效能明顯下降。
效能下降有幾個原因。 你可以解決其中一個主要原因:低效率使用懶惰求值。
懶惰求值是指在陳述句的值被需要之前,不會對其進行評估。 LINQ 查詢是延遲評估的表達式。 序列只會在要求元素時產生。 通常,這是 LINQ 的主要優點。 然而,在這種程式中,懶惰的評估會導致執行時間呈指數成長。
記得你是用 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);
請注意,您不會在每次存取查詢時都記錄。 只有在建立原始查詢時,才會記錄。 程式仍需要很長的時間才能執行,但現在您可以看到原因。 如果您在開啟記錄狀態下執行 in 隨機排列時失去耐心,可以切換回 out 隨機排列。 你仍然會看到懶惰的評估效應。 一次運行中,會執行 2,592 筆查詢,包括值與花色的生成。
你可以提升程式碼效能,減少執行次數。 一個簡單的解決方法是快取原始 LINQ 查詢的結果,該查詢用來建構這副牌組。 目前,每次 do-while 迴圈經過一次迭代時,你都得一次又一次地執行這些查詢,重建牌組並重新洗牌。 要快取這副撲克牌,請應用 LINQ 方法 ToArray 和 ToList。 當你把結果附加到查詢中時,它們會執行你指示的相同動作,但現在會根據你選擇呼叫的方法,將結果儲存在陣列或清單中。 將 LINQ 方法 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 筆查詢。
本 範例旨在 突顯惰性評估可能導致效能問題的應用場景。 在瞭解延遲評估如何影響程式效能的同時,也要認識到並非所有查詢都應以立即方式執行。 在不使用 ToArray 的情況下所遭遇的性能損失,是因為每次新的牌組排列都是從先前的排列中建置的。 使用延遲評估表示每個新的牌組配置皆是基於原始牌組建置,甚至執行建置 startingDeck的程式碼。 這會導致大量的額外工作。
實際上,某些演算法在使用積極式評估時運行良好,而其他演算法則在使用延遲評估時運行良好。 對於日常使用來說,當數據來源是獨立的進程時,延遲評估通常是更好的選擇,例如資料庫引擎。 對於資料庫,惰性求值允許更複雜的查詢只需與資料庫處理程序進行一次往返,然後返回執行其餘程式碼。 LINQ 提供彈性,無論您選擇使用延遲評估或是及時評估,依據您的流程需求仔細衡量,選擇能帶來最佳效能的評估方式。
結論
在此專案中,您所涵蓋的內容有:
- 利用 LINQ 查詢將資料彙整成有意義的序列。
- 撰寫擴充方法以為 LINQ 查詢加入自訂功能。
- 找出程式碼中 LINQ 查詢可能出現效能問題(如速度下降)的區域。
- LINQ 查詢中懶惰且急切的評估,以及這些評估對查詢效能可能產生的影響。
除了 LINQ,你還學到了魔術師用來做撲克牌魔術的技巧。 魔術師使用法羅洗牌,因為他們能控制牌組中每張牌的移動位置。 既然你知道,不要為其他人破壞它!
如需 LINQ 的詳細資訊,請參閱: