So erweitern Sie LINQ

Alle LINQ-basierten Methoden folgen einem von zwei ähnlichen Mustern. Sie nehmen eine aufzählbare Sequenz. Sie geben entweder eine andere Sequenz oder einen einzigen Wert zurück. Die Konsistenz der Form ermöglicht es Ihnen, LINQ zu erweitern, indem Sie Methoden mit einer ähnlichen Form schreiben. Tatsächlich haben die .NET-Bibliotheken in vielen .NET-Versionen seit der Einführung von LINQ neue Methoden erhalten. In diesem Artikel sehen Sie Beispiele für die Erweiterung von LINQ, indem Sie Ihre eigenen Methoden schreiben, die demselben Muster folgen.

Hinzufügen von benutzerdefinierten Methoden für LINQ-Abfragen

Sie erweitern die Methoden, die Sie für LINQ-Abfragen durch Hinzufügen von Erweiterungsmethoden zur IEnumerable<T>-Schnittstelle verwenden. Zusätzlich zu den durchschnittlichen oder maximalen Standardvorgängen erstellen Sie eine benutzerdefinierte Aggregatmethode, um einen einzelnen Wert aus einer Sequenz von Werten zu berechnen. Sie erstellen auch eine Methode, die als benutzerdefinierter Filter oder spezifische Datentransformation für eine Sequenz von Werten agiert und eine neue Sequenz zurückgibt. Beispiele für solche Methoden sind Distinct, Skip und Reverse.

Beim Erweitern der IEnumerable<T>-Schnittstelle können Sie die benutzerdefinierten Methoden auf jede aufzählbare Auflistung anwenden. Weitere Informationen finden Sie unter Erweiterungsmethoden.

Eine aggregierte Methode berechnet einen einzelnen Wert aus einer Gruppe von Werten. LINQ stellt mehrere Aggregatmethoden bereit, einschließlich Average, Min und Max. Sie können Ihre eigene Aggregatmethode erstellen, indem Sie der IEnumerable<T>-Schnittstelle eine Erweiterungsmethode hinzufügen.

Im folgenden Codebeispiel wird veranschaulicht, wie eine Erweiterungsmethode namens Median erstellt wird, um einen Median für eine Zahlensequenz des Typs double zu berechnen.

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];
        }
    }
}

Sie können diese Erweiterungsmethode für jede aufzählbare Auflistung genau so aufrufen, wie Sie andere Aggregatmethoden aus der IEnumerable<T>-Schnittstelle aufrufen.

Im folgenden Codebeispiel wird die Verwendung der Median-Methode für ein Array des Typs double veranschaulicht.

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

Sie können die Aggregatmethode überladen, sodass diese Sequenzen verschiedener Typen akzeptiert. Die Standardmethode ist die Erstellung einer Überladung für jeden Typ. Ein anderer Ansatz ist das Erstellen einer Überladung, die einen generischen Typ annimmt und diesen mit einem Delegaten in einen bestimmten Typ konvertiert. Sie können auch beide Methoden kombinieren.

Sie können eine bestimmte Überladung für jeden Typ erstellen, den Sie unterstützen möchten. Im folgenden Codebeispiel wird eine Überladung der Median-Methode für den int-Typ veranschaulicht.

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

Sie können nun die Median-Überladungen für die integer- und double-Typen aufrufen, so wie im folgenden Code gezeigt:

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

Sie können auch eine Überladung erstellen, die eine generische Sequenz von Objekten akzeptiert. Diese Überladung nimmt einen Delegaten als Parameter und verwendet ihn, um eine Sequenz von Objekten eines generischen Typs in einen bestimmten Typ zu konvertieren.

Der folgende Code zeigt eine Überladung der Median-Methode, die den Func<T,TResult>-Delegaten als Parameter akzeptiert. Dieser Delegat übernimmt ein Objekt des generischen Typs „T“ und gibt ein Objekt vom Typ double zurück.

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

Sie können nun die Median-Methode für eine Sequenz von Objekten beliebigen Typs aufrufen. Wenn der Typ nicht über eine eigene Methodenüberladung verfügt, müssen Sie einen Delegatenparameter übergeben. In C# können Sie zu diesem Zweck einen Lambdaausdruck verwenden. Wenn Sie die Aggregate- oder Group By-Klausel anstatt des Methodenaufrufs verwenden, können Sie auch einen beliebigen Wert oder Ausdruck übergeben, der im Bereich dieser Klausel vorhanden ist. Diese Methode ist nur in Visual Basic verfügbar.

Der folgende Beispielcode veranschaulicht, wie eine Median-Methode für ein Array aus ganzen Zahlen und ein Array aus Zeichenfolgen aufgerufen wird. Für Zeichenfolgen wird der Median für die Längen der Zeichenfolgen im Array berechnet. Das Beispiel zeigt, wie der Delegatparameter Median an die Func<T,TResult>-Methode für jeden Fall übergeben wird.

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

Sie können die Schnittstelle IEnumerable<T> mit einer benutzerdefinierten Abfragemethode erweitern, die eine Sequenz von Werten zurückgibt. In diesem Fall muss die Methode eine Auflistung des Typs IEnumerable<T> zurückgeben. Solche Methoden können verwendet werden, um Filter oder Datentransformationen auf eine Sequenz von Werten anzuwenden.

Das folgende Beispiel zeigt, wie eine Erweiterungsmethode mit dem Namen AlternateElements erstellt wird, die jedes andere Element in einer Auflistung zurückgibt, beginnend mit dem ersten Element.

// 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++;
    }
}

Sie können diese Erweiterungsmethode für jede aufzählbare Auflistung genau so aufrufen, wie Sie andere Methoden aus der Schnittstelle IEnumerable<T> aufrufen, so wie im folgenden Code dargestellt:

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

Gruppieren von Ergebnissen nach zusammenhängenden Schlüsseln

Im folgende Beispiel wird veranschaulicht, wie Elemente in Segmenten gruppiert werden, die Untersequenzen von zusammenhängenden Schlüsseln darstellen. Gehen wir beispielsweise von der folgenden Sequenz von Schlüssel-Wert-Paaren aus:

Schlüssel Wert
Ein Wir
Ein finden
Ein dass
B LINQ
C is
Ein cool
B ist
B !

Die folgenden Gruppen werden in dieser Reihenfolge erstellt:

  1. Wir, finden, dass
  2. LINQ
  3. is
  4. cool
  5. ist, !

Die Lösung wird als threadsichere Erweiterungsmethode implementiert, die die Ergebnisse streamend zurückgibt. Die Methode erzeugt die Gruppen während dem Durchlaufen der Quellsequenz. Im Gegensatz zu den Operatoren group und orderby kann die Methode mit dem Zurückgeben der Gruppen an den Aufrufer beginnen, bevor die ganze Sequenz gelesen wurde. Im folgenden Beispiel werden die Erweiterungsmethode und der Clientcode gezeigt, der sie verwendet:

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-Klasse

Im dargestellten Code der ChunkExtensions Klassenimplementierung durchläuft die while(true)Schleife in der ChunkBy-Methode die Quellsequenz und erstellt eine Kopie der einzelnen Blöcke. Bei jedem Durchlauf wechselt der Iterator zum ersten Element des nächsten „Chunk“, der durch ein Chunk-Objekt dargestellt wird, in der Quellsequenz. Diese Schleife entspricht der äußeren Foreach-Schleife, welche die Abfrage ausführt. In dieser Schleife führt der Code die folgenden Aktionen aus:

  1. Rufen Sie den Schlüssel für den aktuellen Block ab und weisen Sie ihn der key-Variable zu. Der Quell-Iterator durchläuft die Quellsequenz, bis er ein Element mit einem Schlüssel findet, der nicht übereinstimmt.
  2. Erstellen Sie ein neues Blockobjekt (Gruppenobjekt), und speichern Sie es in der current-Variable. Es verfügt über ein GroupItem-Element, eine Kopie des aktuellen Quellelements.
  3. Gibt diesen Block wieder. Ein Block ist ein IGrouping<TKey,TSource>, das der Rückgabewert der ChunkBy-Methode ist. Der Block hat nur das erste Element in seiner Quellsequenz. Die verbleibenden Elemente werden nur wiedergegeben, wenn der Clientcode diesen Block übergibt. Weitere Informationen finden Sie unter Chunk.GetEnumerator.
  4. Überprüfen Sie, ob:
    • Der Block eine Kopie aller Quellelemente erstellt hat oder
    • Der Iterator das Ende der Quellsequenz erreicht hat.
  5. Wenn die aufrufende Funktion alle Blockelemente aufgezählt hat, hat die Chunk.GetEnumerator-Methode alle Blockelemente kopiert. Wenn die Chunk.GetEnumerator-Schleife nicht alle Elemente im Block aufzählt, tun Sie das jetzt, um zu vermeiden, dass der Iterator für Clients beschädigt wird, die uns möglicherweise in einem separaten Thread aufrufen.

Chunk-Klasse

Die Chunk-Klasse ist eine zusammenhängende Gruppe von mindestens einem Quellelement, das denselben Schlüssel hat. Ein Block verfügt über einen Schlüssel und eine Liste von ChunkItem-Objekten, die Kopien der Elemente in der Quellsequenz sind:

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();
}

Jede ChunkItem (dargestellt durch ChunkItem-Klasse) weist einen Verweis auf die nächste ChunkItem in der Liste auf. Die Liste besteht aus der head-Liste, die den Inhalt des ersten Quellelements speichert, das zu diesem Abschnitt gehört und es ist tail, was ein Ende der Liste ist. Das Ende wird jedes Mal neu positioniert, wenn ein neues ChunkItem hinzugefügt wird. Das Ende der verknüpften Liste wird in der CopyNextChunkElement-Methode auf null gesetzt, wenn der Schlüssel des nächsten Elements nicht mit dem Schlüssel des aktuellen Chunks übereinstimmt oder es keine weiteren Elemente in der Quelle gibt.

Die CopyNextChunkElement-Methode der Chunk-Klasse fügt der aktuellen Elementgruppe einen ChunkItem hinzu. Dadurch wird versucht, den Iterator für die Quellsequenz zu durchlaufen. Wenn die MoveNext()-Methode false wiedergibt, liegt die Iteration am Ende und isLastSourceElement wird auf true festgelegt.

Die CopyAllChunkElements-Methode wird aufgerufen, nachdem das Ende des letzten Abschnitts erreicht wurde. Es wird überprüft, ob in der Quellsequenz weitere Elemente vorhanden sind. Ist dies der Fall, wird true wiedergegeben, wenn der Enumerator für diesen Block erschöpft war. Wenn in dieser Methode auf ein privates DoneCopyingChunk-Feld überprüft wirdtrue, wenn isLastSourceElement false ist, signalisiert es dem äußeren Iterator, den Iterator weiter zu durchlaufen.

Die GetEnumerator-Methode der Chunk-Klasse wird von der inneren Foreach-Schleife aufgerufen. Diese Methode bleibt ein Element vor den Clientanforderungen. Es fügt das nächste Element des Abschnitts erst hinzu, nachdem die Clients das vorherige letzte Element in der Liste anfordert.