每天凌晨有一支訂單報表,固定跑 4 分鐘。某天有人為了除錯,加了一行 log。隔天,報表變成 8 分鐘。
var rows = File.ReadLines("orders.csv")
.Select(Parse) // 每一行解析成訂單
.Where(o => o.Paid);
log.Info($"共 {rows.Count()} 筆"); // ← 新加的這一行
foreach (var o in rows) Write(o);
新加的那行看起來只是「數一數」。但它讓整個檔案多讀了一遍,每一行也多解析了一遍。
原因是:rows 裡根本沒有資料,只有一份還沒執行的步驟。
一句話:Where、Select 只是寫好一份食譜,不會開火。每次有人列舉,就照食譜從頭煮一次。要吃兩次,就先 ToList() 煮好、裝進便當盒。
整篇用同一組對應:查詢=食譜、列舉=照食譜煮一次、ToList() 的結果=便當。
| 名詞 | 白話 |
|---|---|
| 列舉(enumerate) | 把序列裡的元素一個一個拿出來。foreach 就是在列舉。 |
| 延遲執行(deferred) | 呼叫當下不做事,只記下步驟;等有人列舉才真的跑。Where、Select 都是。 |
| 立即執行(immediate) | 呼叫當下就把序列走過一遍、算出結果。Count()、First()、ToList() 都是。 |
| 實體化(materialize) | 把查詢跑一次,結果存進 List 或陣列;之後只讀存好的資料。 |
寫下 nums.Where(...).Select(...),很自然會以為篩選已經做完了。其實一行都還沒跑。
這段要看懂:查詢變數裡裝的是「步驟」,不是「結果」。
官方文件對 Where 的說明是:它回傳一個記著所有必要資訊的物件。要等這個物件被列舉,例如 foreach,查詢才會執行。
列舉開始後,資料是一個一個被拉過流水線的。foreach 跟 Select 要下一個,Select 去跟 Where 要,Where 再跟來源要,直到拿到一個合格的。LINQ 運算子全都建在 IEnumerable 這套「要一個、給一個」的列舉方式上。
圖 A:寫查詢只是寫食譜;列舉時才一個一個拉資料過流水線(點圖可放大)
練習 1 會看到:查詢建好之後,Select 的 lambda 執行了 0 次。
這段在講:查詢變數只是一份食譜。沒人列舉,就什麼都沒煮。
食譜的好處是能重複用。壞處也在這裡:它不記得上次煮出了什麼。
這段要看懂:對同一個查詢先 Count() 再 foreach,是兩次列舉,流水線從來源開始跑兩遍。
圖 B:同一個查詢列舉兩次,整條流水線就跑兩遍(動畫依序點亮,會循環;點圖可放大)
微軟文件寫得很直接:列舉的結果不會算一次就存起來。Count() 煮完、數完就丟了;foreach 只好再煮一次。
來源便宜時,例如記憶體裡的小 List,多跑一次無所謂。來源貴的時候就痛了:
File.ReadLines 每次列舉都重新從頭讀(練習 3 實測)。還有一個隱藏問題:來源如果在兩次列舉之間變了,兩次結果可能不一樣。log 寫 2 筆,實際印出 3 筆(練習 4)。
這段在講:同一個查詢列舉 N 次,整條流水線就從來源跑 N 次。
要抓重複列舉,得先認得哪些呼叫會開火。這段教你看回傳型別來判斷。
| 延遲執行(只寫食譜) | 立即執行(當場開火) | |
|---|---|---|
| 例子 | Where、Select、OrderBy、Take、Skip、Distinct | Count、Any、First、Sum、Max、Contains、ToList、ToArray、ToDictionary |
| 回傳 | 另一個 IEnumerable | 一個值,或一個裝好資料的集合 |
| 呼叫當下 | 不碰來源 | 走過來源、算出結果 |
判斷法:回傳還是 IEnumerable 的,多半是延遲;回傳一個值或一個集合的,當場就跑。再加上 foreach,它本身就是列舉。
Any()、First() 比較狡猾。它們找到第一個合格的就停,看起來很省。但它們每次都從頭開始,所以 Any() 後面接 First(),前面那一段還是重跑了。
這段在講:看到 Count、Any、First、Sum、ToList、foreach,就是「這裡煮了一次」。同一個查詢上出現兩個以上,就是重複列舉。
認得哪裡會開火,修法就很直接:在第一次要用結果之前,先 ToList() 或 ToArray()。
這就是實體化:查詢跑一次,結果存進 List。之後的 Count、foreach 讀的都是存好的資料,不再碰來源。開頭的報表這樣改:
var rows = File.ReadLines("orders.csv")
.Select(Parse)
.Where(o => o.Paid)
.ToList(); // 只讀一次檔、只解析一次
log.Info($"共 {rows.Count} 筆"); // List 自己記著數量
foreach (var o in rows) Write(o);
圖 C:同樣「先數再印」,先 ToList() 只開火一次(數字來自練習 1、2;點圖可放大)
練習 2 實測:同樣的 Count+foreach,Select 從 6 次降到 3 次。List 的 Count() 會直接讀它記好的數量,不用再走一遍。
想讓工具幫你抓:.NET 7 起有分析規則 CA1851(可能重複列舉),.NET 10 預設沒開。打開後,build 時就會在重複列舉的地方警告(練習 3 最後)。
這段在講:要讀兩次以上,就先煮成便當。之後拿便當,不再開火。
Where、Take 縮小,或只 foreach 一次、邊讀邊處理。IEnumerable<T>,你不知道收到的是便當還是食譜。方法裡要讀兩次,就在開頭自己實體化一次。EF Core 的查詢是 IQueryable,延遲的規則一樣:串 LINQ 只是在組查詢,結果被取用時才送到資料庫。差別在 ToList() 的位置更要緊:先 Where 再 ToList(),篩選會變成 SQL 交給資料庫做;先 ToList() 再 Where,會先把整張表拉回記憶體。
先開一個練習資料夾:
mkdir lab && cd lab && dotnet new console
每個練習都把 Program.cs 整個換掉。先猜、再跑、再對答案。輸出是在 .NET 10 實際跑出來的。
建檔
int calls = 0;
var nums = new List<int> { 1, 2, 3, 4, 5 };
var query = nums
.Where(n => n % 2 == 1)
.Select(n => { calls++; return n * 10; });
Console.WriteLine($"建好 query 之後:Select 跑了 {calls} 次");
int count = query.Count();
Console.WriteLine($"Count() = {count},Select 累計 {calls} 次");
foreach (var x in query) { }
Console.WriteLine($"foreach 之後,Select 累計 {calls} 次");
先猜三行各印出幾次?
執行dotnet run
建好 query 之後:Select 跑了 0 次
Count() = 3,Select 累計 3 次
foreach 之後,Select 累計 6 次
這證明建查詢時一次都沒跑(圖 A);Count() 和 foreach 各跑一遍(圖 B)。
建檔
int calls = 0;
var nums = new List<int> { 1, 2, 3, 4, 5 };
var list = nums
.Where(n => n % 2 == 1)
.Select(n => { calls++; return n * 10; })
.ToList(); // 這裡就跑完,結果存起來
Console.WriteLine($"ToList() 之後:Select 跑了 {calls} 次");
int count = list.Count;
foreach (var x in list) { }
Console.WriteLine($"Count + foreach 之後,Select 累計 {calls} 次");
執行dotnet run
ToList() 之後:Select 跑了 3 次
Count + foreach 之後,Select 累計 3 次
這證明ToList() 當場開火一次;之後讀的都是便當(圖 C 右邊)。
建檔
File.WriteAllLines("orders.txt", ["A-100", "B-200", "A-300", "B-400"]);
int reads = 0;
var orders = File.ReadLines("orders.txt")
.Select(line => { reads++; return line; }) // 每讀一行 +1
.Where(line => line.StartsWith("A"));
if (orders.Any())
{
Console.WriteLine($"Any() 之後讀了 {reads} 行");
Console.WriteLine($"第一筆:{orders.First()}");
Console.WriteLine($"First() 之後讀了 {reads} 行");
}
Console.WriteLine($"共 {orders.Count()} 筆,累計讀了 {reads} 行");
先猜檔案只有 4 行。最後累計讀了幾行?
執行dotnet run
Any() 之後讀了 1 行
第一筆:A-100
First() 之後讀了 2 行
共 2 筆,累計讀了 6 行
你會看到Any、First、Count 各從頭讀一次,4 行的檔案被讀了 6 行。在 .Where(…) 後面加 .ToList()(Count() 改成 Count)再跑,會印 共 2 筆,累計讀了 4 行。
再試改回沒 ToList 的版本,在專案資料夾放一個 .editorconfig:
[*.cs]
dotnet_diagnostic.CA1851.severity = warning
dotnet build 會在 orders 被重複列舉的地方報:
warning CA1851: Possible multiple enumerations of 'IEnumerable' collection.
建檔
File.WriteAllLines("orders.txt", ["A-100", "B-200", "A-300"]);
var aOrders = File.ReadLines("orders.txt").Where(l => l.StartsWith("A"));
var snapshot = aOrders.ToList();
File.AppendAllLines("orders.txt", ["A-999"]); // 檔案被別人改了
Console.WriteLine($"query 再列舉一次:{aOrders.Count()} 筆");
Console.WriteLine($"ToList 的結果: {snapshot.Count} 筆");
執行dotnet run
query 再列舉一次:3 筆
ToList 的結果: 2 筆
這證明食譜每次都照當下的材料煮;便當是當時的快照。
Go 標準庫沒有 LINQ 這種串接運算子。平常用 for 迴圈把結果 append 進 slice,寫下去就立刻算完,沒有「食譜被重煮」的問題。例外是 Go 1.23 起的 iter.Seq 迭代器:它跟 IEnumerable 一樣,再呼叫一次就再走一遍。
能不看筆記回答下面 4 題,這篇就過了。
var q = list.Where(...).Select(...); 這行跑完,Select 的 lambda 執行了幾次?什麼時候才會執行?看答案收合var vip = LoadCustomers().Where(c => c.Total > 10000);if (vip.Any()) { Log(vip.Count()); foreach (var c in vip) Send(c); }看答案收合Any()、Count()、foreach 各列舉一次,LoadCustomers 和 Where 總共跑三遍。
.ToList(),只跑一次。vip.Count > 0、vip.Count 和 foreach,讀的都是存好的 List。