程式語言 · LINQ 延遲執行

# 懶的流水線，每列舉一次就重跑一次：LINQ 的成本（C# Where / Select / ToList）

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，多跑一次無所謂。來源貴的時候就痛了：

- **讀檔**：`File.ReadLines` 每次列舉都重新從頭讀（練習 3 實測）。
- **資料庫**：EF Core 的查詢在結果被取用時才送出。Count() 送一次，foreach 再送一次。
- **重運算**：Select 裡如果在解析、計算，每個元素都多算一遍。

還有一個隱藏問題：來源如果在兩次列舉之間變了，**兩次結果可能不一樣**。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() 煮成便當

認得哪裡會開火，修法就很直接：**在第一次要用結果之前，先 `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 最後）。

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

## 常見的坑

- **到處都 ToList() 也有成本。**它要多開一份記憶體裝全部結果，而且得先全部跑完。只列舉一次的查詢，直接 foreach 就好。
- **無限或超大的序列，不能整個 ToList()。**便當盒裝不下：永遠產生不完的序列會停不下來，百萬行的檔案會整個塞進記憶體。先用 `Where`、`Take` 縮小，或只 foreach 一次、邊讀邊處理。
- **ToList() 是快照。**之後來源再變，便當不會跟著變。要最新資料，就重新查一次。
- **方法參數是 `IEnumerable<T>`，你不知道收到的是便當還是食譜。**方法裡要讀兩次，就在開頭自己實體化一次。

### 補充：資料庫查詢（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() 只該用在「結果要讀兩次以上」的地方，它也有成本。**
    - 要多開一份記憶體裝全部結果，而且要先全部跑完。
    - 無限或超大的序列會停不下來，或塞爆記憶體。
    - 它是快照，之後來源變了不會反映。

## 參考來源

- [Microsoft Learn — Introduction to LINQ Queries (C#)](https://learn.microsoft.com/en-us/dotnet/csharp/linq/get-started/introduction-to-linq-queries)（查詢變數不存結果；立即與延遲執行的分類表；用 ToList／ToArray 強制執行並快取；多次列舉結果可能不同）
- [Microsoft Learn — Enumerable.Where](https://learn.microsoft.com/en-us/dotnet/api/system.linq.enumerable.where)（回傳記著必要資訊的物件，被列舉才執行）
- [Microsoft Learn — CA1851: Possible multiple enumerations of IEnumerable collection](https://learn.microsoft.com/en-us/dotnet/fundamentals/code-analysis/quality-rules/ca1851)（列舉結果不快取、資料庫查詢時傷效能、實體化的記憶體成本、.NET 7 引入且 .NET 10 預設未開）
- [Microsoft Learn — Deferred execution and lazy evaluation](https://learn.microsoft.com/en-us/dotnet/standard/linq/deferred-execution-lazy-evaluation)（延遲執行的定義；LINQ 大量使用延遲執行）
- [Microsoft Learn — Enumerable.Count](https://learn.microsoft.com/en-us/dotnet/api/system.linq.enumerable.count)（來源實作 ICollection&lt;T&gt; 時直接取用其數量）
- [Microsoft Learn — How Queries Work (EF Core)](https://learn.microsoft.com/en-us/ef/core/querying/how-query-works)（結果被取用時才送查詢到資料庫：迭代、ToList、Count…）
- [Go — package iter](https://pkg.go.dev/iter)、[Go 1.23 Release Notes](https://go.dev/doc/go1.23)（iter.Seq 迭代器，再呼叫一次就再走一遍序列）
