Come estendere LINQ

Tutti i metodi basati su LINQ seguono uno di due modelli simili. Accettano una sequenza enumerabile. Restituiscono una sequenza diversa o un singolo valore. La coerenza della forma consente di estendere LINQ scrivendo metodi con una forma simile. Infatti, le librerie .NET hanno ottenuto nuovi metodi in molte versioni di .NET dalla prima introduzione di LINQ. In questo articolo vengono presentati esempi di estensione di LINQ scrivendo metodi personalizzati che seguono lo stesso modello.

Aggiungere metodi personalizzati per query LINQ

È possibile estendere il set di metodi da usare per le query LINQ aggiungendo metodi di estensione all'interfaccia IEnumerable<T>. Oltre alla media standard o a un numero massimo di operazioni, ad esempio, è possibile creare un metodo di aggregazione personalizzato per calcolare un singolo valore da una sequenza di valori. È anche possibile creare un metodo che funzioni come un filtro personalizzato o una trasformazione di dati specifica per una sequenza di valori che restituisca una nuova sequenza. Esempi di tali metodi sono Distinct, Skip e Reverse.

Quando si estende l'interfaccia IEnumerable<T>, è possibile applicare i metodi personalizzati a qualsiasi raccolta enumerabile. Per altre informazioni, vedere Metodi di estensione.

Un metodo di aggregazione calcola un singolo valore da un set di valori. LINQ offre diversi metodi di aggregazione, tra cui Average, Min e Max. È possibile creare il proprio metodo di aggregazione aggiungendo un metodo di estensione all'interfaccia IEnumerable<T>.

L'esempio di codice seguente illustra come creare un metodo di estensione denominato Median per calcolare un valore mediano per una sequenza di numeri di tipo double.

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

Chiamare questo metodo di estensione per qualsiasi raccolta enumerabile nello stesso modo in cui si chiamano altri metodi di aggregazione dall'interfaccia IEnumerable<T>.

L'esempio di codice seguente illustra come usare il metodo Median di una matrice di tipo double.

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

È possibile eseguire l'overload del metodo di aggregazione in modo che accetti sequenze di tipi diversi. L'approccio standard consiste nel creare un overload per ogni tipo. Un altro approccio consiste nel creare un overload che accetti un tipo generico e lo converta in un tipo specifico tramite un delegato. È anche possibile combinare entrambi gli approcci.

È possibile creare un overload specifico per ogni tipo che si vuole supportare. Nell'esempio di codice seguente viene illustrato l'overload del metodo Median per il tipo int.

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

È ora possibile chiamare gli overload Median per entrambi i tipi integer e double, come illustrato nel codice seguente:

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

È anche possibile creare un overload che accetti una sequenza generica di oggetti. Questo overload accetta un delegato come parametro e lo usa per convertire una sequenza di oggetti di un tipo generico in un tipo specifico.

Il codice seguente mostra un overload del metodo Median che accetta il delegato Func<T,TResult> come parametro. Questo delegato accetta un oggetto di tipo generico T e restituisce un oggetto di tipo double.

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

È ora possibile chiamare il metodo Median per una sequenza di oggetti di qualsiasi tipo. Se il tipo non ha un proprio overload del metodo, è necessario passare un parametro del delegato. In C# è possibile usare un'espressione lambda a questo scopo. Solo in Visual Basic, se si usa la clausola Aggregate o Group By anziché la chiamata al metodo, è possibile passare qualsiasi valore o espressione che si trovi nell'ambito della clausola.

L'esempio di codice seguente illustra come chiamare il metodo Median per una matrice di numeri interi e una matrice di stringhe. Per le stringhe, viene calcolato il valore mediano della lunghezza delle stringhe nella matrice. L'esempio mostra come passare il parametro del delegato Func<T,TResult> al metodo Median per ogni caso.

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

È possibile estendere l'interfaccia IEnumerable<T> con un metodo di query personalizzato che restituisce una sequenza di valori. In questo caso, il metodo deve restituire una raccolta di tipo IEnumerable<T>. Tali metodi possono essere usati per applicare filtri o trasformazioni di dati in una sequenza di valori.

Nell'esempio seguente viene illustrato come creare un metodo di estensione denominato AlternateElements che restituisce tutti gli altri elementi in una raccolta, a partire dal primo elemento.

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

È possibile chiamare questo metodo di estensione per qualsiasi raccolta enumerabile nello stesso modo in cui si chiamano altri metodi dall'interfaccia IEnumerable<T>, come illustrato nel codice seguente:

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

Raggruppare i risultati per chiavi contigue

Nell'esempio seguente viene illustrato come raggruppare elementi in blocchi che rappresentano sottosequenze di chiavi contigue. Si supponga, ad esempio, di avere la sequenza di coppie chiave-valore riportata di seguito:

Chiave valore
Un Me
Un think
Un that
G Linq
A is
Un really
G cool
G !

Vengono creati i gruppi seguenti in questo ordine:

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

La soluzione viene implementata come metodo di estensione thread-safe che restituisce i risultati in modalità di flusso. I gruppi vengono prodotti man mano che ci si sposta lungo la sequenza di origine. A differenza degli operatori group o orderby, questa soluzione può iniziare a restituire i gruppi al chiamante prima di completare la lettura dell'intera sequenza. L'esempio seguente illustra sia il metodo di estensione che il codice client usato:

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

Classe ChunkExtensions

Nel codice presentato dell'implementazione della classe ChunkExtensions, il ciclo while(true) nel metodo ChunkBy, esegue l'iterazione della sequenza di origine e crea una copia di ogni blocco. A ogni passaggio, l'iteratore passa al primo elemento del "blocco" successivo, rappresentato da un oggetto Chunk, nella sequenza di origine. Questo ciclo corrisponde al ciclo foreach esterno che esegue la query. In tale ciclo il codice esegue le azioni seguenti:

  1. Ottenere la chiave per il blocco corrente e assegnarlo alla variabile key. L'iteratore di origine percorre la sequenza di origine fino a quando non trova un elemento con una chiave non corrispondente.
  2. Creare un nuovo oggetto Chunk (gruppo) e archiviarlo nella variabile current. Include un solo GroupItem, ovvero una copia dell'elemento di origine corrente.
  3. Restituire tale blocco. Un blocco è un oggetto IGrouping<TKey,TSource>, che è il valore restituito del metodo ChunkBy. Il blocco ha solo il primo elemento nella sequenza di origine. Gli elementi rimanenti vengono restituiti solo quando il ciclo foreach del codice client passa su questo blocco. Per ulteriori informazioni, Chunk.GetEnumerator vedere:
  4. Controllare se:
    • Il blocco ha creato una copia di tutti i relativi elementi di origine o
    • L'iteratore ha raggiunto la fine della sequenza di origine.
  5. Quando il chiamante ha enumerato tutti gli elementi del blocco, il metodo Chunk.GetEnumerator ha copiato tutti gli elementi del blocco. Se il ciclo Chunk.GetEnumerator non ha enumerato tutti gli elementi nel blocco, eseguire questa operazione in questo momento per evitare di danneggiare l'iteratore per i client che potrebbero chiamarlo in un thread separato.

Classe Chunk

La classe Chunk è un gruppo contiguo di uno o più elementi di origine che hanno la stessa chiave. Un blocco ha una chiave e un elenco di oggetti ChunkItem, che sono copie degli elementi nella sequenza di origine:

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

Ogni oggetto ChunkItem (rappresentato dalla classe ChunkItem) ha un riferimento all'oggetto ChunkItem successivo nell'elenco. L'elenco è costituito dal relativo oggetto head, che archivia il contenuto del primo elemento di origine che appartiene a questo blocco, e dal relativo oggetto tail, ovvero una fine dell'elenco. La coda viene riposizionata ogni volta che viene aggiunto un nuovo oggetto ChunkItem. La coda dell'elenco collegato è impostata su null nel metodo CopyNextChunkElement se la chiave dell'elemento successivo non corrisponde alla chiave del blocco corrente o se non sono presenti altri elementi nell'origine.

Il metodo CopyNextChunkElement della classe Chunk aggiunge un oggetto ChunkItem al gruppo corrente di elementi. Tenta di far avanzare l'iteratore nella sequenza di origine. Se il metodo MoveNext() restituisce false, l'iterazione è alla fine e l'oggetto isLastSourceElement viene impostato su true.

Il metodo CopyAllChunkElements viene chiamato dopo che è stata raggiunta la fine dell'ultimo blocco. Controlla se nella sequenza di origine sono presenti altri elementi. In caso affermativo, restituisce true se l'enumeratore per questo blocco è stato esaurito. In questo metodo, quando viene verificato se il campo privato DoneCopyingChunk è true, se isLastSourceElement è false, segnala all'iteratore esterno di continuare l'iterazione.

Il ciclo foreach interno richiama il metodo GetEnumerator della classe Chunk. Questo metodo rimane avanti di un solo elemento rispetto alle richieste client. Aggiunge l'elemento successivo del blocco solo dopo che il client richiede l'ultimo elemento precedente nell'elenco.