[.NET]快快樂樂學LINQ系列前哨戰-延遲執行 (Deferred Execution)

[.NET]快快樂樂學LINQ系列前哨戰-延遲執行 (Deferred Execution)

前言

講到 LINQ 的基礎,當然不能跳過「延遲執行」的特性。雖然小朱這一篇文章: [.NET] LINQ 的延遲執行 (Deferred Execution) 已經寫的很深入跟清楚了,這邊會在搭配一下 LINQ 中綜合的例子再解釋一下,LINQ 中的延遲執行究竟是怎麼一回事。

這邊將小朱文章中,最該留意的一句話 highlight 出來:LINQ 在呼叫 select 時,還沒有引發查詢動作,真正引發動作會是在 foreach,也就是 IEnumerator<T>.MoveNext()。

 

延遲執行的緣由

一般的情況,程式執行到哪一行,運算式應該就會立刻被執行,如下圖所示:

1. 當呼叫 Power(1) 的時候:

image

2. 會執行 Power() 中的 number * number:

image

3 .接下來會執行 Power(2) 的部份:

image

延遲執行的意義,就是等到實際驅動點時,才會執行原本該執行的程式。(感覺上有點像 delegate 跟呼叫 Invoke() 的情況。)

這邊來看一個實際的案例,如下圖所示,針對 people 先呼叫 Where() 再呼叫 Select():

image

就一般上來說,跑完 names 這一行程式碼後,names 應該就有值了,其值為 Where(person => person.Age > 18) 也就是先把 people 中年紀大於 18 的 people 全抓出來。然後再透過 Select() 把這些「成人」的姓名全撈出來。就語意上來說,的確如此。不過實際上執行的順序就不完全如此了。

 

延遲執行的基礎 - Iterator

Iterator 的部份,可以參考一下前面的這篇文章: [.NET]快快樂樂學LINQ系列前哨戰-IEnumerable, IEnumerator, yield, Iterator 。這裡提到,當方法中使用 yield 關鍵字,C# 會自動將該 block 實作為 iterator 方式執行。來看一下簡單的例子,如下圖所示:

image

如上次文章所提到,其實 foreach(var item in items) 骨子裡就是:

  1. 呼叫 items.GetEnumerator() ,得到一個 IEnumerator ,這邊以 iterator 稱呼它。
  2. 呼叫 iterator.MoveNext() ,這時如果 IEnumerable 已經巡覽完畢,則會回傳 false 。如果還有下一個項目,則會回傳 true ,並將 current 的指標移到下一個 element 上。
  3. 接下來 yield return iterator.Current ,回到 foreach(var item in items),這一次的迴圈(也就是 iterator)取得的 item ,就是剛剛 yield return 的 iterator.Current。

這也是為什麼當大家去偵錯 foreach(var item in items) ,用逐步執行偵錯時,游標會停在:

  1. foreach: 代表要執行 foreach 骨子裡的內容。
    image
  2. items: 因為要呼叫 items 的 GetEnumerator() 。
    image
  3. in: 可以把它對應到呼叫 iterator 的 MoveNext()
    image
  4. var item: 當 MoveNext() 為 true ,代表還沒巡覽結束時,就會 yield return iterator.Current。
    image
  5. 當MoveNext() 為 false 時,代表巡覽已經結束,沒有 yield return , iterator block 結束,就會結束 foreach 迴圈。(不會停在 var item 上)

這也是為什麼, IEnumerable<T> 跟 IEnumerable 兩個介面,基本上只提供一個方法叫做 GetEnumerator() 就可以被 foreach 巡覽。

了解了 foreach 骨子裡是怎麼執行的時候,再回過頭來看小朱的文章,就會比較銜接的上。

回到我們剛剛的例子,var names = people.Where().Select(); 會怎麼執行?

這邊我先簡單示意一下,Where() 跟 Select() 裡面的基本概念程式碼。

Where() 的部份,其方法簽章為 public static IEnumerable<TSource> Where<TSource>(this IEnumerable<TSource> source, Func<TSource, bool> predicate) 。而其目的為篩選出符合 predicate 條件的 elements。其內容大致上如下:


        static IEnumerable<TSource> Where<TSource>(this IEnumerable<TSource> source, Func<TSource, bool> predicate)
        {
            var iterator = source.GetEnumerator();
            while (iterator.MoveNext())
            {
                if (predicate(iterator.Current))
                {
                    yield return iterator.Current;
                }
            }
        }

Select() 的部份,其方法簽章為 public static IEnumerable<TResult> Select<TSource, TResult>(this IEnumerable<TSource> source, Func<TSource, TResult> selector)。而其目的為將 source 的每一個型別為 TSource 的 element 投影成 TResult 。

投影一詞為 projection ,不管英文或中文都挺難懂的,我喜歡把它視為 var y = f(x); 的感覺。傳入 x ,經過一個 function 之後回傳 y 。

其內容大致上如下:

 


        static IEnumerable<TResult> Select<TSource, TResult>(this IEnumerable<TSource> source, Func<TSource, TResult> selector)
        {
            var iterator = source.GetEnumerator();
            while (iterator.MoveNext())
            {
                yield return selector(iterator.Current);
            }
        }

了解了 Where() 與 Select() 基本運作的程式碼,接下來就回到我們的 context 端程式碼,如下所示:


        private static void Main(string[] args)
        {
            var people = GetPeople();
            var names = people
                .Where(person => person.Age > 18)
                .Select(person => person.Name);

            foreach (var name in names)
            {
                Console.WriteLine(name);
            }
        }

        private static IEnumerable<Person> GetPeople()
        {
            yield return new Person { Id = 1, Name = "John", Age = 36 };
            yield return new Person { Id = 2, Name = "Bill", Age = 6 };
            yield return new Person { Id = 3, Name = "Steve", Age = 23 };
        }

原本我們以為程式碼會如下圖這樣跑 (事實上語意順序也是如此)

image

但有興趣的朋友可以拿上面的程式碼去跑跑看逐步偵錯的過程,會發現其實實際執行是倒著打的。

帶著各位看一下整個程式碼是怎麼執行的。

 

延遲執行的運作範例

讓我們從第一行開始進行偵錯,因為第一行的 GetPeople() 就有延遲執行了。

1. 第一行的 var people = GetPeople();
我們期望按下 F11 時,應該進去 GetPeople() 方法內容內的 yield return new Person ,如下圖所示:
image
但事實上按下 F11 時,是跳到下一行的 var names = people.Where().Select();

image

為什麼呢?因為 GetPeople() 中有 yield 關鍵字,因此 GetPeople() 實際執行的時間點,會在某個 iterator 的 MoveNext() 中,需要實際用到 GetPeople() 中的值時,才會執行。

 

2. 同樣的,一般來說,var names = people.Where().Select(); 我們會期望先呼叫 people 的 Where() 方法,再把 Where() 的結果,呼叫 Select() 的方法,取得最後的結果, assign 給 names。

但事實上,當我們按下 F11 時,並沒有進去 GetPeople(), Where() 或 Select() 裡面,而是直接跳到 foreach(var name in names) 這一行。

image

原因與 GetPeople() 沒有被馬上執行相同,因為 Where() 的方法內容中有 yield 關鍵字,而 Select() 方法內容中,也有 yield 關鍵字。因此這一行並不會馬上執行。到這個時候的 CodeMap 如下圖所示:

image

 

3. 重點來了,我們到了 foreach (var name in names) ,如同文章前半段針對 foreach 執行過程的說明,逐步偵錯的過程中,會先停在 foreach (var name in names) 的 in 上,如下圖所示:

image

接下來神奇的事發生了,按下 F11 之後,進去的是 Select() 的方法內容中!

為什麼會這樣?其實很簡單,這就只是個 iterator chain 的感覺而已。

foreach 的 in ,指的是 names 的 GetEnumerator() 後的 MoveNext() , names 要 MoveNext() 就得從 names 來源開始執行,而 names 是怎麼來的? names 是 people.Where().Select() 的結果而來的。

所以要巡覽 names ,得先知道 Select() 完的結果是什麼。因為延遲執行,所以 names 的 MoveNext() 時才會呼叫 Select() 。
image

image

那麼這時候 Select() 的 IEnumerable<TSource> source 怎麼來的? Select() 方法中的 source 是 people.Where() 的結果。所以,當我們在 Select() 方法中,呼叫 source.GetEnumerator().MoveNext() 時,就會實際去執行 Where() 的方法內容,因為需要取得 Where() 的結果了。

image

再按下 F11 的時候,才會進入 Where() 的方法內容中。這時候 Where() 方法中的 source 是怎麼來的? 是 people ,而 people 是怎麼來的呢? people 是 GetPeople() 的結果。所以,當我們在 Where() 方法中,呼叫 source.GetEnumerator().MoveNext() 時,就會去執行 GetPeople() 的方法,因為我們需要 people 了。

image
image

在 Where() 的 iterator.MoveNext() 按下 F11 後,就會進入 GetPeoploe() 的 yield return new Person ,並取得第一個 Person

image
image

這個時候,因為 GetPeople() 中有 yield return 一個 TSource 的 item ,因此在 Where() 中的 iterator.MoveNext() 會是 true 。且可以看到 iterator.Current 就是剛剛 GetPeople() yield return 的第一個 Person 。

image

接下來到 if (predicate(iterator.Current)) 這一行,移至定義時,才會跑到 context 端裡面的 predicate delegate 中,來驗證目前的 iterator.Current 有沒符合 Where() 的條件。

image

可以看到 Age 是 36 ,所以符合 >18 的條件,回到 Where() 方法中, yield return 這一個 Age > 18 的 Person 。

image

因為 Where() 有 yield return 一個 Person ,所以 call stack 回到 Select() 方法中,在 Select() 中的 iterator.MoveNext() 就會是 true 。

image

接下來 Select() 會 yield return selector(iterator.Current) ,因此 call stack 會回到 context 端 Select() 中的 selector delegate,取得 projection 後的結果。

image

最後 Select() yield return 給 foreach 的結果是 "John" 。

foreach 這邊其實機制是一模一樣的,foreach 的 MoveNext() 因為 Select() 有 yield return "John",所以是 true ,接下來 var name 所取得的 name ,就會是剛剛整串延遲執行中,第一個 iterator 所回傳的結果:"John"。

image

然後 foreach 迴圈就這樣直到 MoveNext() 為 false ,結束 foreach 迴圈。而 MoveNext() 為 false 的情況,就是沒有 yield return 且執行完畢,或是碰到 yield break ,這兩者都會結束 iterator block 。

所以,用 Visaul Studio 內建產生 Sequence Diagram 的工具,在延遲執行的機制底下,出來的圖會跟實際 runtime 執行的 call stack 不一樣唷。下方為自動產生的 Sequence Diagram。

image

而上面這張錯誤的 Sequence Diagram 也是大家普遍以為 LINQ 的執行方式。

以這例子來說,實際的執行順序,應該如下圖所示:

image

image

經過上面 trace 的過程,大家就不難發現,為什麼當第二筆 Bill 不符合 predicate 條件時,會直接再跟 GetPeople() 取得第三筆 Steven 。因為在 Where() 的 while (iterator.MoveNext()) 中,其 source 來源就是 GetPeople() 。

 

結論

經過這一篇文章解釋延遲執行,大家應該可以比較清楚地了解幾點:

  1. yield 關鍵字會形成 iterator block ,並帶有延遲執行效果。
  2. 為什麼大部分的 LINQ to Objects API ,也就是 Enumerable static class 上,幾乎都是針對 IEnumerable<TSource> 進行擴充
  3. 為什麼 IEnumerable 可以被巡覽
  4. IEnumerator 的作用
  5. 實際驅動延遲執行的時間點,就是在 IEnumerator<T>.MoveNext() 上。

這也是為什麼前哨戰要花這麼多篇幅跟時間,講解 foreach, IEnumerable, IEnumerator, Extension Method, delegate 跟 Lambda 了。

希望各位讀者下次再聽到別人說, LINQ 在實際執行時,是倒著打回去的,也可以會心一笑。

 

延伸議題

延遲執行基本上分成兩種,一種是 Lazy Evaluation ,另一種是 Eager Evaluation 。

Lazy Evaluation 舉例來說,如下圖所示,每一次 iterator block 才去執行相關的邏輯。

image

Eager Evaluation 則比較像下面這個例子,第一次就把值全都準備好,然後在每一次的 iterator 做 yield return:

image

這兩者對我們來說有什麼差異?

別忘了,只要講到 delegate 就有 closure 的問題,延遲執行的方式可能會影響 closure 的影響範圍。

而在 LINQ to Objects 中,哪一些情況會直接就執行呢?

  1. 轉型類型的 API ,如 ToList(), ToArray(), ToDictionary() 等等…
    因為這個動作需要巡覽每一個 element 並進行轉型,所以會直接執行。
  2. 回傳值為單一值,如 Count(), Max(), Sum(), First(), Last() 等等…
    因為要拿到彙總後的結果,或者說直接取得一個結果,自然不需要用到 yield return ,就不會有延遲執行了。

感謝

感謝同事小林與 Titan 一起設計 training 的教材!

範例下載:DeferredExecutionSample.zip


或許您會對下列培訓課程感興趣:

  1. 2019/12/21(六)~2019/12/22(日):演化式設計:測試驅動開發與持續重構 第七梯次(台北)
  2. 2020/1/4(六)~2020/1/5(日):Clean Coder:DI 與 AOP 進階實戰 第三梯次(台北)
  3. 2020/02/08(六):【針對遺留代碼加入單元測試的藝術】 第八梯次(台北)
  4. 2020/02/09(日):【極速開發+】 第九梯次(台北)
  5. 2020/02/28(五)~2020/3/1(日) C#進階設計-從重構學會高易用性與高彈性API設計 第三梯次(台北)

想收到第一手公開培訓課程資訊,或想詢問企業內訓、顧問、教練、諮詢服務的,請洽 Facebook 粉絲專頁:91敏捷開發之路