[.NET]快快樂樂學LINQ系列-Where() 簡介

[.NET]快快樂樂學LINQ系列-Where() 簡介

前言

經過了漫長的前哨戰,我們終於要開始來介紹 LINQ to Objects 的 API 了。

期望整個 API 的介紹方式,會有下列幾個步驟:

  1. 簡單說明一下實務需求
  2. 說明沒有 LINQ 時,通常會怎麼寫以滿足需求
  3. 有 LINQ 時,只要怎麼做即可
  4. 解釋該 LINQ API 的 signature
  5. 簡單說明該 API 背後,可能可以怎麼實作 (暫不考慮效能最佳化)
  6. 如果該 API 有 overload, 會盡量舉出該 overload 是為了滿足什麼樣的需求,並說明其簽章參數相關的意義。

這一篇文章,要先介紹的是 Where() ,原因是大家即使不懂 LINQ 背後的實作原理,幾乎也都有用過 LINQ 的 Where() ,知道有這東西,也有用過,也知道怎麼用,所以我挑 Where() 來當第一個 LINQ API 的例子。

 

需求

假設我們希望從一群人中,取出年紀大於 18 歲的有哪些人,該怎麼進行呢?

image

上圖轉換成 context 端的程式碼,如下所示:

image

image

 

沒有 LINQ 時怎麼做

有一群人,要找出 Age > 18 的人,當然就是用 foreach 把這一群人展開,並檢查每一個人的 Age 是否大於 18 ,若符合條件,則將這個人加入回傳的集合中。

上述的需求,轉換成程式碼,如下所示:

        private static IEnumerable<Person> GetAdults()
        {
            var people = GetPeople();

            foreach (var person in people)
            {
                if (person.Age > 18)
                {
                    yield return person;
                }
            }
        }

看起來也沒多複雜,用一個迴圈加一個判斷式來取得符合條件的資料。但如果我們的條件是要由呼叫端決定的呢?每一次的條件可能都不相同,也可能不只用到一個 property 來判斷。這時候,就是要用到前兩篇文章所介紹到的 Predicate<T> ,還記得嗎?攤開後,其實就是

public delegate bool Predicate<T>(T obj)

接下來來看一下,當有 LINQ 時,上面這段程式碼會變得多簡潔。

 

有 LINQ 時,只要這麼做

當有 LINQ 時,只需要使用 Where() 的方法,把要判斷的條件傳進去,就可以取得符合條件的集合。

image

大家可以看到,用 LINQ 只需要一行,除了寫法簡潔以外,還包含了使用 delegate 的彈性,使用 Lambda 的簡便,還有一個很大的優點是可讀性。比較一下 Where() 的語意,絕對比一個迴圈中使用一個 delegate 的判斷式,來得更好理解這段程式碼的目的。

 

Where() 的 Signature

下圖是 Where() 在 MSDN 文件上的 Signature :

image

  1. 第一個參數為 Extension Method 所擴充的型別, IEnumerable<TSource> ,代表只要「是」IEnumerable<TSource> 的型別,只要符合使用 Extension Method 的條件,都可以呼叫 Where() 方法。例如 List<T>, T[] 等集合型別,都是繼承自 IEnumerable<T>,因此這些型別都可以直接呼叫 Where()。而 source 這個參數就代表,我們要巡覽的集合來源。因此 generic type 被命名為 TSource 。
  2. 第二個參數 Func<TSource, bool> predicate ,可以看到參數的名稱為 predicate ,因為 Func<TSource, bool> 其實就完全等同於 Predicate<TSource> 。 predicate 這個參數代表,呼叫端希望過濾的條件。因此這個 delegate 會回傳 bool ,來代表目前這個 TSource 物件,是否滿足條件。
  3. 回傳 IEnumerable<TSource> ,因為 Where() 的目的只是要把符合條件的 elements 都篩選出來,因此回傳型別與一開始的 source 型別會相同,都是 IEnumerable<TSource> 。

 

簡單實作自己的  Where()

針對同樣的方法簽章,這邊來簡單說明一下 Where() 的方法可以怎麼實作。所使用到的,都是前哨戰講過的東西。

核心的程式碼很簡單,一樣用 foreach 巡覽 source (也就是 GetEnumerator() 後,呼叫 MoveNext() ,取得 iterator.Current ),接著把原本 hard-code 寫死的 person.Age > 18 的判斷式,改成 delegate 即可。如下所示:

namespace JoeyLinq
{
    public static class MyEnumerable
    {
        public 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;
                }
            }
        }        
    }
}

這邊要提醒一下各位讀者,在 MSDN 查 API 的說明時,務必了解在什麼情境下,會有什麼樣的 exception 。 Where() 的 exception 說明如下:

image

當 source 與 predicate 參數為 null 時,會 throw ArgumentNullException ,這是標準的 argument 防呆要做的事。接著來看程式碼的樣子:

namespace JoeyLinq
{
    public static class MyEnumerable
    {
        public static IEnumerable<TSource> Where<TSource>(this IEnumerable<TSource> source, Func<TSource, bool> predicate)
        {
            if (source == null)
            {
                throw new ArgumentNullException("source");
            }

            if (predicate == null)
            {
                throw new ArgumentNullException("predicate");
            }

            var iterator = source.GetEnumerator();
            while (iterator.MoveNext())
            {
                if (predicate(iterator.Current))
                {
                    yield return iterator.Current;
                }
            }
        }
    }
}

注意!看起來這樣的防呆好像是正確的,但事實上這與原本 LINQ 中的 Where() 行為並不相同。

為什麼會不相同呢?

因為這個 Where() 方法中,有 yield return 關鍵字,會把這個方法標記為 iterator block 且具有延遲執行的效果。延遲執行代表什麼?代表 argument 的檢查,得等到實際取得 Where() 結果時,才會被檢查。而我們的使用習慣,以及 LINQ 中的設計並非如此。不管有沒有延遲執行,我們都希望 argument 的檢查可以立即檢查。要怎麼做才可以讓 argument 的檢查不要等到延遲執行才檢查?

很簡單,讓 argument 檢查不要在 iterator block 中即可,如下面程式碼所示:

        public static IEnumerable<TSource> Where<TSource>(this IEnumerable<TSource> source, Func<TSource, bool> predicate)
        {
            if (source == null)
            {
                throw new ArgumentNullException("source");
            }

            if (predicate == null)
            {
                throw new ArgumentNullException("predicate");
            }

            return InternalWhere(source, predicate);            
        }

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

再強調一下,這個方法唯一的技巧,就只是把原本 hard-code 的判斷式 person.Age > 18 ,轉變成 delegate 的形式,變成 predicate(iterator.Current) ,就擁有讓呼叫端決定條件內容的彈性。

 

Where() 的多載

當使用 Where() 來封裝迴圈巡覽集合,並取出集合中滿足條件的項目時,變成一行雖然相當簡潔,但是有時候我們會碰到其他簡單的需求,用上面這個 Where() 方法簽章,卻反而綁手綁腳。

例如,當今天需要從一群排隊的人中,取出 Age 大於 18 且「他的序號是偶數」時,原本的 Where() 方法中沒法子提供序號,這時就可能導致 context 端僅為了序號,得捨棄 Where() 不用,而自己用 foreach 去做。這樣顯得有點愚蠢,當然比較厲害一點的 developer ,就會自己寫 Extension Method 來支援需要 index 的 Where() 方法。

因此,我們希望能怎麼用 Where() 來解決上述這個需求呢?如下所示:

image

很簡單,我們希望使用 delegate 來當條件時,除了可以取得 TSource 的物件以外, Where() 方法也可以提供呼叫端,這個 TSource 的 index 為何,以便呼叫端設計條件時,可以直接使用。

因此,Where() 因應這樣常見的需求,而產生了另一個多載,簽章如下:

image

可以看到,不一樣的地方就是 Func 的部份多傳入了一個 int 的參數,而這個參數就代表這個 TSource 在 source 中的 index 為何。

理解了需求,簽章的定義以及前面 Where() 方法內容的實作,這個多載的設計就顯得簡單多了,只需要把 index 傳入 predicate 中即可。實作的程式碼如下所示:

image

 

結論

從最常用的 Where() 開始解說,相信各位讀者可以了解下列幾點:

  1. 用 LINQ 比起迴圈來得簡潔、好懂、有彈性,例如 Where() 的語意跟寫法,比起一個迴圈加一個判斷式,來得更容易理解其目的。
  2. 在進行 argument 防呆時,要留意是否在 iterator block 中。
  3. 當條件需要由呼叫端來決定時,可以透過 Predicate<T> 這個 delegate 來達到彈性。

Where() 說穿了,就只是個 foreach + predicate 而已。

 

Reference

  1. MSDN Where 說明
  2. Jon Skeet: Reimplementing "Where"

感謝同事 Shelly 與 King 協助設計 training 教材。

Source Code 下載:LINQ Where Introduction.zip


blog 與課程更新內容,請前往新站位置:http://tdd.best/