Trabalhar com LINQ (Language-Integrated Query)

Introdução

Este tutorial ensina recursos no .NET Core e na linguagem C#. Irá aprender a:

  • Gere sequências com LINQ.
  • Escreva métodos que podem ser facilmente usados em consultas LINQ.
  • Distinga entre avaliação ansiosa e preguiçosa.

Você aprenderá essas técnicas construindo um aplicativo que demonstra uma das habilidades básicas de qualquer mágico: o faro shuffle. Resumidamente, um faro shuffle é uma técnica onde você divide um baralho de cartas exatamente ao meio, então o shuffle intercala cada carta de cada metade para reconstruir o baralho original.

Os mágicos usam essa técnica porque cada carta está em um local conhecido após cada embaralhamento, e a ordem é um padrão de repetição.

Para os seus propósitos, é um olhar leve sobre a manipulação de sequências de dados. O aplicativo que você construirá constrói um baralho de cartas e, em seguida, executa uma sequência de embaralhamentos, escrevendo a sequência de cada vez. Você também comparará o pedido atualizado com o pedido original.

Este tutorial tem várias etapas. Após cada etapa, você pode executar o aplicativo e ver o progresso. Você também pode ver o exemplo concluído no repositório dotnet/samples do GitHub. Para obter instruções de download, consulte Exemplos e tutoriais.

Pré-requisitos

Você precisará configurar sua máquina para executar o .NET core. Você pode encontrar as instruções de instalação na página Download do .NET Core. Você pode executar este aplicativo no Windows, Ubuntu Linux ou OS X, ou em um contêiner Docker. Você precisará instalar seu editor de código favorito. As descrições abaixo usam o Visual Studio Code , que é um editor de código aberto e multiplataforma. No entanto, você pode usar quaisquer ferramentas com as quais se sinta confortável.

Criar o aplicativo

O primeiro passo é criar um novo aplicativo. Abra um prompt de comando e crie um novo diretório para seu aplicativo. Faça disso o diretório atual. Digite o comando no prompt de comando dotnet new console . Isso cria os arquivos iniciais para um aplicativo básico "Hello World".

Se você nunca usou C# antes, este tutorial explica a estrutura de um programa C#. Você pode ler isso e depois voltar aqui para saber mais sobre o LINQ.

Criar o conjunto de dados

Antes de começar, certifique-se de que as seguintes linhas estão na parte superior do Program.cs arquivo gerado por dotnet new console:

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

Se estas três linhas (using instruções) não estiverem na parte superior do ficheiro, o nosso programa não será compilado.

Agora que você tem todas as referências que você vai precisar, considere o que constitui um baralho de cartas. Comumente, um baralho de cartas de baralho tem quatro naipes, e cada terno tem treze valores. Normalmente, você pode considerar criar uma classe logo de cara e preencher uma Card coleção de Card objetos manualmente. Com o LINQ, você pode ser mais conciso do que a maneira habitual de lidar com a criação de um baralho de cartas. Em vez de criar uma Card classe, você pode criar duas sequências para representar naipes e classificações, respectivamente. Você criará um par muito simples de métodos iteradores que gerarão as fileiras e ternos como IEnumerable<T>s de strings:

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

Coloque-os abaixo do Main método em seu Program.cs arquivo. Esses dois métodos utilizam a yield return sintaxe para produzir uma sequência à medida que são executados. O compilador cria um objeto que implementa IEnumerable<T> e gera a sequência de cadeias de caracteres conforme elas são solicitadas.

Agora, use esses métodos iteradores para criar o baralho de cartas. Você colocará a consulta LINQ em nosso Main método. Aqui está uma olhada nisso:

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

As cláusulas múltiplas from produzem um SelectMany, que cria uma única sequência a partir da combinação de cada elemento na primeira sequência com cada elemento na segunda sequência. A encomenda é importante para os nossos propósitos. O primeiro elemento na primeira sequência de origem (Suits) é combinado com todos os elementos na segunda sequência (Ranks). Isso produz todas as treze cartas do primeiro terno. Esse processo é repetido com cada elemento na primeira sequência (Suits). O resultado final é um baralho de cartas ordenado por ternos, seguido de valores.

É importante ter em mente que, se você optar por escrever seu LINQ na sintaxe de consulta usada acima ou usar a sintaxe do método, é sempre possível passar de uma forma de sintaxe para a outra. A consulta acima escrita na sintaxe da consulta pode ser escrita na sintaxe do método como:

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

O compilador traduz instruções LINQ escritas com sintaxe de consulta na sintaxe de chamada de método equivalente. Portanto, independentemente da sua escolha de sintaxe, as duas versões da consulta produzem o mesmo resultado. Escolha qual sintaxe funciona melhor para sua situação: por exemplo, se você estiver trabalhando em uma equipe onde alguns dos membros têm dificuldade com a sintaxe do método, tente preferir usar a sintaxe de consulta.

Vá em frente e execute o exemplo que você criou neste ponto. Ele exibirá todas as 52 cartas no baralho. Você pode achar muito útil executar este exemplo em um depurador para observar como os Suits() métodos e Ranks() são executados. Você pode ver claramente que cada string em cada sequência é gerada apenas quando é necessária.

A console window showing the app writing out 52 cards.

Manipular a Ordem

Em seguida, concentre-se em como você vai embaralhar as cartas no baralho. O primeiro passo em qualquer bom embaralhamento é dividir o baralho em dois. Os Take métodos e Skip que fazem parte das APIs do LINQ fornecem esse recurso para você. Coloque-os debaixo do foreach loop:

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

No entanto, não há nenhum método aleatório para aproveitar na biblioteca padrão, então você terá que escrever o seu próprio. O método shuffle que você criará ilustra várias técnicas que você usará com programas baseados em LINQ, portanto, cada parte desse processo será explicada em etapas.

Para adicionar alguma funcionalidade à forma como você interage com as IEnumerable<T> consultas LINQ, você precisará escrever alguns tipos especiais de métodos chamados métodos de extensão. Resumidamente, um método de extensão é um método estático de finalidade especial que adiciona nova funcionalidade a um tipo já existente sem ter que modificar o tipo original ao qual você deseja adicionar funcionalidade.

Dê aos seus métodos de extensão uma nova página inicial adicionando um novo arquivo de classe estática ao seu programa chamado Extensions.cse, em seguida, comece a criar o primeiro método de extensão:

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

Observe a assinatura do método por um momento, especificamente os parâmetros:

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

Você pode ver a this adição do modificador no primeiro argumento ao método. Isso significa que você chama o método como se fosse um método membro do tipo do primeiro argumento. Esta declaração de método também segue uma linguagem padrão onde os tipos de entrada e saída são IEnumerable<T>. Essa prática permite que os métodos LINQ sejam encadeados para executar consultas mais complexas.

Naturalmente, como você divide o baralho em metades, você precisará juntar essas metades. No código, isso significa que você estará enumerando ambas as sequências que adquiriu e TakeSkip , ao mesmo tempo, os elementos, interleaving e criando uma sequência: seu baralho de cartas agora embaralhado. Escrever um método LINQ que funcione com duas sequências requer que você entenda como IEnumerable<T> funciona.

A IEnumerable<T> interface tem um método: GetEnumerator. O objeto retornado por GetEnumerator tem um método para mover para o próximo elemento e uma propriedade que recupera o elemento atual na sequência. Você usará esses dois membros para enumerar a coleção e retornar os elementos. Esse método Interleave será um método iterador, portanto, em vez de criar uma coleção e retornar a coleção, você usará a yield return sintaxe mostrada acima.

Aqui está a implementação desse método:

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

Agora que você escreveu esse método, volte para o método e embaralhe o Main deck uma vez:

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

Comparações

Quantos embaralhamentos são necessários para colocar o deck de volta à sua ordem original? Para descobrir, você precisará escrever um método que determine se duas sequências são iguais. Depois de ter esse método, você precisará colocar o código que embaralha o deck em um loop e verificar quando o deck está de volta à ordem.

Escrever um método para determinar se as duas sequências são iguais deve ser simples. É uma estrutura semelhante ao método que você escreveu para embaralhar o baralho. Só que, desta vez, em vez de cada elemento, você vai comparar os elementos correspondentes de yield returncada sequência. Quando toda a sequência tiver sido enumerada, se todos os elementos corresponderem, as sequências serão as mesmas:

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

Isso mostra uma segunda expressão LINQ: métodos terminais. Eles tomam uma sequência como entrada (ou, neste caso, duas sequências) e retornam um único valor escalar. Ao usar métodos de terminal, eles são sempre o método final em uma cadeia de métodos para uma consulta LINQ, daí o nome "terminal".

Você pode ver isso em ação quando você usá-lo para determinar quando o deck está de volta à sua ordem original. Coloque o código aleatório dentro de um loop e pare quando a sequência estiver de volta em sua ordem original aplicando o SequenceEquals() método. Você pode ver que ele sempre seria o método final em qualquer consulta, porque retorna um único valor em vez de uma sequência:

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

Execute o código que você tem até agora e tome nota de como o deck se reorganiza em cada shuffle. Após 8 shuffles (iterações do loop do-while), o deck retorna à configuração original em que estava quando você o criou pela primeira vez a partir da consulta LINQ inicial.

Otimizações

A amostra que você construiu até agora executa um shuffle, onde as cartas superior e inferior permanecem as mesmas em cada corrida. Vamos fazer uma mudança: vamos usar um in shuffle , onde todas as 52 cartas mudam de posição. Para um embaralhamento, você intercala o baralho para que a primeira carta na metade inferior se torne a primeira carta do baralho. Isso significa que a última carta na metade superior torna-se a carta inferior. Esta é uma alteração simples para uma linha singular de código. Atualize a consulta de embaralhamento atual alternando as posições de Take e Skip. Isso mudará a ordem das metades superior e inferior do convés:

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

Execute o programa novamente e você verá que são necessárias 52 iterações para que o deck se reordene. Você também começará a notar algumas sérias degradações de desempenho à medida que o programa continua a ser executado.

Há uma série de razões para isso. Você pode atacar uma das principais causas dessa queda de desempenho: o uso ineficiente da avaliação preguiçosa.

Resumidamente, a avaliação preguiçosa afirma que a avaliação de uma declaração não é realizada até que seu valor seja necessário. As consultas LINQ são declarações que são avaliadas preguiçosamente. As sequências são geradas somente quando os elementos são solicitados. Normalmente, esse é um grande benefício do LINQ. No entanto, em um uso como este programa, isso causa um crescimento exponencial no tempo de execução.

Lembre-se de que geramos o deck original usando uma consulta LINQ. Cada shuffle é gerado pela execução de três consultas LINQ no deck anterior. Tudo isso é realizado com preguiça. Isso também significa que eles são executados novamente cada vez que a sequência é solicitada. Quando você chega à 52ª iteração, você está regenerando o deck original muitas e muitas vezes. Vamos escrever um log para demonstrar esse comportamento. Em seguida, você vai corrigi-lo.

No arquivo Extensions.cs , digite ou copie o método abaixo. Esse método de extensão cria um novo arquivo chamado debug.log dentro do diretório do projeto e registra qual consulta está sendo executada no momento para o arquivo de log. Esse método de extensão pode ser anexado a qualquer consulta para marcar que a consulta foi executada.

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

Você verá um rabisco vermelho sob File, o que significa que ele não existe. Ele não vai compilar, uma vez que o compilador não sabe o que File é. Para resolver esse problema, certifique-se de adicionar a seguinte linha de código sob a primeira linha em Extensions.cs:

using System.IO;

Isso deve resolver o problema e o erro vermelho desaparece.

Em seguida, instrumente a definição de cada consulta com uma mensagem de log:

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

Observe que você não registra toda vez que acessa uma consulta. Você registra somente quando cria a consulta original. O programa ainda leva muito tempo para ser executado, mas agora você pode ver o porquê. Se você ficar sem paciência executando o in shuffle com o registro ativado, volte para o shuffle out. Você ainda verá os efeitos preguiçosos da avaliação. Em uma execução, ele executa 2592 consultas, incluindo todo o valor e geração de terno.

Você pode melhorar o desempenho do código aqui para reduzir o número de execuções feitas. Uma correção simples que você pode fazer é armazenar em cache os resultados da consulta LINQ original que constrói o baralho de cartas. Atualmente, você está executando as consultas repetidamente toda vez que o loop do-while passa por uma iteração, reconstruindo o baralho de cartas e remodelando-o sempre. Para armazenar em cache o baralho de cartas, você pode aproveitar os métodos ToArray LINQ e ToList, ao anexá-los às consultas, eles executarão as mesmas ações que você lhes disse, mas agora armazenarão os resultados em uma matriz ou lista, dependendo do método que você escolher chamar. Anexe o método ToArray LINQ a ambas as consultas e execute o programa novamente:

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

Agora, o shuffle de saída é reduzido para 30 consultas. Execute novamente com o in shuffle e você verá melhorias semelhantes: ele agora executa 162 consultas.

Observe que este exemplo foi projetado para destacar os casos de uso em que a avaliação preguiçosa pode causar dificuldades de desempenho. Embora seja importante ver onde a avaliação preguiçosa pode afetar o desempenho do código, é igualmente importante entender que nem todas as consultas devem ser executadas ansiosamente. O acerto de desempenho que você incorre sem usar ToArray é porque cada novo arranjo do baralho de cartas é construído a partir do arranjo anterior. Usar avaliação preguiçosa significa que cada nova configuração de deck é construída a partir do deck original, mesmo executando o código que construiu o startingDeck. Isso causa uma grande quantidade de trabalho extra.

Na prática, alguns algoritmos funcionam bem usando avaliação ansiosa, e outros funcionam bem usando avaliação preguiçosa. Para uso diário, a avaliação preguiçosa geralmente é uma escolha melhor quando a fonte de dados é um processo separado, como um mecanismo de banco de dados. Para bancos de dados, a avaliação preguiçosa permite que consultas mais complexas executem apenas uma viagem de ida e volta ao processo do banco de dados e voltem para o resto do seu código. O LINQ é flexível, quer você opte por utilizar uma avaliação preguiçosa ou ansiosa, portanto, meça seus processos e escolha o tipo de avaliação que lhe proporcione o melhor desempenho.

Conclusão

Neste projeto, você cobriu:

  • usando consultas LINQ para agregar dados em uma sequência significativa
  • escrevendo métodos de extensão para adicionar nossa própria funcionalidade personalizada a consultas LINQ
  • localizar áreas em nosso código onde nossas consultas LINQ podem encontrar problemas de desempenho, como velocidade degradada
  • avaliação preguiçosa e ansiosa em relação às consultas LINQ e as implicações que elas podem ter no desempenho da consulta

Além do LINQ, você aprendeu um pouco sobre uma técnica que os mágicos usam para truques de cartas. Os mágicos usam o baralho Faro porque podem controlar onde cada carta se move no baralho. Agora que você já sabe, não estrague para todo mundo!

Para obter mais informações sobre o LINQ, consulte: