.NET面试题系列[9] - IEnumerable

.NET面试题系列目录

什么是IEnumerable?

IEnumerable及IEnumerable的泛型版本IEnumerable<T>是一个接口,它只含有一个方法GetEnumerator。Enumerable这个静态类型含有很多扩展方法,其扩展的目标是IEnumerable<T>。

实现了这个接口的类可以使用Foreach关键字进行迭代(迭代的意思是对于一个集合,可以逐一取出元素并遍历之)。实现这个接口必须实现方法GetEnumerator。

如何实现一个继承IEnumerable的类型?

实现一个继承IEnumerable的类型等同于实现方法GetEnumerator。想知道如何实现方法GetEnumerator,不妨思考下实现了GetEnumerator之后的类型在Foreach之下的行为:

  • 可以获得第一个或当前成员
  • 可以移动到下一个成员
  • 可以在集合没有下一个成员时退出循环。

假设我们有一个很简单的Person类(例子来自MSDN):

    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();
        }
    }

我们的主函数应当是:

        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方法。

实现方法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循环则可以。

IEnumerable的缺点

  • IEnumerable功能有限,不能插入和删除。
  • 访问IEnumerable只能通过迭代,不能使用索引器。迭代显然是非线程安全的,每次IEnumerable都会生成新的IEnumerator,从而形成多个互相不影响的迭代过程。
  • 在迭代时,只能前进不能后退。新的迭代不会记得之前迭代后值的任何变化。
时间: 2024-10-13 10:40:13

.NET面试题系列[9] - IEnumerable的相关文章

.NET面试题系列[10] - IEnumerable的派生类

.NET面试题系列目录 IEnumerable分为两个版本:泛型的和非泛型的.IEnumerable只有一个方法GetEnumerator.如果你只需要数据而不打算修改它,不打算为集合插入或删除任何成员(例如从远端拿回数据显示),则你不需要任何比IEnumerable更复杂的接口. ICollection继承IEnumerable.可以使用Count方法统计集合的大小.(注意非泛型版本的ICollection并没有Add,Remove等方法)但在实际情况中,我们通常使用ICollection的继

.NET面试题系列[11] - IEnumerable&lt;T&gt;的派生类

“你每次都选择合适的数据结构了吗?” - Jeffery Zhao .NET面试题系列目录 ICollection<T>继承IEnumerable<T>.在其基础上,增加了Add,Remove等方法,可以修改集合的内容.IEnumerable<T>的直接继承者还有Stack<T>和Queue<T>. 所有标准的泛型集合都实现了ICollection<T>.主要的几个继承类有IList<T>,IDictionary<K

【转载】.NET面试题系列[0] - 写在前面

原文:.NET面试题系列[0] - 写在前面 索引: .NET框架基础知识[1] - .NET框架基础知识(1) http://www.cnblogs.com/haoyifei/p/5643689.html .NET框架基础知识[2] - .NET框架基础知识(2) http://www.cnblogs.com/haoyifei/p/5646288.html .NET框架基础知识[3] - C# 基础知识(1) - http://www.cnblogs.com/haoyifei/p/565054

.NET面试题系列[13] - LINQ to Object

.NET面试题系列目录 名言警句 "C# 3.0所有特性的提出都是更好地为LINQ服务的" - Learning Hard LINQ是Language Integrated Query(语言集成查询)的缩写,读音和单词link相同.不要读成“lin-Q”. LINQ to Object将查询语句转换为委托.LINQ to Entity将查询语句转换为表达式树,然后再转换为SQL. LINQ的好处:强类型,相比SQL语句它更面向对象,对于所有的数据库给出了统一的操作方式. LINQ的一些

.NET面试题系列[15] - LINQ:性能

.NET面试题系列目录 当你使用LINQ to SQL时,请使用工具(比如LINQPad)查看系统生成的SQL语句,这会帮你发现问题可能发生在何处. 提升性能的小技巧 避免遍历整个序列 当我们仅需要一个资料的时候,我们可以考虑使用First / FirstOrDefault / Take / Any等方法,它们都会在取得合乎要求的资料后退出,而不会遍历整个序列(除非最后一个资料才是合乎要求的哈哈).而类似ToList / Max / Last / Sum / Contain等方法显而易见会遍历整

.NET面试题系列[0] - 写在前面

经过了四年的工作,我已经跳槽6次了.这四年我全部都使用C#进行开发,除了获得到的offer之外,还面试失败或拒掉了不少offer,加起来面试的次数至少有30次.这些面试有质量很高的,也有泛泛而谈的,不同面试有时候还会问到几乎相同的问题,通过对问题的深入程度,可以大致判断一家公司和面试官的水平. 跳槽就是为了更好的生活.而改善生活需要有强大的业务能力,说服面试官在众多候选人中选择你作为胜利者.在若干年的工作学习中,我的水平也慢慢上升,一开始是什么都不会,后来会一些东西,到现在也可以从面试官的问题中

.NET面试题系列

索引: .NET框架基础知识[1] - http://www.cnblogs.com/haoyifei/p/5643689.html .NET框架基础知识[2] - http://www.cnblogs.com/haoyifei/p/5646288.html 经过了四年的工作,我已经跳槽6次了.这四年我全部都使用C#进行开发,除了获得到的offer之外,还面试失败或拒掉了不少offer,加起来面试的次数至少有30次.这些面试有质量很高的,也有泛泛而谈的,不同面试有时候还会问到几乎相同的问题,通过

iOS面试题系列之Objective-C相关

1.简述你项目中常用的设计模式.它们有什么优缺点? 常用的设计模式有:代理.观察者.单例. (1)单例:它是用来限制一个类只能创建一个对象.这个对象中的属性可以存储全局共享的数据.所有的类都能访问.设置此单例中的属性数据. 优点:是它只会创建一个对象容易供外界访问,节约性能. 缺点:是一个类只有一个对象,可能造成责任过重,在一定程度上违背了"单一职责原则".单例模式中没有抽象层,所以单例类的扩展有很大的困难.不能过多创建单例,因为单例从创建到程序关闭前会一直存在,过多的单例会影响性能,

.NET面试题系列[16] - 多线程概念(1)

.NET面试题系列目录 这篇文章主要是各个百科中的一些摘抄,简述了进程和线程的来源,为什么出现了进程和线程. 操作系统层面中进程和线程的实现 操作系统发展史 直到20世纪50年代中期,还没出现操作系统,计算机工作采用手工操作方式.程序员将对应于程序和数据的已穿孔未的纸带(或卡片)装入输入机,然后启动输入机把程序和数据输入计算机内存,接着通过控制台开关启动程序针对数据运行:计算完毕,打印机输出计算结果:用户取走结果并卸下纸带(或卡片)后,才让下一个用户上机. 手工操作方式的两个特点: (1)用户独