Arbeta med språkintegrerad fråga (LINQ)

Introduktion

I den här självstudien lär du dig funktioner i .NET Core och C#-språket. Du lär dig följande:

  • Generera sekvenser med LINQ.
  • Skrivmetoder som enkelt kan användas i LINQ-frågor.
  • Skilja mellan ivrig och lat utvärdering.

Du lär dig dessa tekniker genom att skapa ett program som visar en av de grundläggande färdigheterna hos alla trollkarlar: faro shuffle. Kort sagt är en faro shuffle en teknik där du delar en kortlek exakt i hälften, sedan blanda interleaves varje kort från varje halv för att återskapa den ursprungliga däck.

Trollkarlar använder den här tekniken eftersom varje kort finns på en känd plats efter varje blandning, och ordningen är ett upprepande mönster.

För dina syften är det en lättsam titt på att manipulera sekvenser av data. Programmet du skapar skapar en kortlek och utför sedan en sekvens med shuffles och skriver ut sekvensen varje gång. Du jämför även den uppdaterade ordningen med den ursprungliga ordern.

Den här självstudien innehåller flera steg. Efter varje steg kan du köra programmet och se förloppet. Du kan också se det slutförda exemplet på GitHub-lagringsplatsen dotnet/samples. Instruktioner för nedladdning finns i Exempel och självstudier.

Förutsättningar

Du måste konfigurera datorn för att köra .NET Core. Du hittar installationsanvisningarna på nedladdningssidan för .NET Core . Du kan köra det här programmet på Windows, Ubuntu Linux eller OS X eller i en Docker-container. Du måste installera din favoritkodredigerare. Beskrivningarna nedan använder Visual Studio Code som är en öppen källkod plattformsoberoende redigerare. Du kan dock använda de verktyg som du är bekväm med.

Skapa programmet

Det första steget är att skapa ett nytt program. Öppna en kommandotolk och skapa en ny katalog för ditt program. Gör den till den aktuella katalogen. Skriv kommandot dotnet new console i kommandotolken. Detta skapar startfilerna för ett grundläggande "Hello World"-program.

Om du aldrig har använt C# tidigare förklarar den här självstudien strukturen för ett C#-program. Du kan läsa det och sedan gå tillbaka hit för att lära dig mer om LINQ.

Skapa datauppsättningen

Innan du börjar kontrollerar du att följande rader finns överst i Program.cs filen som genereras av dotnet new console:

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

Om dessa tre rader (using -uttryck) inte finns överst i filen kompileras inte vårt program.

Nu när du har alla referenser som du behöver bör du fundera på vad som utgör en kortlek. Vanligtvis har en kortlek med spelkort fyra kostymer, och varje kostym har tretton värden. Normalt kan du överväga att skapa en Card klass direkt från fladdermusen och fylla i en samling Card objekt för hand. Med LINQ kan du vara mer koncis än det vanliga sättet att hantera att skapa en kortlek. I stället för att skapa en Card klass kan du skapa två sekvenser för att representera dräkter respektive rangordningar. Du skapar ett riktigt enkelt par iteratormetoder som genererar rangordningen och passar som IEnumerable<T>strängar:

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

Placera dessa under Main metoden i filen Program.cs . Båda dessa två metoder använder syntaxen yield return för att skapa en sekvens när de körs. Kompilatorn skapar ett objekt som implementerar IEnumerable<T> och genererar sekvensen med strängar när de begärs.

Använd nu dessa iteratormetoder för att skapa kortleken. Du placerar LINQ-frågan i vår Main metod. Här är en titt på det:

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

De flera from satserna skapar en SelectMany, som skapar en enda sekvens från att kombinera varje element i den första sekvensen med varje element i den andra sekvensen. Ordern är viktig för våra syften. Det första elementet i den första källsekvensen (Suits) kombineras med varje element i den andra sekvensen (Rangordning). Detta producerar alla tretton kort av första kostym. Den processen upprepas med varje element i den första sekvensen (Suits). Slutresultatet är en kortlek som ordnas efter färg, följt av värden.

Det är viktigt att komma ihåg att oavsett om du väljer att skriva LINQ i frågesyntaxen ovan eller använder metodsyntax i stället, är det alltid möjligt att gå från en form av syntax till en annan. Ovanstående fråga som skrivits i frågesyntaxen kan skrivas i metodsyntaxen som:

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

Kompilatorn översätter LINQ-uttryck skrivna med frågesyntax till motsvarande metodanropssyntax. Oavsett val av syntax ger därför de två versionerna av frågan samma resultat. Välj vilken syntax som passar bäst för din situation: om du till exempel arbetar i ett team där vissa medlemmar har problem med metodsyntaxen kan du försöka att föredra att använda frågesyntax.

Kör det exempel som du har skapat just nu. Den visar alla 52 kort i kortleken. Det kan vara mycket användbart att köra det här exemplet under ett felsökningsprogram för att se hur Suits() metoderna och Ranks() körs. Du kan tydligt se att varje sträng i varje sekvens endast genereras när det behövs.

A console window showing the app writing out 52 cards.

Ändra ordningen

Fokusera sedan på hur du ska blanda korten i leken. Det första steget i någon bra blandning är att dela däcket i två. Metoderna Take och Skip som ingår i LINQ-API:erna tillhandahåller den funktionen åt dig. Placera dem under slingan foreach :

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

Det finns dock ingen shuffle-metod att dra nytta av i standardbiblioteket, så du måste skriva egna. Metoden shuffle som du skapar illustrerar flera tekniker som du kommer att använda med LINQ-baserade program, så varje del av den här processen förklaras i steg.

För att kunna lägga till vissa funktioner i hur du interagerar med IEnumerable<T> linq-frågor måste du skriva några särskilda typer av metoder som kallas tilläggsmetoder. Kort sagt är en tilläggsmetod en statisk metod för specialändamål som lägger till nya funktioner i en redan befintlig typ utan att behöva ändra den ursprungliga typen som du vill lägga till funktioner i.

Ge tilläggsmetoderna ett nytt hem genom att lägga till en ny statisk klassfil i programmet med namnet Extensions.cs, och börja sedan skapa den första tilläggsmetoden:

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

Titta på metodsignaturen ett ögonblick, särskilt parametrarna:

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

Du kan se tillägget av this modifieraren på det första argumentet i metoden. Det innebär att du anropar metoden som om den vore en medlemsmetod av typen för det första argumentet. Den här metoddeklarationen följer också ett standard-idiom där indata- och utdatatyperna är IEnumerable<T>. Den metoden gör att LINQ-metoder kan kopplas samman för att utföra mer komplexa frågor.

Naturligtvis, eftersom du delar upp däcket i halvor, måste du gå med dessa halvor tillsammans. I kod innebär det att du räknar upp båda sekvenserna som du har hämtat genom Take och Skip på en gång, interleaving elementen och skapar en sekvens: din nu blandade kortlek. Att skriva en LINQ-metod som fungerar med två sekvenser kräver att du förstår hur IEnumerable<T> det fungerar.

Gränssnittet IEnumerable<T> har en metod: GetEnumerator. Objektet som returneras av GetEnumerator har en metod för att flytta till nästa element och en egenskap som hämtar det aktuella elementet i sekvensen. Du använder dessa två medlemmar för att räkna upp samlingen och returnera elementen. Den här Interleave-metoden är en iteratormetod, så i stället för att skapa en samling och returnera samlingen använder du syntaxen yield return som visas ovan.

Här är implementeringen av den metoden:

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

Nu när du har skrivit den här metoden går du tillbaka till Main metoden och blandar kortleken en gång:

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

Jämförelser

Hur många blandningar krävs för att återställa däcket till sin ursprungliga ordning? För att ta reda på det måste du skriva en metod som avgör om två sekvenser är lika. När du har den metoden måste du placera koden som blandar däcket i en loop och kontrollera när däcket är i ordning igen.

Det ska vara enkelt att skriva en metod för att avgöra om de två sekvenserna är lika. Det är en liknande struktur som den metod som du skrev för att blanda leken. Men den här gången jämför du matchande element i varje sekvens i stället för yield returnvarje element. När hela sekvensen har räknats upp är sekvenserna samma om varje element matchar:

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

Detta visar ett andra LINQ-formspråk: terminalmetoder. De tar en sekvens som indata (eller i det här fallet två sekvenser) och returnerar ett enda skalärt värde. När du använder terminalmetoder är de alltid den sista metoden i en metodkedja för en LINQ-fråga, därav namnet "terminal".

Du kan se detta i praktiken när du använder det för att avgöra när däcket är tillbaka i sin ursprungliga ordning. Placera shuffle-koden i en loop och stoppa när sekvensen är tillbaka i sin ursprungliga ordning genom att använda SequenceEquals() metoden. Du kan se att det alltid skulle vara den sista metoden i en fråga, eftersom den returnerar ett enda värde i stället för en sekvens:

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

Kör den kod som du har hittills och notera hur däcket ordnar om på varje blandning. Efter 8 blandningar (iterationer av do-while-loopen) återgår däcket till den ursprungliga konfigurationen som den var i när du först skapade den från linq-frågan som startades.

Optimeringar

Exemplet som du har skapat hittills kör en utblandning, där de översta och nedre korten förblir desamma på varje körning. Vi gör en ändring: vi använder en i shuffle i stället, där alla 52 kort byter position. För en i shuffle, interleave du kortleken så att det första kortet i den nedre halvan blir det första kortet i kortleken. Det innebär att det sista kortet i den övre halvan blir det nedre kortet. Det här är en enkel ändring av en enda kodrad. Uppdatera den aktuella shuffle-frågan genom att växla positionerna Take för och Skip. Detta ändrar ordningen på däckets övre och nedre halvor:

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

Kör programmet igen, så ser du att det krävs 52 iterationer för att själva däcket ska ordnas om. Du börjar också märka några allvarliga prestandaförsämringar när programmet fortsätter att köras.

Det finns ett antal orsaker till detta. Du kan ta itu med en av de viktigaste orsakerna till den här prestandaminskningen: ineffektiv användning av lat utvärdering.

Kort sagt visar lat utvärdering att utvärderingen av en -instruktion inte utförs förrän dess värde behövs. LINQ-frågor är instruktioner som utvärderas lazily. Sekvenserna genereras endast när elementen begärs. Vanligtvis är det en stor fördel med LINQ. I en användning som det här programmet orsakar detta dock exponentiell tillväxt i körningstiden.

Kom ihåg att vi genererade den ursprungliga kortleken med hjälp av en LINQ-fråga. Varje blandning genereras genom att utföra tre LINQ-frågor på föregående däck. Alla dessa utförs lazily. Det innebär också att de utförs igen varje gång sekvensen begärs. När du kommer till den 52:a iterationen återskapar du originaldäcket många, många gånger. Nu ska vi skriva en logg för att demonstrera det här beteendet. Sedan fixar du det.

Extensions.cs I filen skriver du in eller kopierar metoden nedan. Den här tilläggsmetoden skapar en ny fil som heter debug.log i projektkatalogen och registrerar vilken fråga som körs till loggfilen. Den här tilläggsmetoden kan läggas till i valfri fråga för att markera att frågan kördes.

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

Du ser en röd squiggle under File, vilket innebär att den inte finns. Den kompileras inte eftersom kompilatorn inte vet vad File som är det. Lös problemet genom att lägga till följande kodrad under den allra första raden i Extensions.cs:

using System.IO;

Detta bör lösa problemet och det röda felet försvinner.

Instrumentera sedan definitionen av varje fråga med ett loggmeddelande:

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

Observera att du inte loggar varje gång du kommer åt en fråga. Du loggar bara när du skapar den ursprungliga frågan. Det tar fortfarande lång tid att köra programmet, men nu kan du se varför. Om du får slut på tålamod när du kör i shuffle med loggning aktiverat växlar du tillbaka till out shuffle. Du ser fortfarande de lata utvärderingseffekterna. I en körning kör den 2 592 frågor, inklusive alla värden och passar genereringen.

Du kan förbättra kodens prestanda här för att minska antalet körningar du gör. En enkel korrigering du kan göra är att cachelagra resultatet av den ursprungliga LINQ-frågan som konstruerar kortleken. För närvarande kör du frågorna om och om igen varje gång do-while-loopen går igenom en iteration, konstruerar om kortleken och omdelar den varje gång. Om du vill cachelagra kortleken kan du använda LINQ-metoderna ToArray och ToList. När du lägger till dem i frågorna utför de samma åtgärder som du har sagt till dem, men nu lagrar de resultaten i en matris eller en lista, beroende på vilken metod du väljer att anropa. Lägg till LINQ-metoden ToArray i båda frågorna och kör programmet igen:

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

Nu är out shuffle nere på 30 frågor. Kör igen med i shuffle så ser du liknande förbättringar: nu körs 162 frågor.

Observera att det här exemplet är utformat för att belysa användningsfall där lat utvärdering kan orsaka prestandaproblem. Även om det är viktigt att se var lat utvärdering kan påverka kodprestanda är det lika viktigt att förstå att inte alla frågor ska köras ivrigt. Prestandan du får utan att använda ToArray beror på att varje nytt arrangemang av kortleken har skapats från det tidigare arrangemanget. Med hjälp av lat utvärdering innebär varje ny däckkonfiguration att den skapas från den ursprungliga däck, även köra koden som skapade startingDeck. Det orsakar en stor mängd extra arbete.

I praktiken körs vissa algoritmer bra med ivrig utvärdering, och andra körs bra med hjälp av lat utvärdering. För daglig användning är lat utvärdering vanligtvis ett bättre val när datakällan är en separat process, till exempel en databasmotor. För databaser gör lat utvärdering att mer komplexa frågor bara kan köra en tur och retur till databasprocessen och tillbaka till resten av koden. LINQ är flexibelt oavsett om du väljer att använda lat eller ivrig utvärdering, så mät dina processer och välj vilken typ av utvärdering som ger dig bästa prestanda.

Slutsats

I det här projektet har du gått igenom:

  • använda LINQ-frågor för att aggregera data i en meningsfull sekvens
  • skriva tilläggsmetoder för att lägga till egna anpassade funktioner i LINQ-frågor
  • hitta områden i koden där våra LINQ-frågor kan stöta på prestandaproblem som försämrad hastighet
  • lat och ivrig utvärdering när det gäller LINQ-frågor och de konsekvenser de kan få för frågeprestanda

Förutom LINQ lärde du dig lite om en teknik som magiker använder för korttricks. Trollkarlar använder Faro shuffle eftersom de kan styra var varje kort rör sig i leken. Nu när du vet, förstör det inte för alla andra!

Mer information om LINQ finns i: