程式語言 · 迭代器

惰性序列:要一個才做一個(C# IEnumerable 與 yield return,附 Go iter.Seq 對照)

Chance Lu · 學習文章 · 2026-11-04 23:32
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;         // 他剛念的那句
    // ……你的迴圈本體
}
foreachforeach (var x in seq) ① 請一位朗讀員seq.GetEnumerator() ② 下一句?e.MoveNext() true ③ 拿這一句x = e.Current 跑完迴圈本體,再喊一次 false → 迴圈結束

圖 A:foreach 就是「請一位朗讀員,一直喊下一句,直到他說沒了」(點圖可放大)

附帶一提,IEnumerable<out T> 那個 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 就是「交出這一句,夾上書籤,等你喊下一句」。

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

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

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

這個物件就是狀態機:

概念上長這樣(簡化示意,真正的名字和細節不同,動手做會偷看真的):

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<string> 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,後面不讀。代價是每列舉一次就重算一次。

一句話:手上拿的是「還沒開始的序列」,不是結果;每次列舉都從頭跑。

常見的坑

動手做

先開一個練習資料夾:

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# yieldGo 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),停住就是一般的函式呼叫。

參考來源