.NET面試題系列[9] - IEnumerable

.NET面試題系列目錄html

什麼是IEnumerable?

IEnumerable及IEnumerable的泛型版本IEnumerable<T>是一個接口,它只含有一個方法GetEnumerator。Enumerable這個靜態類型含有不少擴展方法,其擴展的目標是IEnumerable<T>。面試

實現了這個接口的類可使用Foreach關鍵字進行迭代(迭代的意思是對於一個集合,能夠逐一取出元素並遍歷之)。實現這個接口必須實現方法GetEnumerator。數組

 

如何實現一個繼承IEnumerable的類型?

實現一個繼承IEnumerable的類型等同於實現方法GetEnumerator。想知道如何實現方法GetEnumerator,不妨思考下實現了GetEnumerator以後的類型在Foreach之下的行爲:安全

  • 能夠得到第一個或當前成員
  • 能夠移動到下一個成員
  • 能夠在集合沒有下一個成員時退出循環。

假設咱們有一個很簡單的Person類(例子來自MSDN):ide

    public class Person
    {
        public Person(string fName, string lName)
        {
            FirstName = fName;
            LastName = lName;
        }

        public string FirstName;
        public string LastName;
    }

而後咱們想構造一個沒有實現IEnumerable的類型,其儲存多個Person,而後再對這個類型實現IEnumerable。這個類型實際上的做用就至關於Person[]或List<Person>,但咱們不能使用它們,由於它們已經實現了IEnumerable,故咱們構造一個People類,模擬不少人(People是Person的複數形式)。這個類型容許咱們傳入一組Person的數組。因此它應當有一個Person[]類型的成員,和一個構造函數,其能夠接受一個Person[],而後將Person[]類型的成員填充進去做爲初始化。函數

    //People類就是Person類的集合
    //但咱們不能用List<Person>或者Person[],由於他們都實現了IEnumerable
    //咱們要本身實現一個IEnumerable
    //因此請將People類想象成List<Person>或者相似物
    public class People : IEnumerable
    {
        private readonly Person[] _people;
        public People(Person[] pArray)
        {
            //構造一個Person的集合
            _people = new Person[pArray.Length];

            for (var i = 0; i < pArray.Length; i++)
            {
                _people[i] = pArray[i];
            }
        }

        //實現IEnumerable須要實現GetEnumerator方法
        public IEnumerator GetEnumerator()
        {
            throw new NotImplementedException();
        }
    }

咱們的主函數應當是:this

        public static void Main(string[] args)
        {
            //新的Person數組
            Person[] peopleArray = 
            {
                new Person("John", "Smith"),
                new Person("Jim", "Johnson"),
                new Person("Sue", "Rabon"),
            };

            //People類實現了IEnumerable
            var peopleList = new People(peopleArray);

            //枚舉時先訪問MoveNext方法
            //若是返回真,則得到當前對象,返回假,就退出這次枚舉
            foreach (Person p in peopleList)
                Console.WriteLine(p.FirstName + " " + p.LastName);
        }

但如今咱們的程序不能運行,由於咱們還沒實現GetEnumerator方法。spa

實現方法GetEnumerator

GetEnumerator方法須要一個IEnumerator類型的返回值,這個類型是一個接口,因此咱們不能這樣寫:線程

return new IEnumerator();

由於咱們不能實例化一個接口。咱們必須再寫一個類PeopleEnumerator,它繼承這個接口,實現這個接口全部的成員:Current屬性,兩個方法MoveNext和Reset。因而咱們的代碼又變成了這樣:調試

        //實現IEnumerable須要實現GetEnumerator方法
        public IEnumerator GetEnumerator()
        {
            return new PeopleEnumerator();
        }

在類型中:

    public class PeopleEnumerator : IEnumerator
    {
        public bool MoveNext()
        {
            throw new NotImplementedException();
        }

        public void Reset()
        {
            throw new NotImplementedException();
        }

        public object Current { get; }
    }

如今問題轉移爲實現兩個方法,它們的功能看上去一目瞭然:一個負責將集合中Current向後移動一位,一個則將Current初始化爲0。咱們能夠查看IEnumerator元數據,其解釋十分清楚:

  • Enumerator表明一個相似箭頭的東西,它指向這個集合當前迭代指向的成員
  • IEnumerator接口類型對非泛型集合實現迭代
  • Current表示集合當前的元素,咱們須要用它僅有的get方法取得當前元素
  • MoveNext方法根據Enumerator是否能夠繼續向後移動返回真或假
  • Reset方法將Enumerator移到集合的開頭

經過上面的文字,咱們能夠理解GetEnumerator方法,就是得到當前Enumerator指向的成員。咱們引入一個整型變量position來記錄當前的位置,而且先試着寫下:

    public class PeopleEnumerator : IEnumerator
    {
        public Person[] _peoples;
        public object Current { get; }

        //當前位置
        public int position;

        //構造函數接受外部一個集合並初始化本身內部的屬性_peoples
        public PeopleEnumerator(Person[] peoples)
        {
            _peoples = peoples;           
        }
        
        //若是沒到集合的尾部就移動position,返回一個bool
        public bool MoveNext()
        {
            if (position < _peoples.Length)
            {
                position++;
                return true;
            }
            return false;
        }

        public void Reset()
        {
            position = 0;
        }
    }

這看上去好像沒問題,但一執行以後卻發現:

  • 當執行到MoveNext方法時,position會先增長1,這致使第一個元素(在位置0)會被遺漏,故position的初始值應當爲-1而不是0
  • 當前位置變量position顯然應該是私有的
  • 須要編寫Current屬性的get方法取出當前位置(position)上的集合成員

經過不斷的調試,最後完整的實現應當是:

public class PeopleEnumerator : IEnumerator
{
        public Person[] People;

        //每次運行到MoveNext或Reset時,利用get方法自動更新當前位置指向的對象
        object IEnumerator.Current
        {
            get
            {
                try
                {
                    //當前位置的對象
                    return People[_position];
                }
                catch (IndexOutOfRangeException)
                {
                    throw new InvalidOperationException();
                }
            }
        }

        //當前位置
        private int _position = -1;

        public PeopleEnumerator(Person[] people)
        {
            People = people;           
        }

        //當程序運行到foreach循環中的in時,就調用這個方法得到下一個person對象
        public bool MoveNext()
        {
            _position++;
            //返回一個布爾值,若是爲真,則說明枚舉沒有結束。
            //若是爲假,說明已經到集合的結尾,就結束這次枚舉
            return (_position < People.Length);
        }

        public void Reset() => _position = -1;
    }

爲何當程序運行到in時,會呼叫方法MoveNext呢?咱們並無直接調用這個方法啊?當你試圖查詢IL時,就會獲得答案。實際上下面兩段代碼的做用是相同的:

foreach (T item in collection)
{
  ...
}
IEnumerator<T> enumerator = collection.GetEnumerator();
while (enumerator.MoveNext())
{
  T item = enumerator.Current;
  ...
}

注意:第二段代碼中,沒有呼叫Reset方法,也不須要呼叫。當你呼叫時,你會獲得一個異常,這是由於編譯器沒有實現該方法。

使用yield關鍵字實現方法GetEnumerator

若是iterator自己有實現IEnumerator接口(本例就是一個數組),則能夠有更容易的方法:

        public IEnumerator GetEnumerator()
        {
            return _people.GetEnumerator();
        }

注意,這個方法沒有Foreach的存在,因此若是你改用for循環去迭代這個集合,你得本身去呼叫MoveNext,而後得到集合的下一個成員。並且會出現一個問題,就是你沒法知道集合的大小(IEnumerable沒有Count方法,只有IEnumerable<T>纔有)。此時,能夠作個試驗,若是咱們知道一個集合有3個成員,故意迭代多幾回,好比迭代10次,那麼當集合已經到達尾部時,將會拋出InvalidOperationException異常。

    class Program
    {
        static void Main(string[] args)
        {
            Person p1 = new Person("1");
            Person p2 = new Person("2");
            Person p3 = new Person("3");

            People p = new People(new Person[3]{p1, p2, p3});
            var enumerator = p.GetEnumerator();

            //Will throw InvalidOperationException
            for (int i = 0; i < 5; i++)
            {
                enumerator.MoveNext();
                if (enumerator.Current != null)
                {
                    var currentP = (Person) enumerator.Current;
                    Console.WriteLine("current is {0}", currentP.Name);
                }
            }

            Console.ReadKey();
        }
    }

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

        public Person(string name)
        {
            Name = name;
        }
    }

    public class People : IEnumerable
    {
        private readonly Person[] _persons;

        public People(Person[] persons)
        {
            _persons = persons;
        }

        public IEnumerator GetEnumerator()
        {
            return _persons.GetEnumerator();
        }
    }

使用yield關鍵字配合return,編譯器將會自動實現繼承IEnumerator接口的類和上面的三個方法。並且,當for循環遍歷超過集合大小時,不會拋出異常,Current會一直停留在集合的最後一個元素。

        public IEnumerator GetEnumerator()
        {
            foreach (Person p in _people)
                yield return p;
        }

若是咱們在yield的上面加一句:

        public IEnumerator GetEnumerator()
        {
            foreach (var p in _persons)
            {
                Console.WriteLine("test");
                yield return p;
            }
        }

咱們會發現test只會打印三次。後面由於已經沒有新的元素了,yield也就不執行了,整個Foreach循環將什麼都不作。

yield的延遲執行特性 – 本質上是一個狀態機

關鍵字yield只有當真正須要迭代並取到元素時纔會執行。yield是一個語法糖,它的本質是爲咱們實現IEnumerator接口。

        static void Main(string[] args)
        {
            IEnumerable<string> items = GetItems();
            Console.WriteLine("Begin to iterate the collection.");
            var ret = items.ToList();
            Console.ReadKey();
        }

        static IEnumerable<string> GetItems()
        {
            Console.WriteLine("Begin to invoke GetItems()");
            yield return "1";
            yield return "2";
            yield return "3";
        }

在上面的例子中,儘管咱們呼叫了GetItems方法,先打印出來的句子倒是主函數中的句子。這是由於只有在ToList時,才真正開始進行迭代,得到迭代的成員。咱們可使用ILSpy察看編譯後的程序集的內容,並在View -> Option的Decompiler中,關閉全部的功能對勾(不然你將仍然只看到一些yield),而後檢查Program類型,咱們會發現編譯器幫咱們實現的MoveNext函數,其實是一個switch。第一個yield以前的全部代碼,通通被放在了第一個case中。

     bool IEnumerator.MoveNext()
     {
        bool result;
        switch (this.<>1__state)
        {
        case 0:
            this.<>1__state = -1;
            Console.WriteLine("Begin to invoke GetItems()");
            this.<>2__current = "1";
            this.<>1__state = 1;
            result = true;
            return result;
        case 1:
            this.<>1__state = -1;
            this.<>2__current = "2";
            this.<>1__state = 2;
            result = true;
            return result;
        case 2:
            this.<>1__state = -1;
            this.<>2__current = "3";
            this.<>1__state = 3;
            result = true;
            return result;
        case 3:
            this.<>1__state = -1;
            break;
        }
        result = false;
        return result;
    }

若是某個yield以前有其餘代碼,它會自動包容到它最近的後續的yield的「統治範圍」:

        static IEnumerable<string> GetItems()
        {
            Console.WriteLine("Begin to invoke GetItems()");
            Console.WriteLine("Begin to invoke GetItems()");
            yield return "1";
            Console.WriteLine("Begin to invoke GetItems()");
            yield return "2";
            Console.WriteLine("Begin to invoke GetItems()");
            Console.WriteLine("Begin to invoke GetItems()");
            Console.WriteLine("Begin to invoke GetItems()");
            yield return "3";
        }

它的編譯結果也是能夠預測的:

        case 0:
            this.<>1__state = -1;
            Console.WriteLine("Begin to invoke GetItems()");
            Console.WriteLine("Begin to invoke GetItems()");
            this.<>2__current = "1";
            this.<>1__state = 1;
            result = true;
            return result;
        case 1:
            this.<>1__state = -1;
            Console.WriteLine("Begin to invoke GetItems()");
            this.<>2__current = "2";
            this.<>1__state = 2;
            result = true;
            return result;
        case 2:
            this.<>1__state = -1;
            Console.WriteLine("Begin to invoke GetItems()");
            Console.WriteLine("Begin to invoke GetItems()");
            Console.WriteLine("Begin to invoke GetItems()");
            this.<>2__current = "3";
            this.<>1__state = 3;
            result = true;
            return result;
        case 3:
            this.<>1__state = -1;
            break;

這也就解釋了爲何第一個打印出來的句子在主函數中,由於全部不是yield的代碼通通都被yield吃掉了,併成爲狀態機的一部分。而在迭代開始以前,代碼是沒法運行到switch分支的。

使人矚目的是,編譯器沒有實現reset方法,這意味着不支持屢次迭代:

    void IEnumerator.Reset()
    {
        throw new NotSupportedException();
    }

這部分的文章還能夠參考http://www.alloyteam.com/2016/02/generators-in-depth/

 

yield只返回,不賦值

下面這個例子來自http://www.cnblogs.com/artech/archive/2010/10/28/yield.html#!comments。不過我認爲Artech大大分析的不是很好,我給出本身的解釋。

class Program
    {
        static void Main(string[] args)
        {
            IEnumerable<Vector> vectors = GetVectors();

            //Begin to call GetVectors
            foreach (var vector in vectors)
            {
                vector.X = 4;
                vector.Y = 4;
            }

            //Before this iterate, there are 3 members in vectors, all with X and Y = 4
            foreach (var vector in vectors)
            {
                //But this iterate will change the value of X and Y BACK to 1/2/3
                Console.WriteLine(vector);
            }
        }

        static IEnumerable<Vector> GetVectors()
        {
            yield return new Vector(1, 1);
            yield return new Vector(2, 3);
            yield return new Vector(3, 3);
        }
    }
    public class Vector
    {
        public double X { get; set; }
        public double Y { get; set; }
        public Vector(double x, double y)
        {
            this.X = x;
            this.Y = y;
        }

        public override string ToString()
        {
            return string.Format("X = {0}, Y = {1}", this.X, this.Y);
        }
    }

咱們進行調試,並將斷點設置在第二次迭代以前,此時,咱們發現vector的值確實變成4了,但第二次迭代以後,值又回去了,好像被改回來了同樣。但實際上,並無改任何值,yield只是老老實實的吐出了新的三個vector而已。Yield就像一個血汗工廠,不停的製造新值,不會修改任何值。

從編譯後的代碼咱們發現,只要咱們經過foreach迭代一個IEnumerable,咱們就會跑到GetVectors方法中,而每次運行GetVectors方法,yield都只會返回全新的三個值爲(1,1),(2,2)和(3,3)的vector,彷彿第一次迭代徹底沒有運行過同樣。原文中,也有實驗證實了vector建立了六次,實際上每次迭代都會建立三個新的vector

解決這個問題的方法是將IEnumerable轉爲其子類型例如List或數組。

在迭代的過程當中改變集合的狀態

foreach迭代時不能直接更改集合成員的值,但若是集合成員是類或者結構,則能夠更改其屬性或字段的值。不能在爲集合刪除或者增長成員,這會出現運行時異常。For循環則能夠。

            var vectors = GetVectors().ToList();

            foreach (var vector in vectors)
            {
                if (vector.X == 1)
                    //Error
                    //vectors.Remove(vector);

                    //This is OK
                    vector.X = 99;

                Console.WriteLine(vector);
            }

IEnumerable的缺點

  • IEnumerable功能有限,不能插入和刪除。
  • 訪問IEnumerable只能經過迭代,不能使用索引器。迭代顯然是非線程安全的,每次IEnumerable都會生成新的IEnumerator,從而造成多個互相不影響的迭代過程。
  • 在迭代時,只能前進不能後退。新的迭代不會記得以前迭代後值的任何變化。
相關文章
相關標籤/搜索