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

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

前言

發現蠻多朋友雖然很常用 LINQ to Objects 的方法,卻很少用到 Zip() ,個人覺得很可能是方法名字不夠直覺,但實務上其實也有蠻多場景適合使用 Zip() 來合併兩個集合。

 

需求與範例

這邊以一個發票配號的簡單例子來說明需求:

  1. 有一個 Customer 的集合,假設裡面有 3 個 Customer 。
  2. 有一個 InvoiceNumber 的集合,假設裡面有 4 個發票號碼等待配號。
  3. 希望得到的結果是 3 個帶著 Customer Name 與 發票號碼資訊的物件。
  4. 如果是 3 個 Customer, 2 個發票號碼,則希望得到的結果是 2 個帶著 Customer Name 與 發票號碼資訊的物件。

看一下範例程式:


    [TestClass]
    public class UnitTest1
    {
        [TestMethod]
        public void TestMethod1()
        {
            var customers = new List<Customer>
            {
                new Customer{Name="Joey"},
                new Customer{Name="Kevin"},
                new Customer{Name="Ian"},
            };

            var invoiceNumbers = new List<string>
            {
                "AZ001","AZ007", "AZ101", "AZ999"
            };

            var expected = new List<Tuple<string, string>>
            {
                Tuple.Create("Joey", "AZ001"),
                Tuple.Create("Kevin", "AZ007"),
                Tuple.Create("Ian", "AZ101"),
            };

            List<Tuple<string, string>> actual = this.DispatchInvoice(customers, invoiceNumbers);

            Assert.IsTrue(expected.SequenceEqual(actual));
        }

        private List<Tuple<string, string>> DispatchInvoice(List<Customer> customers, List<string> invoiceNumbers)
        {
            var upbound = customers.Count < invoiceNumbers.Count ? customers.Count : invoiceNumbers.Count;
            var result = new List<Tuple<string, string>>();

            for (int i = 0; i < upbound; i++)
            {
                result.Add(Tuple.Create(customers[i].Name, invoiceNumbers[i]));
            }

            return result;
        }
    }

    public class Customer
    {
        public string Name { get; set; }
    }

可以看到,這樣的需求,如果不用 Zip() ,基本上大部分選擇的作法,就是要用 for 迴圈,透過 index 來選擇兩個 ICollection 的 item 進行結合。而 index 的上限,取決於兩個 ICollection 長度較短的那一個集合長度。

通常不會用 foreach ,因為一個 foreach 無法同時取得兩個 ICollection 的 item 。但用 for 迴圈看起來又有點愚蠢。

而這需求不容易使用 Select() 來做,因為 Select() 比較像是多個 element 使用同一個 selector delegate 來投射成新的物件,除非使用 Select() 的多載,一樣透過 index 來取代剛剛的 for 迴圈,如下所示:


        private List<Tuple<string, string>> DispatchInvoice(List<Customer> customers, List<string> invoiceNumbers)
        {
            //var upbound = customers.Count < invoiceNumbers.Count ? customers.Count : invoiceNumbers.Count;
            //var result = new List<Tuple<string, string>>();

            //for (int i = 0; i < upbound; i++)
            //{
            //    result.Add(Tuple.Create(customers[i].Name, invoiceNumbers[i]));
            //}

            //return result;

            // 使用 Select()
            if (customers.Count < invoiceNumbers.Count)
            {
                return customers.Select((c, index) => Tuple.Create(c.Name, invoiceNumbers[index])).ToList();
            }
            else
            {
                return invoiceNumbers.Select((n, index) => Tuple.Create(customers[index].Name, n)).ToList();
            }
        }

雖然用了 Select() 的多載,但仍卡在要判斷哪一個集合長度較短,來決定要以哪一個集合為 source ,而且這幾乎是為了用 Select() 而用 Select() ,看起來很酷,卻還不如 for 迴圈來得簡單好懂。

這個需求就是使用 Zip() 的標準場景,透過 Zip() 改寫,只要簡單一行程式碼即可。如下所示:


        private List<Tuple<string, string>> DispatchInvoice(List<Customer> customers, List<string> invoiceNumbers)
        {
            //var upbound = customers.Count < invoiceNumbers.Count ? customers.Count : invoiceNumbers.Count;
            //var result = new List<Tuple<string, string>>();

            //for (int i = 0; i < upbound; i++)
            //{
            //    result.Add(Tuple.Create(customers[i].Name, invoiceNumbers[i]));
            //}

            //return result;

            //// 使用 Select()
            //if (customers.Count < invoiceNumbers.Count)
            //{
            //    return customers.Select((c, index) => Tuple.Create(c.Name, invoiceNumbers[index])).ToList();
            //}
            //else
            //{
            //    return invoiceNumbers.Select((n, index) => Tuple.Create(customers[index].Name, n)).ToList();
            //}

            // 使用 Zip()
            return customers.Zip(invoiceNumbers, (c, n) => Tuple.Create(c.Name, n)).ToList();
        }

模擬 Zip() 實作方式

瞭解前面 LINQ 系列的基底時,不難想像 Zip() 骨子裡的實作有多簡單,原本卡在 foreach 沒有 index ,以及 for 迴圈用 index 來取得 Collection item 太醜的這些問題,在 IEnumerable<T> 裡完全不是問題。因為 index 本來就只有 ICollection 才有,在 IEnumerable<T> 中,只有 GetEnumerator(), MoveNext()Current 取 item 三個方式。

只要讓兩個 IEnumerable<T> 一起跑 MoveNext()Current ,把這兩個 item 餵給 resultSelector 的委派即可。而 MoveNext() 中止條件就是其中一個集合已經跑完了,就結束這個 yield 。


    [TestClass]
    public class UnitTest1
    {
        [TestMethod]
        public void TestMethod1()
        {
            var customers = new List<Customer>
            {
                new Customer{Name="Joey"},
                new Customer{Name="Kevin"},
                new Customer{Name="Ian"},
            };

            var invoiceNumbers = new List<string>
            {
                "AZ001","AZ007", "AZ101", "AZ999"
            };

            var expected = new List<Tuple<string, string>>
            {
                Tuple.Create("Joey", "AZ001"),
                Tuple.Create("Kevin", "AZ007"),
                Tuple.Create("Ian", "AZ101"),
            };

            List<Tuple<string, string>> actual = this.DispatchInvoice(customers, invoiceNumbers);

            Assert.IsTrue(expected.SequenceEqual(actual));
        }

        private List<Tuple<string, string>> DispatchInvoice(List<Customer> customers, List<string> invoiceNumbers)
        {
            //var upbound = customers.Count < invoiceNumbers.Count ? customers.Count : invoiceNumbers.Count;
            //var result = new List<Tuple<string, string>>();

            //for (int i = 0; i < upbound; i++)
            //{
            //    result.Add(Tuple.Create(customers[i].Name, invoiceNumbers[i]));
            //}

            //return result;

            //// 使用 Select()
            //if (customers.Count < invoiceNumbers.Count)
            //{
            //    return customers.Select((c, index) => Tuple.Create(c.Name, invoiceNumbers[index])).ToList();
            //}
            //else
            //{
            //    return invoiceNumbers.Select((n, index) => Tuple.Create(customers[index].Name, n)).ToList();
            //}

            //// 使用 Zip()
            //return customers.Zip(invoiceNumbers, (c, n) => Tuple.Create(c.Name, n)).ToList();

            // 使用 MyZip()
            return customers.MyZip(invoiceNumbers, (c, n) => Tuple.Create(c.Name, n)).ToList();
        }
    }

    public static class MyLinqExtension
    {
        public static IEnumerable<TResult> MyZip<TFirst, TSecond, TResult>(
        this IEnumerable<TFirst> first,
        IEnumerable<TSecond> second,
        Func<TFirst, TSecond, TResult> resultSelector)
        {
            using (IEnumerator<TFirst> firstIterator = first.GetEnumerator())
            using (IEnumerator<TSecond> secondIterator = second.GetEnumerator())
            {
                while (firstIterator.MoveNext() && secondIterator.MoveNext())
                {
                    yield return resultSelector(firstIterator.Current, secondIterator.Current);
                }
            }
        }
    }

有些時候,回到原點反而很簡單!

 

結論

如果看到程式碼是在針對兩個集合的每一個 item 進行結合與處理,而處理方式是使用 for 迴圈 + index ,甚至於 foreach 迴圈 + 硬幹 index 時,考慮一下是不是能直接用 Zip 搭配 resultSelector 漂亮的解決這個需求或重構程式碼。

另外補充的是,結合兩個集合,可能還有其他常見的方式:

  1. Concat()Concat() 是用來把兩個同型別的 element 集合串起來。在這個例子,一個是 Customer, 一個是 string ,所以不適用。
  2. Join()Join() 需要兩個集合有關連的 key 來做關連。在這個例子, Customer 與 string 沒有可用來關連的 key ,所以不適用。
  3. Union()Union() 也是針對相同型別的兩個集合來串接,但會把重複的 element 排除掉。

希望這一篇對大家來說,真的有快快樂樂學 LINQ 的感覺

 

Reference

  1. Enumerable.Zip<TFirst, TSecond, TResult> 方法
  2. Reimplementing LINQ to Objects: Part 35 - Zip

對敏捷開發有興趣的朋友,可以參考我的粉絲專頁:91敏捷開發之路

對 TDD 課程有興趣的朋友,課程內容、大綱與學員心得,可以參考 skilltree 的公開課程:自動測試與 TDD 實務開發

若需要聯絡我,可以透過粉絲專頁私訊或是側欄的關於我。