LinQ基礎 - 延遲執行(Deferred Execution)

LinQ基礎 - 延遲執行(Deferred Execution)

原始文章將記錄於此
https://github.com/s0920832252/LinQ-Note

延遲執行的基礎 - 疊代器 & 走訪

詳細請參考下列文章.

這邊會用一個例子快速回顧 , 以下是自定義類別

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 的執行順序其實是

  1. 進入 foreach

  2. 執行 GetEnumerator() , 得到一個 IEnumerator

  3. 執行 MoveNext() , 以判斷走訪是否結束. 若尚未結束則將 Current 屬性移動到下一個元素.

  4. 回傳 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 = "老黃"

但實際情況卻並非如此 :warning: 再次使用 Visual Studio 去逐步偵錯可發現執行結果為

  1. 開始
  1. 不斷按下 F11 , 本以為會進入 GetStudent 內 ,但卻一路執行到 foreach. 原因是 students 以及 names 都是 IEnumerable<T> 型別. 在開始走訪前 , 都不會執行敘述.
  1. 呼叫 GetEnumerator()
  1. 執行 MoveNext() , 這裡指的是 names 的下一個. 但有趣的是 names 的下一個是什麼!? names 其實是從 people.Where().Select() 的結果而來的. 所以要走訪 names 就需要知道 Select() 完的結果是什麼. 因為延遲執行 , 所以 names 的 MoveNext() 會呼叫 Select(). 有點 chain 的感覺.
  1. 進入 Select() , 準備開始走訪.
  1. 當我們在 Select() 方法中 , 呼叫 MoveNext() 時會去執行 Where() 的方法內容 , 因為 source 是 Where()的結果 , 所以想要走訪 source , 就需要取得 Where() 的結果.
  1. 進入 Where() , 準備開始走訪.
  1. 同理 , where() 內的 source 是 students , 而 studnets 是來自於 GetStudent() 的結果.
  1. 進入 GetStudent() 內 , 並回傳第一個結果 , 小王.
  1. 回到 Where() , 因為小王不符合 predicate 的條件 , 因此沒進入 if 敘述內. 直接繼續執行 while(). 也就是繼續呼叫 MoveNext().
  1. 取得第二個結果 , 大明.
  1. 再次回到 Where , 並再次讓 predicate 來判斷. 大明符合條件 , 所以進入 if 區域內執行 yield return , 回傳結果.
  1. 回到 Select , 執行 yield retrun , 回傳 selector() 的結果.
  1. 回到 main , name 接收到回傳的結果.
  1. 印出結果大明 , 之後繼續執行 foreach , 直到 MoveNext() 回傳 false 為止

所以實際的執行順序 , 應該如下圖所示 :

foreach(走訪查詢結果)foreach(走訪查詢結果)SelectSelectWhereWheregetStudent()getStudent()取資料取資料取資料回傳資料 ("小王", 15)Age > 18 ? false繼續取資料回傳資料 ("大明", 23)Age > 18 ? true回傳資料 ("大明", 23)經過selector()轉換回傳 "大明"印出 "大明"繼續取資料 , 直到取完.

結論

  1. 可以透過 yield 關鍵字輕易地完成延遲執行的效果.
  2. 走訪的動作 , 其實是透過 IEnumerator 來達成.
  3. IEnumerable 型別可以作為資料集合操作.
  4. 大部分地 LINQ to Objects API 幾乎都是針對IEnumerable<TSource> 進行擴充.
  5. 回傳 IEnumerable 型別代表回傳的結果可以走訪. 但卻不會立即走訪. 會直到執行 MoveNext() , 才會開始走訪到下一個. 這也解釋了設定查詢以及執行查詢的驅動時間點不同的原因. 也就是具有延遲執行的特性.
  6. 因為 LinQ 具有延遲執行的特性這代表
    • 設定查詢式後 , 異動來源資料的內容 , 稍後取得查詢結果時是依據最後的資料集合去做查詢的.
    • 撰寫一個查詢式後 , 不論要查詢結果幾次 , 都不需要重新撰寫查詢式.
  7. 使用 LinQ 時需要注意是否需要立刻取得即時的查詢結果. 是否介意查詢結果會隨著查詢源的操作改變而不同.

補充 : 立即執行

雖然 LinQ 的方法有些具有延遲執行的特性 , 但有些則沒有. 像是

  1. 轉型類型的 API
    • ToList()
    • ToArray()
    • ToDictionary()
    • ToHashSet()
    • ToLookUp()
    • …?
  2. 回傳值為單一值
    • Max()
    • Min()
    • Count()
    • First()
    • Last()
    • Single()
    • Average()
    • Sum()
    • …?

Thank you!

You can find me on

若有謬誤 , 煩請告知 , 新手發帖請多包涵

:100: :muscle: :tada: :sheep: