C#中,如果实现遍历一个数组,除了for循环,还可以是foreach循环。在foreach循环中,我们只需要创建一个同类型的值,来表示我们遍历后的值就可以了。但是实际上,只有实现了IEnumerable接口的类型,才能使用foreach遍历。
那么什么是迭代器呢:
我们先手动实现以下迭代,我们使用迭代器写个和foreach类似的功能来遍历一个字符串,输出它每个字符。在foreach前面调用它:
static void Main()
{
string str = "ABCDEFG";
foreachFunc(str);
foreach (char a in str)
{
Console.WriteLine("官方foreach里的循环是:" + a);
}
}
static void foreachFunc(string str)
{
IEnumerator e = str.GetEnumerator();
while (e.MoveNext())
{
Console.WriteLine("民间foreach里的循环是:" + e.Current);
}
}
实现的效果是一样的:
我们发现民间的foreach也是可以完成工作的。
string 里面有一个GetEnumerator方法,这个方法返回一个IEnumerator的对象。一个官方定义的数据元素的数组,一般都继承了这个IEnumerable和IEnumerator接口,来配合foreach实现遍历的操作。
枚举接口:IEnumerable和IEnumerator
枚举接口IEnumerable和IEnumerator是迭代器模式(iterator pattern)在C#中的实现。它们实现在集合上进行简单迭代的效果。
1.IEnumerable接口定义了一个可以返回IEnumerator类型对象的方法:GetIEnumerator。
2.IEnumerator接口在它内部的字段和方法主要有三个:
- current字段,它是只读(属性只有get)的。
- MoveNext函数,对集合上实现循环迭代的效果。返回一个bool值,来表示是否可以继续迭代。
- Reset函数,表示将枚举数设置为其初始位置,该位置位于集合中第一个元素之前。
代码中有可能出现两个不同的迭代器对同一个序列进行迭代,我们需要两个状态能被正确的处理,所以C#把枚举接口分为IEnumerator和IEnumerable。而为了不违背单一职责原则,IEnumerable本身没有实现MoveNext方法。
我们可以自定义类来手动实现枚举接口的功能:
我们先定义IEnumerable的接口的类,里面存放一个数组:
class GameEnumerable : IEnumerable
{
private string[] Games = new string[5] { "彩虹六号", "赛博朋克", "骑马与砍杀", "神界原罪", "刺客信条" };
public IEnumerator GetEnumerator()
{
return new GameEnumerator(Games);
}
}
然后我们再定义实现IEnumerator接口的类,同样的,用数组索引的加减来实现迭代:
class GameEnumerator : IEnumerator
{
private string[] Games;
private int position = -1;
//用于遍历的标志索引,一般默认值为-1,以便于第一次输出就能输出0
public GameEnumerator(string[] gamenames)
{
Games = new string[gamenames.Length];
for (int i = 0; i < Games.Length; i++)
{
Games[i] = gamenames[i];
}
}
public object Current
{
//Current只读,且要注意索引position越界的情况
get
{
if(position>=Games.Length)
{
return null;
}
return Games[position];
}
}
bool IEnumerator.MoveNext()
{
if (position < Games.Length)
{
position++;
return true;
}
return false;
}
void IEnumerator.Reset()
{
position = -1;
}
}
然后我们在主函数中创建IEnumerable的实例,然后使用IEnumerator来接受它。
注意,实现IEnumerator接口的类的MoveNext方法使用了接口约束,所以只有IEnumerator接口的对象接受GameEnumerator 类的实例才能访问到MoveNext方法。
然后我们在主函数中调用MoveNext方法就可以实现遍历了:
static void Main()
{
GameEnumerable enumerable = new GameEnumerable();
IEnumerator game = enumerable.GetEnumerator();
while(game.MoveNext())
{
if (game.Current == null)
{
break;
}
Console.WriteLine("当前数组里的游戏是" + game.Current);
}
}
效果和我们想象的一样:
但是很明显,这样来进行遍历太繁琐了,但在C# 1.0里,一切都是这么发生的,如果想要加一点灵活性,可以使用IEnumerable和IEnumerator的泛型版本,我们上面的改一改就变成这样:
class Program
{
static void Main()
{
Console.WriteLine("请输入需要的数组长度:");
int Length = int.Parse(Console.ReadLine());
int[] array = new int[Length];
for (int i = 0; i < Length; i++)
{
Console.WriteLine("请输入第" + (i + 1) + "个数");
array[i] = int.Parse(Console.ReadLine());
}
GameEnumerable<int> enumerable = new GameEnumerable<int>(array);
IEnumerator game = enumerable.GetEnumerator();
while(game.MoveNext())
{
if (game.Current == null)
{
break;
}
Console.WriteLine("当前数组里的是" + game.Current);
}
}
}
class GameEnumerable<T> : IEnumerable<T>
{
private T[] Games;
public GameEnumerable(T[] games)
{
Games = new T[games.Length];
for (int i = 0; i < Games.Length; i++)
{
Games[i] = games[i];
}
}
public IEnumerator GetEnumerator(int i)
{
return null;
}
public IEnumerator<T> GetEnumerator()
{
return new GameEnumerator<T>(Games);
}
IEnumerator IEnumerable.GetEnumerator()
{
return ((IEnumerable<T>)Games).GetEnumerator();
}
}
class GameEnumerator<T> : IEnumerator<T>
{
private T[] Games;
private int position = -1;
//用于遍历的标志索引,一般默认值为-1,以便于第一次输出就能输出0
public GameEnumerator(T[] gamenames)
{
Games = new T[gamenames.Length];
for (int i = 0; i < Games.Length; i++)
{
Games[i] = gamenames[i];
}
}
public object Current
{
//Current只读,且要注意索引position越界的情况
get
{
if(position>=Games.Length)
{
return null;
}
return Games[position];
}
}
T IEnumerator<T>.Current
{
get
{
if (position >= Games.Length)
{
return default(T);
}
return Games[position];
}
}
public void Dispose()
{
throw new NotImplementedException();
}
bool IEnumerator.MoveNext()
{
if (position < Games.Length)
{
position++;
return true;
}
return false;
}
void IEnumerator.Reset()
{
position = -1;
}
}
有点长,不过功能还是一样的,区别在于我们可以自定义类型,我们这里尝试了一下int型然后输出:
不过要注意的是:由于IEnumerable<T>和IEnumerator<T>都是各自继承自IEnumerable和IEnumerator的,受制于接口的继承原则,实现泛型枚举接口的类对于同一个字段或函数可能要声明两个版本,两个版本的区别在于返回值的不同,可以不使用,但是不能不声明。例如我们上文中的Current,实际上写了两个版本,一个是Object的,一个是泛型的。
迭代器和yield语句
上面的遍历虽然说功能实现了,但是逻辑比较复杂,而且由于定义了接口的原因,里面的字段和方法的要求特别多。C#2.0以后引入了迭代器,简化了上述的流程。
迭代器的声明格式为:
IEnumerable/IEnumerator FunctionName()
{
yield return ...
}
它的返回值是IEnumerable或IEnumerator的对象,后面跟随的是函数的名字,然后可以在一个逻辑分支里多次执行yield return 语句来返回,不同的是,在yield return执行完毕以后,函数并不会销毁,而是“休克”,等待返回值的IEnumerator执行下一次MoveNext();
迭代器返回的IEnumerator对象没有手动实现例如上文中的MoveNext、Current的方法。它使用一个或多个yield return语句告诉编译器创建枚举器类,yield return语句指定了枚举器中下一个可枚举项,迭代器在每次调用MoveNext函数时,会顺着上一次的枚举项(yield return)按照我们自己写的逻辑执行到下一个枚举项去。
在迭代器中需要注意的是:
- IEnumerator和IEnumerable如果是非泛型版本,yield return返回的Current值是Object类型
- 泛型IEnumerator<T>和IEnumerable<T>,yield return返回的Current值是T类型,例如IEnumerable<string>返回值是string类型的
- 当执行到yield return的时候,后面返回值会传入Current内部。
- 迭代器不会在调用迭代器的时候(即下文中调用Function(Length))就开始执行,而是会在第一次调用MoveNext方法的时候开始执行。
- 若迭代器返回IEnumerable对象,那么每执行一次MoveNext()。根据IEnumerable生成的IEnumerator对象会执行一次IEnumerable对象的GetIEnumerator方法,来准备下一个执行的数据。
我们看个例子:
static void Main()
{
Console.WriteLine("请输入您需要的数组的长度");
int Length = int.Parse(Console.ReadLine());
int[] Array = new int[Length];
IEnumerable enumerable = Function(Length);
IEnumerator enumerator = enumerable.GetEnumerator();
int i = 0;
while (enumerator.MoveNext())
{
Array[i] = (int)enumerator.Current;
i++;
}
Console.WriteLine("数组里的值是:");
foreach (int t in Array)
{
Console.Write(t.ToString()+" ");
}
Console.WriteLine(" ");
}
static IEnumerable Function(int Length)
{
for (int i = 0; i < Length; i++)
{
Console.WriteLine("请输入您需要放在数组里的值:");
int x = int.Parse(Console.ReadLine());
Console.WriteLine("此时我们要放入数组的值是:" + x);
yield return x;
}
}
这个例子中,我们迭代器中有一个for循环,每次循环都yield return一次,返回的值放入我们的数组中。每次我们输入一个值,可以通过Current来传递给我们在主函数里声明的数组。当然,我们也可以不用IEnumerable返回,可以直接使用IEnumerator来返回,代码还可以精简一丢丢。
看一下结果:
我们再看一个例子:
在这个例子中,我们实时监测MoveNext的返回值情况,我们设定一个迭代器中循环的值X,当我们迭代器中循环超出5的时候我们将执行yield break,即迭代终止:
static void Main()
{
Console.WriteLine("输入你想从何时迭代结束");
int x = int.Parse(Console.ReadLine());
IEnumerable<int> ienumerable = TestStateChange(x);
IEnumerator<int> ienumerator = ienumerable.GetEnumerator();
Console.WriteLine("主函数:第一次调用MoveNext,迭代器开始运行");
bool Next = ienumerator.MoveNext();
Console.WriteLine("主函数:是否有数据" + Next + ",Current:" + ienumerator.Current);
Console.WriteLine("主函数:第二次调用MoveNext");
Next = ienumerator.MoveNext();
Console.WriteLine("主函数:是否有数据" + Next + ",Current:" + ienumerator.Current);
Console.WriteLine("主函数:第三次调用MoveNext");
Next = ienumerator.MoveNext();
Console.WriteLine("主函数:是否有数据" + Next + ",Current:" + ienumerator.Current);
}
static IEnumerable<int> TestStateChange(int count)
{
Console.WriteLine("迭代器:我是第一行代码");
Console.WriteLine("迭代器:我是第一个YieldReturn前的");
yield return 1;
Console.WriteLine("迭代器:我是第一个YieldReturn后的代码");
for (int i = 0; i < count; i++)
{
Console.WriteLine("迭代器:这是第" + i + "次了");
if (i > 5)
{
yield break;
}
}
Console.WriteLine("迭代器:我是第二个YieldReturn前的代码");
yield return 2;
Console.WriteLine("迭代器:我是第二个YieldReturn后的代码");
}
我们首先先设定只循环3次,小于5,不会迭代终止,输出为:
我们可以观察到:
- 迭代器在没有执行MoveNext之前函数是不会开始的,只有调用了第一次MoveNext迭代器才会开始运行。
- 迭代器运行之前的Current值是该类型的默认值,比如我们使用int类型,Current在MoveNext执行前的值就是0。
- 且在迭代器里的值在yield return后不会销毁,它会存在直至迭代器逻辑结束。
- 当迭代器逻辑运行到最后一个yield return,MoveNext返回值也是true,这样能保证最后一个yield return之后的逻辑能够顺利运行,当这最后的逻辑执行完,我们再次执行MoveNext才会是false。
我们如果输入大于5的值,导致yield break会如何呢。
我们看到:
执行了yield break后,迭代器立即停止,MoveNext立马返回false,且Current保留在最后一次return的值上。
我们可以画一张图来表示IEnumerable和IEnumerator和迭代器的关系:
迭代器背后的状态机
迭代器在我们看不见的地方,实际上的原理就是一个状态机,迭代器有四种可能状态,分别是Before状态、Running状态、Suspended状态、After状态。这四个状态的的转换是这样的:
由图我们可以看到,是
- 第一次运行MoveNext才会进入Running状态,即迭代器开始执行。
- yield return状态让迭代器暂停挂起,直到我们再次执行MoveNext。
- 逻辑结束或yield break才会进入after状态,此时MoveNext返回值是false。
在编译器的内部,我们的迭代器实际上生成了一个类,该类继承了IEnumerator接口,并且在内部创建了有关于迭代器的Current字段和MoveNext函数,MoveNext函数实际上是一个很大的Switch语句,它实现yield return功能实际上靠着goto语句半路插入才能使迭代器能从yield return语句处执行。
泛型迭代器中的Finally语句
我们平常普通情况下的return关键字的用法一般有两个:
- 给调用者提供返回值。
- 终止方法的执行,在退出时执行合适的finally代码块。
在C#中由于Finally语句比return语句的优先级高,所以Try-Catch语句可以由return语句退出,但是finally里面的逻辑是不会跳过的,但是对于泛型迭代器来说,相较于非泛型的迭代器,泛型迭代器继承了IDisposable接口,由此也多了一个Dispose方法。
在泛型迭代器中,如果存在Finally语句,如果不使用Foreach语句或者显式调用Dispose方法,将不会执行finally逻辑块。
由于foreach会在它自身的finally语句中调用IEnumerator所提供的Dispose方法。在迭代器迭代完成之前,若yield return 语句处于try-catch逻辑块中,如果调用了泛型迭代器上面的Dispose方法,则会执行Finally逻辑块。(注意,此时迭代器不会因此终止)。
我们可以理解为,只要对迭代器使用了Foreach语句,那么迭代器中的Finally语句将会按照正常的方式工作。
但是,如果没有调用Dispose方法,泛型迭代器将不会调用Finally逻辑块。我们写一个例子看看:
class Program
{
static void Main()
{
DateTime stop = DateTime.Now.AddSeconds(5);
foreach (int i in CountWithTimeLimit(stop))
{
Console.ForegroundColor = ConsoleColor.Yellow;
Console.WriteLine("得到了" + i);
if (i > 10)
{
Console.WriteLine("迭代器终止.....");
return;
}
Thread.Sleep(300);
}
}
static IEnumerable<int> CountWithTimeLimit(DateTime limit)
{
try
{
for (int i = 1; i <= 100; i++)
{
Console.ForegroundColor = ConsoleColor.Red;
Console.WriteLine("当前时间是" + DateTime.Now);
if (DateTime.Now > limit)
{
yield break;
}
yield return i;
}
}
finally
{
Console.WriteLine("迭代器finally块已经调用!");
}
}
}
那么我们看到的结果是:
如果我们不用foreach语句,而是使用for循环执行同样的逻辑,我们把上面主函数的逻辑注释掉,修改成自定义的for循环,即以下的样子:
static void Main()
{
DateTime stop = DateTime.Now.AddSeconds(5);
IEnumerable<int> ienum = CountWithTimeLimit(stop);
IEnumerator<int> ienumer = ienum.GetEnumerator();
for (int i = ienumer.Current; ; i = ienumer.Current)
{
Console.ForegroundColor = ConsoleColor.Yellow;
Console.WriteLine("得到了" + i);
if (i > 10)
{
Console.WriteLine("迭代器终止");
return;
}
Thread.Sleep(300);
ienumer.MoveNext();
}
}
那么很显然,finally语句块将不会被调用。
但是,如果是非泛型迭代器,那么处于try逻辑块的yield return一定会执行finally逻辑块。需要注意的是,非泛型迭代器没有继承IDisposable接口。
同样的,C#的迭代器也需要我们注意以下几点:
- 迭代器的参数列表不能用传值参数out和引用参数ref来修饰。
- 在C# 2.0以后的迭代器的Reset属性不可靠,在我们自己定义的迭代器块中,Reset是没有实现的。
- Current的值在迭代器结束后将一直存在直至GC将它清除。我们可以认为Current属性总是在迭代器开始前为默认值,在迭代器结束后一直为最后的yield return 生成值。