TLDR: IEnumerable に関連する最も一般的な間違いである繰り返し列挙を詳しく確認してみましょう。ただし、今回はもう少し深く掘り下げて、繰り返し列挙が間違いである理由と、見つけにくく再現が難しいバグなど、どのような潜在的な問題が発生する可能性があるかを確認します。
まず最初に、ジェネリックと非ジェネリックの両方の IEnumerable とは何かを (もう一度、これに関する記事がかなりたくさんあるので) 確認しましょう。多くのインタビューやコード レビューが示すように、多くの開発者は無意識のうちに IEnumerable のインスタンスをコレクションとして見ています。ここから始めます。
IEnumerable のインターフェース定義を見ると、次のことがわかります。
public interface IEnumerable<out T> : IEnumerable { IEnumerator<T> GetEnumerator(); }
列挙子などの詳細については説明しません。重要な点を 1 つ述べれば十分です。IEnumerable はコレクションではありません。ほとんどのコレクション型は IEnumerable を実装していますが、それによってすべての IEnumerable 実装がコレクションになるわけではありません。驚くべきことに、多くの開発者は IEnumerable を消費または生成するコードを実装するときにこの点を見落としており、これが問題を引き起こす大きな原因となっています。
では、IEnumerable とは何でしょうか。IEnumerable にはさまざまな実装がありますが、簡単にするために、1 つの (かなりあいまいな) 定義にまとめることができます。つまり、反復処理で要素を生成するコードです。メモリ内コレクションの場合、このコードは、基になるコレクションから現在の要素を読み取り、その内部ポインタを次の要素 (存在する場合) に移動します。より複雑なケースでは、ロジックは非常に多様で、共有状態の変更を含む、または共有状態に依存するあらゆる種類の副作用がある可能性があります。
これで、IEnumerable がどのようなものであるかについて、少し理解が深まりました。これにより、次の点について何の仮定も行わない方法で使用コードを実装することが示唆されます。
ご覧のとおり、これはメモリ内コレクションを反復処理する場合の一般的な規則とほぼ逆です。たとえば、次のようになります。
IEnumerable を安全に見るには、これを「オンデマンド データ プロデューサー」として認識します。このデータ プロデューサーが提供する唯一の保証は、呼び出されたときに別のアイテムを取得するか、使用可能なアイテムがもうないことを通知することです。その他はすべて、特定のデータ プロデューサーの実装の詳細です。ちなみに、ここでは、IEnumerable インスタンスを反復処理できる IEnumerator インターフェイスのコントラクトについて説明しました。
オンデマンド データ プロデューサーのもう 1 つの重要な部分は、反復ごとに 1 つのアイテムを生成し、消費コードはプロデューサーが生成できるものをすべて使い切るか、消費を早期に停止するかを決定できることです。オンデマンド データ プロデューサーは、潜在的な「将来の」アイテムに取り組もうとさえしていないため、消費が早期に終了した場合にリソースを節約できます。
したがって、IEnumerable プロデューサーを実装する場合、消費パターンについて想定しないでください。コンシューマーはいつでも消費を開始および停止できます。
さて、IEnumerable を使用する適切な方法を定義したので、反復処理の繰り返しとその潜在的な影響のいくつかの例を確認してみましょう。
否定的な例に進む前に、IEnumerable がメモリ内コレクション (配列、リスト、ハッシュセットなど) を模倣する場合、反復処理を繰り返すこと自体に害はないことを述べておく価値があります。ほとんどの場合、メモリ内コレクションよりも IEnumerable を使用するコードは、一致するコレクション型を使用するコードと (ほぼ) 同じくらい効率的に実行されます。もちろん、特定のケースでは違いがあるかもしれませんが、必ずしも悪いわけではありません。Linq では、たとえばメモリ内コレクションにベクトル化された CPU 命令を使用したり、複雑な Linq 式に対して複数のインターフェイス メソッド呼び出しを 1 つにまとめたりできるなど、パフォーマンスが大幅に向上しています。詳細については、次の記事をお読みください: https://devblogs.microsoft.com/dotnet/performance-improvements-in-net-8/#linqおよびhttps://devblogs.microsoft.com/dotnet/performance-improvements-in-net-9/#linq
ただし、コード品質の観点からは、IEnumerable に対して複数の反復処理を実行することは、内部でどのような具体的な実装が行われるかを確実に知ることができないため、悪い習慣であると考えられます。
補足: IEnumerable はインターフェイスなので、具体的な型の代わりにこれを使用すると、具体的な基になるクラスがこのメソッドを非仮想として実装し、非仮想メソッド呼び出しで十分な場合でも、コンパイラは仮想メソッド呼び出し ('callvirt' IL 命令) を発行するように強制されます。仮想メソッド呼び出しは、メソッド アドレスを解決するために常にインスタンス メソッド テーブルを調べる必要があるため、コストが高くなります。また、潜在的なメソッドのインライン化も防止されます。これはマイクロ最適化と見なされる場合もありますが、インターフェイスの代わりに具体的な型を使用すると、パフォーマンス メトリックが異なるコード パスが多数存在します。
小さな免責事項: この例は、匿名化され、実際の実装の詳細がすべて抽象化された実際のコードに基づいています。
このコードは、受信パラメータ リストのデータをリモート エンドポイントから取得していました。
async Task<IEnumerable<IData>> RetrieveAndProcessDataAsync(IList<int> ids, CancellationToken ct) { var retrievalTasks = ids.Select(id => externalService.QueryForDataAsync(id, ct)); await Task.WhenAll(retrievalTasks); return retrievalTasks.Select(t => t.Result); }
ここで何が問題になるのでしょうか? 最も簡単な例を見てみましょう。
var results = await RetrieveAndProcessDataAsync(ids, cancellationToken); var output = results.ToArray();
多くの開発者は、このコードは安全だと考えるでしょう。メソッド出力をメモリ内コレクションに具体化することで繰り返しの反復を防ぐからです。しかし、本当にそうでしょうか?
詳細に入る前に、テストを実行してみましょう。テストには、非常に単純な「externalService」実装を使用できます。
record Data(int Value); class Service { private static int counter = 0; public async Task<IData> QueryForDataAsync(int id, CancellationToken ct) { var timestamp = Stopwatch.GetTimestamp(); await Task.Delay(TimeSpan.FromMilliseconds(30), ct); int cv = Interlocked.Increment(ref counter); Console.WriteLine($"QueryForData - id={id} - {cv}; took {Stopwatch.GetElapsedTime(timestamp).TotalMilliseconds:F0} ms"); return new Data(id); } }
次にテストを実行します。
var externalService = new Service(); var results = (await RetrieveAndProcessDataAsync([1, 2, 3], CancellationToken.None)).ToList(); Console.WriteLine("Querying completed"); int count = results.Count(); if (count == 0) { Console.WriteLine("No results"); } else { var array = results.ToArray(); Console.WriteLine($"Retrieved {array.Length} elements"); } Console.WriteLine($"Getting the count again: {results.Count()}");
出力は次のようになります:
QueryForData - id=3 - 1; took 41 ms QueryForData - id=1 - 3; took 43 ms QueryForData - id=2 - 2; took 42 ms QueryForData - id=1 - 4; took 33 ms QueryForData - id=2 - 5; took 30 ms QueryForData - id=3 - 6; took 31 ms Querying completed Retrieved 3 elements Getting the count again: 3
何かおかしいですね。入力引数には 3 つの ID しかないので、'QueryForData' 出力は 3 回だけ取得されると予想していました。しかし、出力を見ると、ToList() 呼び出しが完了する前でも実行回数が 2 倍になっていることがはっきりとわかります。
理由を理解するために、RetrieveAndProcessDataAsync メソッドを見てみましょう。
1: var retrievalTasks = ids.Select(id => externalService.QueryForDataAsync(id, ct)); 2: await Task.WhenAll(retrievalTasks); 3: return retrievalTasks.Select(t => t.Result);
この通話を見てみましょう:
(await RetrieveAndProcessDataAsync([1, 2, 3], CancellationToken.None)).ToList();
RetrieveAndProcessDataAsync メソッドが呼び出されると、次のことが起こります。
1 行目では、 IEnumerable<Task<Data>>
インスタンスを取得します。この場合、3 つの要素を持つ入力配列を送信するため、タスクは 3 つになります。各タスクは実行のためにスレッド プールによってキューに入れられ、スレッドが使用可能になるとすぐに開始されます。これらのタスクの正確な完了ポイントは、スレッド プールのスケジュールの詳細と、このコードが実行される具体的なハードウェアにより不確定です。
2 行目のTask.WhenAll
呼び出しは、 IEnumerable<Task<Data>>
インスタンスのすべてのタスクが完了したことを確認します。基本的に、この時点で QueryForDataAsync メソッドから最初の 3 つの出力を取得します。2 行目が完了すると、3 つのタスクもすべて完了したことを確認できます。
しかし、3 行目は悪魔たちが待ち伏せしている場所です。彼らを掘り出しましょう。
'retrievalTasks' 変数 (1 行目) はIEnumerable<Task<Data>>
インスタンスです。ここで、一歩下がって、IEnumerable はプロデューサーに他ならないことを思い出してください。プロデューサーとは、特定の型のインスタンスを生成 (作成または再利用) するコードです。この場合、'retrievalTasks' 変数は次のことを行うコードです。
IEnumerable<Task<Data>>
インスタンスの背後にあるこのすべてのロジックを少し異なる方法で表現できます。このコード部分は元のids.Select(id => externalService.QueryForDataAsync(id, ct))
式とはまったく異なっているように見えますが、まったく同じことを行うことに注意してください。
IEnumerable<Task<Data>> DataProducer(IList<int> ids, CancellationToken ct) { foreach (int id in ids) { var task = externalService.QueryForData(id, ct); yield return task; } }
したがって、'retrievalTasks' 変数を、定義済みの定数入力セットを使用した関数呼び出しとして扱うことができます。この関数は、変数値を解決するたびに呼び出されます。このアイデアを完全に反映し、最初の実装とまったく同じように機能するように、RetrieveAndProcessDataAsync メソッドを書き直すことができます。
async Task<IEnumerable<Data>> RetrieveAndProcessDataAsync(IList<int> ids, CancellationToken ct) { var retrievalFunc = () => DataProducer(ids, ct); await Task.WhenAll(retrievalFunc()); return retrievalFunc().Select(t => t.Result); }
これで、テスト コードの出力が 2 倍になった理由がはっきりとわかります。つまり、「retrievalFunc」関数が 2 回呼び出されるのです。消費コードが同じ IEnumerable インスタンスを継続的に処理すると、「DataProducer」メソッドが繰り返し呼び出されることになり、各反復でロジックが何度も実行されることになります。
IEnumerable の繰り返し反復の背後にあるロジックが明確になったと思います。
ただし、このコードサンプルについて言及すべきことが 1 つあります。
書き直した実装をもう一度見てみましょう。
IEnumerable<Task<Data>> DataProducer(IList<int> ids, CancellationToken ct) { foreach (int id in ids) { var task = externalService.QueryForData(id, ct); yield return task; } } async Task<IEnumerable<Data>> RetrieveAndProcessDataAsync(IList<int> ids, CancellationToken ct) { var retrievalFunc = () => DataProducer(ids, ct); await Task.WhenAll(retrievalFunc()); // First producer call. return retrievalFunc().Select(t => t.Result); // Second producer call. }
この場合、プロデューサーは毎回新しいタスク インスタンスを作成し、それを 2 回呼び出します。これにより、 Task.WhenAll
と.Select(t => t.Result)
呼び出すと、これら 2 つのコード部分が操作するタスク インスタンスが異なるという、かなり奇妙であまり明白ではない事実が生じます。待機されていた (したがって完了に到達した) タスクは、メソッドが結果を返すタスクと同じではありません。
つまり、ここでプロデューサーは 2 つの異なるタスク セットを作成します。最初のタスク セットは非同期的に待機されます ( Task.WhenAll
呼び出し) が、2 番目のタスク セットは待機されません。代わりに、コードはResult
プロパティ ゲッターを直接呼び出します。これは事実上、悪名高い同期オーバー非同期アンチパターンです。このアンチパターンの詳細については説明しません。これは大きなテーマです。Stephen Toub によるこの記事では、この点についてかなり詳しく説明しています: https://devblogs.microsoft.com/pfxteam/should-i-expose-synchronous-wrappers-for-asynchronous-methods/
ただし、完全を期すために、このコードによって発生する可能性のある問題をいくつか示します。
これらの単純なタスクを生成していた現在のコード サンプルを抽象化すると、反復処理の繰り返しによって任意の操作が複数回実行される可能性が高く、冪等性がない (つまり、同じ入力による後続の呼び出しでは異なる結果が生成されるか、単に失敗する) 可能性があるという事実に直面します。たとえば、口座残高が変わります。
たとえこれらの操作がべき等であったとしても、計算コストが高くなる可能性があり、そのため繰り返し実行するとリソースが無駄に消費されるだけです。また、クラウドで実行されるコードについて言えば、これらのリソースには、支払わなければならないコストがかかる可能性があります。
繰り返しになりますが、IEnumerable インスタンスの繰り返し反復は見逃されやすいため、アプリケーションがクラッシュしたり、大量のリソース (お金を含む) を消費したり、本来実行すべきでない処理を実行したりする理由を見つけるのは非常に難しい場合があります。
元のテスト コードを少し変更してみましょう。
var externalService = new Service(); var cts = new CancellationTokenSource(); // New line. var results = (await RetrieveAndProcessDataAsync([1, 2, 3], cts.Token)); // Using cts.Token instead of a default token, and not materializing the IEnumerable. Console.WriteLine("Querying completed"); int count = results.Count(); if (count == 0) { Console.WriteLine("No results"); } else { var array = results.ToArray(); Console.WriteLine($"Retrieved {array.Length} elements"); } cts.Cancel(); // New line. Console.WriteLine($"Getting the count again: {results.Count()}");
このコードを実行してみるのは読者の皆さんにお任せします。繰り返し実行することで予期せず発生する可能性のある副作用のよい例となるでしょう。
見てみましょう:
async Task<IEnumerable<IData>> RetrieveAndProcessDataAsync(IList<int> ids, CancellationToken ct) { var retrievalTasks = ids.Select(id => externalService.QueryForDataAsync(id, ct)).ToArray(); // Adding .ToArray() call. await Task.WhenAll(retrievalTasks); return retrievalTasks.Select(t => t.Result); }
最初のIEnumerable<Task<Data>>
に.ToArray()
呼び出しを 1 つ追加するだけで、IEnumerable インスタンスがメモリ内コレクションに「具体化」され、その後のメモリ内コレクションに対する繰り返し処理では、まさに想定どおりに、つまり、繰り返しコードを実行することで予期しない副作用が発生することなく、メモリからデータを読み取るだけになります。
基本的に、開発者がこのようなコード (最初のコード サンプルのように) を書くときは、通常、このデータは「固定」されており、アクセスしても予期しない事態は発生しないと想定します。しかし、先ほど見たように、これは真実からかなりかけ離れています。
この方法をさらに改善することもできますが、それは次の章に残しておきます。
ここまで、IEnumerable の使用が誤解に基づくものである場合、つまり IEnumerable を使用する際にこれらの仮定のいずれも行わない必要があることを考慮に入れていない場合に、IEnumerable の使用から生じる可能性のある問題について見てきました。
ここで、IEnumerable プロデューサーが (理想的には) コンシューマーに対して守るべき約束について見てみましょう。
もう一度、この観点から以前のコードサンプルを確認してみましょう。
async Task<IEnumerable<IData>> RetrieveAndProcessDataAsync(IList<int> ids, CancellationToken ct) { var retrievalTasks = ids.Select(id => externalService.QueryForDataAsync(id, ct)).ToArray(); await Task.WhenAll(retrievalTasks); return retrievalTasks.Select(t => t.Result); }
本質的に、このコードはこれらの約束を果たしません。すべてのハード リフティングは、IEnumerable の生成を開始する前の最初の 2 行で実行されるためです。そのため、いずれかのコンシューマーが消費を早期に停止することを決定した場合、またはまったく消費を開始しない場合でも、QueryForDataAsync メソッドはすべての入力に対して呼び出されます。
最初の 2 行の動作を考慮すると、次のようにメモリ内コレクションを生成するようにメソッドを書き直す方がはるかに適切です。
async Task<IList<IData>> RetrieveAndProcessDataAsync(IList<int> ids, CancellationToken ct) { var retrievalTasks = ids.Select(id => externalService.QueryForDataAsync(id, ct)).ToArray(); await Task.WhenAll(retrievalTasks); return retrievalTasks.Select(t => t.Result).ToArray(); }
この実装では、「オンデマンド」の保証は提供されません。逆に、指定された入力を処理するために必要なすべての作業が完了し、一致する結果が返されることは非常に明らかです。
ただし、「オンデマンド データ プロデューサー」の動作が必要な場合は、それを提供するためにメソッドを完全に書き直す必要があります。例:
async IAsyncEnumerable<Data> RetrieveAndProcessDataAsAsyncEnumerable(IList<int> ids, [EnumeratorCancellation] CancellationToken ct) { foreach (int id in ids) { var result = await externalService.QueryForData(id, ct); yield return result; } }
開発者は通常、IEnumerable のこれらのコントラクトの詳細について考えませんが、それを使用する他のコードは、これらの詳細に一致する仮定を行うことがよくあります。したがって、IEnumerable を生成するコードがこれらの詳細に一致すると、アプリケーション全体がより適切に動作します。
この記事が、コレクション コントラクトと IEnumerable コントラクトの詳細の違いを理解する上で読者の役に立ったことを願っています。コレクションは一般に、アイテム用のストレージ (通常はメモリ内) と、格納されているアイテムを操作する方法を提供します。読み取り専用でないコレクションも、格納されているアイテムの変更、追加、削除を許可することでこのコントラクトを拡張します。コレクションは格納されているアイテムに関して非常に一貫性がありますが、IEnumerable は、アイテムが IEnumerable インスタンスの反復処理時に生成されるため、この点で基本的に非常に高い変動性を宣言します。
では、IEnumerable を使用する場合のベスト プラクティスは何でしょうか? ポイントをリストアップしてみましょう。
.Where
や.Select
など)、実際の反復を引き起こすその他の呼び出しは避けてください。処理ロジックで IEnumerable を複数回通過する必要がある場合は、それをメモリ内コレクションに具体化するか、ロジックを項目ごとに 1 回の通過に変更できるかどうかを確認してください。