【基础】迭代器详解

一、前言

在我们的日常工作中,使用foreach循环对集合进行迭代操作,是最常用的操作之一。有时我们会遇到这样的需求,在遍历迭代元素集合的过程中,根据需求去筛选修改元素,于是就顺手使用foreach进行迭代并修改,当然编译的时候会报错,提示我们在迭代的过程重视不允许对元素进行修改的,此时我们关心的是业务逻辑而并非代码本身,于是我们掉头寻找其他的解决方案。下面我们就来看看foreach迭代器的工作过程。

二、提出问题

foreach背后的原理是什么?

foreach循环中为什么只能读数据,不能修改数据?

如果想实现foreach遍历,必须要实现IEnumberable接口么?

可以自己实现在foreach中修改数据么?

三、自己实现迭代器

首先通过反编译来看一下迭代器代码:

 1 namespace System.Collections.Generic
 2 {
 3     using System.Collections;
 4     using System.Runtime.CompilerServices;
 5     
 6     [TypeDependency("System.SZArrayHelper"), __DynamicallyInvokable]
 7     public interface IEnumerable<out T> : IEnumerable
 8     {
 9         [__DynamicallyInvokable]
10         IEnumerator<T> GetEnumerator();
11     }
12 }
IEnumerable接口很简单,只包含了一个返回类型为IEnumerator的GetEnumerator方法。
 1 namespace System.Collections
 2 {
 3     using System;
 4     using System.Runtime.InteropServices;
 5     
 6     [Guid("496B0ABF-CDEE-11d3-88E8-00902754C43A"), ComVisible(true), __DynamicallyInvokable]
 7     public interface IEnumerator
 8     {
 9         [__DynamicallyInvokable]
10         bool MoveNext();                                //将游标的内部位置向前移动
11         [__DynamicallyInvokable]
12         object Current { [__DynamicallyInvokable] get; }//获取当前的项(只读属性)
13         [__DynamicallyInvokable]
14         void Reset();                                   //将游标重置到第一个成员前面
15     }
16 }

IEnumberator接口包含了两个方法和一个只读属性,MoveNext方法返回值为bool类型,如果指针移动到下一个索引位置有效则返回True,否则返回False;Reset方法用于将游标重置到第一个成员前面;Current属性用于读取当前索引项(只读)。代码中我手动添加了注释。既然得到了反编译后的代码接口声明,那我们就模仿着写一个相同功能的接口来实现自己的迭代器。

IEnumerator接口包含三个函数成员: CurrentMoveNext以及Reset‰

  • Current返回序列中当前位置项的属性;它是只读属性;它返回object类型的引用,所以可以返回任何类型。
  • ‰MoveNext是把枚举数位置前进到集合中下一项的方法。它也返回布尔值,指示新的位置是有效位置或已经超过了序列的尾部。如果新的位置是有效的,方法返回true如果新的位置是无效的(比如到达了尾部),方法返回false枚举数的原始位置在序列中的第一项之前。MoveNext必须在第一次使用Current之前使用,否则CLR会抛出一个InvalidOperationException异常。
  • ‰Reset方法把位置重置为原始状态。

以下代码和反编译出来的代码几乎是一模一样的,代码如下:

 1 namespace Xhb.IEnumberable
 2 {
 3     public interface IEnumerable
 4     {
 5         IEnumerator GetEnumerator();
 6     }
 7 
 8     public interface IEnumerator
 9     {
10         object Current { get; } //获取当前的项(只读属性)
11         bool MoveNext();        //将游标的内部位置向前移动
12         void Reset();           //将游标重置到第一个成员前面
13     }
14 }                              

下面我们来自己实现具体的迭代器功能,新增一个UserEnumerable 类并实现IEnumerable接口,同时新增一个UserEnumerator类来实现IEnumerator接口,编写代码逻辑如下:

 1 namespace Xhb.IEnumberable
 2 {
 3     class UserEnumerable : Xhb.IEnumberable.IEnumerable
 4     {
 5         private string[] _info;
 6 
 7         public UserEnumerable(string[] info)
 8         {
 9             _info = info;
10         }
11 
12         public IEnumerator GetEnumerator()
13         {
14             return new UserEnumerator(_info); //返回一个实现了IEnumerator接口的实例
15         }
16     }
17 }
 1 namespace Xhb.IEnumberable
 2 {
 3     /// <summary>
 4     /// 自定义迭代器
 5     /// </summary>
 6     class UserEnumerator : Xhb.IEnumberable.IEnumerator
 7     {
 8 
 9         private string[] _info; 
10         private int position;   //存放当前指针位置信息
11         public UserEnumerator(string[] info)
12         {
13             _info = info;
14             position = -1;      //初始化位置信息
15         }
16         public object Current
17         {
18             get
19             {
20                 return _info[position]; //返回当前指针指向的元素
21             }
22         }
23 
24         public bool MoveNext()
25         {
26             position++;
27             return (position < _info.Length) ? true : false;
28         }
29 
30         public void Reset()
31         {
32             position = -1; //复位指针位置
33         }
34     }
35 }

这样我们就实现了自己的迭代器,下图说明了可枚举类型和枚举数之间的关系

下面我们来测试一下效果,在Main方法中编写如下代码进行测试:

 1 namespace Xhb.IEnumberable
 2 {
 3     class Program
 4     {
 5         static void Main(string[] args)
 6         {
 7             //定义数据源
 8             string[] info =
 9             {
10                 "两个黄鹂鸣翠柳,",
11                 "一行白鹭上青天。",
12                 "窗含西岭千秋雪,",
13                 "门泊东吴万里船。"
14             };
15 
16             //以原始的方式调用
17             UserEnumerable userEnum = new UserEnumerable(info);
18             //获取实现了IEnumerable接口的实例
19             var instance = userEnum.GetEnumerator();
20             //开始遍历输出
21             while (instance.MoveNext())
22             {
23                 Console.WriteLine(instance.Current);
24             }
25             Console.ReadLine();
26         }
27     }
28 }

输出结果就不在这里展示了,就是我在代码中定义的info私有变量。这段代码的运行过程是这样的,首先在UserEnumerable的构造函数中,传入了一个string类型的数组作为数据源,UserEnumerable是实现了IEnumerable接口的,也就实现了IEnumerable接口中的GetEnumerator方法,该方法返回了一个将传入的数据源作为参数并且实现了IEnumerator接口的UserEnumerator实例。这样在UserEnumerator类中就可以通过实现的IEnumerator接口的成员对数据源进行遍历操作了。其实,这段代码和foreach进行遍历的效果是一模一样的。那么如果不实现IEnumerable接口可不可以使用foreach进行遍历呢?下面添加一个NonUserEnumerable类来进行下验证,代码如下:

 1 namespace Xhb.IEnumberable
 2 {
 3     class NonUserEnumerable
 4     {
 5         private string[] _info;
 6 
 7         public NonUserEnumerable(string[] info)
 8         {
 9             _info = info;
10         }
11 
12         public IEnumerator GetEnumerator()
13         {
14             return new UserEnumerator(_info); //返回一个实现了IEnumerator接口的实例
15         }
16     }
17 }

其实很简单,就是在UserEnumerable类的基础上把实现IEnumerable接口的部分删掉了,经过测试发现,居然可以foreach遍历,所以实现IEnumerable接口不是foreach遍历的必要条件,但是需要定义和IEnumerable接口一样的成员,即存在GetEnumerator无参方法,并且返回值是IEnumerator或其对应的泛型即可。yield 关键字向编译器指示它所在的方法是迭代器块。编译器生成一个类来实现迭代器块中表示的行为。在迭代器块中,yield 关键字与 return 关键字结合使用,向枚举器对象提供值。这是一个返回值,例如,在 foreach 语句的每一次循环中返回的值。yield 关键字也可与 break 结合使用,表示迭代结束。

还有一个问题,在迭代的过程中,是否可以修改当前索引的值呢?我们在开发的过程中很多的时候都会遇到这种场景,就是对于一个集合中所有元素进行过滤修改,如果符合修改条件就进行更改,但是我们的做法通常是使用for循环,或者其他的方式,下面我们在这个小例子中实现在迭代中也能修改元素的功能。

 1 namespace Xhb.IEnumberable
 2 {
 3     /// <summary>
 4     /// 自定义迭代器
 5     /// </summary>
 6     class UserEnumerator : Xhb.IEnumberable.IEnumerator
 7     {
 8 
 9         private string[] _info; 
10         private int position;   //存放当前指针位置信息
11         public UserEnumerator(string[] info)
12         {
13             _info = info;
14             position = -1;      //初始化位置信息
15         }
16         public object Current
17         {
18             get
19             {
20                 return _info[position]; //返回当前指针指向的元素
21             }
22             set
23             {
24                 //为Current属性添加可写访问
25                 _info[position]=value.ToString();
26             }
27         }
28 
29         public bool MoveNext()
30         {
31             position++;
32             return (position < _info.Length) ? true : false;
33         }
34 
35         public void Reset()
36         {
37             position = -1; //复位指针位置
38         }
39     }
40 }

注意上面代码中加粗倾斜的部分,就是为Current属性添加了set访问器,下面来看一下调用方代码:

 1 namespace Xhb.IEnumberable
 2 {
 3     class Program
 4     {
 5         static void Main(string[] args)
 6         {
 7             //定义数据源
 8             string[] info =
 9             {
10                 "两个黄鹂鸣翠柳,",
11                 "一行白鹭上青天。",
12                 "窗含西岭千秋雪,",
13                 "门泊东吴万里船。"
14             };
15 
16             //以原始的方式调用
17             //UserEnumerable userEnum = new UserEnumerable(info);
18             UserEnumerable userEnum = new UserEnumerable(info);
19             //获取实现了IEnumerable接口的实例
20             var instance = userEnum.GetEnumerator();
21             //开始遍历输出
22             while (instance.MoveNext())
23             {
24                 instance.Current = instance.Current + "<"; //为Current属性赋值
25                 Console.WriteLine(instance.Current);
26             }
27 
28             Console.WriteLine("--------------------------");
29 
30             foreach (var item in userEnum)
31             {
32                 item = "New Value"; //报错信息 : 无法为“item”赋值,因为它是“foreach迭代变量”
33                 Console.WriteLine(item);
34             }
35             Console.ReadLine();
36         }
37     }
38 }

上述代码中,同样重点关注加粗倾斜部分的代码,在while循环中,我为Current属性赋值后再输出。注意,在前面的代码中这是不被允许的,因为Current属性是只读的。而我在自定义迭代器中为Current添加了set访问器后,就可以在遍历时修改元素的值。再来看上述代码的foreach循环,即便我给Current属性添加了set访问器,仍然不能修改item的值,报错信息我加在了注释中。那么,是不是可以得出这样的结论?无论迭代对象的Current属性是不是可写,在foreach中item都是不允许被赋值的。我们姑且去验证一下。在这个例子中,我采用的是string类型的数组,下面我使用struct集合和class集合来分别作为迭代的数据源进行测试。

首先使用struct数组作为测试迭代的数据源,代码如下:

 1 class Program
 2 {
 3     static void Main(string[] args)
 4     {
 5         
 6         //类集合作为数据源
 7         StructPoint[] structPoint = new StructPoint[]
 8         {
 9             new StructPoint() {X=30,Y=63 },
10             new StructPoint() {X=34,Y=65 },
11             new StructPoint() {X=38,Y=68 }
12         };
13 
14         //用于测试赋值操作
15         StructPoint sp = new StructPoint() { X = 12, Y = 25 };
16 
17         //以原始的方式调用
18         UserEnumerable userEnum = new UserEnumerable(structPoint);
19         
20         //获取实现了IEnumerable接口的实例
21         var instance = userEnum.GetEnumerator();
22         
23         //开始遍历输出
24         while (instance.MoveNext())
25         {
26             instance.Current = sp;
27             StructPoint tmp = (StructPoint)instance.Current;
28             Console.WriteLine(tmp.X);
29         }
30 
31         Console.WriteLine("--------------------------");
32 
33         foreach (StructPoint item in userEnum)
34         {                
35             item =sp; //报错信息 : 无法为“item”赋值,因为它是“foreach迭代变量”
36             item.Y = sp.Y; //报错信息 : “item”是一个“foreach迭代变量”,因此无法修改其成员
37             Console.WriteLine(item.Y);
38         }
39         Console.ReadLine();
40     }
41 }

由上面的代码可以看出,在对struct数组进行迭代的时候,无论是修改item本身还是修改item的成员,都是不被允许的,具体的错误信息我已经在注释中标注了。下面来看下采用class的数组作为数据源的时候,会发生什么,代码如下:

 1 class Program
 2 {
 3     static void Main(string[] args)
 4     {
 5         
 6         //类集合作为数据源
 7         ClassPoint[] classPoint = new ClassPoint[]
 8         {
 9             new ClassPoint() {X=30,Y=63 },
10             new ClassPoint() {X=34,Y=65 },
11             new ClassPoint() {X=38,Y=68 }
12         };
13 
14         //用于测试赋值操作
15         ClassPoint cp = new ClassPoint() { X = 12, Y = 2 };
16 
17         //以原始的方式调用
18         UserEnumerable userEnum = new UserEnumerable(classPoint);
19         
20         //获取实现了IEnumerable接口的实例
21         var instance = userEnum.GetEnumerator();
22         
23         //开始遍历输出
24         while (instance.MoveNext())
25         {
26             instance.Current = cp;
27             ClassPoint tmp = (ClassPoint)instance.Current;
28             Console.WriteLine(tmp.X);
29         }
30 
31         Console.WriteLine("--------------------------");
32 
33         foreach (ClassPoint item in userEnum)
34         {                
35             item =cp; //报错信息 : 无法为“item”赋值,因为它是“foreach迭代变量”
36             item.Y = cp.Y; //这里已经不报错了!!!
37             Console.WriteLine(item.Y);
38         }
39         Console.ReadLine();
40     }
41 }

同样地,当使用class数组作为迭代数据源时,在迭代的过程中,item本身是不允许被修改的,但是item的成员却是允许被修改而且不会报错!具体的过程我同样在注释中标明了。通过以上代码的运行对比,我们不难发现一个规律:当迭代变量为引用类型的时候,foreach在迭代过程中,可以修改迭代变量的属性但不可以修改迭代变量本身;而当迭代变量为值类型的时候,既不可以修改迭代变量本身也不可以修改迭代变量的属性(如果存在)。

四、总结

经过上面的叙述以及代码演示,现在我们再回过头来看一下第二节中提出的问题,针对问题进行如下的总结:

第一、如果想使用foreach进行迭代,那么迭代的对象必须存在GetEnumerator方法返回IEnumerator接口实例

第二、因为Current属性是只读的,所以在进行foreach迭代的时候不可以修改item的值(某些资料上是这么说的,但我不认同,在上面的代码中我已经为Current属性添加了set访问器,在while循环的时候是可以修改被迭代对象的值)。

第三、在foreach循环中,不能修改值类型的数据,包括结构体的属性等,也不能修改引用类型数据本身,但是却可以修改类的属性。

每一个小的知识点展开后,后面都有很多非常有意思且值得我们去深入探究的东西,本文就算是回顾基础吧,如果文中有表述不妥当的地方,请及时评论或私信,我会及时更正,欢迎共同交流讨论。

posted @ 2017-02-08 08:46  悠扬的牧笛  阅读(858)  评论(1编辑  收藏  举报