程式語言 · LINQ 延遲執行

懶的流水線,每列舉一次就重跑一次:LINQ 的成本(C# Where / Select / ToList)

Chance Lu · 學習文章 · 2026-11-04 23:32
Chance Lu

每天凌晨有一支訂單報表,固定跑 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 或陣列;之後只讀存好的資料。

第一步:Where、Select 只記步驟,不做事

寫下 nums.Where(...).Select(...),很自然會以為篩選已經做完了。其實一行都還沒跑。

這段要看懂:查詢變數裡裝的是「步驟」,不是「結果」。

官方文件對 Where 的說明是:它回傳一個記著所有必要資訊的物件。要等這個物件被列舉,例如 foreach,查詢才會執行。

列舉開始後,資料是一個一個被拉過流水線的。foreach 跟 Select 要下一個,Select 去跟 Where 要,Where 再跟來源要,直到拿到一個合格的。LINQ 運算子全都建在 IEnumerable 這套「要一個、給一個」的列舉方式上。

① 寫下查詢:只寫好食譜 來源 nums Where:留奇數 Select:×10 →→ 還沒開火 Select 執行 0 次 ② 有人列舉(foreach、Count…):才一次拉一個過來 來源1 2 3 4 5 Where1 是奇數,放行 Select1 → 10 foreach拿到 10 要下一個:再往回問,一路問到來源

圖 A:寫查詢只是寫食譜;列舉時才一個一個拉資料過流水線(點圖可放大)

練習 1 會看到:查詢建好之後,Select 的 lambda 執行了 0 次。

這段在講:查詢變數只是一份食譜。沒人列舉,就什麼都沒煮。

第二步:每列舉一次,整條流水線從頭重跑

食譜的好處是能重複用。壞處也在這裡:它不記得上次煮出了什麼。

這段要看懂:對同一個查詢先 Count() 再 foreach,是兩次列舉,流水線從來源開始跑兩遍。

第 1 次列舉:Count() 來源從頭讀第 1 遍 Where逐個檢查 Select跑 3 次 Count()得到 3,結果丟掉 第 2 次列舉:foreach 來源又從頭讀第 2 遍 Where又逐個檢查 Select又跑 3 次 foreach印出 3 筆 Select 累計 6 次、來源讀了 2 遍。結果只用到一次,工作卻做了兩次

圖 B:同一個查詢列舉兩次,整條流水線就跑兩遍(動畫依序點亮,會循環;點圖可放大)

微軟文件寫得很直接:列舉的結果不會算一次就存起來。Count() 煮完、數完就丟了;foreach 只好再煮一次。

來源便宜時,例如記憶體裡的小 List,多跑一次無所謂。來源貴的時候就痛了:

還有一個隱藏問題:來源如果在兩次列舉之間變了,兩次結果可能不一樣。log 寫 2 筆,實際印出 3 筆(練習 4)。

這段在講:同一個查詢列舉 N 次,整條流水線就從來源跑 N 次。

第三步:認出哪些呼叫會「開火」

要抓重複列舉,得先認得哪些呼叫會開火。這段教你看回傳型別來判斷。

延遲執行(只寫食譜)立即執行(當場開火)
例子Where、Select、OrderBy、Take、Skip、DistinctCount、Any、First、Sum、Max、Contains、ToList、ToArray、ToDictionary
回傳另一個 IEnumerable一個值,或一個裝好資料的集合
呼叫當下不碰來源走過來源、算出結果

判斷法:回傳還是 IEnumerable 的,多半是延遲;回傳一個值或一個集合的,當場就跑。再加上 foreach,它本身就是列舉。

Any()、First() 比較狡猾。它們找到第一個合格的就停,看起來很省。但它們每次都從頭開始,所以 Any() 後面接 First(),前面那一段還是重跑了。

這段在講:看到 Count、Any、First、Sum、ToList、foreach,就是「這裡煮了一次」。同一個查詢上出現兩個以上,就是重複列舉。

第四步:要用兩次以上,先 ToList() 煮成便當

認得哪裡會開火,修法就很直接:在第一次要用結果之前,先 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);
沒實體化:每次都重煮 Count() 煮一次:讀檔+Select foreach 再煮一次:讀檔+Select Select 6 次・讀檔 2 遍 先 ToList():煮一次裝便當 ToList() 煮一次:讀檔+Select 便當:List(3 筆)存在記憶體裡 Count foreach Select 3 次・讀檔 1 遍

圖 C:同樣「先數再印」,先 ToList() 只開火一次(數字來自練習 1、2;點圖可放大)

練習 2 實測:同樣的 Count+foreach,Select 從 6 次降到 3 次。List 的 Count() 會直接讀它記好的數量,不用再走一遍。

想讓工具幫你抓:.NET 7 起有分析規則 CA1851(可能重複列舉),.NET 10 預設沒開。打開後,build 時就會在重複列舉的地方警告(練習 3 最後)。

這段在講:要讀兩次以上,就先煮成便當。之後拿便當,不再開火。

常見的坑

補充:資料庫查詢(EF Core)

EF Core 的查詢是 IQueryable,延遲的規則一樣:串 LINQ 只是在組查詢,結果被取用時才送到資料庫。差別在 ToList() 的位置更要緊:先 Where 再 ToList(),篩選會變成 SQL 交給資料庫做;先 ToList() 再 Where,會先把整張表拉回記憶體。

動手做

先開一個練習資料夾:

mkdir lab && cd lab && dotnet new console

每個練習都把 Program.cs 整個換掉。先猜、再跑、再對答案。輸出是在 .NET 10 實際跑出來的。

練習 1:Select 到底跑了幾次?

建檔

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)。

練習 2:先 ToList() 再用兩次

建檔

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 右邊)。

練習 3:找出重複列舉(讀真的檔案)

建檔

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.

練習 4:兩次列舉之間,來源變了

建檔

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 對照

Go 標準庫沒有 LINQ 這種串接運算子。平常用 for 迴圈把結果 append 進 slice,寫下去就立刻算完,沒有「食譜被重煮」的問題。例外是 Go 1.23 起的 iter.Seq 迭代器:它跟 IEnumerable 一樣,再呼叫一次就再走一遍。

✔ 過關標準

能不看筆記回答下面 4 題,這篇就過了。

  1. var q = list.Where(...).Select(...); 這行跑完,Select 的 lambda 執行了幾次?什麼時候才會執行?看答案收合
    0 次。Where、Select 是延遲執行,要等 q 被列舉才跑。
    • q 裡裝的是步驟(食譜),不是結果。
    • foreach、Count()、First()、ToList() 這類呼叫才會開火,資料一個一個被拉過流水線(圖 A)。
  2. 下面這段,哪幾行會讓查詢從頭跑?怎麼修?
    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。
  3. Any() 和 First() 都只讀到第一筆就停,為什麼 Any() 後面接 First() 還算重複列舉?看答案收合
    因為每次列舉都從來源的開頭重新開始。
    • Any() 從頭讀到第一個合格的;First() 再從頭讀一次同一段。
    • 練習 3:Any() 讀了 1 行,First() 又從頭讀 1 行;最後 Count() 再從頭讀完 4 行,累計 6 行。
  4. 有人說「保險起見,每個 LINQ 後面都加 ToList()」。哪裡不對?看答案收合
    ToList() 只該用在「結果要讀兩次以上」的地方,它也有成本。
    • 要多開一份記憶體裝全部結果,而且要先全部跑完。
    • 無限或超大的序列會停不下來,或塞爆記憶體。
    • 它是快照,之後來源變了不會反映。

參考來源