延遲執行的基礎 - 疊代器 & 走訪
詳細請參考下列文章.
這邊會用一個例子快速回顧 , 以下是自定義類別
public class CityCollection : IEnumerable
{
public IEnumerator GetEnumerator()
{
Console.WriteLine("item - 1");
yield return 1;
Console.WriteLine("item - 2");
yield return 2;
Console.WriteLine("item - 3");
yield return 3;
Console.WriteLine("item - finish");
}
}
以下是執行 foreach 的程式
static void Main(string[] args)
{
var city = new CityCollection();
Console.WriteLine("foreach start");
foreach (var item in city)
{
Console.WriteLine("foreach item :" + item);
}
Console.WriteLine("foreach end");
Console.ReadKey();
}
以下是運行結果
使用上面例子 , 利用 Visual Studio 去逐步偵錯, 可以知道 foreach 的執行順序其實是
-
進入 foreach
-
執行 GetEnumerator() , 得到一個 IEnumerator
-
執行 MoveNext() , 以判斷走訪是否結束. 若尚未結束則將 Current 屬性移動到下一個元素.
-
回傳 Current 屬性給 item (也就是 yield return value; 這一行.)
所以不論是 IEnumerable 或是 IEnumerable<T> 都提供一個 GetEnumerator() 方法. 再透過所得到的 Enumerator 物件去執行走訪這個動作.
延遲執行的時機
一般來說程式執行到哪一行 , 該行運算式就應該立即被執行. 但 LINQ 有一個很重要的特性 , 叫做「延遲執行」(deferred execution), 或稱為惰性求值(lazy evaluation). 顧名思義 , 就是在需要取用查詢結果的時候,才去執行查詢表示式. 請看下面範例
自定義 Where 以及 Select
// 回傳符合條件的項目
public static IEnumerable<TSource> MyWhere<TSource>(this IEnumerable<TSource> source, Func<TSource, bool> predicate)
{
var iterator = source.GetEnumerator();
while (iterator.MoveNext())
{
if (predicate(iterator.Current))
{
yield return iterator.Current;
}
}
}
// 將項目轉換成某個樣式
public static IEnumerable<TResult> MySelect<TSource, TResult>(this IEnumerable<TSource> source, Func<TSource, TResult> selector)
{
var iterator = source.GetEnumerator();
while (iterator.MoveNext())
{
yield return selector(iterator.Current);
}
}
測試程式 - query 會篩選 list 中大於 3 的數字並將其加一.
List<int> list = new List<int>() { 5, 9, 8 };
IEnumerable<int> query = list.MyWhere(item => item > 3).MySelect(item => item + 1);
list.Remove(9);
foreach (var item in query)
{
Console.WriteLine(item);
}
結果會印出 6 , 9
query 若是立即執行查詢的話 , 則結果應該是 6 , 10 , 9 才對. 所以查詢時機應該是在 foreach 那一行. 由此可以推測 LinQ 的延遲執行有兩個特性
- 建立查詢與執行查詢的時機是不同的
- 執行查詢的時機為存取 IEnumerable 中元素的時候.
延遲執行的運作過程
測試程式
public static IEnumerable<TResult> MySelect<TSource, TResult>(this IEnumerable<TSource> source, Func<TSource, TResult> selector)
{
var iterator = source.GetEnumerator();
while (iterator.MoveNext())
{
yield return selector(iterator.Current);
}
}
public static IEnumerable<TSource> MyWhere<TSource>(this IEnumerable<TSource> source, Func<TSource, bool> predicate)
{
var iterator = source.GetEnumerator();
while (iterator.MoveNext())
{
if (predicate(iterator.Current))
{
yield return iterator.Current;
}
}
}
public static IEnumerable<(string Name, int Age)> GetStudent()
{
yield return (Name: "小王", Age: 15);
yield return (Name: "大明", Age: 23);
yield return (Name: "老黃", Age: 39);
}
static void Main(string[] args)
{
var students = GetStudent();
var names = students.MyWhere(student => student.Age > 18).MySelect(student => student.Name);
foreach (var name in names)
{
Console.WriteLine(name);
}
Console.ReadKey();
}
原本我以為下列這行的執行順序是 Where 計算完結果後 , 在繼續執行 Select 如下圖.
students.MyWhere(student => student.Age>18).MySelect(student => student.Name);
IEnumerableIEnumerableWhere(Age大於18)Where(Age大於18)SelectSelect輸出結果輸出結果給予所有學生物件Name = "小王", Age = 15Name = "大明", Age = 23Name = "老黃", Age = 39過濾 : 大於十八歲給予大於十八歲的學生物件Name = "大明", Age = 23Name = "老黃", Age = 39轉換物件為字串給予大於十八歲的學生姓名Name = "大明"Name = "老黃"
但實際情況卻並非如此 再次使用 Visual Studio 去逐步偵錯可發現執行結果為
- 開始
- 不斷按下 F11 , 本以為會進入 GetStudent 內 ,但卻一路執行到 foreach. 原因是 students 以及 names 都是 IEnumerable<T> 型別. 在開始走訪前 , 都不會執行敘述.
- 呼叫 GetEnumerator()
- 執行 MoveNext() , 這裡指的是 names 的下一個. 但有趣的是 names 的下一個是什麼!? names 其實是從 people.Where().Select() 的結果而來的. 所以要走訪 names 就需要知道 Select() 完的結果是什麼. 因為延遲執行 , 所以 names 的 MoveNext() 會呼叫 Select(). 有點 chain 的感覺.
- 進入 Select() , 準備開始走訪.
- 當我們在 Select() 方法中 , 呼叫 MoveNext() 時會去執行 Where() 的方法內容 , 因為 source 是 Where()的結果 , 所以想要走訪 source , 就需要取得 Where() 的結果.
- 進入 Where() , 準備開始走訪.
- 同理 , where() 內的 source 是 students , 而 studnets 是來自於 GetStudent() 的結果.
- 進入 GetStudent() 內 , 並回傳第一個結果 , 小王.
- 回到 Where() , 因為小王不符合 predicate 的條件 , 因此沒進入 if 敘述內. 直接繼續執行 while(). 也就是繼續呼叫 MoveNext().
- 取得第二個結果 , 大明.
- 再次回到 Where , 並再次讓 predicate 來判斷. 大明符合條件 , 所以進入 if 區域內執行 yield return , 回傳結果.
- 回到 Select , 執行 yield retrun , 回傳 selector() 的結果.
- 回到 main , name 接收到回傳的結果.
- 印出結果大明 , 之後繼續執行 foreach , 直到 MoveNext() 回傳 false 為止
所以實際的執行順序 , 應該如下圖所示 :
foreach(走訪查詢結果)foreach(走訪查詢結果)SelectSelectWhereWheregetStudent()getStudent()取資料取資料取資料回傳資料 ("小王", 15)Age > 18 ? false繼續取資料回傳資料 ("大明", 23)Age > 18 ? true回傳資料 ("大明", 23)經過selector()轉換回傳 "大明"印出 "大明"繼續取資料 , 直到取完.
結論
- 可以透過 yield 關鍵字輕易地完成延遲執行的效果.
- 走訪的動作 , 其實是透過 IEnumerator 來達成.
- IEnumerable 型別可以作為資料集合操作.
- 大部分地 LINQ to Objects API 幾乎都是針對IEnumerable<TSource> 進行擴充.
- 回傳 IEnumerable 型別代表回傳的結果可以走訪. 但卻不會立即走訪. 會直到執行 MoveNext() , 才會開始走訪到下一個. 這也解釋了設定查詢以及執行查詢的驅動時間點不同的原因. 也就是具有延遲執行的特性.
- 因為 LinQ 具有延遲執行的特性這代表
- 設定查詢式後 , 異動來源資料的內容 , 稍後取得查詢結果時是依據最後的資料集合去做查詢的.
- 撰寫一個查詢式後 , 不論要查詢結果幾次 , 都不需要重新撰寫查詢式.
- 使用 LinQ 時需要注意是否需要立刻取得即時的查詢結果. 是否介意查詢結果會隨著查詢源的操作改變而不同.
補充 : 立即執行
雖然 LinQ 的方法有些具有延遲執行的特性 , 但有些則沒有. 像是
- 轉型類型的 API
- ToList()
- ToArray()
- ToDictionary()
- ToHashSet()
- ToLookUp()
- …?
- 回傳值為單一值
- Max()
- Min()
- Count()
- First()
- Last()
- Single()
- Average()
- Sum()
- …?
Thank you!
You can find me on
若有謬誤 , 煩請告知 , 新手發帖請多包涵