Прочитать на английском

Поделиться через


Работа с запросом Language-Integrated (LINQ)

Введение

В этом руководстве описаны функции .NET Core и языка C#. Вы узнаете, как:

  • Создание последовательностей с помощью LINQ.
  • Напишите методы, которые можно легко использовать в запросах LINQ.
  • Различаете жадную и ленивую оценку.

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

Фокусники используют этот метод, потому что каждая карта находится в известном месте после каждой перетасовки, и порядок повторяется.

Для ваших целей это легкий взгляд на манипулирование последовательностями данных. Приложение, которое вы создадите, конструирует колоду карт и затем выполняет серию перетасовок, записывая её каждый раз. Вы также сравните обновленный заказ с оригинальным заказом.

В этом руководстве описано несколько шагов. После каждого шага можно запустить приложение и просмотреть ход выполнения. Вы также можете увидеть готовый пример в репозитории dotnet/samples на GitHub. Инструкции по скачиванию смотрите в разделах Образцы и руководства.

Предпосылки

Создание приложения

Первым шагом является создание нового приложения. Откройте командную строку и создайте новый каталог для приложения. Сделайте это текущим каталогом. Введите команду dotnet new console в командной строке. При этом создаются начальные файлы для базового приложения Hello World.

Если вы еще не использовали C#, в этом руководстве объясняется структура программы C#. Вы можете прочитать это, а затем вернуться сюда, чтобы узнать больше о LINQ.

Создание набора данных

Прежде чем начать, убедитесь, что следующие строки находятся в верхней части файла Program.cs, созданного dotnet new console:

C#
// Program.cs
using System;
using System.Collections.Generic;
using System.Linq;

Если эти три строки (директивыusing) не входят в начало файла, программа может не компилироваться.

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

C#
// Program.cs
// The Main() method

static IEnumerable<string> Suits()
{
    yield return "clubs";
    yield return "diamonds";
    yield return "hearts";
    yield return "spades";
}

static IEnumerable<string> Ranks()
{
    yield return "two";
    yield return "three";
    yield return "four";
    yield return "five";
    yield return "six";
    yield return "seven";
    yield return "eight";
    yield return "nine";
    yield return "ten";
    yield return "jack";
    yield return "queen";
    yield return "king";
    yield return "ace";
}

Поместите их под методом Main в вашем файле Program.cs. Оба метода используют синтаксис yield return для создания последовательности во время их выполнения. Компилятор создает объект, реализующий IEnumerable<T>, и создает последовательность строк по мере их запроса.

Теперь используйте эти методы итератора для создания колоды карт. Вы поместите запрос LINQ в наш метод Main. Вот посмотрите на это:

C#
// Program.cs
static void Main(string[] args)
{
    var startingDeck = from s in Suits()
                       from r in Ranks()
                       select new { Suit = s, Rank = r };

    // Display each card that we've generated and placed in startingDeck in the console
    foreach (var card in startingDeck)
    {
        Console.WriteLine(card);
    }
}

Когда несколько предложений from создают SelectMany, это формирует единую последовательность путем объединения элементов первой и второй последовательностей. Порядок важен для наших целей. Первый элемент в первой исходной последовательности (Suits) комбинируется с каждым элементом во второй последовательности (Ranks). Это создает все тринадцать карт первой масти. Этот процесс повторяется с каждым элементом в первой последовательности (Suits). Конечный результат — это колода карт, упорядоченная по мастям, после чего по значениям.

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

C#
var startingDeck = Suits().SelectMany(suit => Ranks().Select(rank => new { Suit = suit, Rank = rank }));

Компилятор преобразует инструкции LINQ, написанные с синтаксисом запроса, в эквивалентный синтаксис вызова метода. Таким образом, независимо от выбранного синтаксиса, две версии запроса создают один и тот же результат. Выберите, какой синтаксис лучше подходит для вашей ситуации: например, если вы работаете в команде, где некоторые члены имеют трудности с синтаксисом метода, попробуйте использовать синтаксис запроса.

Запустите пример, созданный на этом этапе. Он будет отображать все 52 карточки в колоде. Вы можете найти очень полезным запустить этот пример в отладчике, чтобы наблюдать за выполнением методов Suits() и Ranks(). Ясно видно, что каждая строка в каждой последовательности создается только по мере необходимости.

окно консоли, в котором приложение выводит 52 карточки.

Управление порядком

Затем сосредоточьтесь на том, как вы собираетесь перемешать карты в колоде. Первый шаг в любом хорошем перетасовке заключается в разбиении колоды на две части. Методы Take и Skip, которые являются частью API LINQ, предоставляют эту функцию. Поместите их под циклом foreach:

C#
// Program.cs
public static void Main(string[] args)
{
    var startingDeck = from s in Suits()
                       from r in Ranks()
                       select new { Suit = s, Rank = r };

    foreach (var c in startingDeck)
    {
        Console.WriteLine(c);
    }

    // 52 cards in a deck, so 52 / 2 = 26
    var top = startingDeck.Take(26);
    var bottom = startingDeck.Skip(26);
}

Тем не менее, нет метода перетасовки, чтобы воспользоваться преимуществами стандартной библиотеки, поэтому вам придется писать собственные. Метод перетасовки, который вы создадите, иллюстрирует несколько техник, которые будут использоваться с программами на основе LINQ, поэтому каждая часть этого процесса будет объяснена по этапам.

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

Добавьте методы расширения в новое место, создав в программе новый статический класс с именем Extensions.cs, а затем начните разработку первого метода расширения:

C#
// Extensions.cs
using System;
using System.Collections.Generic;
using System.Linq;

namespace LinqFaroShuffle
{
    public static class Extensions
    {
        public static IEnumerable<T> InterleaveSequenceWith<T>(this IEnumerable<T> first, IEnumerable<T> second)
        {
            // Your implementation will go here soon enough
        }
    }
}

Просмотрите сигнатуру метода в течение некоторого момента, в частности параметры:

C#
public static IEnumerable<T> InterleaveSequenceWith<T> (this IEnumerable<T> first, IEnumerable<T> second)

Вы можете увидеть добавление модификатора this в первом аргументе к методу. Это означает, что метод вызывается так, как если бы это был метод-член типа первого аргумента. Это объявление метода также соответствует распространенной идиоме, где типы входных и выходных данных обозначены как IEnumerable<T>. Эта практика позволяет объединить методы LINQ в цепочку для выполнения более сложных запросов.

Естественно, так как вы разделили колоду на половины, вам потребуется соединить эти половинки вместе. В коде это означает, что вы будете одновременно обрабатывать обе последовательности, которые вы получили через Take и Skip, interleaving элементы, и создадите одну последовательность: ваша теперь перемешанная колода карт. Написание метода LINQ, работающего с двумя последовательности, требует понимания того, как работает IEnumerable<T>.

Интерфейс IEnumerable<T> имеет один метод: GetEnumerator. Объект, возвращаемый GetEnumerator, имеет метод перемещения к следующему элементу, а свойство, извлекающее текущий элемент в последовательности. Вы будете использовать эти два элемента для перечисления коллекции и возврата элементов. Этот метод Interleave будет методом итератора, поэтому вместо создания коллекции и возврата коллекции вы будете использовать синтаксис yield return, показанный выше.

Вот реализация этого метода:

C#
public static IEnumerable<T> InterleaveSequenceWith<T>
    (this IEnumerable<T> first, IEnumerable<T> second)
{
    var firstIter = first.GetEnumerator();
    var secondIter = second.GetEnumerator();

    while (firstIter.MoveNext() && secondIter.MoveNext())
    {
        yield return firstIter.Current;
        yield return secondIter.Current;
    }
}

Теперь, когда вы написали этот метод, вернитесь к методу Main и перетасуйте колоду один раз.

C#
// Program.cs
public static void Main(string[] args)
{
    var startingDeck = from s in Suits()
                       from r in Ranks()
                       select new { Suit = s, Rank = r };

    foreach (var c in startingDeck)
    {
        Console.WriteLine(c);
    }

    var top = startingDeck.Take(26);
    var bottom = startingDeck.Skip(26);
    var shuffle = top.InterleaveSequenceWith(bottom);

    foreach (var c in shuffle)
    {
        Console.WriteLine(c);
    }
}

Сравнения

Сколько перетасовок требуется, чтобы вернуть колоду обратно в её первоначальный порядок? Чтобы узнать, необходимо написать метод, определяющий, равны ли две последовательности. После использования этого метода вам потребуется поместить код, который перетасовывает колоду, в цикл и проверить, когда колода снова оказывается в нужной последовательности.

Написать метод для определения, равны ли две последовательности, должно быть несложно. Это аналогичный метод, который вы написали, чтобы перетасовать колоду. Только на этот раз вместо использования yield returnдля каждого элемента вы будете сравнивать соответствующие элементы каждой последовательности. При перечислении всей последовательности, если каждый элемент совпадает, последовательности одинаковы:

C#
public static bool SequenceEquals<T>
    (this IEnumerable<T> first, IEnumerable<T> second)
{
    var firstIter = first.GetEnumerator();
    var secondIter = second.GetEnumerator();

    while ((firstIter?.MoveNext() == true) && secondIter.MoveNext())
    {
        if ((firstIter.Current is not null) && !firstIter.Current.Equals(secondIter.Current))
        {
            return false;
        }
    }

    return true;
}

В этом примере показана вторая идиома LINQ: методы терминала. Они принимают последовательность в качестве входных данных (или в данном случае две последовательности) и возвращают одно скалярное значение. При использовании терминальных методов они всегда являются последним методом в цепочке методов для запроса LINQ, отсюда и название "терминальные".

Это можно увидеть в действии, когда используется для определения, когда палуба возвращается в обратный исходный порядок. Поместите код перетасовки внутри цикла и остановите, когда последовательность восстановилась в своем исходном порядке, применяя метод SequenceEquals(). Вы можете увидеть, что он всегда будет окончательным методом в любом запросе, так как он возвращает одно значение вместо последовательности:

C#
// Program.cs
static void Main(string[] args)
{
    // Query for building the deck

    // Shuffling using InterleaveSequenceWith<T>();

    var times = 0;
    // We can re-use the shuffle variable from earlier, or you can make a new one
    shuffle = startingDeck;
    do
    {
        shuffle = shuffle.Take(26).InterleaveSequenceWith(shuffle.Skip(26));

        foreach (var card in shuffle)
        {
            Console.WriteLine(card);
        }
        Console.WriteLine();
        times++;

    } while (!startingDeck.SequenceEquals(shuffle));

    Console.WriteLine(times);
}

Запустите код, который вы написали до сих пор, и запишите, как колода перестраивается при каждом перетасовке. После 8 перетасовок (итераций цикла do-while) колода возвращается к первоначальной конфигурации, в которой она находилась при создании из исходного запроса LINQ.

Оптимизации

Созданный вами на данный момент пример выполняет перетасовку, в которой верхняя и нижняя карты остаются на своих местах при каждом запуске. Давайте сделаем одно изменение: вместо этого мы будем использовать в перемешивании, где все 52 карты меняют позицию. Для перемешивания, когда вы чередуете карты, первая карта в нижней половине колоды становится первой картой в колоде. Это означает, что последняя карточка в верхней половине становится нижней карточкой. Это простое изменение в единственной строке кода. Обновите текущий запрос перетасовки, переключив позиции Take и Skip. Это изменит порядок верхних и нижних половин палубы:

C#
shuffle = shuffle.Skip(26).InterleaveSequenceWith(shuffle.Take(26));

Запустите программу еще раз, и вы увидите, что для переупорядочивания колоды требуется 52 итерации. Вы также начнете замечать серьёзное снижение производительности по мере продолжения работы программы.

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

Вкратце, отложенное вычисление означает, что вычисление выражения не выполняется до тех пор, пока значение выражения не потребуется. Запросы LINQ — это запросы, которые оцениваются лениво. Последовательности создаются только при запросе элементов. Как правило, это основное преимущество LINQ. Однако при использовании такой программы это приводит к экспоненциальному росту времени выполнения.

Помните, что мы создали исходную колоду с помощью запроса LINQ. Каждое перемешивание осуществляется путем выполнения трех запросов LINQ на предыдущей колоде. Все это выполняется лениво. Это также означает, что они выполняются снова при каждом запросе последовательности. К тому времени, когда вы дойдете до 52-й итерации, вам приходится многократно воссоздавать исходную колоду. Давайте создадим журнал, чтобы продемонстрировать это поведение. Затем вы исправите его.

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

C#
public static IEnumerable<T> LogQuery<T>
    (this IEnumerable<T> sequence, string tag)
{
    // File.AppendText creates a new file if the file doesn't exist.
    using (var writer = File.AppendText("debug.log"))
    {
        writer.WriteLine($"Executing Query {tag}");
    }

    return sequence;
}

Под Fileпоявится красная волнистая линия, что означает, что она не существует. Компиляция не произойдет, потому что компилятор не знает, что такое File. Чтобы решить эту проблему, обязательно добавьте следующую строку кода в самую первую строку в Extensions.cs:

C#
using System.IO;

Это должно решить проблему, и красная ошибка исчезнет.

Затем добавьте лог-сообщение к определению каждого запроса.

C#
// Program.cs
public static void Main(string[] args)
{
    var startingDeck = (from s in Suits().LogQuery("Suit Generation")
                        from r in Ranks().LogQuery("Rank Generation")
                        select new { Suit = s, Rank = r }).LogQuery("Starting Deck");

    foreach (var c in startingDeck)
    {
        Console.WriteLine(c);
    }

    Console.WriteLine();
    var times = 0;
    var shuffle = startingDeck;

    do
    {
        // Out shuffle
        /*
        shuffle = shuffle.Take(26)
            .LogQuery("Top Half")
            .InterleaveSequenceWith(shuffle.Skip(26)
            .LogQuery("Bottom Half"))
            .LogQuery("Shuffle");
        */

        // In shuffle
        shuffle = shuffle.Skip(26).LogQuery("Bottom Half")
                .InterleaveSequenceWith(shuffle.Take(26).LogQuery("Top Half"))
                .LogQuery("Shuffle");

        foreach (var c in shuffle)
        {
            Console.WriteLine(c);
        }

        times++;
        Console.WriteLine(times);
    } while (!startingDeck.SequenceEquals(shuffle));

    Console.WriteLine(times);
}

Обратите внимание, что каждый раз, когда вы обращаетесь к запросу, не ведётся запись в журнал. Логирование происходит только при создании исходного запроса. Программа по-прежнему занимает много времени, но теперь вы можете увидеть, почему. Если у вас закончится терпение во время выполнения перетасовки с включенным ведением журнала, переключитесь обратно на исходную перетасовку. Вы по-прежнему увидите эффекты ленивой оценки. В одном прогоне выполняется 2592 запроса, включая все значения и генерацию мастей.

Вы можете повысить производительность кода, чтобы уменьшить количество выполняемых выполнений. Простое исправление, которое вы можете внести, — это закэшировать результаты исходного запроса LINQ, который формирует колоду карт. В настоящее время вы выполняете запросы снова и снова, когда цикл do-while проходит через итерацию, повторно создавая колоду карт и перетасовывая его каждый раз. Чтобы кэшировать колоду карточек, можно использовать методы LINQ ToArray и ToList; При добавлении их в запросы они будут выполнять те же действия, которые вы сказали им, но теперь они будут хранить результаты в массиве или списке в зависимости от того, какой метод вы решили вызвать. Добавьте метод LINQ ToArray в оба запроса и снова запустите программу:

C#
public static void Main(string[] args)
{
    IEnumerable<Suit>? suits = Suits();
    IEnumerable<Rank>? ranks = Ranks();

    if ((suits is null) || (ranks is null))
        return;

    var startingDeck = (from s in suits.LogQuery("Suit Generation")
                        from r in ranks.LogQuery("Value Generation")
                        select new { Suit = s, Rank = r })
                        .LogQuery("Starting Deck")
                        .ToArray();

    foreach (var c in startingDeck)
    {
        Console.WriteLine(c);
    }

    Console.WriteLine();

    var times = 0;
    var shuffle = startingDeck;

    do
    {
        /*
        shuffle = shuffle.Take(26)
            .LogQuery("Top Half")
            .InterleaveSequenceWith(shuffle.Skip(26).LogQuery("Bottom Half"))
            .LogQuery("Shuffle")
            .ToArray();
        */

        shuffle = shuffle.Skip(26)
            .LogQuery("Bottom Half")
            .InterleaveSequenceWith(shuffle.Take(26).LogQuery("Top Half"))
            .LogQuery("Shuffle")
            .ToArray();

        foreach (var c in shuffle)
        {
            Console.WriteLine(c);
        }

        times++;
        Console.WriteLine(times);
    } while (!startingDeck.SequenceEquals(shuffle));

    Console.WriteLine(times);
}

Теперь количество перетасовок уменьшено до 30 запросов. Запустите снова в режиме перетасовки, и вы увидите аналогичные улучшения: теперь выполняется 162 запроса.

Обратите внимание, что этот пример создан, чтобы подчеркнуть случаи использования, в которых отложенная оценка может вызвать трудности с производительностью. Хотя важно понять, где отложенная оценка может повлиять на производительность кода, столь же важно осознавать, что не все запросы должны выполняться сразу. Производительность снижается без использования ToArray, потому что каждое новое расположение колоды карт строится из предыдущего расположения. Использование отложенной оценки означает, что каждая новая конфигурация палубы создается на основе исходной палубы, даже выполняя код, созданный startingDeck. Это приводит к большому количеству дополнительной работы.

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

Заключение

В этом проекте вы рассмотрели:

  • использование запросов LINQ для агрегирования данных в понятной последовательности
  • написание методов расширения для добавления собственных пользовательских функций в запросы LINQ
  • Поиск областей в нашем коде, где наши запросы LINQ могут столкнуться с проблемами производительности, такими как снижение скорости
  • отложенная и немедленная оценка с точки зрения запросов LINQ и влияние, которое они могут иметь на эффективность выполнения запросов

Помимо LINQ, вы узнали немного о технике магов, используемых для карточных трюков. Фокусники используют перетасовку Фаро, потому что могут контролировать, куда движется каждая карта в колоде. Теперь, когда вы знаете, не портите это для всех остальных!

Дополнительные сведения о LINQ см. в следующем разделе: