藏在foreach下的秘密: foreach原理說明

在開始使用LINQ之後,以前大量使用的foreach已經慢慢的淡出了我的螢光幕前...,我其實一直都沒意識到這一點,直到我在構思這次的文章時,才又想起了這昔日的好戰友,究竟為什麼會因為使用了LINQ而減少了foreach使用的次數呢?讓我們繼續看下去。

嘗試的第一步

總而言之我們先寫一個foreach的範例:

int[] integers = new int[] { 1, 2, 3, 4, 5, 6, 7, 8, 9 };

Console.WriteLine($"Is Array: {integers is Array}"); //Is Array: true

foreach (int integer in integers)
{
    Console.Write($"{integer} ");
} // 1 2 3 4 5 6 7 8 9 

這個是巡覽一個1~9數字的陣列,然後把這些數字印到終端的範例。

非常簡單的範例,但卻帶出了不簡單的疑問: 為什麼foreach知道要怎麼做巡覽?,可能有人已經發現我有個提示在程式碼裡:

Console.WriteLine($"Is Array: {integerArray is Array}"); //Is Array: true

因為integersArray!!,嗯...這個答案是對也是不對,因為其實有其他非Array的物件也是可以用foreach來做巡覽的,例如我改為下面這樣子:

String integers = "123456789";

Console.WriteLine($"Is Array: {integers is Array}"); //Is Array: false

foreach (char integer in integers)
{
    Console.Write($"{integer} ");
} // 1 2 3 4 5 6 7 8 9 

integersint[]改為String照樣還是可以做巡覽,這是為什麼呢?

從錯誤中學習

失敗為成功之母這句話在寫程式時一直在應證,我們總是在Trial and error中學習,現在我們要再來嘗試這個解決之道了。

首先要想辦法讓foreach出錯,我們先塞個int給它看看會發生什麼事:

int integers = 123456789;

foreach (int integer in integers)	//error
{
    Console.Write($"{integer} ");
}

Get Enumerator

YA!! 發生錯誤: because 'int' does not contain a public definition for 'GetEnumerator',找到關鍵字,原來是沒有定義GetEnumerator,那我們就來加上定義:

public class IntegerEnum
{

}

public class Integers
{
    private int _integers;

    public Integers(int integers)
    {
        _integers = integers;
    }

    public IntegerEnum GetEnumerator()
    {
        return new IntegerEnum();
    }
}

我們自定義一個Integers,有實作GetEnumeratorGetEnumerator不知道要傳回什麼,就先傳回一個什麼都沒有的IntegerEnum

接著把integers的型別改成我們自定義的Integers:

//int integers = 123456789;
Integers integers = new Integers(123456789);

再來看看會發生什麼事:

Enumerable

又發生錯誤了: 'UserQuery.IntegerEnum' of 'UserQuery.Integers.GetEnumerator()' must have a suitable public MoveNext method and public Current property

這回問題是發生在我們自定義的IntegerEnum上,它叫我們實作MoveNext方法跟Current屬性,看到這邊我突然想到了什麼,這不就是Iterator Pattern嗎?

我們之後再來講解原理,先把Integers完成:

public class IntegerEnum
{
    private int _integers;
    private int _index;
    private int _maxDigit;
    public int Current { get; private set; }

    public IntegerEnum(int integers)
    {
        _integers = integers;
        _index = 0;
        _maxDigit = (int)Math.Log10(integers);
    }

    public bool MoveNext()
    {
        if (_maxDigit < _index) return false;

        Current = getCurrent();
        _index++;

        return true;
    }

    private int getCurrent()
    {
        int currentDigit = _maxDigit - _index;
        int result = (_integers / (int)Math.Pow(10, currentDigit)) % 10;  //Get first digit

        return result;
    }
}

public class Integers
{
    private int _integers;

    public Integers(int integers)
    {
        _integers = integers;
    }

    public IntegerEnum GetEnumerator()
    {
        return new IntegerEnum(_integers);
    }
}

這樣一來我們就可以用Integer取得我們想要的資料了。

原理

費了一番心力,終於把1-9的數字給印出來了,但是他是怎麼運作的呢?我們現在就來瞧瞧吧。

Iterator Pattern

foreach的實作是Iterator Pattern,下圖為UML圖:

640px-Iterator_UML_class_diagram.svg.png

在範例程式中分別對應:

  • ConcreteAggregate: Integers
    • Iterator(): GetEnumerator()
  • ConcreteIterator: IntegerEnum
    • next(): 傳回下一個元素,在C#中是以Current來抓出目前元素
    • hasNext(): 確認是否有下一個元素,在C#中是由MoveNext()做確認

Iterator裡有個跟C#上的實作差異:

  • Ierator Pattern上是用hasNext()判斷是否有下一個元素,確定有了再Call Next()取得元素並更新index
  • c#裡是用MoveNext()判斷是否有下一個元素,確定有了之後去更新Currentindex

到這裡應該對foreach的運作上應該有個基本的認識了,但是我又會想問問題了: 那UML上方的Aggregate跟Iterator呢?

IEnumerable and IEnumerator

千呼萬喚始出來,我們整個主題的重點隆重登場,IEnumerableIEnumerator,它們其實就是UML上方的那兩塊:

  • Aggregate: IEnumerable(ConcreteAggregate的介面)
  • Iterator: IEnumerator(ConcreteIterator的介面)

所以我們的Integers其實就是實作了IEnumerable,而IntegerEnum就是實作了IEnumerator,現在我們來把這兩個介面加到剛剛的例子中:

public class IntegerEnum : IEnumerator
{
    ...

    public object Current { get; private set; }

    public bool MoveNext()
    {
        if (_maxDigit < _index) return false;

        Current = getCurrent();
        _index++;

        return true;
    }

    public void Reset()
    {
        _index = 0;
    }

    ...
}

public class Integers : IEnumerable
{
    ...

    public IEnumerator GetEnumerator()
    {
        return new IntegerEnum(_integers);
    }
}

這也是為什麼StringArrayList...等物件可以被foreach所解譯,因為這些物件都有繼承IEnumerable

運作

依照C# Spec的foreach statement說明,我們可以知道一段foreach的程式碼會被定義為下面這樣:

foreach (V v in x) embedded_statement

它會被擴充為:

{
    E e = ((C)(x)).GetEnumerator();
    try {
        while (e.MoveNext()) {
            V v = (V)(T)e.Current;
            embedded_statement
        }
    }
    finally {
        ... // Dispose e
    }
}

跟剛剛的例子對照:

  • V: int
  • v: integer
  • x: integers
  • embedded_statement: Console.Write($"{integer} ");
  • C: Collection Type: Integers
  • E: Enumerator Type: IntegerEnum
  • T: element Type: Integer

從這裡就可以明顯的看出來foreach其實就會被轉譯為Iterator Pattern的場景物件(Client)。

結語

終於把謎題解開了,為什麼我們使用了LINQ就會減少使用foreach,就是因為它們都是對IEnumerable做事情,所以本來我們需要用foreach處理資料集時,用LINQ也可以處理,自然而然好用的LINQ就變成我們的主角啦。

範例程式

GitHub

參考