Como estender o LINQ

Todos os métodos baseados em LINQ seguem um dos dois padrões semelhantes. Eles pegam uma sequência enumerável. Eles retornam uma sequência diferente ou um único valor. A consistência da forma permite que você estenda 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ê pode estender o conjunto de métodos que usa para consultas LINQ adicionando métodos de extensão à interface IEnumerable<T>. Por exemplo, além do padrão médio ou máximo de operações, é possível criar um método de agregação personalizado para calcular um único valor de uma sequência de valores. Também é possível criar um método que funciona como filtro personalizado ou transformação de dados específicos para uma sequência de valores e retorna uma nova sequência. Exemplos desses métodos são Distinct, Skip e Reverse.

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

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

O exemplo de código a seguir mostra como criar um método de extensão chamado Median para calcular uma mediana de 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 maneira que chama outros métodos de agregação da interface IEnumerable<T>.

O exemplo de código a seguir mostra como usar o método Median 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 o método de agregação 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 pega um tipo genérico e convertê-lo em um tipo específico, usando um delegado. Você também pode combinar as duas abordagens.

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

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

Agora você pode chamar as sobrecargas Median para os tipos integer e double, 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 recebe um delegado como parâmetro e usa-o 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 Median que recebe o delegado Func<T,TResult> como um parâmetro. Esse delegado recebe um objeto de 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 método Median para uma sequência de objetos de qualquer tipo. Se o tipo não tiver sua própria sobrecarga de método, será necessário passar um parâmetro delegado. No C# você pode usar uma expressão lambda para essa finalidade. Além disso, no Visual Basic, se você usar a cláusula Aggregate ou Group By em vez da chamada de método, você pode passar qualquer valor ou expressão que estiver no escopo dessa cláusula.

O exemplo de código a seguir mostra como chamar o método Median para uma matriz de inteiros e para uma matriz de cadeias de caracteres. Será calculada a mediana dos comprimentos das cadeias de caracteres na matriz. O exemplo também mostra como passar o parâmetro delegado Func<T,TResult> ao método Median 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 interface IEnumerable<T> com um método de consulta personalizada que retorna uma sequência de valores. Nesse caso, o método deve retornar uma coleção do tipo IEnumerable<T>. Esses métodos podem ser usados para aplicar transformações de dados ou filtros 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 pelo 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 exatamente como chamaria outros métodos da interface IEnumerable<T>, 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

Agrupar resultados 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:

Chave Valor
Um We
Um think
Um that
B Linq
C is
Um really
B cool
B !

Os seguintes grupos são criados nesta ordem:

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

A solução é implementada como um método de extensão thread-safe que retorna os resultados de uma maneira de streaming. Ela produz seus grupos à medida que percorre a sequência de origem. Diferentemente dos operadores group ou orderby, ela pode começar a 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}");
            }
        }
    }
}

Classe ChunkExtensions

No código apresentado da implementação da classe ChunkExtensions, o while(true) faz um loop no método ChunkBy, itera pela 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 objeto Chunk, 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. Obtém a chave para o Chunk atual e a atribui à variável key. O iterador de origem consome a sequência de origem até encontrar um elemento com uma chave que não seja correspondente.
  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. Retorna esse Chunk. Um Chunk é um IGrouping<TKey,TSource>, que é o valor retornado do método ChunkBy. O Chunk tem apenas o primeiro elemento na sequência de origem. Os elementos restantes são retornados somente quando o foreach do código do cliente estiver nessa parte. Confira Chunk.GetEnumerator para obter mais informações.
  4. Verifique se:
    • o Chunk tem uma cópia de todos os respectivos elementos de origem ou
    • o iterador atingiu o fim da sequência de origem.
  5. Quando o chamador enumerou todos os itens de parte, o método Chunk.GetEnumerator copiou todos os itens de chunk. Se o loop de Chunk.GetEnumerator não enumerar todos os elementos no chunk, faça isso agora para evitar corromper o iterador para clientes que podem estar chamando-o em um thread separado.

Classe Chunk

A classe Chunk é um grupo contíguo de um ou mais elementos de origem que têm a mesma chave. Uma parte 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 (representado pela classe ChunkItem) tem uma referência ao próximo ChunkItem na lista. A lista consiste no head, que armazena o conteúdo do primeiro elemento de origem que pertence a esse Chunk, e no tail, que é um fim da lista. O fim da lista é reposicionado cada vez que um novo ChunkItem é adicionado. A parte final da lista vinculada é definida como null no método CopyNextChunkElement se a chave do próximo elemento não corresponde à chave da parte atual ou se não há mais elementos na origem.

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

O método CopyAllChunkElements é chamado depois que o final da última parte foi alcançado. Ele verifica se há mais elementos na sequência de origem. Se houver, ele retornará true se o enumerador dessa parte tiver sido esgotado. Nesse método, quando é verificado se o campo privado DoneCopyingChunk é true, caso isLastSourceElement seja false, ele sinaliza ao iterador externo para continuar iterando.

O loop foreach interno invoca o método GetEnumerator da classe Chunk. Esse método fica apenas um elemento à frente das solicitações do cliente. Ele adiciona o próximo elemento da parte somente depois que o cliente solicita o elemento anterior da lista.