動作環境
.Net 6.0
Visual Studio 2022
この特殊なメソッドの使い方を調べたので、そのまとめ。
「GetEnumerator ってよく聞くけど、使い方はよく分かんない」という人向け。
大きく分けると以下の2つの目的で使用
- foreach でグルグル回すために使う
- コルーチンとして使う
using System.Collections;
以上
クラスに public IEnumerator<T> GetEnumerator() {} を追加するだけで foreach で使えるようになる魔法のような機能。
foreach でグルグル回したいときに、これに対応するかどうかでクラスの使いやすさが格段に変わる。
例
using System.Collections; var t = new test(); foreach (var i in t) { Console.WriteLine("return {0}", i); } class test { public IEnumerator<int> GetEnumerator() { Console.WriteLine("test.GetEnumerator()"); // return ではなく yield return がないといけない yield return 0; } }
実行結果
return 0
例の中では一度も GetEnumerator を呼び出していないが、実行結果には GetEnumerator を呼び出したと分かる出力が行われている。
このことから、foreach でぐるぐる回すときに、見えないところで GetEnumerator を呼び出してくれているっぽいことが分かる。
GetEnumerator から抜けるときは return は使えない。yield return を使う。
もうひとつ、GetEnumerator を抜ける方法として、yield break がある。
このどちらかが GetEnumerator の中にないとコンパイルエラーになる。
※両方あっても問題ない。
例
using System.Collections; var t = new test(); foreach (var i in t) { Console.WriteLine("return {0}", i); } class test { public IEnumerator<int> GetEnumerator() { yield break; //Console.WriteLine("test.GetEnumerator()"); //到達できないコード } }
実行結果
何も出力されない。
foreach ブロックが処理されれば、実行結果に return 0 が出力されるが、何も表示されない。
このことから、yield break; で GetEnumerator を抜けると、呼び出し元の foreach も break して抜けることが分かる。
呼び出し元の foreach を抜けてしまうので、呼び出し元に値を返す必要がない。
IEnumerator<T> GetEnumerator()
戻り値 IEnumerator<T> と宣言しているが、yield break は例外。
ここまでで分かることは、yield return も yield break も、呼び出し元の foreach ブロックに入る前に処理される。
foreach (var i in t) ← ここで GetEnumerator の処理を行っている
{
//foreach ブロック
}
yield return | GetEnumerator を抜ける 呼び出し元のループは抜けない |
yield break | GetEnumerator を抜ける 呼び出し元のループも抜ける |
break | GetEnumerator を抜けない 呼び出し元のループも抜けない GetEnumerator の中の今いるループを抜けるだけ ループの中にいないなら使えない |
return | GetEnumerator の中では使えない |
GetEnumerator の処理をいったん停止して、呼び出し元に処理を行う主導権を明け渡す…と考えれば、なんとなく感覚はつかめるかも知れない…。
例
using System.Collections; var t = new test(); foreach (var i in t) { Console.WriteLine("return {0}", i); } class test { public IEnumerator<int> GetEnumerator() { for (var i = 0; i < 5; i++) { yield return i; } } }
実行結果
return 1
return 2
return 3
return 4
GetEnumerator の中で for を使ってぐるぐるしている。
public IEnumerator<int> GetEnumerator() { for (var i = 0; i < 5; i++) { yield return i; } }
GetEnumerator じゃない場合、プログラムの動作を気にせずコンパイルが通るように↑と似たようなコードを書くと、以下のようになる。
public int GetEnumeratorじゃないよ() { for (var i = 0; i < 5 i++) { return i; } return 0; // 到達できないコード }
「GetEnumeratorじゃないよ()」の場合、i = 0 で return i してしまうので、i が 1 以上になることはない。
GetEnumerator の場合、yield return i でメソッドを抜けていることは実行結果から分かるが、foreach の次のループで何故か yield return i; の続きから処理が再開されていることも実行結果から分かる。
処理の流れ
順を追ってみる
- foreach (var i in t) ←ここで GetEnumerator() が呼ばれる
- GetEnumerator() { for (略) { yield return i;
yield return でリターンして 0 が返る - foreach (var i ←これに yield return i; の結果が入る
2. で 0 を返したので i == 0 - foreach (var i in t) { Console.WriteLine(“return {0}”, i);
i = 0 なので “return 0” が出力される - foreach のループへ
- foreach (var i in t) ← GetEnumerator() 内の yield return i; の次の行から処理を再開
- GetEnumerator() { for (var i = 0… の次のループへ
i++ されて i = 1 になる - GetEnumerator() { for (略) { yield return i; で 1 が返る
- foreach (var i ←これに yield return i; の結果が入る
8. で 1 を返したので i == 1 - 以下、i == 4 まで繰り返し
例
using System.Collections; var t = new test(); foreach (var i in t) { Console.WriteLine("return {0}", i); } class test { public IEnumerator<int> GetEnumerator() { var i = 0; yield return i; i++; yield return i; i++; yield return i; i++; for (; ; i++) { if (i > 4) { yield break; } yield return i; } } }
実行結果
return 1
return 2
return 3
return 4
GetEnumerator の中の処理を変更したが、実行結果は変わっていない。
ここから分かることは、GetEnumerator の中の処理は、好きなところで中断でき、再度 GetEnumerator を呼び出すと、中断した次の処理から再開できること。
マルチスレッドのワーカースレッドでも同じことができるし、lua 言語のコルーチンでも同じことができる。
GetEnumerator の処理は別スレッドでは行われない=呼び出し元と同じスレッドで処理されるので、GetEnumerator はマルチスレッドではなく、コルーチンだと分かる。
ゲームプログラミングでは、コルーチンは割とポピュラーな仕組み。
Unity には MonoBehaviour に IEnumerator を使った StartCoroutine() メソッドがある。
以下は、執筆時点で話題だった Bing AI を使って、Unity のコルーチンを使う場面について質問したときのスクリーンショット。
完全な並列処理が必要ならマルチスレッドを使う。
コルーチンの利点はシングルスレッドで並列であるかのような処理を手軽に実装できる点。
ぱっと思いついたのは、ガベコレにコルーチンを使う方法。
ガベコレは毎フレームやれるならやった方が良いが、余裕があるときだけ10%だけガベコレする…とか、余裕がないときはスキップするといった使い分けができそう。
ただ、.Net の GC には10%だけガベコレする…みたいな仕組みがないので、それをやるならアロケータ自体を自分で作らないといけない。
.Net のガベコレは世代という単位で実行できる。
第0世代は一番最近確保したメモリで、頻繁に確保と解放を繰り返す。
第1世代、第2世代は、長期間確保し続けているメモリで、期間は第2世代の方が長い。
普段は0世代をガベコレして、余裕があったら、1世代、2世代をガベコレする…という使い分けはできそう。
例
using System.Diagnostics; using System.Runtime.InteropServices; // Thread.Sleep の精度を変更 class WinmmDLL { [DllImport("Winmm.dll")] public static extern uint timeBeginPeriod(uint uuPeriod); } class Sample { static IEnumerator<int> gcIterator = new GarbageCollector().GetEnumerator(); static void Main() { { uint v = WinmmDLL.timeBeginPeriod(1); } for (;;) { Console.WriteLine("Start MainProcess"); // メインの処理 double elapsed = TimeProcess.Execute(() => { var rand = new Random(); // ランダムなサイズの領域を new for (var i = 0; i < 100; i++) { var n = new int[rand.Next(100000) + 1]; } Thread.Sleep(12); // マシンパワーに合わせて変える }); Console.WriteLine("End MainProcess elapsed={0} msec.", elapsed); // 余裕があればガベコレ if (elapsed <= 15) { CollectGarbage(); } // FPS60を想定 elapsed = 16.666 - elapsed; if (elapsed > 0) { // 余った時間待つ int wait = (int)elapsed; Console.WriteLine("Wait for {0} msec.", wait); Thread.Sleep(wait); } string? line = Console.ReadLine(); } } static void CollectGarbage() { double total = 0; for (;;) { double elapsed = TimeProcess.Execute(() => { if (gcIterator.MoveNext()) { Console.WriteLine("Collected Generation {0}", gcIterator.Current); } else { // 第二世代まで行ったのでリセット gcIterator = new GarbageCollector().GetEnumerator(); } }); int generation = gcIterator.Current; total += elapsed; Console.WriteLine("Collect Garbage Total Elapsed Time={0} msec.", total); // 第二世代まで処理した、または、0.5msec 以上経過したら終了 if (generation >= 2 || total >= 0.5) { break; } } } } class TimeProcess { public static double Execute(Action action) { var stopwatch = new Stopwatch(); stopwatch.Start(); action(); stopwatch.Stop(); return stopwatch.Elapsed.TotalMilliseconds; } } class GarbageCollector { public IEnumerator<int> GetEnumerator() { GC.Collect(0); yield return 0; GC.Collect(1); yield return 1; GC.Collect(2); yield return 2; } }
「【C#】GetEnumerator() の使い方まとめ」への1件のフィードバック