概述
Unity协程就是借助了迭代器模式实现的,所以我们要先了解该模式.
迭代器(Iterator)模式是一种常见且非常重要的设计模式,它允许我们逐步遍历集合中的元素,而无需关心集合的内部实现细节。在C#中 , List<T> 是一个非常典型的实现迭代器模式的集合类型,理解它如何工作,能让我们更好地掌握 C# 语言中迭代器的工作原理。
注: “迭代”泛指逐一处理一组数据的过程。这个术语更偏向概念层面,指的是通过某种方式依次访问集合中的每一个元素。C# 中的 foreach 循环就是一种典型的迭代方式,它在内部使用了枚举器。
广义宽泛的描述整个访问过程。例如,“我们使用迭代访问集合中的每个元素。”
1. 从 List<T> 开始
List<T> 是 .NET 中最常用的泛型集合类型之一,因为它实现了 IEnumerable<T> 接口,因此可以通过 foreach 循环或显式迭代来遍历集合中的元素。
我们来看一个简单的 List<T> 示例:
List<int> numbers = new List<int> { 1, 2, 3, 4, 5 };
foreach (int number in numbers)
{
Console.WriteLine(number);
}
在这个 foreach 循环中,我们逐一遍历了 List<int> 中的每个元素,输出结果是 1 到 5。
2. IEnumerable 和 IEnumerator
要理解 foreach 背后的工作原理,首先我们需要了解 IEnumerable 和 IEnumerator 接口。List<T> 实现了 IEnumerable<T> 接口,这意味着它是一个可枚举对象,可以生成枚举器来遍历它的元素。
注:“枚举”更具体,指的是使用特定的机制遍历集合。在 C# 中,枚举指的是通过 IEnumerable 接口和 IEnumerator 对象来访问集合。枚举器(Enumerator)是一个实际用于访问集合的对象。
当讨论 IEnumerable 和 IEnumerator 时,使用“枚举”来描述通过这些接口进行的集合访问。例如,“实现了 IEnumerable 接口的集合可以被枚举。”下面的内容我们主要使用枚举来描述迭代行为.
2.1 IEnumerable<T> 接口
IEnumerable<T> 接口只有一个方法 GetEnumerator(),它返回一个 IEnumerator<T> 对象,用于实际枚举集合。
public interface IEnumerable<out T>
{
IEnumerator<T> GetEnumerator();
}
使用GetEnumerator方法可以获取一个枚举器,枚举的功能是依靠枚举器实现的.
当你在 List<int> 上调用 foreach 时,实际上是在背后调用 GetEnumerator() 方法来获取一个枚举器,然后通过枚举器逐步遍历集合。
2.2 IEnumerator<T> 接口
IEnumerator<T> 是负责控制枚举过程的接口,它提供了三件关键的功能:
MoveNext():将枚举器移动到下一个元素,返回一个布尔值,表示是否还有更多的元素。
Current:获取枚举器当前所在位置的元素。
Reset():将枚举器重置到初始位置(一般不推荐手动使用)。
public interface IEnumerator<out T>
{
bool MoveNext();
T Current { get; }
void Reset();
}
IEnumerator 是 List<T> 的枚举核心。每次调用 MoveNext(),枚举器就会指向下一个元素,而 Current 属性则提供当前元素的访问。
禅宗有一个指月的典故:想象一下天上有十个月亮,我们的手就是迭代器,我们的初始状态是没指月亮,那么我们是无法访问Current的(此时Current处于未定义状态,访问可能抛异常),调用一下MoveNext,我们就指向了第一个月亮,此时Current就是第一个月亮.接下来多次调用MoveNext,那么每次调用后都会指向下一个月亮,一个关键点到来了:我们指向了第十个月亮,我们再调用MoveNext,那么因为这是最后一个月亮了,MoveNext会返回一个false,告诉我们,指不到下一个月亮了,月亮都指完了,该放下手臂歇一会了,此时我们不能访问Current,Current处于一个未定义状态.
注意:
泛型版本的接口继承了对应的非泛型版本,这是由于C#的发展导致的,最初C#没有泛型概念,这里不做过多的讨论,参考下表了解它们的关系.
接口名称 | 继承关系 | 返回类型 | 主要方法/属性 | 用途描述 |
---|---|---|---|---|
IEnumerable | 无 | IEnumerator | GetEnumerator() | 提供一个非泛型的枚举器,遍历集合中的元素 |
IEnumerable<T> | 继承自 IEnumerable | IEnumerator<T> | GetEnumerator() | 提供一个泛型的枚举器,遍历类型为 T 的集合元素 |
IEnumerator | 无 | object (通过 Current ) | MoveNext() Reset() Current | 非泛型的枚举器接口,用于遍历集合并访问当前元素 |
IEnumerator<T> | 继承自 IEnumerator 和 IDisposable | T (通过 Current ) | MoveNext() Reset() Current Dispose() | 泛型的枚举器接口,提供类型安全的元素访问和资源释放功能 |
我们首先要实现IEnumerable接口,那么就要实现一个类(该类实现了IEnumerable接口),为了实现IEnumerable接口的GetEnumerator方法,我们还要再实现一个类(该类实现了IEnumerator接口). 下面是一个示例
using System;
using System.Collections;
using System.Collections.Generic;
// 手动实现 IEnumerable<int> 和 IEnumerator<int>
public class NumberEnumerable : IEnumerable<int>
{
public IEnumerator<int> GetEnumerator()
{
return new NumberEnumerator();
}
// 显式实现非泛型版本的 GetEnumerator,因为继承了对应的非泛型版本的接口
IEnumerator IEnumerable.GetEnumerator()
{
return GetEnumerator();
}
}
public class NumberEnumerator : IEnumerator<int>
{
private int _currentState = 0;
// Current 返回当前的数值
public int Current
{
get
{
switch (_currentState)
{
case 1:
return 1;
case 2:
return 2;
case 3:
return 3;
default:
throw new InvalidOperationException();
}
}
}
// 显式实现非泛型的 Current 属性,因为继承了对应的非泛型版本的接口
object IEnumerator.Current => Current;
// MoveNext 控制状态机的转换
public bool MoveNext()
{
if (_currentState < 3)
{
_currentState++;
return true;
}
return false;
}
// Reset 将状态机重置为初始状态
public void Reset()
{
_currentState = 0;
}
// Dispose 方法,不需要额外清理资源
public void Dispose()
{
}
}
public class Program
{
public static void Main(string[] args)
{
IEnumerable<int> numbers = new NumberEnumerable();
IEnumerator<int> enumerator = numbers.GetEnumerator();
while (enumerator.MoveNext())
{
Console.WriteLine(enumerator.Current);
}
}
}
3. yield return:简化迭代器的实现
虽然 IEnumerator 提供了强大的枚举功能,但在实现自定义集合的枚举器时,手动编写 MoveNext() 和 Current 是相对繁琐的。C# 提供了 yield return 作为语法糖,简化了迭代器的实现。
yield return 关键字帮助我们自动实现了 IEnumerable<T> 和 IEnumerator<T> 接口的方法,而不需要手动实现这些接口。
什么是 yield return?
yield return 是 C# 的一个特殊关键字(或者说是语法糖),当它被执行的时候,方法的执行会被暂停,并将当前值返回给调用者。每次调用 MoveNext() 时,方法会从上次 yield return 暂停的地方继续执行,正是依赖这个机制,Unity可以灵活的暂停/恢复代码的执行,实现了协程的机制.
我们可以通过 yield return 来简化迭代器的实现。来看一个简单的例子:
IEnumerable<int> GetNumbers()
{
yield return 1;
yield return 2;
yield return 3;
//没有显式地返回一个 IEnumerable<int> 对象,但实际上,
//它是通过 yield return 语句隐式返回了一个可枚举对象
}
IEnumerator enumerator = GetNumbers().GetEnumerator();
while (enumerator.MoveNext())
{
Console.WriteLine(enumerator.Current);
}
通常情况下,如果没有 yield return,需要手动实现这些接口,比如 IEnumerable<int> 需要实现 GetEnumerator() 方法,而 IEnumerator<int> 需要实现 MoveNext()、Current 和 Reset() 等方法。这会变得很繁琐。
然而,使用 yield return 时,编译器会自动生成一个状态机类,隐式实现了 IEnumerable<T> 和 IEnumerator<T>。这样一来,只需使用 yield return 语句来逐步返回数据,编译器会帮你处理枚举器的创建、状态管理和遍历逻辑。
当你使用 yield return 时,如果直接返回IEnumerable<T>类型,那么编译器做了以下工作:
分别生成了一个实现了 IEnumerable<int> 的类和 一个实现了IEnumerator<int> 的类。
当 GetEnumerator() 被调用时,它会返回实现了 IEnumerator<int> 的对象。每次调用 MoveNext() 时,编译器生成的状态机会跟踪迭代器的位置,逐步返回 yield return 中的值。当迭代器结束时,MoveNext() 返回 false,表示遍历完毕。
因此,yield return 使得编写迭代器变得非常简洁和直观,你不需要关心枚举器的底层实现。yield return 自动实现了接口中的所有细节,简化了迭代器的编写过程。
using System;
using System.Collections.Generic;
public class Program
{
public static void Main(string[] args)
{
var x = Test();
x.MoveNext();
Console.WriteLine(x.Current);
}
static IEnumerator<int> Test()
{
yield return 1;
yield return 2;
yield return 3;
}
}
当你使用 yield return 时,如果直接返回IEnumerator<T>类型,那么就只会隐式生成实现了IEnumerator<int> 的类.
通常返回IEnumerable<T>类型会多一些,因为foreach会帮我们调用获取枚举器的方法.
注意:没有都没有实现Reset方法,使用yield return 不会帮我们实现该方法,调用这个方法会抛出 System.NotSupportedException,但是我们可以重新获取迭代器.
下面再举一个错误例子
using System;
using System.Collections.Generic;
public class Program
{
public static void Main(string[] args)
{
IEnumerable<int> x = Test();
IEnumerator<int> y = x.GetEnumerator();
//y.MoveNext();
Console.WriteLine(y.Current);
//这是一个错误的做法,刚获得枚举器时必须调用MoveNext
//因为未调用之前Current处于无效状态
//当然这个例子不会报错,因为是int类型,如果是引用类型(string除外)可能会抛异常
}
static IEnumerable<int> Test()
{
yield return 1;
yield return 2;
yield return 3;
}
}
自定义实现
通过 yield return,我们可以轻松实现自定义集合的迭代器,而不需要手动管理 IEnumerator 的状态。以下是一个简单的自定义集合迭代器的例子:
using System;
using System.Collections.Generic;
public class CustomCollection
{
private int[] items = { 1, 2, 3, 4, 5 };
public IEnumerable<int> GetCustomIterator()
{
foreach (var item in items)
{
yield return item;
}
}
}
class Program
{
static void Main()
{
CustomCollection collection = new CustomCollection();
// 使用自定义迭代器逐一遍历元素
foreach (int item in collection.GetCustomIterator())
{
Console.WriteLine(item);
}
}
}
枚举器背后的状态机
当你在方法中使用 yield return 时,C# 编译器会自动将方法转换为一个状态机。这个状态机会保存方法的执行状态(例如局部变量、执行位置等),以便在每次调用 MoveNext() 时能够恢复执行。通过这种方式,C# 可以暂停方法的执行,并在稍后继续执行。
小结
C# 的迭代器机制通过 IEnumerable 和 IEnumerator 接口实现,提供了一种灵活的方式来逐步遍历集合中的元素。List<T> 是 IEnumerable<T> 的典型实现,它通过迭代器模式,允许我们轻松遍历集合中的元素。
同时,yield return 提供了一个简洁的语法糖,简化了实现。我们可以通过它来创建自定义的枚举器,而无需手动实现 IEnumerator 的所有细节。
4.Unity 协程和 C# 迭代器
Unity 的协程和 C# 迭代器模式共享了相同的底层机制:状态机(state machine)。当你在 C# 中使用 yield return 时,编译器会自动将你的代码转换为一个状态机,以追踪每次 yield return 的执行位置。Unity 利用这一点来实现协程的延迟执行。
1. C# 编译器的状态机机制
当你使用 yield return 时,C# 编译器会将方法转换成一个状态机。状态机会保存当前的执行状态和本地变量,使得每次 yield return 后程序可以暂停并在稍后恢复。例如,在迭代器模式中,每次调用 MoveNext() 时,状态机会更新为下一步的执行状态。
2. Unity 协程是如何利用 C# 状态机的
Unity 的协程在本质上是 C# 的 IEnumerator 对象。Unity 调用 IEnumerator 的 MoveNext() 方法来推进协程的执行,而 yield return 控制着协程的暂停和恢复。
协程是非阻塞的,这意味着当你在协程中使用 yield return 时,Unity 会在每一帧检查协程的状态并决定是否继续执行或暂停它。例如,yield return new WaitForSeconds(3) 会告诉 Unity "在3秒后恢复协程",而 Unity 会在等待期内暂时中止该协程的执行。
3. 状态机是如何工作的
通过以下两个伪代码来简化解释 Unity 协程和 C# 迭代器的状态机工作原理:
public IEnumerator MyCoroutine()
{
Debug.Log("Step 1");
yield return new WaitForSeconds(2); // Step 2 after 2 seconds
Debug.Log("Step 2");
}
编译器将这个协程方法转换成类似下面的状态机:
public class MyCoroutineStateMachine : IEnumerator
{
int state = 0; // 保存当前的执行状态
float waitTime = 2f; // 记录需要等待的时间
public bool MoveNext()
{
switch (state)
{
case 0:
Debug.Log("Step 1");
state = 1; // 更新状态为1,意味着等待开始
return false; // 暂停协程,直到下次调用 MoveNext()
case 1:
if (Time.time >= waitTime) // 检查是否已经等待了2秒
{
Debug.Log("Step 2");
state = 2; // 结束状态
}
return false;
}
return false;
}
}
public class MyCoroutineStateMachine : IEnumerator
{
private int state = 0; // 跟踪状态机的当前状态
private float waitTime; // 记录需要等待的时间
private float startTime; // 记录当前的游戏时间
// MoveNext 是 Unity 用来推进协程的核心方法
public bool MoveNext()
{
switch (state)
{
case 0:
// Step 1: 执行第一个打印语句
Debug.Log("Starting Step 1");
// 记录当前的游戏时间,并设置为等待 2 秒
waitTime = 2f;
startTime = Time.time;
// 切换到状态 1,表示我们在等待
state = 1;
return true; // 协程继续运行
case 1:
// Step 2: 检查是否等待了 2 秒
if (Time.time >= startTime + waitTime)
{
Debug.Log("Starting Step 2");
// 切换到状态 2,准备执行下一帧操作
state = 2;
}
return true; // 继续等待
case 2:
// Step 3: 等待一帧
Debug.Log("Starting Step 3");
// 切换到完成状态
state = -1;
return true; // 表示下一帧继续执行
case -1:
// 协程完成,返回 false 以停止执行
return false;
}
return false;
}
// Reset 是 IEnumerator 接口中的一个方法,在这里我们不实现
public void Reset() {}
// Current 是 IEnumerator 接口的一部分,表示当前返回值
public object Current => null;
}
4. 如何暂停和恢复协程
在这个状态机中,每次 MoveNext() 方法被调用时,状态机会检查当前状态并决定如何执行下一步。每当 yield return 被调用时,状态机暂停并将控制权交回给调用者(在 Unity 中,控制权交给了主游戏循环)。然后在下一帧或特定条件满足时,Unity 会再次调用 MoveNext(),恢复协程的执行。
例如:
当协程遇到 yield return null 时,它会暂停到下一帧。
遇到 yield return new WaitForSeconds(3) 时,它会暂停执行,等待 3 秒钟后再继续。
遇到 yield return new WWW(url) 时,它会等待网络请求完成后继续。
5. Unity 引擎与协程
Unity 协程是 Unity 游戏引擎提供的一个框架,用来方便我们控制代码的异步执行和时间控制。C# 提供了基础的迭代器机制,而 Unity 利用这个机制,通过 yield return 和 IEnumerator 实现了协程的功能。实际上,Unity 协程只是 IEnumerator 的一个特殊实现,借助 C# 状态机来实现暂停和恢复。
这就意味着,当使用 yield 来控制协程时,Unity 在背后管理这个状态机,以确保在游戏主循环的每一帧正确执行和恢复协程。
6. 执行顺序和帧更新
Unity 的每一帧都会调用所有协程的 MoveNext() 方法,查看它们是否可以继续执行。协程通过 yield return 控制其暂停时间或条件,当条件满足时,MoveNext() 返回 true,协程继续执行;否则,协程暂停并等待下一个满足条件的时刻。