🐛 IEnumerable + yield 的運作機制詳解
一般集合的回傳方式 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 void Main (){ var store = new DemoStore(); var productList = store.GetProducts(); foreach (var product in productList) { $"{product.Id} ,{product.Name} " .Dump(); } } public class DemoStore { public List<Product> GetProducts () { List<Product> result = new List<Product>(); for (int i = 0 ; i < 5 ; i++) { $"{i} 產生Product中" .Dump(); result.Add(new Product() { Id = i, Name = $"Pro {i} " }); } return result; } } public class Product { public int Id { get ; set ; } public string Name { get ; set ; } }
方法是「一次性跑完」,建立好完整集合,再交給呼叫端使用。呼叫 GetProducts() 的那一刻,就已經完成所有運算。
yield return 的回傳方式 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 void Main (){ var store = new DemoStore(); var productList = store.GetProducts(); foreach (var product in productList) { $"{product.Id} ,{product.Name} " .Dump(); } } public class DemoStore { public IEnumerable<Product> GetProducts () { int count = 5 ; for (int i = 0 ; i < count; i++) { $"{i} 產生Product中" .Dump(); yield return new Product () { Id = i, Name = $"Pro {i} " }; } } } public class Product { public int Id { get ; set ; } public string Name { get ; set ; } }
方法並沒有「馬上跑完」,而是被編譯器轉換成「狀態機」
foreach 的 MoveNext() 第一次呼叫 → 跑到 yield return,吐出第一個值,暫停
第二次呼叫 → 從暫停處繼續,吐出下一個值
重複,直到迴圈結束
換句話說,不是「先造好整個清單 → 再走訪」,而是「一邊造、一邊走」 。
為什麼 foreach 能跑? 因為 foreach 的本質是這樣
1 2 3 4 5 6 var enumerator = products.GetEnumerator();while (enumerator.MoveNext()){ var current = enumerator.Current; }
只要一個型別實作了 IEnumerable,就能用 foreach。List、Array:實作自己的 Enumerator(固定集合) 而 yield return 是由編譯器幫你產生 Enumerator(狀態機,動態生成),所以,foreach 不是專門為 yield return 設計的,而是為所有 IEnumerable 設計的。
🐛 IEnumerable + yield 的效能實驗 傳統方法回傳「完整集合」 → 先做完,再交出去。yield return 方法回傳「逐步產生的序列」 → 邊做邊交,按需生成。 這兩者差別,直接影響到 時間 和 記憶體。
🧪 實驗一:時間成本(提早停止) 分別有兩個方法,FilterRandomDataset、FilterRandomDatasetYield
FilterRandomDataset 讀取所有檔案,把尾巴是 000 的檔案抓出來 FilterRandomDatasetYield 掃到 000 就先回傳了,不需要看完全部的檔案
所以當我們今天的需求是,只要拉到三筆 000 結尾的資料時,更適合使用 yield return 來處理
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 static void Main (string [] args ){ var stTime = DateTime.Now; var path = @"C:\Users\Allen Lin\Desktop\AllenLab\PowerShellV2\RandomFile" ; Log("Dump All Start" , stTime); foreach (var file in FilterRandomDataset (path )) { Log(file, stTime); } Log("End" , stTime); stTime = DateTime.Now; Log("Show First 3 Start" , stTime); FilterRandomDataset(path).Take(3 ).ToList().ForEach(file => Log(file, stTime)); Log("End" , stTime); } private static string [] FilterRandomDataset (string path ){ return Directory.GetFiles(path) .Where(file => File.ReadAllText(file).TrimEnd(Environment.NewLine.ToCharArray()).EndsWith("000" )).ToArray(); } private static IEnumerable<string > FilterRandomDatasetYield (string path ){ foreach (var file in Directory.GetFiles(path)) { if (File.ReadAllText(file) .TrimEnd(Environment.NewLine.ToCharArray()).EndsWith("000" )) yield return file; } } private static void Log (string message, DateTime stTime ){ Console.WriteLine($"{(DateTime.Now - stTime).TotalMilliseconds / 1000 :000.00 } s {message} " ); }
用 FilterRandomDataset 跑結果
用 FilterRandomDatasetYield 跑結果
重複 3 次結果都差不多 0.7 –> 0.2 秒,明顯呈現出 yield return 免除了因為多撈資料造成的時間浪費
🧪 實驗二:記憶體成本(避免多餘分配) 關於程式用了多少記憶體這件事,有個簡單測量方法叫做 GC.GetTotalMemory
GC.GetTotalMemory GC.GetTotalMemory 方法是一個用於測量這個程式 Managed Heap 記憶體使用情況的工具。 它回傳應用程式中所有存活(live)的 Managed Object 所佔用的記憶體總量,也就是存活物件的記憶體大小 測量的數值表示「目前程式裡還在用的東西」所佔用的記憶體空間。這些東西(物件)可能包括:已分配的變數、new 出來的 instances、字串、集合等。 即使有些東西程式已經用不到,但垃圾回收器(GC)還沒來得及清掉,它們暫時也會算在這個數字裡。 測量的範圍只管 .NET 自己管理的記憶體,.NET 有一個「託管的記憶體區域」(Managed Heap),專門用來存放程式裡分配的物件。 GC.GetTotalMemory 只會測量這部分的大小,不會管那些由其他地方管理的記憶體,比如你自己用外部函式庫分配的內存、未託管的資源(例如文件指標、資料庫連線等)。
GetTotalMemory 主要有兩種方式
直接返回當前已分配的記憶體快照,無需進行垃圾收集, Managed Heap 中的所有已分配物件,”包括可能不再使用但尚未被垃圾收集的物件”,適用於需要快速了解程式當前的記憶體使用情況,但不要求完全準確,就是還沒被丟掉的都算進來啦!
特點
快速,不會因執行垃圾收集而影響應用的性能。
數值可能偏大,因為它可能包含一些已成為垃圾(但尚未被回收)的記憶體。
在測量前,強制執行一次完整的垃圾收集,清理掉所有可回收的物件後,再測量記憶體。
只包含仍然被引用且活躍的物件,過時或不再需要的記憶體已被釋放。
特點
更準確,因為結果反映了程式實際需要的記憶體大小。
花費時間更長,可能對應用性能產生短暫影響。
簡單類比一下就是
只是掃一眼房間,看看房間裡所有東西的總體積(包括垃圾和有用的物品)。 像是先把房間的垃圾清理掉(做一次徹底的打掃),然後再計算房間裡有用物品的總體積。
而決定哪個模式就是帶入參數,簡單的快照帶入 false, 精準的資訊為 true
回到實驗本身 情境是生成 10000個 Guid,將他們轉換成字串,印出來,我需要的資訊分別是最終集合會得到的數量、第一筆 GUID
GetGuidStrings 全部轉換成字串後,生成一個 Array
GetGuidStringsYield 一個個 loop
以 Count() 來說,他沒有生成一個真正的集合出來占用一波記憶體,而是單純計數 以 First() 而言,更容易理解了,只是單純的想取得第一筆資料
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 static void Main (string [] args ){ TestMemory(); } private static void TestMemory (){ List<Guid> guidPool = Enumerable.Range(0 , 10000 ).Select(i => Guid.NewGuid()).ToList(); GetMemorySize(); Console.WriteLine(GetGuidStrings(guidPool).Count()); GetMemorySize(); Console.WriteLine(GetGuidStrings(guidPool).First()); GetMemorySize(); Console.ReadLine(); } private static void GetMemorySize (){ var size = GC.GetTotalMemory(false ) / 1024 / 1024 ; Console.WriteLine($"Memory Size {size} MB" ); } private static string [] GetGuidStrings (List<Guid> guids ){ return guids.Select(guid => guid.ToString()).ToArray(); } private static IEnumerable<string > GetGuidStringsYield (List<Guid> guids ){ foreach (var guid in guids) { yield return guid.ToString(); } }
傳統寫法結果
yield 結果
用 Bytes 為單位觀察,最後結果記憶體占用是有差異的,這件事與原實驗結果相符
有結果立即回傳,提供更好的即時性 (這是要串接成生產線模式的必要條件)
只需部分結果時,省去處理無用資料的成本
不需耗用記憶體儲存全部結果
🐛 參考文章 善用 yield return 省時省 CPU 省 RAM,打造高效率程式
Understanding C# foreach Internals and Custom Iterators with yield
由 C# yield return 引发的思考
IEnumerable + yield 的坑踩過了嗎