迭代器状态机与鸭子类型

迭代器方法

简单写一个迭代器方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
void Start()
{
foreach (var e in Iterator())
{
Console.WriteLine(e);
}
}

IEnumerable<int> Iterator()
{
Console.WriteLine("00");
yield return 1;
Console.WriteLine("11");
yield return 2;
Console.WriteLine("22");
}

上面的迭代器方法,在内部,编译器会生成一个匿名类,代码如下:

  • 仔细观察会发现,这个匿名类同时继承IEnumerator和IEnumerable,意味着它同时负责生产迭代器和迭代。
  • 状态点数字的含义:-1代表正在执行/当前不处于挂起点;-2代表尚未枚举或者已经Dispose;大于等于0代表各个恢复点
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
public class Program
{
public void Start()
{
IEnumerator<int> enumerator = this.Iterator().GetEnumerator();
try
{
while (enumerator.MoveNext())
Console.WriteLine(enumerator.Current);
}
finally
{
if (enumerator != null)
enumerator.Dispose();
}
}

[NullableContext(1)]
[IteratorStateMachine(typeof (Program.<Iterator>d__2))]
private IEnumerable<int> Iterator()
{
Program.<Iterator>d__2 iteratorD2 = new Program.<Iterator>d__2(-2);
iteratorD2.<>4__this = this;
return (IEnumerable<int>) iteratorD2;
}

// 编译器生成的匿名类
[CompilerGenerated]
private sealed class <Iterator>d__2 :
IEnumerable<int>,
IEnumerable,
IEnumerator<int>,
IEnumerator,
IDisposable
{
private int <>1__state;
private int <>2__current;
private int <>l__initialThreadId;
public Program <>4__this;

[DebuggerHidden]
public <Iterator>d__2(int _param1)
{
base..ctor();
this.<>1__state = _param1;
this.<>l__initialThreadId = Environment.CurrentManagedThreadId;
}

// 标准Roslyn编译器里Dispose会设置state = -2,
// 从而允许GetEnumerator复用当前实例(同线程且state==-2时)。
// 但旧版Unity/Mono编译器里Dispose可能被编译为空,
// 此时state停留在最后一个case值(如2),
// GetEnumerator的复用判断永远不成立,每次foreach都会new一个新实例。
[DebuggerHidden]
void IDisposable.Dispose()
{
this.<>1__state = -2;
}

bool IEnumerator.MoveNext()
{
switch (this.<>1__state)
{
case 0:
// 防止内部逻辑异常先要设置-1
this.<>1__state = -1;
Console.WriteLine("00");
this.<>2__current = 1;
this.<>1__state = 1;
return true;
case 1:
this.<>1__state = -1;
Console.WriteLine("11");
this.<>2__current = 2;
this.<>1__state = 2;
return true;
case 2:
this.<>1__state = -1;
Console.WriteLine("22");
return false;
default:
return false;
}
}

int IEnumerator<int>.Current
{
[DebuggerHidden] get
{
return this.<>2__current;
}
}

[DebuggerHidden]
void IEnumerator.Reset()
{
throw new NotSupportedException();
}

object IEnumerator.Current
{
[DebuggerHidden] get
{
return (object) this.<>2__current;
}
}

[DebuggerHidden]
[return: Nullable(1)]
IEnumerator<int> IEnumerable<int>.GetEnumerator()
{
Program.<Iterator>d__2 enumerator;
if (this.<>1__state == -2 && this.<>l__initialThreadId == Environment.CurrentManagedThreadId)
{
this.<>1__state = 0;
enumerator = this;
}
else
{
enumerator = new Program.<Iterator>d__2(0);
enumerator.<>4__this = this.<>4__this;
}
return (IEnumerator<int>) enumerator;
}

[DebuggerHidden]
[return: Nullable(1)]
IEnumerator IEnumerable.GetEnumerator()
{
return (IEnumerator) this.System.Collections.Generic.IEnumerable<System.Int32>.GetEnumerator();
}
}
}

yield break 的行为

如果在迭代器方法中执行yield break,MoveNext会直接return false,不会再执行后续代码:

1
2
3
4
5
6
7
IEnumerable<int> Demo()
{
yield return 1;
if (someCondition)
yield break; // 生成一个独立的状态点,直接 return false
yield return 2;
}

本质上,编译器同样会为yield break分配一个状态点,只不过该状态点的逻辑就是直接return false——等价于提前走到了方法末尾。

GetEnumerator 中的线程安全复用

注意上面的GetEnumerator方法里有这样一个判断:

1
2
3
4
5
if (this.<>1__state == -2 && this.<>l__initialThreadId == Environment.CurrentManagedThreadId)
{
this.<>1__state = 0;
enumerator = this;
}

只有在state==-2(已Dispose或尚未开始)且当前线程等于创建线程时,才会复用当前实例。跨线程调用时永远new新实例,避免了线程安全问题。

Unity协程

协程的方法体返回类型是IEnumerator而不是IEnumerable,因为它不需要重复产生枚举对象,枚举对象是一次性的,每次StartCoroutine(Cor())(Cor()的时候就已经分配)的时候都会new一个新的迭代器状态机:

1
2
3
4
5
6
7
[IteratorStateMachine(typeof (TempScript.<Cor>d__3))]
private IEnumerator Cor()
{
TempScript.<Cor>d__3 corD3 = new TempScript.<Cor>d__3(0);
corD3.<>4__this = this;
return (IEnumerator) corD3;
}

这个迭代器状态机不可缓存来复用,内部状态已经用过就污染了。但是像是WaitForSeconds这类YieldInstruction还是可以复用的,Unity协程调度器并不会改变它们内部的状态,状态由调度器自己保存。但是有些比如WaitForSecondsRealtime这类CustomYieldInstruction是会被改变内部状态的,但是它们同时拥有Reset()重置的功能。可以将WaitForEndOfFrame, WaitForFixedUpdate, WaitForSeconds[0.1f/0.5f/1f]它们作为单例去复用

鸭子类型

编译期鸭子类型(compile-time duck typing),一种比接口更加宽松的模式匹配。

foreach

编译器尝试寻找

1
GetEnumerator()

返回的对象内再去寻找有没有

1
2
bool MoveNext()
T Current { get; }

只要同时满足以上条件,就可以进行foreach:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
public struct MyEnumerator
{
int _i;

public bool MoveNext()
{
_i++;
return _i <= 3;
}

public int Current => _i;
}

public class MyCollection
{
public MyEnumerator GetEnumerator()
{
return new MyEnumerator();
}
}

public static Main()
{
// 合法使用
foreach (var x in new MyCollection())
{
Console.WriteLine(x);
}
}

C#明明是静态语言,为什么会有鸭子类型?因为C#编译器在少数语法糖里会进行模式匹配成员查找,而不是接口检查,
可以看作是一种特殊的编译器规则。为什么要有这些?一方面为了避免装箱,因为如果通过接口承接迭代器,迭代器如果是结构体
就会发生装箱。

foreach支持ref返回

1
2
public ref int Current => ref _current;
foreach(ref var x in iterator);

编译期间的不同版本处理

前面我们知道,编译器对List执行foreach会进行如下代码展开:

1
2
3
4
5
6
7
8
9
List<T>.Enumerator enumerator1 = new List<T>.GetEnumerator();
try
{
while(enumerator1.MoveNext())
Handle(enumerator1.Current);
}
finally{
enumerator1.Dispose();
}

但对数组T[]执行foreach,则会是另一种展开:

1
2
3
T[] numArray = new T[3];
for (int index = 0; index < numArray.Length; ++index)
Console.WriteLine(numArray[index]);

foreach的行为取决于编译时类型,而不是运行时类型

我们有如下代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
{
List<int> arr = new List<int>();
foreach (var e in arr)
{
Console.WriteLine(e);
}
}
{
IList<int> arr = new List<int>();
foreach (var e in arr)
{
Console.WriteLine(e);
}
}

编译器展开后:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
List<int>.Enumerator enumerator1 = new List<int>().GetEnumerator();
try
{
while (enumerator1.MoveNext())
Console.WriteLine(enumerator1.Current);
}
finally
{
enumerator1.Dispose();
}
IEnumerator<int> enumerator2 = ((IEnumerable<int>) new List<int>()).GetEnumerator();
try
{
while (enumerator2.MoveNext())
Console.WriteLine(enumerator2.Current);
}
finally
{
if (enumerator2 != null)
enumerator2.Dispose();
}
  • 前者,其编译期类型就是List<int>,编译器直接去匹配List<int>.GetEnumerator(),它返回的是List<int>.Enumerator
    这是一个结构体,直接生成代码List<int>.Enumerator e = list.GetEnumerator();,整个过程没有接口、装箱,非常干净。
  • 后者,其编译期类型是IList<int>,编译器不知道它的实际类型是什么,虽然我这简单写的就是赋值,但是代码里可能是通过一个复杂函数,
    返回各种不同容器,比如List<int>, int[], ReadOnlyCollection<int>, 自定义容器等等,编译器只能按照IList<int>的类型
    公事公办。但是IList<int>没有GetEnumerator,但是它继承自IEnumerable<T>,所以编译器只能生成IEnumerator<int> e = ((IEnumerable<int>)list).GetEnumerator();
    由于List<T>.Enumerator是结构体,因此赋值给接口后就会发生装箱

根据以上描述,加深了一个印象:foreach优先进行模式匹配,其次才是走接口。

await

并不要求被await的一定是Task,只要求它具有

1
GetAwaiter()

且返回值里包含:

1
2
3
IsCompleted
OnCompleted()
GetResult()

任何满足上述条件的都可以被await。

同样的模式也出现在await foreach(C# 8):

1
await foreach (var x in asyncEnumerable)

编译器会去找GetAsyncEnumerator(),返回的对象里再找MoveNextAsync()Current

deconstruction解构

1
var (x, y) = obj;

要求实现

1
Deconstruct(out ...)

using

旧版要求必须继承IDisposable,但是目前会优先进行模式匹配识别

1
Dispose()

fixed

1
fixed(...)

会找:

1
GetPinnableReference()