Como estender o LINQ

Todos os métodos baseados em LINQ seguem um de dois padrões semelhantes. Eles tomam uma sequência enumerável. Eles retornam uma sequência diferente ou um único valor. A consistência da forma permite estender o LINQ escrevendo métodos com uma forma semelhante. Na verdade, as bibliotecas .NET ganharam novos métodos em muitas versões do .NET desde que o LINQ foi introduzido pela primeira vez. Neste artigo, você verá exemplos de extensão do LINQ escrevendo seus próprios métodos que seguem o mesmo padrão.

Adicionar métodos personalizados para consultas LINQ

Você estende o conjunto de métodos que você usa para consultas LINQ adicionando métodos de extensão à IEnumerable<T> interface. Por exemplo, além das operações médias ou máximas padrão, você cria um método de agregação personalizado para calcular um único valor a partir de uma sequência de valores. Você também cria um método que funciona como um filtro personalizado ou uma transformação de dados específica para uma sequência de valores e retorna uma nova sequência. Exemplos de tais métodos são Distinct, Skip, e Reverse.

Ao estender a IEnumerable<T> interface, você pode aplicar seus métodos personalizados a qualquer coleção enumerável. Para obter mais informações, consulte Métodos de extensão.

Um método agregado calcula um único valor a partir de um conjunto de valores. O LINQ fornece vários métodos agregados, incluindo Average, Mine Max. Você pode criar seu próprio método de agregação adicionando um método de extensão à IEnumerable<T> interface.

O exemplo de código a seguir mostra como criar um método de extensão chamado Median para calcular uma mediana para uma sequência de números do 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];
        }
    }
}

Você chama esse método de extensão para qualquer coleção enumerável da mesma forma que chama outros métodos agregados da IEnumerable<T> interface.

O exemplo de código a seguir mostra como usar o Median método para uma matriz do 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

Você pode sobrecarregar seu método agregado para que ele aceite sequências de vários tipos. A abordagem padrão é criar uma sobrecarga para cada tipo. Outra abordagem é criar uma sobrecarga que usa um tipo genérico e convertê-lo em um tipo específico usando um delegado. Você também pode combinar ambas as abordagens.

Você pode criar uma sobrecarga específica para cada tipo que deseja suportar. O exemplo de código a seguir mostra uma sobrecarga do Median método para o int tipo.

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

Agora você pode chamar as Median sobrecargas para ambos e doubleinteger tipos, conforme mostrado no código a seguir:

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

Você também pode criar uma sobrecarga que aceita uma sequência genérica de objetos. Essa sobrecarga usa um delegado como parâmetro e o usa para converter uma sequência de objetos de um tipo genérico em um tipo específico.

O código a seguir mostra uma sobrecarga do método que toma o MedianFunc<T,TResult> delegado como um parâmetro. Este delegado pega um objeto do tipo genérico T e retorna um objeto do 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();

Agora você pode chamar o Median método para uma sequência de objetos de qualquer tipo. Se o tipo não tiver sua própria sobrecarga de método, você terá que passar um parâmetro delegado. Em C#, você pode usar uma expressão lambda para essa finalidade. Além disso, somente no Visual Basic, se você usar a Aggregate cláusula ou Group By em vez da chamada de método, poderá passar qualquer valor ou expressão que esteja no escopo desta cláusula.

O código de exemplo a seguir mostra como chamar o Median método para uma matriz de inteiros e uma matriz de cadeias de caracteres. Para strings, a mediana para os comprimentos de strings na matriz é calculada. O exemplo mostra como passar o Func<T,TResult> parâmetro delegate para o Median método para cada 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

Você pode estender a IEnumerable<T> interface com um método de consulta personalizado que retorna uma sequência de valores. Nesse caso, o método deve retornar uma coleção do tipo IEnumerable<T>. Tais métodos podem ser usados para aplicar filtros ou transformações de dados a uma sequência de valores.

O exemplo a seguir mostra como criar um método de extensão chamado AlternateElements que retorna todos os outros elementos em uma coleção, começando a partir do primeiro 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++;
    }
}

Você pode chamar esse método de extensão para qualquer coleção enumerável da mesma forma que chamaria outros métodos da IEnumerable<T> interface, conforme mostrado no código a seguir:

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

Resultados do grupo por chaves contíguas

O exemplo a seguir mostra como agrupar elementos em partes que representam subsequências de chaves contíguas. Por exemplo, suponha que você receba a seguinte sequência de pares chave-valor:

Key valor
A Nós
A Pense
A Isso
N Linq
C é
A realmente
N Fresco
N !

Os seguintes grupos são criados nesta ordem:

  1. Nós, pensamos, que
  2. Linq
  3. é
  4. realmente
  5. Fresco!

A solução é implementada como um método de extensão thread-safe que retorna seus resultados de forma de streaming. Ele produz seus grupos à medida que se move através da sequência de origem. Ao contrário dos operadores ororderby, ele pode começar a group retornar grupos para o chamador antes de ler toda a sequência. O exemplo a seguir mostra o método de extensão e o código do cliente que o usa:

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 Classe

No código apresentado da implementação da classe, o while(true) loop no método itera ChunkBy através da ChunkExtensions sequência de origem e cria uma cópia de cada Chunk. Em cada passagem, o iterador avança para o primeiro elemento do próximo "Chunk", representado por um Chunk objeto, na sequência de origem. Esse loop corresponde ao loop foreach externo que executa a consulta. Nesse loop, o código executa as seguintes ações:

  1. Obtenha a chave para o Chunk atual e atribua-a à key variável. O iterador de origem consome a sequência de origem até encontrar um elemento com uma chave que não corresponde.
  2. Crie um novo objeto Chunk (grupo) e armazene-o em current variável. Ele tem um GroupItem, uma cópia do elemento de origem atual.
  3. Devolva esse Chunk. Um Chunk é um IGrouping<TKey,TSource>, que é o valor de retorno do ChunkBy método. O Chunk tem apenas o primeiro elemento em sua sequência de origem. Os elementos restantes são retornados somente quando o código do cliente é foreach sobre este bloco. Consulte Chunk.GetEnumerator para mais informações.
  4. Verifique se:
    • O bloco tem uma cópia de todos os seus elementos de origem, ou
    • O iterador chegou ao final da sequência de origem.
  5. Quando o chamador enumerou todos os itens de bloco, o Chunk.GetEnumerator método copiou todos os itens de bloco. Se o Chunk.GetEnumerator loop não enumerou todos os elementos no bloco, faça-o agora para evitar corromper o iterador para clientes que podem estar chamando-o em um thread separado.

Chunk Classe

A Chunk classe é um grupo contíguo de um ou mais elementos de origem que têm a mesma chave. Um Chunk tem uma chave e uma lista de objetos ChunkItem, que são cópias dos elementos na sequência de origem:

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

Cada ChunkItem um (representado por ChunkItem classe) tem uma referência para o próximo ChunkItem na lista. A lista consiste em seu head - que armazena o conteúdo do primeiro elemento de origem que pertence a este bloco, e seu tail - que é um fim da lista. A cauda é reposicionada cada vez que um novo ChunkItem é adicionado. A cauda da lista vinculada é definida como null no CopyNextChunkElement método se a chave do próximo elemento não corresponder à chave do bloco atual ou se não houver mais elementos na fonte.

O CopyNextChunkElement método da Chunk classe adiciona um ChunkItem ao grupo atual de itens. Ele tenta avançar o iterador na sequência de origem. Se o MoveNext() método retornar false a iteração está no final e isLastSourceElement é definido como true.

O CopyAllChunkElements método é chamado depois que o final do último bloco foi alcançado. Ele verifica se há mais elementos na sequência de origem. Se houver, ele retornará true se o enumerador para este bloco foi esgotado. Neste método, quando o campo privado DoneCopyingChunk é verificado para true, se isLastSourceElement é false, ele sinaliza para o iterador externo para continuar a iteração.

O loop foreach interno invoca o GetEnumeratorChunk método da classe. Esse método permanece um elemento à frente das solicitações do cliente. Ele adiciona o próximo elemento do bloco somente depois que o cliente solicita o último elemento anterior na lista.