Dela via


Arbeta med Language-Integrated Query (LINQ)

Inledning

Den här handledningen visar funktioner i .NET Core och C#-språket. Du får lära dig att:

  • 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 på mitten, sedan flätas korten från varje halva samman för att återskapa den ursprungliga kortleken.

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 granskning av hur man manipulerar dataserier. Applikationen du bygger konstruerar en kortlek och utför sedan en sekvens av blandningar och skriver ned sekvensen varje gång. Du jämför även den uppdaterade ordningen med den ursprungliga ordern.

Den här handledningen har flera steg. Efter varje steg kan du köra programmet och se förloppet. Du kan också se slutförda exempel i GitHub-lagringsplatsen dotnet/samples. Instruktioner för nedladdning finns i Exempelfiler och Handledningar.

Förutsättningar

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 detta 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 den här självstudien förklarar 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 den Program.cs fil som genereras av dotnet new console:

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

Om dessa tre rader (using direktiv) inte finns överst i filen kanske programmet inte kompileras.

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 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 kostymer respektive rangordningar. Du skapar ett riktigt enkelt par iteratormetoder som genererar rangordningar och passar 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 metoden Main i din Program.cs-fil. Båda dessa två metoder använder yield return syntax 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 många from-satserna skapar en SelectMany, som resulterar i en enda sekvens genom 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 metoderna Suits() och Ranks() körs. Du kan tydligt se att varje sträng i varje sekvens endast genereras när det behövs.

Ett konsolfönster som visar hur appen skriver ut 52 kort.

Hantera ordningen

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

// 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> som du får tillbaka från LINQ-frågor måste du skriva några speciella typer av metoder som kallas tilläggsmetoder. Kort sagt är en tilläggsmetod ett särskilt syfte statisk metod 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 ditt program med namnet Extensions.csoch 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 sätta ihop dessa halvor. I kod innebär det att du gör en genomgång av båda sekvenserna som du har hämtat via Take och Skip på samma gång, interleaving elementen, och skapar en sekvens: din nu blandade uppsättning av kort. Att skriva en LINQ-metod som fungerar med två sekvenser kräver att du förstår hur IEnumerable<T> fungerar.

IEnumerable<T>-gränssnittet 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 den yield return syntax 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 metoden Main 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 kortleken 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 fått metoden måste du placera koden som blandar kortleken i en loop och kontrollera när kortleken är i ordning igen.

Det ska vara enkelt att skriva en metod för att avgöra om de två sekvenserna är lika. Strukturen är liknande den metod du använde för att blanda kortleken. Men den här gången jämför du matchande element i varje sekvens i stället för att 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 metoden SequenceEquals(). 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 du har hittills och notera hur kortleken blandas om vid varje blandning. Efter 8 blandningar (iterationer av do-while-loopen) återgår kortleken till den ursprungliga konfigurationen som den hade när du först skapade den från den ursprungliga LINQ-frågan.

Optimeringar

Exemplet som du har skapat hittills utför en out shuffle, där de översta och nedersta korten förblir desamma vid 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 in-shuffle, interleaver 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 för Take 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 innebär lat utvärdering att utvärderingen av ett uttryck inte utförs förrän dess värde behövs. LINQ-frågor är instruktioner som utvärderas lättjefullt. Sekvenserna genereras endast när elementen begärs. Vanligtvis är det en stor fördel med LINQ. Dock orsakar detta exponentiell tillväxt i körtiden i en användning som det här programmet.

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å den föregående kortleken. Alla dessa utförs slarvigt. 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.

I filen Extensions.cs skriver du in eller kopierar metoden nedan. Den här tilläggsmetoden skapar en ny fil med namnet 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 är. 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 tappar tålamodet när du kör in shuffle med loggning aktiverat, så växla tillbaka till out shuffle. Du kommer fortfarande att se effekterna av lat utvärdering. I en körning kör den 2 592 frågor, inklusive alla värden och passar genereringen.

Du kan förbättra prestandan i koden här för att minska det antal gånger du kör den. En enkel korrigering du kan göra är att cachea resultaten 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 cachelägga kortleken kan du använda LINQ-metoderna ToArray och ToList. Genom att lägga till dem i frågorna utför de samma funktioner som du har angett, men nu lagrar de resultaten i en matris eller en lista, beroende på vilken metod du väljer att använda. Lägg till LINQ-metoden ToArray till 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ågeoperationer ska köras ivrigt. Prestandaförlusten du drabbas av utan att använda ToArray beror på att varje nytt arrangemang av kortleken är byggda från föregående arrangemang. Att använda lat utvärdering innebär att varje ny lek-konfiguration skapas från den ursprungliga leken, och även att 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 möjliggör fördröjd utvärdering att mer komplexa frågor endast behöver en interaktion med databasprocessen och tillbaka till resten av din kod. 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: