Compartir vía


Cómo extender LINQ

Todos los métodos basados en LINQ siguen uno de los dos patrones similares. Toman una secuencia enumerable. Devuelven una secuencia diferente o un único valor. La coherencia de la forma permite extender LINQ escribiendo métodos con una forma similar. De hecho, las bibliotecas de .NET obtuvieron nuevos métodos en muchas versiones de .NET desde que se introdujo LINQ por primera vez. En este artículo, verá ejemplos de extensión de LINQ escribiendo sus propios métodos siguiendo el mismo patrón.

Agregar métodos personalizados para las consultas LINQ

Para extender el conjunto de métodos que usa para consultas LINQ, agregue métodos de extensión a la interfaz IEnumerable<T>. Por ejemplo, además de las operaciones habituales de promedio o de máximo, puede crear un método de agregación personalizado para calcular un solo valor a partir de una secuencia de valores. También puede crear un método que funcione como un filtro personalizado o como una transformación de datos específica para una secuencia de valores y que devuelva una nueva secuencia. Ejemplos de dichos métodos son Distinct, Skip y Reverse.

Si extiende la interfaz IEnumerable<T>, puede aplicar los métodos personalizados a cualquier colección enumerable. Para obtener más información, vea Métodos de extensión.

Un método de agregación calcula un valor único a partir de un conjunto de valores. LINQ proporciona varios métodos de agregación, incluidos Average, Min y Max. Si quiere crear su propio método de agregación, agregue un método de extensión a la interfaz IEnumerable<T>.

En el ejemplo de código siguiente se muestra cómo crear un método de extensión denominado Median para calcular la mediana de una secuencia de números de 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];
        }
    }
}

Puede llamar a este método de extensión para cualquier colección enumerable de la misma manera en la que llamaría a otros métodos de agregación desde la interfaz IEnumerable<T>.

En el ejemplo de código siguiente se muestra cómo usar el método Median para una matriz de 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

Puede sobrecargar el método de agregación para que acepte secuencias de varios tipos. El enfoque estándar consiste en crear una sobrecarga para cada tipo. Otro enfoque consiste en crear una sobrecarga que tome un tipo genérico y lo convierta a un tipo específico mediante un delegado. También puede combinar ambos enfoques.

Puede crear una sobrecarga específica para cada tipo que desee admitir. En el siguiente ejemplo de código se muestra una sobrecarga del método Median para el tipo int.

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

Ahora puede llamar a las sobrecargas Median para los tipos integer y double, como se muestra en el código siguiente:

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

También puede crear una sobrecarga que acepte una secuencia genérica de objetos. Esta sobrecarga toma un delegado como parámetro y lo usa para convertir una secuencia de objetos de un tipo genérico a un tipo específico.

En el código siguiente se muestra una sobrecarga del método Median que toma el delegado Func<T,TResult> como parámetro. Este delegado toma un objeto del tipo genérico T y devuelve un objeto de 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();

Ahora puede llamar al método Median para una secuencia de objetos de cualquier tipo. Si el tipo no tiene su propia sobrecarga de métodos, deberá pasar un parámetro de delegado. En C# puede usar una expresión lambda para este propósito. Además, solo en Visual Basic, si usa la cláusula Aggregate o Group By en lugar de la llamada al método, puede pasar cualquier valor o expresión que esté en el ámbito de esta cláusula.

En el ejemplo de código siguiente se muestra cómo llamar al método Median para una matriz de enteros y una matriz de cadenas. Para las cadenas, se calcula la mediana de las longitudes de las cadenas de la matriz. En el ejemplo se muestra cómo pasar el parámetro del delegado Func<T,TResult> al 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

Extienda la interfaz IEnumerable<T> con un método de consulta personalizado que devuelva una secuencia de valores. En este caso, el método debe devolver una colección de tipo IEnumerable<T>. Estos métodos se pueden usar para aplicar filtros o transformaciones de datos a una secuencia de valores.

En el ejemplo siguiente se muestra cómo crear un método de extensión denominado AlternateElements que devuelve los demás elementos de una colección, empezando por el primer 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++;
    }
}

Puede llamar a este método de extensión para cualquier colección enumerable de la misma manera en la que llamaría a otros métodos desde la interfaz IEnumerable<T>, como se muestra en el siguiente código:

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 claves contiguas

En el ejemplo siguiente se muestra cómo agrupar elementos en fragmentos que representan subsecuencias de claves contiguas. Por ejemplo, suponga que tiene la siguiente secuencia de pares clave-valor:

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

Los siguientes grupos se crean en este orden:

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

La solución se implementa como método de extensión seguro para subprocesos que devuelve los resultados mediante transmisión por secuencias. Genera sus grupos a medida que se desplaza por la secuencia de origen. A diferencia de los operadores group o orderby, es posible empezar a devolver grupos al autor de la llamada antes de leer toda la secuencia. En el ejemplo siguiente se muestran el método de extensión y el código de cliente que lo 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}");
            }
        }
    }
}

Clase ChunkExtensions

En el código presentado de la implementación de la clase ChunkExtensions, el bucle while(true) del método ChunkBy recorre en iteración la secuencia de origen y crea una copia de cada fragmento. En cada paso, el iterador avanza hasta el primer elemento del siguiente "Fragmento", representado por un objeto Chunk en la secuencia de origen. Este bucle corresponde al bucle foreach externo que ejecuta la consulta. En ese bucle, el código realiza las siguientes acciones:

  1. Obtenga la clave del fragmento actual y asígnela a la variable key. El iterador de origen consume la secuencia de origen hasta que encuentre un elemento con una clave que no coincida.
  2. Cree un nuevo objeto de Fragmento (grupo) y almacénelo en la variable current. Tiene un objeto GroupItem, una copia del elemento de origen actual.
  3. Devuelve ese fragmento. Un fragmento es un IGrouping<TKey,TSource>, que es el valor devuelto del método ChunkBy. El Fragmento solo tiene el primer elemento de su secuencia de origen. Los elementos restantes solo se devolverán cuando el foreach del código de cliente se aplique sobre este fragmento. Consulte Chunk.GetEnumerator para obtener más información.
  4. Compruebe si:
    • El fragmento tuviera una copia de todos sus elementos de origen o
    • El iterador alcanzó el final de la secuencia de origen.
  5. Cuando el autor de la llamada enumeró todos los elementos del fragmento, el método Chunk.GetEnumerator habrá copiado todos los elementos del fragmento. Si el bucle Chunk.GetEnumerator no enumerase todos los elementos del fragmento, hágalo en este momento para evitar dañar el iterador para los clientes que pudieran llamarlo en un subproceso independiente.

Clase Chunk

La clase Chunk es un grupo contiguo de uno o varios elementos de origen que tienen la misma clave. Un fragmento tiene una clave y una lista de objetos ChunkItem, que son copias de los elementos de la secuencia de origen:

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 por ChunkItem clase) tiene una referencia a la siguiente ChunkItem de la lista. La lista consta de su head, que almacena el contenido del primer elemento de origen que pertenece a este fragmento y es tail, que es el final de la lista. Se cambia la posición de la cola cada vez que se agrega un nuevo ChunkItem. La cola de la lista de vínculo se establece en null en el método CopyNextChunkElement si la clave del elemento siguiente no coincide con la clave del fragmento actual o no hay más elementos en el origen.

El método CopyNextChunkElement de la clase Chunk agrega un ChunkItem al grupo actual de elementos. Intenta avanzar el iterador en la secuencia de origen. Si el método MoveNext() devolviese false, la iteración estará al final y isLastSourceElement se establece en true.

Se llama al método CopyAllChunkElements después de que se alcance el final del último fragmento. Comprueba si hay más elementos en la secuencia de origen. Si los hubiera, devolverá true si se agotó el enumerador de este fragmento. En este método, cuando se comprueba el campo privado DoneCopyingChunk es revisado para true, si isLastSourceElement es false, indicará al iterador externo para continuar iterando.

El bucle foreach interno invoca el método GetEnumerator de la clase Chunk. Este método permanece un elemento por delante de las solicitudes de cliente. Agrega el siguiente elemento del fragmento solo después de que el cliente solicite el último elemento anterior de la lista.