C#にはイテレータ(IEnumerator)が簡単で直感的に記述できる構文があります。
代表的な使い方の一つに、Unityではコルーチンを記述するために使用されていますが、元々はリストのような値を順番に取り出せるものを記述するためにあります。
このイテレータ構文が実際にはどのような動作をするのか、C#とILを実際に見比べながら確かめてみます。(コンパイルは.NETCore3.0)
IEnumeratorの定義
interface IEnumerator
{
void Reset();
bool MoveNext(); // falseの時、終了
object Current { get; }
// object型ではなく、任意の型を返せるIEnumerator<T>もあります。
}
これを反復子(yield return
)を使用せずに記述しようとすると、新しくクラスを作成し、この三つのメンバを実装しなければなりません。
一方で、反復子を使ったメソッドでは、returnするのはobject型であり(Generic版であればT型)IEnumeratorをreturnする記述はしません。
ILには反復子の命令はありませんので反復子で記述されたメソッドを上記のインターフェイスを実装するクラスに書き下す必要があります。(コンパイラがやってくれます)
単純なEnumerator
例えば次のような例を見てみましょう。
class Repeat0To1: IEnumeratable
{
// 0~1を順番に返すイテレータ
public IEnumerator GetEmumerator()
{
yield return 0;
yield return 1;
}
}
void Main(string[] args)
{
foreach(object item in new Repat0To1)
{
Console.WriteLine(item);
}
// foreachはコンパイラによって以下のように書き下される
// IEnumerator iterator = new Repat0To1().GetEnumerator();
// while(iterator.MoveNext())
// {
// Console.WriteLine(iterator.Current);
// }
}
0
1
これをコンパイルすると次のようになります。ILで書くと長いので、C#に書き起こしています。
class Repeat0To1: IEnumeratable
{
// <>はGenericではなく、名前。ILでは<>が名前に使えます。
class <Iterator1>d_1 : IEnumerator
{
int <>1__state;
object <>2__current;
public <Iterator1>d_1(int <>1__state) => this.<>1__state = <>1__state;
public object Current => <>2__current;
public bool MoveNext()
{
switch(<>1__state)
{
case 0:
{
<>1__state = -1;
<>2__current = 0;
<>1__state = 1
return true;
}
case 1:
{
<>1__state = -1;
<>2__current = 0;
<>1__state = 2
return true;
}
default
{
<>1__state = -1;
return false;
}
}
}
public void Reset() => throw new NotSupportedException();
}
public IEnumerator GetEmumerator() => new <Iterator1>d_1(0);
}
MoveNext
は想像に難くない感じですが、Reset
は使えなくなっているのですね。
ローカル変数と引数、そして可変長
public IEnumerator GetEmumerator(string[] args)
{
for (int i = 0; i < args.Length; i++)
{
yield return args[i];
}
}
さらにメソッドを複雑にしてみます。次のメソッドでは、引数とローカル変数を使用しています。また、先ほどのとは異なり終了までの回数があらかじめわかっていません。これをコンパイルすると次のようになります。ILで書くと長いので、C#に書き起こしています。
class <Iterator1>d_1 : IEnumerator
{
int <>1__state;
object <>2__current;
int <i>5__2;
public string[] args; //
public <Iterator1>d_1(int <>1__state) => this.<>1__state = <>1__state;
public object Current => <>2__current;
public bool MoveNext()
{
switch(<>1__state)
{
case 0: // 1回目
{
<>1__state = -1;
<i>5__2 = 0;
}
case 1: // 2~args.Length回目
{
<>1__state = -1;
<i>5__2++;
<>1__state = 2
return true;
}
default: // args.Length+1回目以降
{
return false;
}
}
if(<i>5__2 < args.Length)
{
<>2__current = args[<i>5__2];
<>1__state = 1;
return true;
}
else
{
return false;
}
}
public void Reset() => throw new NotSupportedException();
}
public IEnumerator GetEmumerator(string[] args)
{
IEnumerator iterator = new <Iterator1>d_1(0);
iterator.args = args;
return iterator;
}
引数や、ローカル変数はクラスのメンバにキャプチャされます。反復子をまたぐローカル変数を使いまくるとモリモリとヒープが肥大化するので注意が必要です。
イテレータの回数が決まっていないので、switchによるジャンプはできず、条件式を評価するようになりました。しかし、一度終了した後は条件式を評価せず、終了したことがキャッシュされるようです。
スコープの終了で処理の入る構文をつかう(using
,lock
)
static IEnumerator GetEmumerator()
{
using(new MemoryStream())
{
yield return null;
}
}
using
の方が単純なのでusing
を使うことにします。lock
やcheck
もスコープを抜ける際に特定の(.Netの)メソッドコールがされるので実質的には同じものと思われます。
コンパイルすると次のように書き下されます。
class <Iterator1>d_1 : IEnumerator
{
int <>1__state;
object <>2__current;
IDispose <>7__wrap1;
void <>m__Finally1() => <>7__wrap1.Dispose();
void Dispose => <>m__Finally1();
public <Iterator1>d_1(int <>1__state) => this.<>1__state = <>1__state;
public object Current => <>2__current;
public bool MoveNext()
{
bool result;
switch(<>1__state)
{
case 0:
{
try
{
<>1__state = -1;
<>7__wrap1 = new MemoryStream();
<>1__state = -3;
<>2__current = 0;
<>1__state = 1;
result = true;
}
catch
{
Dispose();
}
}
case 1:
{
try
{
<>1__state = -3;
<>m__Finally1(); // -> <>7__wrap1.Dispose()
<>7__wrap1 = null;
result = false;
}
catch
{
Dispose(); // -> <>m__Finally1() -> <>7__wrap1.Dispose()
}
}
default:
{
result = false;
}
}
return result;
}
public void Reset() => throw new NotSupportedException();
}
public IEnumerator GetEmumerator() => new <Iterator1>d_1(0);
<>7__wrap1.Dispose
や、<Iterator1>d_1.Dispose
を経由してややこしくなっています。MemoryStream.Dispose
はイテレータが終了する時のMoveNext
または、毎MoveNext
で例外が出たときに呼ばれます。つまり、必ず呼ばれる仕組みになっています。イテレータがちゃんと最後まで実行されれば…という前提ですが。
まとめ
C#の反復子は便利でちゃんと直感的な挙動をしてくれるようになっているようです。ファイルを一行づつ読んでいって…(using(StreamReader)
)なども安心して使えます。
また、メソッドはクラスにコンパイルされるので、<>c__Iterator0.MoveNext null refarence exception
みたいな知らないメソッドでエラーが出ても<>c_Iterator0
…?反復子のメソッドから生成されたクラスだな、元のメソッドを見てみることができます。
ちなみに、fixed
やstackalloc
、つまりunsafe
とyield return
は併用できませんので検証していません。
try-catch-finally
はILレベルでサポートされています。これもそれぞれのMoveNext
でtryされるようになります。