【基础】迭代器详解
一、前言
在我们的日常工作中,使用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接口包含三个函数成员: Current、 MoveNext以及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循环中,不能修改值类型的数据,包括结构体的属性等,也不能修改引用类型数据本身,但是却可以修改类的属性。
每一个小的知识点展开后,后面都有很多非常有意思且值得我们去深入探究的东西,本文就算是回顾基础吧,如果文中有表述不妥当的地方,请及时评论或私信,我会及时更正,欢迎共同交流讨论。
—— EOF ——
作者:悠扬的牧笛
地址:https://www.cnblogs.com/xhb-bky-blog/p/6369882.html
声明:本博客原创文字只代表本人工作中在某一时间内总结的观点或结论,与本人所在单位没有直接利益关系。非商业,未授权贴子请以现状保留,转载时必须保留此段声明,且在文章页面明显位置给出原文连接。