Расширение LINQ

Все методы на основе LINQ соответствуют одному из двух аналогичных шаблонов. Они принимают перечисленную последовательность. Они возвращают другую последовательность или одно значение. Согласованность фигуры позволяет расширить LINQ путем написания методов с аналогичной фигурой. На самом деле библиотеки .NET получили новые методы во многих выпусках .NET, так как LINQ впервые появился. В этой статье приведены примеры расширения LINQ путем написания собственных методов, которые соответствуют тому же шаблону.

Добавление пользовательских методов для запросов LINQ

Вы можете расширить набор методов, которые можно использовать для запросов LINQ, путем добавления методов расширения в интерфейс IEnumerable<T>. Например, помимо стандартных операций вычисления среднего или максимального значения, можно создать настраиваемый метод агрегирования для вычисления одного значения на основе последовательности значений. Также можно создать метод, который работает как настраиваемый фильтр или особое преобразование данных для последовательности значений и возвращает новую последовательность. Примерами таких методов являются Distinct, Skip и Reverse.

При расширении интерфейса IEnumerable<T> настраиваемые методы можно применять к любой перечислимой коллекции. Дополнительные сведения см. в статье Методы расширения.

Агрегатный метод вычисляет одно значение из набора значений. LINQ реализует несколько методов агрегирования, включая Average, Min и Max. Вы можете создать собственный метод агрегирования, добавив метод расширения в интерфейс IEnumerable<T>.

В следующем примере кода показано, как создать метод расширения Median для вычисления срединного значения последовательности чисел типа 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];
        }
    }
}

Этот метод расширения вызывается для любых перечислимых коллекций так же, как другие методы агрегирования из интерфейса IEnumerable<T>.

В следующем примере кода показано использование метода Median для массива типа 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

Вы можете перегрузить агрегатный метод , чтобы он принимал последовательности различных типов. Стандартный способ — создание перегрузки для каждого типа. Другой подход заключается в создании перегрузки, которая принимает универсальный тип и преобразует ее в конкретный тип с помощью делегата. Вы можете сочетать оба способа.

Вы можете создать конкретную перегрузку для каждого типа, который требуется поддерживать. В следующем примере кода показана перегрузка метода Median для типа int.

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

Теперь можно вызвать перегрузки Median для типов integer и double, как показано в следующем примере кода:

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

Вы также можете создать перегрузку, которая принимает универсальную последовательность объектов. Эта перегрузка принимает делегат в качестве параметра и использует его для преобразования последовательности объектов универсального типа в конкретный тип.

В следующем примере кода демонстрируется перегрузка метода Median, принимающая делегат Func<T,TResult> в качестве параметра. Этот делегат принимает объект универсального типа T и возвращает объект типа double.

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

Теперь вы можете вызвать метод Median для последовательности объектов любого типа. Если тип не содержит собственную перегрузку метода, вам потребуется передать параметр делегата. В C# для этой цели можно использовать лямбда-выражение. Кроме того, только в Visual Basic, если вы используете предложение Aggregate или Group By вместо вызова метода, вы можете передать любое значение или выражение, которое находится в области этого предложения.

В следующем примере кода показано, как вызвать метод Median для массива целых чисел и массива строк. Для строк вычисляется срединное значение длин строк в массиве. В примере демонстрируется передача параметра делегата Func<T,TResult> в метод Median для каждого из случаев.

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

Интерфейс можно расширить IEnumerable<T> с помощью пользовательского метода запроса, который возвращает последовательность значений. В этом случае метод должен возвращать коллекцию типа IEnumerable<T>. Такие методы можно использовать для применения фильтров или преобразований данных к последовательности значений.

В следующем примере показано, как создать метод расширения с именем AlternateElements, который возвращает каждый второй элемент в коллекции, начиная с первого элемента.

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

Этот метод расширения вызывается для любых перечислимых коллекций так же, как другие методы из интерфейса IEnumerable<T>, как показано в следующем примере кода:

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

Группирование результатов по смежным ключам

В приведенном ниже примере показано, как сгруппировать элементы в блоки, представляющие последовательности смежных ключей. Например, предположим, что вы получите следующую последовательность пар "ключ-значение":

Ключ Значение
а Мы
а думаете
а который
Б LINQ
О -
а действительно
Б известным
Б !

В этом порядке создаются следующие группы:

  1. Мы думаем, что
  2. LINQ
  3. -
  4. действительно
  5. известным, !

Решение реализуется как метод расширения, безопасный для потока, который возвращает результаты потоковой передачи. Он создает свои группы по мере перемещения по исходной последовательности. grouporderby В отличие от операторов, он может начать возвращать группы вызывающему объекту перед чтением всей последовательности. В приведенном ниже примере показаны метод расширения и использующий его клиентский код:

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

В представленном коде ChunkExtensions реализации while(true) класса цикл в ChunkBy методе выполняет итерацию по исходной последовательности и создает копию каждого блока. На каждом проходе итератор переходит к первому элементу следующего элемента "Chunk", представленного объектом в исходной Chunk последовательности. Этот цикл соответствует внешнему циклу foreach, который выполняет запрос. В этом цикле код выполняет следующие действия:

  1. Получите ключ для текущего блока и назначьте его переменной key . Исходный итератор использует исходную последовательность, пока не найдет элемент с ключом, который не соответствует.
  2. Создайте новый объект Chunk (group) и сохраните его в current переменной. Он содержит одну копию текущего исходного элемента GroupItem.
  3. Верните этот блок. Блок — это IGrouping<TKey,TSource>возвращаемое значение ChunkBy метода. Блок имеет только первый элемент в исходной последовательности. Остальные элементы возвращаются только в том случае, если клиентский код выполняется над этим блоком. Дополнительные сведения см. в разделе Chunk.GetEnumerator .
  4. Проверьте, есть ли:
    • Блок содержит копию всех его исходных элементов или
    • Итератор достиг конца исходной последовательности.
  5. Когда вызывающий объект перечислил все элементы блока, Chunk.GetEnumerator метод скопировал все элементы блока. Chunk.GetEnumerator Если цикл не перечислил все элементы в блоке, сделайте это, чтобы избежать повреждения итератора для клиентов, которые могут вызывать его в отдельном потоке.

Класс Chunk

Класс Chunk представляет собой непрерывную группу одного или нескольких исходных элементов, имеющих один и тот же ключ. Блок содержит ключ и список объектов ChunkItem, которые являются копиями элементов в исходной последовательности:

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

Каждый ChunkItem (представленный классом ChunkItem ) имеет ссылку на следующий ChunkItem в списке. Список состоит из его head - который хранит содержимое первого исходного элемента, который принадлежит этому блоку, и его tail - это конец списка. Хвост перемещается при каждом добавлении нового ChunkItem . Хвост связанного списка имеет null значение в CopyNextChunkElement методе, если ключ следующего элемента не соответствует ключу текущего блока или отсутствуют элементы в источнике.

Метод CopyNextChunkElementChunk класса добавляет один ChunkItem в текущую группу элементов. Он пытается продвинуть итератор в исходной последовательности. MoveNext() Если метод возвращает false итерацию в конце и isLastSourceElement имеет значение true.

Метод CopyAllChunkElements вызывается после достижения конца последнего блока. Он проверка, есть ли дополнительные элементы в исходной последовательности. Если есть, он возвращается true , если перечислитель для этого блока был исчерпан. В этом методе, когда частное DoneCopyingChunk поле проверка для true, если isLastSourceElement имеет значениеfalse, оно сигнализирует внешнему итератору, чтобы продолжить итерацию.

Внутренний цикл foreach вызывает GetEnumerator метод Chunk класса. Этот метод остается одним элементом перед клиентскими запросами. Он добавляет следующий элемент блока только после того, как клиент запрашивает предыдущий последний элемент в списке.