1. はじめに
巨大なログファイルを処理して何百万行もある場合や、とてつもなく長い数列を生成する必要があると想像してみてください。ジェネレーターなしでどうするでしょうか?
従来のアプローチはこんな感じになります:
// 問題: コレクション全体を一度にメモリに生成する
List<int> GenerateAllNumbersSync(int count)
{
List<int> numbers = new List<int>();
for (int i = 0; i < count; i++)
{
numbers.Add(i);
}
return numbers; // 全部できたら返す
}
// 使用例:
var myNumbers = GenerateAllNumbersSync(1_000_000); // 一度に100万個の数がメモリに!
foreach (var num in myNumbers) { /* 処理 */ }
ここでの問題点は?
- メモリ消費: count が非常に大きいと、コレクション全体がメモリに作られ、OutOfMemoryException を引き起こす可能性があります。
- 遅延: ユーザーや次の処理は、すべてのデータが完全に生成されメモリに読み込まれるまで待たされます。
- 無限列: もし列が潜在的に無限であれば、このやり方は機能しません。
ここで助けになるのがジェネレーターです!ジェネレーターは遅延評価(Lazy Evaluation)とストリーミング処理の概念を実現します。すべてを一度に生成する代わりに、ジェネレーターは要素を一つずつ必要なときにだけ生成します。
2. ジェネレーターの基本
C#ではジェネレーターは特別なキーワードyieldを使って作ります。
ジェネレーターとは?
それはメソッド、プロパティのgetブロック、または一連のyield return式を含むステートメントです。
yield return
これはジェネレーターの核心です。コンパイラがyield returnに遭遇すると:
- yield return の後にある要素が呼び出し元に渡されます。
- ジェネレーターの実行は一時停止され、その時点の状態(ループの位置やローカル変数の値など)が保存されます。
- 次の要素が要求されたとき(例えば次の foreach イテレーションで)、メソッドの実行は中断した場所から再開されます。
戻り型: ジェネレーターメソッドは IEnumerable<T> または IEnumerator<T> を返す必要があります。コンパイラが必要な「魔法」を自動生成します。
// 例 2.1: シンプルな数生成ジェネレーター
IEnumerable<int> GenerateNumbers(int count)
{
Console.WriteLine("ジェネレーション開始...");
for (int i = 0; i < count; i++)
{
Console.WriteLine($"生成中: {i}");
yield return i; // 一時停止して要素を返す
}
Console.WriteLine("ジェネレーション完了。");
}
// 使用例:
// "ジェネレーション開始..." は最初のイテレーション時にだけ表示される点に注意!
// "生成中: X" は各イテレーションで表示される。
foreach (var num in GenerateNumbers(3))
{
Console.WriteLine($"foreachで受け取った: {num}");
}
yield break
イテレーションを早期終了するために使います。yield break の後はそれ以上要素は返されません。メソッドの終わりに到達するなら別途 yield break は不要です。
// 例 2.2: 途中終了するジェネレーター
IEnumerable<string> GetFirstNElements(List<string> source, int n)
{
int count = 0;
foreach (var item in source)
{
if (count >= n)
{
yield break; // ジェネレーターを抜ける
}
yield return item;
count++;
}
}
// 使用例:
// var fruits = new List<string> { "Apple", "Banana", "Orange", "Grape" };
// foreach (var fruit in GetFirstNElements(fruits, 2))
// {
// Console.WriteLine(fruit); // "Apple", "Banana" を出力
// }
3. ステートマシン
「内部的にどう動いているのか?」というと、ここに魔法はなく、コンパイラの賢い変換があります。
yield を含むメソッドを書くと、C#コンパイラはそれを IEnumerator<T> と IEnumerable<T> を実装するクラスに変換します。この生成されたクラスがステートマシンです。
- 状態の保存: マシンはどこで止まったかを示す状態番号と、停止時のすべてのローカル変数の値を保持します。
- イテレーション: foreach で列挙するときは MoveNext() が呼ばれ、Current プロパティが読まれます。MoveNext() は次の yield return/yield break まで実行を再開し、Current は現在の要素を返します。
実際にはコンパイラがあなたの代わりにIterator Patternを実装してくれているわけです。
4. ジェネレーターの用途
例: 大量データの処理
ファイルを行ごとに読みつつ、ファイル全体をメモリに読み込まない方法。
// 大きなファイルの読み取りを模擬
IEnumerable<string> ReadBigFileLines(string filePath)
{
Console.WriteLine($"ファイルを開く: {filePath}");
// 実際のアプリではここで StreamReader を使う
yield return "データ行 1";
yield return "データ行 2";
yield return "データ行 3";
Console.WriteLine("ファイル読み取りの模擬を終了。");
}
// 使用例:
Console.WriteLine("処理開始。");
foreach (var line in ReadBigFileLines("my_huge_log.txt"))
{
Console.WriteLine($"処理した行: {line}");
if (line.Contains("2")) break; // 好きなときに停止できる
}
Console.WriteLine("処理終了。");
終了メッセージはイテレーションが終わるまで表示されない点に注意してください。
例: 無限列
IEnumerable<long> FibonacciSequence()
{
long a = 0;
long b = 1;
while (true) // 潜在的に無限のシーケンス
{
yield return a;
long temp = a;
a = b;
b = temp + b;
}
}
// 使用例:
int count = 0;
foreach (var num in FibonacciSequence())
{
Console.WriteLine(num);
count++;
if (count >= 10) break; // ハングしないように制限する必要がある
}
例: データ処理のパイプライン
各ステップが「途中で」データを処理するようなメソッドのチェーンを作る。
IEnumerable<int> GetNumbers()
{
yield return 1; yield return 2; yield return 3; yield return 4; yield return 5;
}
IEnumerable<int> FilterEven(IEnumerable<int> source)
{
foreach (var num in source)
{
if (num % 2 == 0) yield return num;
}
}
IEnumerable<int> Square(IEnumerable<int> source)
{
foreach (var num in source)
{
yield return num * num;
}
}
// 使用例:
foreach (var result in Square(FilterEven(GetNumbers())))
{
Console.WriteLine(result); // 4, 16
}
これは LINQ の多くのオペレーター(例えば Where, Select, Take, Skip)がどのように動くかに似ています。
5. 非同期ジェネレーター
同期ジェネレーターは優秀ですが、各要素が非同期操作(例えばネットワークリクエスト)を必要とする場合はどうでしょう? C# 8.0以前は実装が困難でした。
問題: 非同期データストリーム
同期ジェネレーターメソッドの中で await は使えません。
// これはコンパイルされない!
IEnumerable<string> GetStringsAsyncProblem()
{
await Task.Delay(100); // エラー: await は async メソッドでしか使えない
yield return "Hello";
}
解決策: IAsyncEnumerable<T> と await foreach
- IAsyncEnumerable<T> は IEnumerable<T> の非同期版です。
- await foreach は非同期シーケンスを列挙するための便利な構文です(内部では MoveNextAsync() を呼び出し、非同期状態を処理します)。
async yield return
今では async メソッド内で yield return を使い、IAsyncEnumerable<T> を返せます。コンパイラは非同期ステートマシンを構築します。
// 例 5.1: 非同期数生成ジェネレーター
async IAsyncEnumerable<int> GenerateNumbersAsync()
{
Console.WriteLine("非同期ジェネレーション開始...");
for (int i = 0; i < 5; i++)
{
await Task.Delay(100); // 非同期処理の模擬(例: ネットワークリクエスト)
Console.WriteLine($"非同期に生成: {i}");
yield return i; // 要素を返す
}
Console.WriteLine("非同期ジェネレーション完了。");
}
// 使用例:
async Task ConsumeAsyncNumbers()
{
Console.WriteLine("非同期処理開始...");
await foreach (var number in GenerateNumbersAsync())
{
Console.WriteLine($"非同期に受け取った: {number}");
}
Console.WriteLine("非同期処理完了。");
}
// 実行:
// async Main などのコンテキストから呼び出す
await ConsumeAsyncNumbers();
IAsyncDisposable と await using(ジェネレーター文脈で)
ジェネレーターが非同期に解放されるリソースを開く場合(DisposeAsync())、await using を使います。await foreach を終了すると、内部のイテレーターが IAsyncDisposable を実装していれば自動的に DisposeAsync() を呼びます。
// 例 5.2: await using を使った非同期ファイル読み取り
// 実際の StreamReader は IAsyncDisposable を実装している
async IAsyncEnumerable<string> ReadFileLinesAsync(string filePath)
{
Console.WriteLine($"[Generator] 非同期でファイルを開く: {filePath}");
// await using はブロック終了後に DisposeAsync() を保証する
await using var reader = new StreamReader(filePath);
string? line;
while ((line = await reader.ReadLineAsync()) != null) // 行を非同期に読む
{
yield return line;
}
Console.WriteLine($"[Generator] ファイル読み取り完了: {filePath}");
}
// 使用例:
async Task ProcessFileAsync()
{
Console.WriteLine("[Processor] ファイル処理開始。");
await foreach (var line in ReadFileLinesAsync("path_to_some_file.txt")) // 実パスに置き換えてください
{
Console.WriteLine($"[Processor] 受け取った行: {line}");
// 各行に対して非同期処理を行える
await Task.Delay(50);
}
Console.WriteLine("[Processor] ファイル処理完了。");
}
// 実行:
await ProcessFileAsync(); // async Main から呼んでください
非同期ジェネレーターのキャンセル: CancellationToken
呼び出し側が生成をキャンセルできるように、非同期ジェネレーターに CancellationToken を追加しましょう。
// 例 5.3: キャンセル可能な非同期ジェネレーター
async IAsyncEnumerable<int> GenerateCancelableSequence(
int start, int count,
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken token = default)
{
for (int i = 0; i < count; i++)
{
token.ThrowIfCancellationRequested(); // キャンセルトークンを確認
await Task.Delay(100, token); // Task.Delay もキャンセル対応
yield return start + i;
}
}
使用例
var cts = new CancellationTokenSource();
Task.Run(async () =>
{
await Task.Delay(300); // ジェネレーターに少し作業させる
cts.Cancel(); // キャンセル!
});
try
{
await foreach (var num in GenerateCancelableSequence(0, 100, cts.Token))
{
Console.WriteLine($"受け取った: {num}");
}
}
catch (OperationCanceledException)
{
Console.WriteLine("生成がキャンセルされました。");
}
6. 制約と注意して使うべき場面
yield の制約:
- yield return を含む try ブロック内で、catch または finally にも yield を含めることはできません。
- yield を含むメソッドは unsafe にできません。
- async void メソッドの中で yield は使えません(async Task または IAsyncEnumerable<T> を使ってください)。
パフォーマンス: 非常に小さいまたは固定サイズのコレクションでは、ステートマシンのオーバーヘッドにより直接 List<T> を返す方がわずかに高速なことがあります。しかし大規模データでは遅延評価とストリーミングの利点が通常は圧倒的に大きいです。
エラー処理: ジェネレーター内部でスローされた例外は呼び出し元まで適切に伝播し、通常のメソッドと同じように呼び出し側でキャッチできます。
GO TO FULL VERSION