C# でコレクションを扱うとき、foreach ループはほぼ必ず登場します。その裏側を支えているのが IEnumerable と IEnumerator という 2 つのインターフェースです。
この記事では両インターフェースの役割と仕組みを整理し、yield キーワードを使ったイテレータメソッドの実装方法、C# 7.0 のローカル関数を使ったガード句パターン、名前付きイテレータの作り方までを実例とともに解説します。
IEnumerator — 「今どこにいるか」を管理する
IEnumerator は、シーケンスを一方向に進みながら要素を取り出すためのインターフェースです。System.Collections 名前空間に定義されており、次のメンバを持ちます。
public interface IEnumerator
{
object? Current { get; } // 現在の要素
bool MoveNext(); // 次の要素に進む。要素があれば true
void Reset(); // 先頭に巻き戻す(実装は任意)
}
ジェネリック版は IEnumerator<T> で、Current が型安全な T を返します。
public interface IEnumerator<out T> : IEnumerator, IDisposable
{
new T Current { get; }
}
IDisposable を継承しているため、foreach は処理完了後(または例外発生時)に Dispose() を自動で呼びます。
状態遷移
IEnumerator は次の 3 状態を持つ小さなステートマシンです。
| 状態 | 説明 |
|---|---|
| 初期状態 | Current は未定義。MoveNext() 未呼び出し |
| 列挙中 | MoveNext() が true を返した。Current に要素がある |
| 終端 | MoveNext() が false を返した。Current は未定義 |
// 手動で IEnumerator を使うイメージ
var list = new List<int> { 10, 20, 30 };
IEnumerator<int> e = list.GetEnumerator();
while (e.MoveNext())
{
Console.WriteLine(e.Current);
}
e.Dispose();
// 10
// 20
// 30
Reset() は COM 相互運用のために残されているレガシーなメンバで、多くの実装は NotSupportedException を投げます。使わないのが無難です。
IEnumerable — 「列挙できる」ことを表す
IEnumerable は、その型が「列挙可能であること」を宣言するインターフェースです。持っているのは 1 つだけです。
public interface IEnumerable
{
IEnumerator GetEnumerator();
}
ジェネリック版は IEnumerable<T> です。
public interface IEnumerable<out T> : IEnumerable
{
new IEnumerator<T> GetEnumerator();
}
GetEnumerator() を呼ぶと、シーケンスの先頭を指す新しい IEnumerator が返ります。foreach はこのメソッドを呼んで取得したイテレータを使って要素を巡回します。
2 つのインターフェースの関係
| インターフェース | 役割 |
|---|---|
IEnumerable<T> |
「列挙できる」ことを宣言。イテレータのファクトリ |
IEnumerator<T> |
「今どこにいるか」を管理。実際の移動ロジックを担う |
IEnumerable が「本棚」、IEnumerator が「栞」のイメージです。本棚は何人でも共有できますが、栞(読み進める位置)は人それぞれが持ちます。GetEnumerator() を呼ぶたびに新しい栞が返るため、複数のループを同時に安全に動かせます。
var nums = new List<int> { 1, 2, 3 };
// 2 つのループが独立したイテレータを持つ
foreach (int x in nums)
foreach (int y in nums)
Console.Write($"({x},{y}) ");
// (1,1) (1,2) (1,3) (2,1) (2,2) ...
foreach の内部動作
foreach はコンパイラが展開するシンタックスシュガーです。次のコードは
foreach (int n in collection)
{
Console.WriteLine(n);
}
大まかに次のように展開されます。
{
IEnumerator<int> enumerator = collection.GetEnumerator();
try
{
while (enumerator.MoveNext())
{
int n = enumerator.Current;
Console.WriteLine(n);
}
}
finally
{
enumerator.Dispose();
}
}
finally ブロックで Dispose() が呼ばれるため、例外が発生しても後処理が保証されます。
ダックタイピング
foreach は厳密に IEnumerable を要求するわけではありません。「GetEnumerator() という名前のパブリックメソッドを持ち、返り値が MoveNext() と Current を持つ型」であれば動作します(ダックタイピング)。これにより Span<T> や Range など、インターフェースを実装しない型でも foreach が使えます。
IEnumerable<T> を実装するクラスを自作する
手動で IEnumerable<T> を実装する例を示します。シンプルな数値範囲クラスです。
public class NumberRange : IEnumerable<int>
{
private readonly int _start;
private readonly int _end;
public NumberRange(int start, int end)
{
_start = start;
_end = end;
}
public IEnumerator<int> GetEnumerator()
=> new NumberRangeEnumerator(_start, _end);
IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); // 非ジェネリック版
}
public class NumberRangeEnumerator : IEnumerator<int>
{
private readonly int _start;
private readonly int _end;
private int _current;
public NumberRangeEnumerator(int start, int end)
{
_start = start;
_end = end;
_current = start - 1; // 初期状態は先頭の 1 つ前
}
public int Current => _current;
object? IEnumerator.Current => _current;
public bool MoveNext()
{
_current++;
return _current <= _end;
}
public void Reset() => _current = _start - 1;
public void Dispose() { } // リソースがないので何もしない
}
foreach (int n in new NumberRange(1, 5))
Console.Write(n + " "); // 1 2 3 4 5
このように手動で実装するとコードが長くなります。そこで yield キーワードの出番です。
yield キーワードでイテレータメソッドを作る
C# 2 で導入された yield を使うと、コンパイラがイテレータクラスを自動生成してくれます。IEnumerator を手書きする必要がなくなり、シーケンスを生成するロジックを素直な手続き型スタイルで書けます。
yield return — 値を一つずつ返す
yield return を使うと、メソッドは呼ばれるたびに次の値を返す「遅延実行ジェネレータ」になります。
public static IEnumerable<int> Range(int start, int end)
{
for (int i = start; i <= end; i++)
{
yield return i; // i を返し、次の呼び出しでループを再開
}
}
foreach (int n in Range(1, 5))
Console.Write(n + " "); // 1 2 3 4 5
yield return に達すると、その値が Current として呼び出し元に渡されます。次に MoveNext() が呼ばれると、yield return の直後から実行が再開されます。これをコンパイラがステートマシンとして実現します。
yield break — イテレータを途中で終了する
yield break を使うと、シーケンスをその場で終了できます。
public static IEnumerable<int> TakeWhilePositive(IEnumerable<int> source)
{
foreach (int n in source)
{
if (n <= 0)
yield break; // 0 以下に達したら終了
yield return n;
}
}
var nums = new[] { 3, 7, 2, -1, 5 };
foreach (int n in TakeWhilePositive(nums))
Console.Write(n + " "); // 3 7 2
遅延実行(Lazy Evaluation)
イテレータメソッドの本体は、実際に要素が要求されるまで実行されません。これを遅延実行と言います。
public static IEnumerable<int> Lazy()
{
Console.WriteLine("開始");
yield return 1;
Console.WriteLine("1 の後");
yield return 2;
Console.WriteLine("2 の後");
}
var seq = Lazy(); // この時点では何も出力されない
foreach (int n in seq)
{
Console.WriteLine($"取得: {n}");
}
// 開始
// 取得: 1
// 1 の後
// 取得: 2
// 2 の後
LINQ の Where や Select も同様に遅延実行です。イテレータを組み合わせることで、巨大なシーケンスでもメモリを使い切ることなく処理できます。
無限シーケンス
yield を使うと終端のないシーケンスを表現できます。
public static IEnumerable<int> NaturalNumbers()
{
int n = 1;
while (true)
yield return n++;
}
// LINQ で先頭 10 件だけ取り出す
foreach (int n in NaturalNumbers().Take(10))
Console.Write(n + " "); // 1 2 3 4 5 6 7 8 9 10
ガード句とローカル関数(C# 7.0)
イテレータメソッドには重要な落とし穴があります。引数のバリデーションが遅延実行されるという点です。
public static IEnumerable<int> Range(int start, int end)
{
// ここに ArgumentException を書いても...
if (start > end)
throw new ArgumentException("start は end 以下である必要があります");
for (int i = start; i <= end; i++)
yield return i;
}
var seq = Range(10, 1); // ここでは例外が発生しない!
// ...
foreach (int n in seq) // foreach を呼んで初めて例外が発生する
Console.WriteLine(n);
これは呼び出し側にとって直感に反します。Range(10, 1) を呼んだ時点でエラーを検出したいはずです。
C# 7.0 のローカル関数でガード句を実装する
C# 7.0 のローカル関数を使うと、この問題をエレガントに解決できます。バリデーションを外側のメソッドに残し、実際のイテレータをローカル関数に切り出す方法です。
public static IEnumerable<int> Range(int start, int end)
{
// ガード句: 引数チェックはここで即時実行される
if (start > end)
throw new ArgumentException($"{nameof(start)} は {nameof(end)} 以下にしてください");
return Core(); // ローカル関数を返す(遅延実行)
// ローカル関数: 実際のイテレータロジック
IEnumerable<int> Core()
{
for (int i = start; i <= end; i++)
yield return i;
}
}
// 引数が不正なら呼び出した時点で例外
var seq = Range(10, 1); // ArgumentException がここで投げられる
// 正常な呼び出し
foreach (int n in Range(1, 5))
Console.Write(n + " "); // 1 2 3 4 5
このパターンのポイントは:
- 外側のメソッド(
Range)はyieldを使わないため、通常のメソッドとして即時実行される - 引数チェックを先に行い、問題があれば即座に例外を投げる
- 正常な場合は
yieldを含むローカル関数Core()を返す Core()内の処理は遅延実行されるが、引数はすでに検証済み
C# 7.0 以前は、同じことを達成するために別クラスにメソッドを切り出す必要がありましたが、ローカル関数によって同じスコープ内で完結させられます。
null チェックも同様に
public static IEnumerable<string> ToUpperAll(IEnumerable<string> source)
{
ArgumentNullException.ThrowIfNull(source); // .NET 6 以降
return Core();
IEnumerable<string> Core()
{
foreach (string s in source)
yield return s.ToUpper();
}
}
ToUpperAll(null!); // 即座に ArgumentNullException
foreach (var s in ToUpperAll(new[] { "abc", "def" }))
Console.WriteLine(s); // ABC、DEF
名前付きイテレータ(Named Iterator)
「名前付きイテレータ」とは、同一クラスが複数の列挙方法をそれぞれ異なる名前のメソッドとして提供するパターンです。
たとえばバイナリツリーの巡回順序(前順・中順・後順)を別々のメソッドとして用意する場合がこれに当たります。
public class BinaryTree<T> : IEnumerable<T> where T : IComparable<T>
{
public T Value { get; }
public BinaryTree<T>? Left { get; }
public BinaryTree<T>? Right { get; }
public BinaryTree(T value, BinaryTree<T>? left = null, BinaryTree<T>? right = null)
{
Value = value;
Left = left;
Right = right;
}
// --- 名前付きイテレータ ---
// 前順(Pre-order): 根 → 左 → 右
public IEnumerable<T> PreOrder()
{
yield return Value;
if (Left is not null)
foreach (var v in Left.PreOrder()) yield return v;
if (Right is not null)
foreach (var v in Right.PreOrder()) yield return v;
}
// 中順(In-order): 左 → 根 → 右 (二分探索木では昇順になる)
public IEnumerable<T> InOrder()
{
if (Left is not null)
foreach (var v in Left.InOrder()) yield return v;
yield return Value;
if (Right is not null)
foreach (var v in Right.InOrder()) yield return v;
}
// 後順(Post-order): 左 → 右 → 根
public IEnumerable<T> PostOrder()
{
if (Left is not null)
foreach (var v in Left.PostOrder()) yield return v;
if (Right is not null)
foreach (var v in Right.PostOrder()) yield return v;
yield return Value;
}
// IEnumerable<T> の実装は中順をデフォルトにする
public IEnumerator<T> GetEnumerator() => InOrder().GetEnumerator();
IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
}
// 4
// / \
// 2 6
// / \ / \
// 1 3 5 7
var tree = new BinaryTree<int>(4,
new BinaryTree<int>(2,
new BinaryTree<int>(1),
new BinaryTree<int>(3)),
new BinaryTree<int>(6,
new BinaryTree<int>(5),
new BinaryTree<int>(7)));
Console.Write("前順: ");
foreach (int n in tree.PreOrder()) Console.Write(n + " "); // 4 2 1 3 6 5 7
Console.Write("\n中順: ");
foreach (int n in tree.InOrder()) Console.Write(n + " "); // 1 2 3 4 5 6 7
Console.Write("\n後順: ");
foreach (int n in tree.PostOrder()) Console.Write(n + " "); // 1 3 2 5 7 6 4
Console.Write("\nforeach: ");
foreach (int n in tree) Console.Write(n + " "); // 1 2 3 4 5 6 7(中順)
名前付きイテレータの利点
- 一つのクラスが複数の列挙戦略を持てる
- 呼び出し側は目的に応じたメソッドを選べる
GetEnumerator()に縛られず、任意の名前でメソッドを公開できる- 各イテレータは独立しているので同時使用でも安全
フィルタリングを組み合わせた名前付きイテレータ
public class EventLog : IEnumerable<string>
{
private readonly List<(string Level, string Message)> _entries = new();
public void Add(string level, string message) => _entries.Add((level, message));
// 全件
public IEnumerator<string> GetEnumerator()
=> _entries.Select(e => $"[{e.Level}] {e.Message}").GetEnumerator();
IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
// エラーのみ
public IEnumerable<string> Errors()
{
foreach (var (level, message) in _entries)
{
if (level == "ERROR")
yield return $"[ERROR] {message}";
}
}
// 警告以上(WARNING / ERROR)
public IEnumerable<string> Warnings()
{
foreach (var (level, message) in _entries)
{
if (level is "WARNING" or "ERROR")
yield return $"[{level}] {message}";
}
}
}
var log = new EventLog();
log.Add("INFO", "サービス開始");
log.Add("WARNING", "メモリ使用率 80%");
log.Add("ERROR", "DB 接続失敗");
log.Add("INFO", "リトライ中");
log.Add("ERROR", "タイムアウト");
Console.WriteLine("-- エラーのみ --");
foreach (string entry in log.Errors())
Console.WriteLine(entry);
// [ERROR] DB 接続失敗
// [ERROR] タイムアウト
Console.WriteLine("-- 警告以上 --");
foreach (string entry in log.Warnings())
Console.WriteLine(entry);
// [WARNING] メモリ使用率 80%
// [ERROR] DB 接続失敗
// [ERROR] タイムアウト
IEnumerable<T> と LINQ
IEnumerable<T> を実装すると、LINQ の拡張メソッド(Where、Select、OrderBy など)がすべて使えるようになります。これは LINQ が IEnumerable<T> を対象とした拡張メソッドとして実装されているためです。
IEnumerable<int> evens = Range(1, 10).Where(n => n % 2 == 0);
IEnumerable<string> labels = evens.Select(n => $"偶数: {n}");
foreach (string s in labels)
Console.WriteLine(s);
// 偶数: 2
// 偶数: 4
// 偶数: 6
// 偶数: 8
// 偶数: 10
yield を使って自作したイテレータも、IEnumerable<T> を返す限り LINQ チェーンの中に組み込めます。
// 自作イテレータ + LINQ を組み合わせた例
var result = NaturalNumbers() // 無限シーケンス
.Where(n => n % 3 == 0) // 3 の倍数だけ
.Select(n => n * n) // 二乗
.Take(5); // 先頭 5 件で打ち切り
foreach (int n in result)
Console.Write(n + " "); // 9 36 81 144 225
まとめ
| トピック | ポイント |
|---|---|
IEnumerator<T> |
MoveNext() / Current / Dispose() を持つステートマシン |
IEnumerable<T> |
GetEnumerator() を返す。イテレータのファクトリ |
foreach |
GetEnumerator() → MoveNext() → Current → Dispose() の展開 |
yield return |
値を一つ返してサスペンド。次回はその直後から再開 |
yield break |
イテレータを即座に終了 |
| 遅延実行 | イテレータ本体は要素が要求されるまで実行されない |
| ガード句 + ローカル関数 | 引数バリデーションを即時実行し、イテレータ本体を遅延実行に切り離す |
| 名前付きイテレータ | 複数の列挙戦略を異なるメソッドとして公開するパターン |
| LINQ との組み合わせ | IEnumerable<T> を返せば LINQ 全体と自由に組み合わせられる |
IEnumerable と IEnumerator を理解することは、LINQ やコレクション操作の根底を理解することに直結します。yield による遅延実行は特にパフォーマンスに効く場面が多く、ローカル関数ガード句と組み合わせることで安全で読みやすいイテレータが書けます。