程式語言 · 迭代器

# 惰性序列：要一個才做一個（C# IEnumerable 與 yield return，附 Go iter.Seq 對照）

Chance Lu

你手上有一個 20 GB 的 log 檔，只想找出**第一行 ERROR**。

用 `File.ReadAllLines`，它會把 20 GB **全部讀進記憶體**，才把陣列交給你。換成 `File.ReadLines`，**第一行讀到就能開始看**，找到 ERROR 就停，後面不用讀。Microsoft 文件也說：大檔案用 `ReadLines` 比較有效率。

這種「**要一個，才做一個**」的序列，叫**惰性序列**（很多語言叫 generator）。在 C# 裡自己寫一個，只要一個關鍵字：`yield return`。這篇把它拆開來看。

> 一句話：含 `yield return` 的方法會被編譯器改寫成**狀態機**。**呼叫時一行都不跑**；每次 `MoveNext` 才往下跑到下一個 yield 停住；**列舉兩次，就從頭跑兩次**。

全篇用同一個比喻：**請朗讀員念書**。你喊一聲「下一句」，他才念一句，念完**夾上書籤**等你。

## 四個名詞就夠

| 名詞 | 白話 | 比喻 |
| --- | --- | --- |
| `IEnumerable<T>` | 能「一個一個拿」的東西。它只做一件事：給你一個**新的**列舉器 | 一本書，每次都能請一位新朗讀員 |
| `IEnumerator<T>` | 列舉器，負責「下一個」：`MoveNext()` 往前一格，`Current` 是目前那個 | 朗讀員本人 |
| 狀態機 | 用一個數字記「停在哪」，下次照數字從那裡接著跑的物件 | 書籤加一張便條 |
| 延遲執行 | 呼叫時不做事，有人來拿才做 | 請來朗讀員，不等於開始念 |

## 第一步：foreach 背後，是兩個介面在接力

你天天寫 `foreach`。陣列、`List`、`ReadLines` 回來的東西長得完全不同，**foreach 怎麼都能走？**這一步看 foreach 實際做了什麼。

C# 規格寫得很清楚，foreach 會被展開成這樣（簡化，省略了最後的 `Dispose`）：

```
var e = seq.GetEnumerator();   // 請一位朗讀員
while (e.MoveNext())           // 喊「下一句」：有就回 true
{
    var x = e.Current;         // 他剛念的那句
    // ……你的迴圈本體
}
```

- **`IEnumerable<T>`** 只有一個方法 `GetEnumerator()`：交出一個列舉器。
- **`IEnumerator<T>`** 的 `MoveNext()` 往前一格，回 `true` 表示「有」，`false` 表示「沒了」；`Current` 就是目前那一格。
- 列舉器一開始站在**第一個元素之前**，所以一定要先 `MoveNext()` 才能讀 `Current`。

foreachforeach (var x in seq)

① 請一位朗讀員seq.GetEnumerator()

② 下一句？e.MoveNext()
true
③ 拿這一句x = e.Current

跑完迴圈本體，再喊一次

false → 迴圈結束

圖 A：foreach 就是「請一位朗讀員，一直喊下一句，直到他說沒了」（點圖可放大）

附帶一提，`IEnumerable<out T>` 那個 `out`，[〈共變與逆變〉](https://docs.smartech.tw/n/generics-variance-in-out)講過，這裡不重複。

> 一句話：foreach 只認 **GetEnumerator、MoveNext、Current** 這三樣，誰提供都能走。

## 第二步：yield return，念到這裡就停

想自己做一個惰性序列，照第一步，就得**手寫一個列舉器類別**。麻煩在於：`MoveNext` 每次被叫，都得**記得上次停在哪**，迴圈變數也要存成欄位。又長又容易錯。這一步看 `yield return` 怎麼替你做掉。

你只要寫一個**普通的方法**，回傳 `IEnumerable<T>`，想交出值的地方寫 `yield return`：

```
static IEnumerable<string> ReadLog()
{
    Console.WriteLine("[方法] 開始");
    yield return "第 1 行";
    Console.WriteLine("[方法] 從第 1 行之後繼續");
    yield return "第 2 行";
    Console.WriteLine("[方法] 結束");
}
```

Microsoft 文件描述的規則：

- 開始列舉時，方法**跑到第一個 `yield return` 就停**，把值交給呼叫端。
- 下次再要，**從那個 `yield return` 後面接著跑**，跑到下一個再停。
- 跑到方法結尾（或遇到 `yield break`），序列就結束。

> 一句話：`yield return` 就是「**交出這一句，夾上書籤**，等你喊下一句」。

## 第三步：編譯器把方法改寫成狀態機

一般方法一旦 return，區域變數就消失了，不可能「停在中間、下次再接」。**那 yield 怎麼辦到的？**這一步看編譯器在背後寫了什麼。

C# 規格說：呼叫含 yield 的方法時，**不會執行裡面的程式碼**，而是建立並回傳一個物件，通常是**編譯器產生的隱藏類別**。你寫的程式碼被搬進它的 `MoveNext`。

這個物件就是**狀態機**：

- 一個整數 `state` 記「**停在哪個 yield**」，這是書籤。
- 區域變數和參數被**搬成欄位**，這是便條。所以停下再回來，值都還在。

概念上長這樣（**簡化示意**，真正的名字和細節不同，動手做會偷看真的）：

```
class ReadLogStateMachine : IEnumerable<string>, IEnumerator<string>
{
    int state = 0;                         // 書籤：0 = 還沒開始
    public string Current { get; private set; }

    public bool MoveNext()
    {
        switch (state)
        {
            case 0: WriteLine("開始"); Current = "第 1 行"; state = 1; return true;
            case 1: WriteLine("繼續"); Current = "第 2 行"; state = 2; return true;
            case 2: WriteLine("結束"); state = -1; return false;
            default: return false;         // 念完了，再喊也是 false
        }
    }
    // GetEnumerator、Dispose 等省略
}
```

規格把它的一生分成四種狀態：**before**（還沒開始）、**running**（正在跑）、**suspended**（停在某個 yield）、**after**（跑完）。圖 B 帶你走一遍：

你寫的方法
列舉器（狀態機）

IEnumerable&lt;string&gt; ReadLog()
{
WriteLine("開始");
yield return "第 1 行";
WriteLine("繼續");
yield return "第 2 行";
WriteLine("結束");
}

state（書籤）
Current（剛念的那句）
MoveNext 回傳
before 還沒開始（還沒有）—
停在第 1 個 yield"第 1 行"true
停在第 2 個 yield"第 2 行"true
after 念完了（沒意義了）false
① 呼叫 ReadLog()：只建出狀態機，一行都沒跑書籤夾在最前面
② 第一次 MoveNext：從頭跑到第 1 個 yield，停住印出「開始」，交出第 1 行，書籤夾在這裡
③ 第二次 MoveNext：從書籤後面接著跑印出「繼續」，交出第 2 行，書籤往下移
④ 第三次 MoveNext：跑到結尾，回 false印出「結束」，序列結束，foreach 跳出

圖 B：每次 MoveNext 只跑「書籤到下一個 yield」那一段（動畫會循環，點圖可放大）

> 一句話：yield 方法被改寫成「**一個 state 數字＋一個 switch**」，每次 MoveNext 照 state 跳回上次停的地方。

## 第四步：延遲執行，呼叫不跑、列舉才跑、列舉兩次跑兩次

知道方法被改寫成物件後，「什麼時候跑」就能推出來。很多 bug 都來自**以為呼叫完就做完了**。這一步把時機講清楚。

1. **呼叫 `ReadLog()`**：只建出狀態機，方法本體**一行都沒跑**。
2. **第一次 `MoveNext`**：從頭跑到第一個 yield，停。
3. **列舉完**：一段一段接著跑，跑到結尾時 `MoveNext` 回 `false`。
4. **再 foreach 一次**：foreach 又呼叫一次 `GetEnumerator()`，拿到**全新的列舉器**，從頭**再跑一遍**。

lines = ReadLog()

一個還沒開始的序列物件方法本體：0 行被執行
每次 foreach 都呼叫一次 GetEnumerator()，拿到全新的朗讀員
第 1 次 foreach

開始
第 1 行
繼續
第 2 行
結束
第 2 次 foreach

開始
第 1 行
繼續
第 2 行
結束
第二次不是「拿上次的結果」，而是整個方法從頭再跑一遍

圖 C：手上的 lines 只是「配方」，每 foreach 一次就照配方重做一次（點圖可放大）

這就是**延遲執行**。好處是**不需要的就不算**：找到 ERROR 就 break，後面不讀。代價是**每列舉一次就重算一次**。

> 一句話：手上拿的是「**還沒開始的序列**」，不是結果；**每次列舉都從頭跑**。

## 常見的坑

- **參數檢查跑太晚。**把 `ThrowIfNull` 寫在有 yield 的方法裡，呼叫時不會爆，**等到列舉才爆**，錯誤離真正傳錯參數的地方很遠。解法：外層用**一般方法**先檢查，再呼叫有 yield 的**區域函式**（寫在方法裡的小方法，練習 3）。
- **以為拿到結果，其實拿到配方。**列舉兩次就跑兩次；本體若會寫 log、打 API、讀檔，也會**做兩次**。需要固定結果，就自己存成一份集合，例如 `new List<string>(lines)`。
- **想用 `Reset()` 重來。**yield 產生的列舉器不支援，會丟 `NotSupportedException`。想重來，就再拿一個新的列舉器。

## 動手做

先開一個練習資料夾：

```
mkdir lab && cd lab && dotnet new console
```

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

### 練習 1：方法本體到底什麼時候跑？

建檔把 `Program.cs` 換成：

```
Console.WriteLine("① 呼叫 ReadLog()");
IEnumerable<string> lines = ReadLog();

Console.WriteLine("② 拿列舉器，第一次 MoveNext");
IEnumerator<string> e = lines.GetEnumerator();
e.MoveNext();
Console.WriteLine($"   Current = {e.Current}");

Console.WriteLine("③ 用 foreach 列舉完");
foreach (var line in lines)
    Console.WriteLine($"   拿到 {line}");

Console.WriteLine("④ 再列舉一次");
foreach (var line in lines)
    Console.WriteLine($"   拿到 {line}");

static IEnumerable<string> ReadLog()
{
    Console.WriteLine("   [方法] 開始");
    yield return "第 1 行";
    Console.WriteLine("   [方法] 從第 1 行之後繼續");
    yield return "第 2 行";
    Console.WriteLine("   [方法] 結束");
}
```

先猜① 之後會印出「[方法] 開始」嗎？③ 是從「開始」跑，還是接著 ② 停下的地方跑？

執行`dotnet run`：

```
① 呼叫 ReadLog()
② 拿列舉器，第一次 MoveNext
   [方法] 開始
   Current = 第 1 行
③ 用 foreach 列舉完
   [方法] 開始
   拿到 第 1 行
   [方法] 從第 1 行之後繼續
   拿到 第 2 行
   [方法] 結束
④ 再列舉一次
   [方法] 開始
   拿到 第 1 行
   [方法] 從第 1 行之後繼續
   拿到 第 2 行
   [方法] 結束
```

你會看到① 之後**什麼都沒印**；② 只跑到第一個 yield。③ 又從「開始」跑，因為 foreach **拿了新的列舉器**，`e` 還停在原地。④ 再從頭跑。

### 練習 2：偷看編譯器產生的狀態機

建檔用反射（程式執行時查看自己的型別）偷看隱藏類別的欄位。這些名字是編譯器內部用的，**不同版本可能不同**，只拿來觀察：

```
using System.Reflection;

var lines = ReadLog(3);
Console.WriteLine(lines.GetType().Name);
foreach (var f in lines.GetType().GetFields(BindingFlags.NonPublic | BindingFlags.Instance))
    Console.WriteLine($"  {f.FieldType.Name,-7} {f.Name}");

var e = lines.GetEnumerator();
Peek("拿到列舉器");
while (e.MoveNext())
    Peek($"MoveNext → true，Current = {e.Current}");
Peek("MoveNext → false");

void Peek(string label)
{
    object Get(string name) => e.GetType()
        .GetField(name, BindingFlags.NonPublic | BindingFlags.Instance)!.GetValue(e)!;
    Console.WriteLine($"{label}｜state={Get("<>1__state")}, i={Get("<i>5__1")}");
}

static IEnumerable<string> ReadLog(int count)
{
    for (int i = 1; i <= count; i++)
        yield return $"第 {i} 行";
}
```

先猜迴圈變數 `i` 是區域變數，方法停下後它還在嗎？

執行`dotnet run`：

```
<<<Main>$>g__ReadLog|0_1>d
  Int32   <>1__state
  String  <>2__current
  Int32   <>l__initialThreadId
  Int32   count
  Int32   <i>5__1
拿到列舉器｜state=0, i=0
MoveNext → true，Current = 第 1 行｜state=1, i=1
MoveNext → true，Current = 第 2 行｜state=1, i=2
MoveNext → true，Current = 第 3 行｜state=1, i=3
MoveNext → false｜state=-1, i=4
```

這證明回傳的是一個**編譯器產生的類別**。`<>1__state` 是書籤，`<i>5__1` 是被搬成欄位的 `i`（便條），`<>2__current` 存 `Current`。只有一個 yield，所以 state 一直是 1，念到第幾行記在 i；跑完變 -1（after）。`initialThreadId` 先忽略。

### 練習 3：參數檢查跑太晚

建檔把 `Program.cs` 換成：

```
Console.WriteLine("呼叫 ReadLog(null)");
var lines = ReadLog(null);
Console.WriteLine("呼叫完了，還沒出事");

try
{
    foreach (var line in lines) Console.WriteLine(line);
}
catch (ArgumentNullException ex)
{
    Console.WriteLine($"列舉時才爆：{ex.ParamName}");
}

Console.WriteLine("呼叫 ReadLogSafe(null)");
try
{
    var safe = ReadLogSafe(null);
}
catch (ArgumentNullException ex)
{
    Console.WriteLine($"呼叫當下就爆：{ex.ParamName}");
}

static IEnumerable<string> ReadLog(string? path)
{
    ArgumentNullException.ThrowIfNull(path);
    yield return $"{path} 的第 1 行";
}

static IEnumerable<string> ReadLogSafe(string? path)
{
    ArgumentNullException.ThrowIfNull(path);   // 一般方法：呼叫當下就檢查
    return Iterate(path);

    static IEnumerable<string> Iterate(string path)
    {
        yield return $"{path} 的第 1 行";      // 有 yield 的部分搬進區域函式
    }
}
```

先猜傳 `null` 給 `ReadLog`，哪一行會丟例外？

執行`dotnet run`：

```
呼叫 ReadLog(null)
呼叫完了，還沒出事
列舉時才爆：path
呼叫 ReadLogSafe(null)
呼叫當下就爆：path
```

這證明檢查也是方法本體的一部分，一樣**延遲到第一次 MoveNext**。`ReadLogSafe` 本身沒有 yield，是一般方法，所以呼叫當下就檢查。

## Go 對照：iter.Seq

Go 1.23 起，`for range` 可以直接跑一個函式。標準型別是 `iter.Seq[V]`，長這樣：`func(yield func(V) bool)`。

方向跟 C# **反過來**：C# 是迴圈**拉**（喊 `MoveNext`）；Go 是 iterator **推**，每有一個值就呼叫 `yield`，也就是你的迴圈本體。`yield` 回 `false` 代表對方 break 了，該停。

C#：呼叫端拉
foreach 迴圈
MoveNext()：給我下一個
Current：這是下一個
狀態機
Go：iterator 推
iterator 函式
yield(v)：給你一個
回 true 繼續／false 停
迴圈本體

圖 D：同樣是「要一個給一個」，C# 由迴圈拉，Go 由 iterator 推（點圖可放大）

```
func ReadLog() iter.Seq[string] {
    return func(yield func(string) bool) {
        fmt.Println("   [函式] 開始")
        if !yield("第 1 行") { return }
        fmt.Println("   [函式] 從第 1 行之後繼續")
        if !yield("第 2 行") { return }
        fmt.Println("   [函式] 結束")
    }
}

func main() {
    fmt.Println("① 呼叫 ReadLog()")
    lines := ReadLog()
    fmt.Println("② range 列舉")
    for line := range lines { fmt.Println("   拿到", line) }
    fmt.Println("③ 再 range 一次，拿到第一行就 break")
    for line := range lines { fmt.Println("   拿到", line); break }
}
```

用 Go 1.25 實際跑（go.mod 寫 `go 1.23` 以上，記得 import `fmt` 和 `iter`）：

```
① 呼叫 ReadLog()
② range 列舉
   [函式] 開始
   拿到 第 1 行
   [函式] 從第 1 行之後繼續
   拿到 第 2 行
   [函式] 結束
③ 再 range 一次，拿到第一行就 break
   [函式] 開始
   拿到 第 1 行
```

|  | C# yield | Go iter.Seq |
| --- | --- | --- |
| 呼叫時 | 只建出狀態機，本體不跑 | 只拿到一個函式，裡面不跑 |
| 誰掌控節奏 | 呼叫端拉（MoveNext） | iterator 推（呼叫 yield） |
| 怎麼「停住」 | 編譯器改寫成狀態機 | 就是一般函式呼叫：等 yield 回來再繼續 |
| 列舉兩次 | 從頭跑兩次 | 從頭跑兩次（iter 文件：再呼叫一次就再走一遍） |

## 接下來：LINQ 就蓋在這上面

`Where`、`Select` 這些 LINQ 方法，吃的、吐的都是 `IEnumerable<T>`，**也是延遲執行**：串起來時不跑，列舉時才跑。它們的成本，例如重複列舉、什麼時候該先存起來，留到之後的筆記。

## ✔ 過關標準

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

1. 呼叫一個含 yield return 的方法，方法本體跑了幾行？為什麼？
**一行都沒跑。**
    - 編譯器把方法改寫成狀態機類別；呼叫只是建出這個物件（before 狀態）並回傳。
    - 本體被搬進 MoveNext，要等有人列舉才跑。
2. 第一次 MoveNext 會跑到哪裡停？停下時，狀態機記住了什麼？
**從頭跑到第一個 yield return 就停，回 true，Current 設成那個值。**
    - 它記住 **state**（停在哪個 yield，書籤），以及被搬成欄位的**區域變數和參數**（便條）。
    - 下次 MoveNext 照 state 跳回來，從 yield 後面接著跑。
3. ```
    var s = Nums();
    Console.WriteLine("A");
    foreach (var n in s) Console.WriteLine(n);
    foreach (var n in s) Console.WriteLine(n);
    
    static IEnumerable<int> Nums()
    {
        Console.WriteLine("start");
        yield return 1;
        Console.WriteLine("end");
    }
    ```

這段會依序印出什麼？
**A、start、1、end、start、1、end（實際跑過）。**
    - 呼叫 `Nums()` 不跑，所以 A 先印。
    - 第一個 foreach：MoveNext 跑到 yield 印 start、交出 1；再 MoveNext 印 end、回 false。
    - 第二個 foreach 拿新列舉器，**從頭再跑一遍**。
4. 參數檢查寫在有 yield 的方法裡，為什麼會晚爆？怎麼修？
**檢查也是本體的一部分，要等第一次 MoveNext 才跑。**
    - 修法：外層寫**沒有 yield 的一般方法**先檢查，再回傳有 yield 的區域函式。
5. Go 的 iter.Seq 跟 C# 的 yield，哪裡一樣、哪裡不同？
**一樣：都是惰性的，range／foreach 才跑，列舉兩次跑兩次。**
    - 不同：C# 由呼叫端**拉**（MoveNext），靠編譯器的狀態機停住；Go 由 iterator **推**（呼叫 yield），停住就是一般的函式呼叫。

## 參考來源

- [Microsoft Learn — yield statement](https://learn.microsoft.com/en-us/dotnet/csharp/language-reference/statements/yield)（呼叫不執行、跑到 yield 停、從 yield 後面接著跑）
- [Microsoft Learn — Iterators](https://learn.microsoft.com/en-us/dotnet/csharp/fundamentals/building-software/iterators)（延遲執行、每次列舉重跑、何時改回傳集合）
- [Microsoft Learn — IEnumerator&lt;T&gt;](https://learn.microsoft.com/en-us/dotnet/api/system.collections.generic.ienumerator-1)（一開始在第一個元素之前、MoveNext 回 false）
- [C# 標準 §15.15 Iterators](https://github.com/dotnet/csharpstandard/blob/draft-v8/standard/classes.md#1515-synchronous-and-asynchronous-iterators)（編譯器產生的類別、before／running／suspended／after、不支援 Reset）
- [C# 標準 §13.9.5 The foreach statement](https://github.com/dotnet/csharpstandard/blob/draft-v8/standard/statements.md#1395-the-foreach-statement)（foreach 展開成 GetEnumerator／MoveNext／Current）
- [Microsoft Learn — File.ReadLines](https://learn.microsoft.com/en-us/dotnet/api/system.io.file.readlines)（大檔用 ReadLines 較有效率）
- [Go Blog — Range Over Function Types](https://go.dev/blog/range-functions)（Go 1.23、push iterator）
- [Go — package iter](https://pkg.go.dev/iter)（iter.Seq、yield 回 false 就停、再呼叫就再走一遍）
