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


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

Введение

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

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

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

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

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

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

Предпосылки

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

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

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

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

Подсказка

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

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

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";
}

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

Теперь используйте эти методы итератора для создания колоды карт. Поместите запрос LINQ в начало Program.cs файла. Вот как выглядит:

var startingDeck = from s in Suits()
                   from r in Ranks()
                   select (Suit: s, Rank: r);

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

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

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

var startingDeck = Suits().SelectMany(suit => Ranks().Select(rank => (Suit: suit, Rank: rank )));

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

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

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

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

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

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

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

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

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

public static class CardExtensions
{
    extension<T>(IEnumerable<T> sequence)
    {
        public IEnumerable<T> InterleaveSequenceWith(IEnumerable<T> second)
        {
            // Your implementation goes here
            return default;
        }
    }
}

Замечание

Если вы используете редактор, отличный от Visual Studio (например, Visual Studio Code), вам может потребоваться добавить using LinqFaroShuffle; в верхнюю часть файла Program.cs , чтобы методы расширения были доступны. Visual Studio автоматически добавляет директиву using, но другие редакторы могут не делать этого.

Контейнер extension задает расширенный тип. Узел extension объявляет тип и имя параметра приемника для всех членов в контейнере extension . В этом примере вы расширяете IEnumerable<T>, а параметр называется sequence.

Объявления членов расширения отображаются, как будто они являются членами типа приемника:

public IEnumerable<T> InterleaveSequenceWith(IEnumerable<T> second)

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

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

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

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

public IEnumerable<T> InterleaveSequenceWith(IEnumerable<T> second)
{
    var firstIter = sequence.GetEnumerator();
    var secondIter = second.GetEnumerator();

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

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

var shuffledDeck = top.InterleaveSequenceWith(bottom);

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

Сравнения

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

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

public bool SequenceEquals(IEnumerable<T> second)
{
    var firstIter = sequence.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(). Вы можете увидеть, что он всегда будет окончательным методом в любом запросе, так как он возвращает одно значение вместо последовательности:

var startingDeck = from s in Suits()
                   from r in Ranks()
                   select (Suit: s, Rank: r);

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

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

var shuffledDeck = top.InterleaveSequenceWith(bottom);

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

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

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

Console.WriteLine(times);

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

Оптимизации

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

shuffledDeck = shuffledDeck.Skip(26).InterleaveSequenceWith(shuffledDeck.Take(26));

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

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

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

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

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

public IEnumerable<T> LogQuery(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;
}

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

var startingDeck = (from s in Suits().LogQuery("Suit Generation")
                    from r in Ranks().LogQuery("Rank Generation")
                    select (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 проходит через итерацию, реконструируете колоду карт и перетасовываете её каждый раз. Чтобы кэшировать колоду карт, примените методы ToArray LINQ и ToList. При добавлении их к запросам они выполняют те же действия, которые вы сказали им, но теперь они хранят результаты в массиве или списке в зависимости от того, какой метод вы решили вызвать. Добавьте метод LINQ ToArray в оба запроса и снова запустите программу:

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 см. в следующем разделе: