【C#】GetEnumerator() の使い方まとめ

動作環境
.Net 6.0
Visual Studio 2022

IEnumerator<T> GetEnumerator()

この特殊なメソッドの使い方を調べたので、そのまとめ。
「GetEnumerator ってよく聞くけど、使い方はよく分かんない」という人向け。

大きく分けると以下の2つの目的で使用

  1. foreach でグルグル回すために使う
  2. コルーチンとして使う

使うための準備

using System.Collections;

以上

foreach でグルグル回すために使う 初級編

クラスに 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;
    }
}

実行結果

test.GetEnumerator()
return 0
GetEnumerator は foreach で勝手に呼ばれる

の中では一度も GetEnumerator を呼び出していないが、実行結果には GetEnumerator を呼び出したと分かる出力が行われている。
このことから、foreach でぐるぐる回すときに、見えないところで GetEnumerator を呼び出してくれているっぽいことが分かる。

なんだそりゃ?って思いますが、そういう仕様です。
GetEnumerator は yield return か yield break で抜ける

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 は例外。

そういう仕様なので考えても仕方がないです。
GetEnumerator を処理しているところ

ここまでで分かることは、yield return も yield break も、呼び出し元の foreach ブロックに入る前に処理される。

foreach (var i in t) ← ここで GetEnumerator の処理を行っている
{
 //foreach ブロック
}

yield return と yield break の違い
yield return GetEnumerator を抜ける
呼び出し元のループは抜けない
yield break GetEnumerator を抜ける
呼び出し元のループも抜ける
break GetEnumerator を抜けない
呼び出し元のループも抜けない
GetEnumerator の中の今いるループを抜けるだけ
ループの中にいないなら使えない
return GetEnumerator の中では使えない
yield は、明け渡す、押し戻す、停止する…といった意味を持つ言葉。
GetEnumerator の処理をいったん停止して、呼び出し元に処理を行う主導権を明け渡す…と考えれば、なんとなく感覚はつかめるかも知れない…。

foreach でグルグル回すために使う 中級編

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 0
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; の続きから処理が再開されていることも実行結果から分かる。

yield return の挙動が特殊すぎる

処理の流れ

順を追ってみる

  1. foreach (var i in t) ←ここで GetEnumerator() が呼ばれる
  2. GetEnumerator() { for (略) { yield return i;
    yield return でリターンして 0 が返る
  3. foreach (var i ←これに yield return i; の結果が入る
    2. で 0 を返したので i == 0
  4. foreach (var i in t) { Console.WriteLine(“return {0}”, i);
    i = 0 なので “return 0” が出力される
  5. foreach のループへ
  6. foreach (var i in t) ← GetEnumerator() 内の yield return i; の次の行から処理を再開
  7. GetEnumerator() { for (var i = 0… の次のループへ
    i++ されて i = 1 になる
  8. GetEnumerator() { for (略) { yield return i; で 1 が返る
  9. foreach (var i ←これに yield return i; の結果が入る
    8. で 1 を返したので i == 1
  10. 以下、i == 4 まで繰り返し

foreach でグルグル回すために使う 上級編

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 0
return 1
return 2
return 3
return 4

GetEnumerator の中の処理を変更したが、実行結果は変わっていない。
ここから分かることは、GetEnumerator の中の処理は、好きなところで中断でき、再度 GetEnumerator を呼び出すと、中断した次の処理から再開できること。

マルチスレッドのワーカースレッドでも同じことができるし、lua 言語のコルーチンでも同じことができる。
GetEnumerator の処理は別スレッドでは行われない=呼び出し元と同じスレッドで処理されるので、GetEnumerator はマルチスレッドではなく、コルーチンだと分かる。

ゲームプログラミングでは、コルーチンは割とポピュラーな仕組み。
Unity には MonoBehaviour に IEnumerator を使った StartCoroutine() メソッドがある。

以下は、執筆時点で話題だった Bing AI を使って、Unity のコルーチンを使う場面について質問したときのスクリーンショット。

アニメーションやエフェクト制御で使える…と記載されていますが、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件のフィードバック

コメントを残す

メールアドレスが公開されることはありません。 が付いている欄は必須項目です