Iterators

プログラムを記述するうえで、ほとんどのプログラムに必要になるのがコレクションの反復処理です。 反復処理が必要な場合は、コレクション内のすべての項目を調べるコードを記述します。

また、そのクラスの要素に対する "反復子" を生成するメソッドである、反復子メソッドも作成することになります。 "反復子" は、コンテナー (特にリスト) を走査するオブジェクトです。 反復子は、次の目的に使用できます。

  • コレクション内の各項目に対するアクションの実行。
  • カスタム コレクションの列挙。
  • LINQ やその他のライブラリの拡張。
  • 反復子メソッドによってデータ フローを効率化するデータ パイプラインの作成。

C# 言語には、シーケンスの生成と使用の両方の機能が用意されています。 これらのシーケンスは、同期的または非同期的に作成および使用できます。 この記事では、それらの機能の概要について説明します。

foreach を使用した反復処理

コレクションの列挙処理は単純です。foreach キーワードによってコレクション内の要素ごとに埋め込みステートメントを 1 回実行し、コレクションを列挙します。

foreach (var item in collection)
{
    Console.WriteLine(item?.ToString());
}

これで全部です。 foreach ステートメントさえあれば、コレクションに含まれるすべての内容を反復処理できます。 ただし、foreach ステートメントは魔法ではありません。 コレクションの反復処理に必要なコードを生成するためには、.NET コア ライブラリで定義されている 2 つのジェネリック インターフェイス IEnumerable<T>IEnumerator<T> が不可欠です。 このメカニズムについては、後ほど詳しく説明します。

これら 2 つのインターフェイスに対応する非ジェネリック インターフェイスとして、IEnumerableIEnumerator があります。 最新のコード向けにはジェネリック バージョンが適しています。

シーケンスが非同期的に生成される場合は、await foreach ステートメントを使用して、シーケンスを非同期的に使用できます。

await foreach (var item in asyncSequence)
{
Console.WriteLine(item?.ToString());
}

シーケンスが System.Collections.Generic.IEnumerable<T> の場合は、foreach を使用します。 シーケンスが System.Collections.Generic.IAsyncEnumerable<T> の場合は、await foreach を使用します。 後者の場合、シーケンスは非同期に生成されます。

反復子メソッドを使用した列挙型のソース

C# 言語のもう 1 つの優れた機能を利用することで、列挙型用のソースを作成するメソッドを構築できます。 このようなメソッドを、"反復子メソッド" と呼びます。 反復子メソッドでは、要求があった場合にオブジェクトがどのようなシーケンスで生成されるかを定義します。 反復子メソッドを定義するには、yield return コンテキスト キーワードを使用します。

次のメソッドを記述することで、0 ~ 9 の整数からなるシーケンスを生成できます。

public IEnumerable<int> GetSingleDigitNumbers()
{
    yield return 0;
    yield return 1;
    yield return 2;
    yield return 3;
    yield return 4;
    yield return 5;
    yield return 6;
    yield return 7;
    yield return 8;
    yield return 9;
}

上記のコードでは、複数の yield return ステートメントを反復子メソッド内で個別に使用できるという点を強調するために、さまざまな yield return ステートメントを示しています。 反復子メソッドのコードを簡略化するために、他の言語構成要素を使用することができます (実際、頻繁に使用します)。 次のメソッド定義では、まったく同じ数値のシーケンスが生成されます。

public IEnumerable<int> GetSingleDigitNumbersLoop()
{
    int index = 0;
    while (index < 10)
        yield return index++;
}

どちらか一方に決める必要はありません。 メソッドのニーズに合わせて必要な数だけ yield return ステートメントを使用できます。

public IEnumerable<int> GetSetsOfNumbers()
{
    int index = 0;
    while (index < 10)
        yield return index++;

    yield return 50;

    index = 100;
    while (index < 110)
        yield return index++;
}

これまでのすべての例には、対応する非同期の例があります。 いずれの場合も、戻り値の型 IEnumerable<T>IAsyncEnumerable<T> に置き換えます。 たとえば、前の例の非同期バージョンは次のようになります。

public async IAsyncEnumerable<int> GetSetsOfNumbersAsync()
{
    int index = 0;
    while (index < 10)
        yield return index++;

    await Task.Delay(500);

    yield return 50;

    await Task.Delay(500);

    index = 100;
    while (index < 110)
        yield return index++;
}

これは、同期と非同期両方の反復子の構文です。 実際の例を見てみましょう。 自分が IoT プロジェクトに参加しているとして、デバイス センサーから膨大な量のデータ ストリームが生成されている状況を想像してください。 データをおおまかに把握するためには、N 番目ごとにデータ要素をサンプリングするメソッドを記述することになります。 このような処理は、次の小さな反復子メソッドで実現できます。

public static IEnumerable<T> Sample<T>(this IEnumerable<T> sourceSequence, int interval)
{
    int index = 0;
    foreach (T item in sourceSequence)
    {
        if (index++ % interval == 0)
            yield return item;
    }
}

IoT デバイスからの読み取りで非同期シーケンスが生成される場合は、次のメソッドで示すようにメソッドを変更します。

public static async IAsyncEnumerable<T> Sample<T>(this IAsyncEnumerable<T> sourceSequence, int interval)
{
    int index = 0;
    await foreach (T item in sourceSequence)
    {
        if (index++ % interval == 0)
            yield return item;
    }
}

反復子メソッドには重要な制限事項が 1 つあります。return ステートメントと yield return ステートメントの両方を同じメソッドで使用することはできません。 次のコードはコンパイルされません。

public IEnumerable<int> GetSingleDigitNumbers()
{
    int index = 0;
    while (index < 10)
        yield return index++;

    yield return 50;

    // generates a compile time error:
    var items = new int[] {100, 101, 102, 103, 104, 105, 106, 107, 108, 109 };
    return items;
}

通常は、この制限が問題になることはありません。 メソッド全体で yield return を使用するか、元のメソッドを複数に分割して一部のメソッドでは return、一部のメソッドでは yield return を使用するか、いずれかの方法を選択できます。

前のメソッドを少し修正すると、メソッド全体で yield return のみを使用するように変更できます。

public IEnumerable<int> GetFirstDecile()
{
    int index = 0;
    while (index < 10)
        yield return index++;

    yield return 50;

    var items = new int[] {100, 101, 102, 103, 104, 105, 106, 107, 108, 109 };
    foreach (var item in items)
        yield return item;
}

反復子メソッドを 2 つの異なるメソッドに分割することが正解となる場合もあります。 つまり、return を使用するメソッドと yield return を使用するメソッドの 2 つです。 ブール型の引数を基に、空のコレクションまたは最初の 5 つの奇数を返す必要があるような場合を考えてみてください。 この処理は、次の 2 つのメソッドとして記述できます。

public IEnumerable<int> GetSingleDigitOddNumbers(bool getCollection)
{
    if (getCollection == false)
        return new int[0];
    else
        return IteratorMethod();
}

private IEnumerable<int> IteratorMethod()
{
    int index = 0;
    while (index < 10)
    {
        if (index % 2 == 1)
            yield return index;
        index++;
    }
}

上記のメソッドを見てみましょう。 1 つ目のメソッドでは、標準の return ステートメントを使用して空のコレクションまたは 2 つ目のメソッドで作成された反復子のいずれかを返します。 2 つ目のメソッドでは、yield return ステートメントを使用して要求されたシーケンスを作成します。

foreach の詳細

foreach ステートメントは、IEnumerable<T> および IEnumerator<T> インターフェイスを使用してコレクションの全要素を反復処理する標準的な表現形式に展開されます。 また、開発者の不適切なリソース管理によって発生するエラーも最小限になります。

最初の例に登場する foreach ループは、コンパイラによって次のコンストラクトに似たコードに変換されます。

IEnumerator<int> enumerator = collection.GetEnumerator();
while (enumerator.MoveNext())
{
    var item = enumerator.Current;
    Console.WriteLine(item.ToString());
}

コンパイラによって実際に生成されるコードはさらに複雑であり、GetEnumerator() から返されるオブジェクトで IDisposable インターフェイスを実装する場合の処理も含まれています。 全展開によって生成されるコードは、次のようになります。

{
    var enumerator = collection.GetEnumerator();
    try
    {
        while (enumerator.MoveNext())
        {
            var item = enumerator.Current;
            Console.WriteLine(item.ToString());
        }
    }
    finally
    {
        // dispose of enumerator.
    }
}

最初の非同期のサンプルは、コンパイラによって次のようなコンストラクトに変換されます。

{
    var enumerator = collection.GetAsyncEnumerator();
    try
    {
        while (await enumerator.MoveNextAsync())
        {
            var item = enumerator.Current;
            Console.WriteLine(item.ToString());
        }
    }
    finally
    {
        // dispose of async enumerator.
    }
}

列挙子が破棄される場合、その方法は enumerator の型の特性によって異なります。 一般的な同期の場合、finally 句は次のように展開されます。

finally
{
   (enumerator as IDisposable)?.Dispose();
}

一般的な非同期の場合は、次のように展開されます。

finally
{
    if (enumerator is IAsyncDisposable asyncDisposable)
        await asyncDisposable.DisposeAsync();
}

ただし、enumerator の型がシールド型で、enumerator から IDisposable または IAsyncDisposable への暗黙的な型変換がない場合、finally 句は空のブロックに展開されます。

finally
{
}

enumerator から IDisposable への暗黙的な型変換があり、enumerator が null 非許容値型である場合、finally 句は次のように展開されます。

finally
{
   ((IDisposable)enumerator).Dispose();
}

さいわいなことに、これらの詳細をすべて覚えておく必要はありません。 このような微妙な差異は、いずれも foreach ステートメントによって処理されます。 コンパイラでは、これらすべてのコンストラクトに対して正しいコードが生成されます。