你手上有一個 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。陣列、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。圖 A:foreach 就是「請一位朗讀員,一直喊下一句,直到他說沒了」(點圖可放大)
附帶一提,IEnumerable<out T> 那個 out,〈共變與逆變〉講過,這裡不重複。
一句話:foreach 只認 GetEnumerator、MoveNext、Current 這三樣,誰提供都能走。
想自己做一個惰性序列,照第一步,就得手寫一個列舉器類別。麻煩在於: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 帶你走一遍:
圖 B:每次 MoveNext 只跑「書籤到下一個 yield」那一段(動畫會循環,點圖可放大)
一句話:yield 方法被改寫成「一個 state 數字+一個 switch」,每次 MoveNext 照 state 跳回上次停的地方。
知道方法被改寫成物件後,「什麼時候跑」就能推出來。很多 bug 都來自以為呼叫完就做完了。這一步把時機講清楚。
ReadLog():只建出狀態機,方法本體一行都沒跑。MoveNext:從頭跑到第一個 yield,停。MoveNext 回 false。GetEnumerator(),拿到全新的列舉器,從頭再跑一遍。圖 C:手上的 lines 只是「配方」,每 foreach 一次就照配方重做一次(點圖可放大)
這就是延遲執行。好處是不需要的就不算:找到 ERROR 就 break,後面不讀。代價是每列舉一次就重算一次。
一句話:手上拿的是「還沒開始的序列」,不是結果;每次列舉都從頭跑。
ThrowIfNull 寫在有 yield 的方法裡,呼叫時不會爆,等到列舉才爆,錯誤離真正傳錯參數的地方很遠。解法:外層用一般方法先檢查,再呼叫有 yield 的區域函式(寫在方法裡的小方法,練習 3)。new List<string>(lines)。Reset() 重來。yield 產生的列舉器不支援,會丟 NotSupportedException。想重來,就再拿一個新的列舉器。先開一個練習資料夾:
mkdir lab && cd lab && dotnet new console
每個練習都是把 Program.cs 整個換掉,再 dotnet run。先猜、再跑、再對答案。輸出是在 .NET 10 實際跑出來的。
建檔把 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 還停在原地。④ 再從頭跑。
建檔用反射(程式執行時查看自己的型別)偷看隱藏類別的欄位。這些名字是編譯器內部用的,不同版本可能不同,只拿來觀察:
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 先忽略。
建檔把 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 1.23 起,for range 可以直接跑一個函式。標準型別是 iter.Seq[V],長這樣:func(yield func(V) bool)。
方向跟 C# 反過來:C# 是迴圈拉(喊 MoveNext);Go 是 iterator 推,每有一個值就呼叫 yield,也就是你的迴圈本體。yield 回 false 代表對方 break 了,該停。
圖 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 文件:再呼叫一次就再走一遍) |
Where、Select 這些 LINQ 方法,吃的、吐的都是 IEnumerable<T>,也是延遲執行:串起來時不跑,列舉時才跑。它們的成本,例如重複列舉、什麼時候該先存起來,留到之後的筆記。
能不看筆記回答下面 5 題,這篇就過了。
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");
}
Nums() 不跑,所以 A 先印。