LINQ を拡張する方法

すべての LINQ ベースのメソッドは、似た 2 つのパターンのいずれかに従います。 これらは列挙可能なシーケンスを受け取ります。 そして、別のシーケンスまたは単一の値を返します。 シェイプの一貫性により、似たシェイプを持つメソッドを記述することで LINQ を拡張できます。 実際、.NET ライブラリは、LINQ が最初に導入されて以来、多数の .NET リリースで新しいメソッドを取得しています。 この記事では、同じパターンに従う独自のメソッドを記述して LINQ を拡張する例を示します。

LINQ クエリのカスタム メソッドを追加する

IEnumerable<T> インターフェイスに拡張メソッドを追加することで、LINQ クエリに使用するメソッド セットを拡張します。 たとえば、一連の値から単一の値を計算するために、平均や最大を求める標準的な演算に加えて、カスタムの集計メソッドを作成します。 また、一連の値を受け取って新しい一連の値を返す特定のデータ変換やカスタム フィルターとして動作するメソッドも作成します。 このようなメソッドには、DistinctSkipReverse があります。

IEnumerable<T> インターフェイスを拡張すると、列挙可能なコレクションにカスタム メソッドを適用できます。 詳細については、「拡張メソッド」を参照してください。

"集計" メソッドは、値の集合から単一の値を計算するメソッドです。 LINQ は、AverageMinMax などの集計メソッドを提供します。 IEnumerable<T> インターフェイスに拡張メソッドを追加することで、独自の集計メソッドを作成できます。

次のコード例は、double 型の一連の数値から中央値を求める Median という拡張メソッドの作成方法を示しています。

public static class EnumerableExtension
{
    public static double Median(this IEnumerable<double>? source)
    {
        if (source is null || !source.Any())
        {
            throw new InvalidOperationException("Cannot compute median for a null or empty set.");
        }

        var sortedList =
            source.OrderBy(number => number).ToList();

        int itemIndex = sortedList.Count / 2;

        if (sortedList.Count % 2 == 0)
        {
            // Even number of items.
            return (sortedList[itemIndex] + sortedList[itemIndex - 1]) / 2;
        }
        else
        {
            // Odd number of items.
            return sortedList[itemIndex];
        }
    }
}

この拡張メソッドは、IEnumerable<T> インターフェイスにある他の集計メソッドを呼び出すときと同じように、列挙可能な任意のコレクションに対して呼び出すことができます。

double 型の配列に対して Median メソッドを使用する方法を次のコード例に示します。

double[] numbers = [1.9, 2, 8, 4, 5.7, 6, 7.2, 0];
var query = numbers.Median();

Console.WriteLine($"double: Median = {query}");
// This code produces the following output:
//     double: Median = 4.85

集計メソッドでさまざまな型を受け取るように、"集計メソッドをオーバーロードする" ことができます。 その標準的な方法として、型ごとにオーバーロードを作成します。 または、ジェネリック型を受け取り、デリゲートを使って特定の型に変換するオーバーロードを作成する方法もあります。 その両方の方法を組み合わせることもできます。

サポート予定の型ごとに固有のオーバーロードを作成できます。 次のコード例では、int 型の Median メソッドのオーバーロードを示します。

// int overload
public static double Median(this IEnumerable<int> source) =>
    (from number in source select (double)number).Median();

これで、次のコードに示すように、integer 型と double 型の両方に対して、Median のオーバーロードを呼び出すことができるようになりました。

double[] numbers1 = [1.9, 2, 8, 4, 5.7, 6, 7.2, 0];
var query1 = numbers1.Median();

Console.WriteLine($"double: Median = {query1}");

int[] numbers2 = [1, 2, 3, 4, 5];
var query2 = numbers2.Median();

Console.WriteLine($"int: Median = {query2}");
// This code produces the following output:
//     double: Median = 4.85
//     int: Median = 3

オブジェクトの "ジェネリック シーケンス" を受け取るオーバーロードを作成することもできます。 このオーバーロードは、デリゲートをパラメーターとして受け取り、ジェネリック型の一連のオブジェクトを特定の型に変換します。

次のコードは、Func<T,TResult> デリゲートをパラメーターとして受け取る Median メソッドのオーバーロードを示しています。 このデリゲートは、ジェネリック型 T のオブジェクトを受け取り、double 型のオブジェクトを返します。

// generic overload
public static double Median<T>(
    this IEnumerable<T> numbers, Func<T, double> selector) =>
    (from num in numbers select selector(num)).Median();

これで、任意の型の一連のオブジェクトに対して Median メソッドを呼び出すことができます。 型に固有のメソッド オーバーロードがない場合は、デリゲート パラメーターを渡す必要があります。 この目的で、C# ではラムダ式を使用できます。 また、Visual Basic に限り、メソッド呼び出しの代わりに Aggregate 句または Group By 句を使用した場合、その句のスコープにある任意の値または式を渡すことができます。

次のコード例では、整数の配列と文字列の配列に対して Median メソッドを呼び出す方法を示します。 文字列の場合は、配列に格納されている文字列の長さの中央値が計算されます。 この例は、それぞれのケースについて、Median メソッドに Func<T,TResult> デリゲート パラメーターを渡す方法を示しています。

int[] numbers3 = [1, 2, 3, 4, 5];

/*
    You can use the num => num lambda expression as a parameter for the Median method
    so that the compiler will implicitly convert its value to double.
    If there is no implicit conversion, the compiler will display an error message.
*/
var query3 = numbers3.Median(num => num);

Console.WriteLine($"int: Median = {query3}");

string[] numbers4 = ["one", "two", "three", "four", "five"];

// With the generic overload, you can also use numeric properties of objects.
var query4 = numbers4.Median(str => str.Length);

Console.WriteLine($"string: Median = {query4}");
// This code produces the following output:
//     int: Median = 3
//     string: Median = 4

IEnumerable<T> インターフェイスは、"値のシーケンス" を返すカスタム クエリ メソッドを追加することで拡張できます。 その場合、メソッドで型 IEnumerable<T> のコレクションを返す必要があります。 このようなメソッドを使用すると、一連の値にフィルターまたはデータ変換を適用することができます。

次の例では、コレクション内の最初の要素から 1 つおきに要素を返す AlternateElements という名前の拡張メソッドを作成する方法を示しています。

// Extension method for the IEnumerable<T> interface.
// The method returns every other element of a sequence.
public static IEnumerable<T> AlternateElements<T>(this IEnumerable<T> source)
{
    int index = 0;
    foreach (T element in source)
    {
        if (index % 2 == 0)
        {
            yield return element;
        }

        index++;
    }
}

この拡張メソッドは、次のコードに示すとおり、IEnumerable<T> インターフェイスにある他のメソッドを呼び出すときと同じように、列挙可能な任意のコレクションに対して呼び出すことができます。

string[] strings = ["a", "b", "c", "d", "e"];

var query5 = strings.AlternateElements();

foreach (var element in query5)
{
    Console.WriteLine(element);
}
// This code produces the following output:
//     a
//     c
//     e

連続するキーで結果をグループ化する

要素をグループ化し、連続するキーのサブシーケンスを表すチャンクにする方法を次の例に示します。 たとえば、次の一連のキーと値のペアがあるとします。

Key
A
A think
A that
B Linq
C is
A really
B cool
B !

次のグループがこの順序で作成されます。

  1. We, think, that
  2. Linq
  3. is
  4. really
  5. cool, !

ソリューションは、結果をストリーミングで返すスレッド セーフな拡張メソッドとして実装されます。 ソース シーケンス内を移動しながらグループを生成します。 group 演算子や orderby 演算子とは異なり、シーケンス全体を読み取る前に、呼び出し元に対してグループを返し始めることができます。 拡張メソッドと、それを使用するクライアント コードの両方を次の例に示します。

public static class ChunkExtensions
{
    public static IEnumerable<IGrouping<TKey, TSource>> ChunkBy<TSource, TKey>(
            this IEnumerable<TSource> source,
            Func<TSource, TKey> keySelector) =>
                source.ChunkBy(keySelector, EqualityComparer<TKey>.Default);

    public static IEnumerable<IGrouping<TKey, TSource>> ChunkBy<TSource, TKey>(
            this IEnumerable<TSource> source,
            Func<TSource, TKey> keySelector,
            IEqualityComparer<TKey> comparer)
    {
        // Flag to signal end of source sequence.
        const bool noMoreSourceElements = true;

        // Auto-generated iterator for the source array.
        IEnumerator<TSource>? enumerator = source.GetEnumerator();

        // Move to the first element in the source sequence.
        if (!enumerator.MoveNext())
        {
            yield break;        // source collection is empty
        }

        while (true)
        {
            var key = keySelector(enumerator.Current);

            Chunk<TKey, TSource> current = new(key, enumerator, value => comparer.Equals(key, keySelector(value)));

            yield return current;

            if (current.CopyAllChunkElements() == noMoreSourceElements)
            {
                yield break;
            }
        }
    }
}
public static class GroupByContiguousKeys
{
    // The source sequence.
    static readonly KeyValuePair<string, string>[] list = [
        new("A", "We"),
        new("A", "think"),
        new("A", "that"),
        new("B", "LINQ"),
        new("C", "is"),
        new("A", "really"),
        new("B", "cool"),
        new("B", "!")
    ];

    // Query variable declared as class member to be available
    // on different threads.
    static readonly IEnumerable<IGrouping<string, KeyValuePair<string, string>>> query =
        list.ChunkBy(p => p.Key);

    public static void GroupByContiguousKeys1()
    {
        // ChunkBy returns IGrouping objects, therefore a nested
        // foreach loop is required to access the elements in each "chunk".
        foreach (var item in query)
        {
            Console.WriteLine($"Group key = {item.Key}");
            foreach (var inner in item)
            {
                Console.WriteLine($"\t{inner.Value}");
            }
        }
    }
}

ChunkExtensions クラス

ChunkExtensions クラス実装のために示したコードでは、ChunkBy メソッドの while(true) ループがソース シーケンスを反復処理し、各チャンクのコピーを作成します。 各パスでは、反復子は、ソース シーケンス内で Chunk オブジェクトによって表される次の "チャンク" の最初の要素に進みます。 このループは、クエリを実行する外側の foreach ループに対応しています。 このループでは、コードは次のアクションを実行します。

  1. 現在のチャンクのキーを取得し、key 変数に割り当てます。 ソース反復子は、一致しないキーを持つ要素が見つかるまで、ソース シーケンスを繰り返します。
  2. 新しいチャンク (グループ) オブジェクトを作成し、current 変数に保存します。 これは、現在のソース要素のコピーである GroupItem を 1 つ持ちます。
  3. その Chunk を返します。 Chunk は IGrouping<TKey,TSource> であり、それは ChunkBy メソッドの戻り値です。 このチャンクにはソース シーケンスの最初の要素のみが含まれています。 残りの要素は、クライアント コードの foreach がこのチャンクを終えた場合にのみ返されます。 詳細については、Chunk.GetEnumerator を参照してください。
  4. 次を確認します。
    • (a) チャンクがすべてのソース要素のコピーを作成した
    • (b) 反復子がソース シーケンスの末尾に達した。
  5. 呼び出し元がすべてのチャンク項目を列挙した時点で、Chunk.GetEnumerator メソッドはすべてのチャンク項目をコピーしました。 Chunk.GetEnumerator ループがチャンク内のすべての要素を列挙しなかった場合は、すぐにそうするようにします。そうすることで、クライアントが別のスレッドで呼び出されている可能性のある反復子が壊れないようにします。

Chunk クラス

Chunk クラスは、同じキーを持つ 1 つ以上のソース要素の連続したグループです。 Chunk にはキーと、ソース シーケンス内の要素のコピーである ChunkItem オブジェクトのリストがあります:

class Chunk<TKey, TSource> : IGrouping<TKey, TSource>
{
    // INVARIANT: DoneCopyingChunk == true ||
    //   (predicate != null && predicate(enumerator.Current) && current.Value == enumerator.Current)

    // A Chunk has a linked list of ChunkItems, which represent the elements in the current chunk. Each ChunkItem
    // has a reference to the next ChunkItem in the list.
    class ChunkItem
    {
        public ChunkItem(TSource value) => Value = value;
        public readonly TSource Value;
        public ChunkItem? Next;
    }

    public TKey Key { get; }

    // Stores a reference to the enumerator for the source sequence
    private IEnumerator<TSource> enumerator;

    // A reference to the predicate that is used to compare keys.
    private Func<TSource, bool> predicate;

    // Stores the contents of the first source element that
    // belongs with this chunk.
    private readonly ChunkItem head;

    // End of the list. It is repositioned each time a new
    // ChunkItem is added.
    private ChunkItem? tail;

    // Flag to indicate the source iterator has reached the end of the source sequence.
    internal bool isLastSourceElement;

    // Private object for thread synchronization
    private readonly object m_Lock;

    // REQUIRES: enumerator != null && predicate != null
    public Chunk(TKey key, [DisallowNull] IEnumerator<TSource> enumerator, [DisallowNull] Func<TSource, bool> predicate)
    {
        Key = key;
        this.enumerator = enumerator;
        this.predicate = predicate;

        // A Chunk always contains at least one element.
        head = new ChunkItem(enumerator.Current);

        // The end and beginning are the same until the list contains > 1 elements.
        tail = head;

        m_Lock = new object();
    }

    // Indicates that all chunk elements have been copied to the list of ChunkItems.
    private bool DoneCopyingChunk => tail == null;

    // Adds one ChunkItem to the current group
    // REQUIRES: !DoneCopyingChunk && lock(this)
    private void CopyNextChunkElement()
    {
        // Try to advance the iterator on the source sequence.
        isLastSourceElement = !enumerator.MoveNext();

        // If we are (a) at the end of the source, or (b) at the end of the current chunk
        // then null out the enumerator and predicate for reuse with the next chunk.
        if (isLastSourceElement || !predicate(enumerator.Current))
        {
            enumerator = default!;
            predicate = default!;
        }
        else
        {
            tail!.Next = new ChunkItem(enumerator.Current);
        }

        // tail will be null if we are at the end of the chunk elements
        // This check is made in DoneCopyingChunk.
        tail = tail!.Next;
    }

    // Called after the end of the last chunk was reached.
    internal bool CopyAllChunkElements()
    {
        while (true)
        {
            lock (m_Lock)
            {
                if (DoneCopyingChunk)
                {
                    return isLastSourceElement;
                }
                else
                {
                    CopyNextChunkElement();
                }
            }
        }
    }

    // Stays just one step ahead of the client requests.
    public IEnumerator<TSource> GetEnumerator()
    {
        // Specify the initial element to enumerate.
        ChunkItem? current = head;

        // There should always be at least one ChunkItem in a Chunk.
        while (current != null)
        {
            // Yield the current item in the list.
            yield return current.Value;

            // Copy the next item from the source sequence,
            // if we are at the end of our local list.
            lock (m_Lock)
            {
                if (current == tail)
                {
                    CopyNextChunkElement();
                }
            }

            // Move to the next ChunkItem in the list.
            current = current.Next;
        }
    }

    System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator() => GetEnumerator();
}

ChunkItem (ChunkItem クラスで表される) には、リスト内の次の ChunkItem への参照があります。 リストはその head (このチャンクに属する最初のソース要素の内容を保存) と tail (リストの末尾) で構成されています。 新しい ChunkItem が追加されるたびに末尾の位置変更が行われます。 次の要素のキーがカレント チャンクのキーと一致しない場合、またはソースにそれ以上の要素がない場合には、リンク リストの末尾は CopyNextChunkElement メソッドにより null に設定されます。

Chunk クラスの CopyNextChunkElement メソッドでは、項目のカレント グループに ChunkItem を 1 つ追加します。 それは、ソース シーケンス上で反復子を進めることを試行します。 MoveNext() メソッドが false を返す場合、反復は末尾に到達していて、isLastSourceElementtrue に設定されます。

CopyAllChunkElements メソッドは、最後のチャンクの末尾に達した後に呼び出されます。 これは、ソース シーケンスにこれ以上要素が存在しないかどうかを確認します。 存在する場合は、このチャンクの反復子を使い果たしていれば、true を返します。 このメソッドでは、非公開の DoneCopyingChunk フィールドが true であることが確認され、isLastSourceElement が false である場合は、外側の反復子に反復を続けるようシグナルを送ります。

内部 foreach ループは、Chunk クラスの GetEnumerator メソッドを呼び出します。 このメソッドは、クライアント要求よりも 1 つ前の要素の位置を維持します。 これは、クライアントがその前の時点でのリスト内の最後の要素を要求した後にのみ、チャンクの次の要素を追加します。