Praca z zapytaniem zintegrowanym z językiem (LINQ)

Wprowadzenie

Ten samouczek zawiera informacje na temat funkcji platformy .NET Core i języka C#. Omawiane tematy:

  • Generowanie sekwencji za pomocą LINQ.
  • Pisanie metod, które można łatwo używać w zapytaniach LINQ.
  • Rozróżnianie między chętnymi i leniwymi ocenami.

Poznasz te techniki, tworząc aplikację, która demonstruje jedną z podstawowych umiejętności każdego magika: mieszania faro. Krótko, shuffle faro to technika, w której dzielisz pokład kart dokładnie na pół, a następnie przeplata każdą kartę z każdej połowy, aby odbudować oryginalny pokład.

Magiści używają tej techniki, ponieważ każda karta znajduje się w znanej lokalizacji po każdym przetasowaniu, a kolejność jest powtarzającym się wzorcem.

Dla Twoich celów jest to lekkie spojrzenie na manipulowanie sekwencjami danych. Aplikacja, którą utworzysz, tworzy talii kart, a następnie wykonuje sekwencję mieszania, zapisuj sekwencję za każdym razem. Porównasz również zaktualizowaną kolejność z oryginalną kolejnością.

Ten samouczek zawiera wiele kroków. Po każdym kroku możesz uruchomić aplikację i zobaczyć postęp. Gotowy przykład można również zobaczyć w repozytorium GitHub dotnet/samples. Aby uzyskać instrukcje dotyczące pobierania, zobacz Przykłady i samouczki.

Wymagania wstępne

Musisz skonfigurować maszynę do uruchamiania platformy .NET Core. Instrukcje instalacji można znaleźć na stronie pobierania platformy .NET Core. Tę aplikację można uruchomić w systemach Windows, Ubuntu Linux lub OS X albo w kontenerze platformy Docker. Musisz zainstalować ulubiony edytor kodu. Poniższe opisy używają programu Visual Studio Code , który jest edytorem międzyplatformowym typu open source. Można jednak używać dowolnych narzędzi, z których korzystasz.

Tworzenie aplikacji

Pierwszym krokiem jest utworzenie nowej aplikacji. Otwórz wiersz polecenia i utwórz nowy katalog dla aplikacji. Utwórz bieżący katalog. Wpisz polecenie dotnet new console w wierszu polecenia. Spowoduje to utworzenie plików startowych dla podstawowej aplikacji "Hello World".

Jeśli wcześniej nie używasz języka C#, w tym samouczku wyjaśniono strukturę programu w języku C#. Możesz to przeczytać, a następnie wrócić tutaj, aby dowiedzieć się więcej o LINQ.

Tworzenie zestawu danych

Przed rozpoczęciem upewnij się, że w górnej części pliku wygenerowanego Program.cs przez dotnet new consoleprogram znajdują się następujące wiersze:

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

Jeśli te trzy wiersze (using instrukcje) nie są w górnej części pliku, nasz program nie zostanie skompilowany.

Teraz, gdy masz wszystkie potrzebne odwołania, zastanów się, co stanowi talii kart. Często talia kart do gry ma cztery garnitury, a każdy garnitur ma trzynaście wartości. Zwykle można rozważyć utworzenie Card klasy bezpośrednio od nietoperza i wypełnienie kolekcji Card obiektów ręcznie. Dzięki LINQ można być bardziej zwięzły niż zwykle sposób radzenia sobie z tworzeniem talii kart. Zamiast tworzyć klasę Card , można utworzyć dwie sekwencje reprezentujące odpowiednio garnitury i rangi. Utworzysz naprawdę prostą parę metod iteratora, które będą generować rangi i garnitury jako IEnumerable<T>ciągi:

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

Umieść je poniżej Main metody w pliku Program.cs . Obie te dwie metody wykorzystują składnię yield return do tworzenia sekwencji podczas ich uruchamiania. Kompilator kompiluje obiekt, który implementuje IEnumerable<T> i generuje sekwencję ciągów podczas ich żądania.

Teraz użyj tych metod iteratora, aby utworzyć talii kart. Zapytanie LINQ zostanie wprowadzone w naszej Main metodzie. Oto spojrzenie na to:

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

Klauzule wielokrotne from tworzą element , który tworzy pojedynczą SelectManysekwencję na podstawie łączenia każdego elementu w pierwszej sekwencji z każdym elementem w drugiej sekwencji. Kolejność jest ważna dla naszych celów. Pierwszy element w pierwszej sekwencji źródłowej (Garnitury) jest połączony z każdym elementem w drugiej sekwencji (Rangi). To produkuje wszystkie trzynaście kart pierwszego garnituru. Ten proces jest powtarzany z każdym elementem w pierwszej sekwencji (Garnitury). Wynik końcowy to talii kart uporządkowanych według garniturów, a następnie wartości.

Należy pamiętać, że niezależnie od tego, czy zdecydujesz się napisać linQ w składni zapytania używanej powyżej, czy użyć składni metody, zawsze można przejść z jednej formy składni do drugiej. Powyższe zapytanie napisane w składni zapytania można napisać w składni metody jako:

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

Kompilator tłumaczy instrukcje LINQ napisane za pomocą składni zapytania na równoważną składnię wywołania metody. W związku z tym, niezależnie od wybranej składni, dwie wersje zapytania generują ten sam wynik. Wybierz, która składnia działa najlepiej w twojej sytuacji: na przykład jeśli pracujesz w zespole, w którym niektórzy członkowie mają trudności ze składnią metody, spróbuj użyć składni zapytania.

Przejdź dalej i uruchom przykład, który został utworzony w tym momencie. Na pokładzie będą wyświetlane wszystkie 52 karty. Bardzo przydatne może być uruchomienie tego przykładu w debugerze, aby zobaczyć, jak Suits() działają metody i Ranks() . Można wyraźnie zobaczyć, że każdy ciąg w każdej sekwencji jest generowany tylko w razie potrzeby.

A console window showing the app writing out 52 cards.

Manipulowanie kolejnością

Następnie skoncentruj się na tym, jak będziesz tasować karty na pokładzie. Pierwszym krokiem w każdym dobrym mieszania jest podzielenie pokładu w dwóch. Metody Take i Skip , które są częścią interfejsów API LINQ, zapewniają tę funkcję. Umieść je pod pętlą 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);
}

Jednak nie ma metody shuffle, aby skorzystać z biblioteki standardowej, więc trzeba będzie napisać własne. Metoda tasowania, którą utworzysz, ilustruje kilka technik, których będziesz używać z programami opartymi na linQ, więc każda część tego procesu zostanie wyjaśniona w krokach.

Aby dodać niektóre funkcje umożliwiające interakcję z IEnumerable<T> zapytaniami LINQ, należy napisać kilka specjalnych rodzajów metod nazywanych metodami rozszerzeń. Krótko mówiąc, metoda rozszerzenia jest specjalną metodą statyczną, która dodaje nowe funkcje do już istniejącego typu bez konieczności modyfikowania oryginalnego typu, do którego chcesz dodać funkcje.

Nadaj metodom rozszerzenia nowy dom, dodając nowy plik klasy statycznej do programu o nazwie Extensions.cs, a następnie zacznij kompilować pierwszą metodę rozszerzenia:

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

Przyjrzyj się podpisowi metody na chwilę, w szczególności parametrom:

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

Możesz zobaczyć dodanie this modyfikatora w pierwszym argumencie do metody . Oznacza to, że metoda jest wywoływana tak, jakby była to metoda składowa typu pierwszego argumentu. Ta deklaracja metody jest również zgodna ze standardowym idiomem, w którym typy danych wejściowych i wyjściowych to IEnumerable<T>. Ta praktyka umożliwia łączenie ze sobą metod LINQ w celu wykonywania bardziej złożonych zapytań.

Oczywiście, ponieważ podzielisz pokład na połówki, musisz połączyć te połówki razem. W kodzie oznacza to, że będziesz wyliczać obie sekwencje pozyskane za pośrednictwem Take i Skip jednocześnie, interleaving elementy i jedną sekwencję: teraz potasowane talii kart. Pisanie metody LINQ, która działa z dwiema sekwencjami, wymaga zrozumienia, jak IEnumerable<T> działa.

Interfejs IEnumerable<T> ma jedną metodę: GetEnumerator. Obiekt zwracany przez GetEnumerator element ma metodę, która ma przejść do następnego elementu, oraz właściwość, która pobiera bieżący element w sekwencji. Użyjesz tych dwóch elementów członkowskich, aby wyliczyć kolekcję i zwrócić elementy. Ta metoda interleave będzie metodą iteratora, więc zamiast kompilować kolekcję i zwracać kolekcję, użyjesz yield return składni pokazanej powyżej.

Oto implementacja tej metody:

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

Teraz, po napisaniu tej metody, wróć do Main metody i przetasuj talię raz:

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

Porównania

Ile tasów potrzeba, aby ustawić pokład z powrotem na jego oryginalną kolejność? Aby dowiedzieć się, należy napisać metodę, która określa, czy dwie sekwencje są równe. Po utworzeniu tej metody należy umieścić kod, który tasuje pokład w pętli, i sprawdzić, kiedy talia jest z powrotem w kolejności.

Pisanie metody w celu określenia, czy dwie sekwencje są równe, powinny być proste. Jest to podobna struktura do metody, którą napisałeś, aby tasować pokład. Tylko tym razem, zamiast yield returnkażdego elementu, porównasz pasujące elementy każdej sekwencji. Gdy cała sekwencja została wyliczona, jeśli każdy element jest zgodny, sekwencje są takie same:

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

Pokazuje to drugi idiom LINQ: metody terminalowe. Przyjmują sekwencję jako dane wejściowe (lub w tym przypadku dwie sekwencje) i zwracają pojedynczą wartość skalarną. W przypadku używania metod terminalowych są one zawsze ostateczną metodą w łańcuchu metod zapytania LINQ, stąd nazwa "terminal".

Można to zobaczyć w akcji, gdy używasz go do określenia, kiedy pokład jest z powrotem w oryginalnej kolejności. Umieść kod mieszania wewnątrz pętli i zatrzymaj, gdy sekwencja jest z powrotem w oryginalnej kolejności, stosując metodę SequenceEquals() . Można zobaczyć, że zawsze będzie to ostateczna metoda w dowolnym zapytaniu, ponieważ zwraca pojedynczą wartość zamiast sekwencji:

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

Uruchom kod, który masz do tej pory, i zanotuj, jak talia zmienia kolejność na każdym mieszania. Po 8 przetasowaniach (iteracji pętli do-while) pokład powraca do oryginalnej konfiguracji, w której został utworzony po raz pierwszy od początkowego zapytania LINQ.

Optymalizacje

Przykład, który został utworzony do tej pory, wykonuje mieszania out, gdzie górne i dolne karty pozostają takie same w każdym przebiegu. Wprowadźmy jedną zmianę: zamiast tego użyjemy elementu w mieszania , w którym wszystkie 52 karty zmieniają pozycję. Dla in shuffle, przeplatasz pokład tak, aby pierwsza karta w dolnej połowie stała się pierwszą kartą w talii. Oznacza to, że ostatnia karta w górnej połowie staje się dolną kartą. Jest to prosta zmiana w pojedynczym wierszu kodu. Zaktualizuj bieżące zapytanie mieszania, przełączając pozycje Take i Skip. Spowoduje to zmianę kolejności górnej i dolnej połowy pokładu:

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

Uruchom ponownie program i zobaczysz, że zmiana kolejności zajmuje 52 iteracji. Zaczniesz również zauważyć pewne poważne spadki wydajności, ponieważ program nadal działa.

Istnieje wiele przyczyn tego problemu. Możesz rozwiązać jedną z głównych przyczyn tego spadku wydajności: nieefektywne wykorzystanie leniwej oceny.

Krótko, leniwa ocena stwierdza, że ocena instrukcji nie jest wykonywana, dopóki jej wartość nie będzie potrzebna. Zapytania LINQ to instrukcje, które są oceniane leniwie. Sekwencje są generowane tylko wtedy, gdy wymagane są elementy. Zwykle jest to główna korzyść z LINQ. Jednak w przypadku użycia takiego jak ten program powoduje to wykładniczy wzrost czasu wykonywania.

Pamiętaj, że wygenerowaliśmy oryginalny pokład przy użyciu zapytania LINQ. Każda mieszania jest generowana przez wykonywanie trzech zapytań LINQ na poprzednim pokładzie. Wszystkie te czynności są wykonywane leniwie. Oznacza to również, że są one wykonywane ponownie za każdym razem, gdy żądana jest sekwencja. Do czasu, gdy dostaniesz się do 52 iteracji, regenerujesz oryginalny pokład wiele, wiele razy. Napiszmy dziennik, aby zademonstrować to zachowanie. Następnie go naprawisz.

Extensions.cs W pliku wpisz lub skopiuj poniższą metodę. Ta metoda rozszerzenia tworzy nowy plik o nazwie debug.log w katalogu projektu i rejestruje, które zapytanie jest obecnie wykonywane w pliku dziennika. Tę metodę rozszerzenia można dołączyć do dowolnego zapytania, aby oznaczyć, że zapytanie zostało wykonane.

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

W obszarze File, co oznacza, że nie istnieje, zobaczysz czerwony wywiórz. Nie zostanie skompilowany, ponieważ kompilator nie wie, co File to jest. Aby rozwiązać ten problem, dodaj następujący wiersz kodu w pierwszym wierszu w Extensions.cspliku :

using System.IO;

Powinno to rozwiązać problem, a czerwony błąd zniknie.

Następnie instrumentacja definicji każdego zapytania za pomocą komunikatu dziennika:

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

Zwróć uwagę, że nie rejestrujesz się za każdym razem, gdy uzyskujesz dostęp do zapytania. Rejestrujesz tylko podczas tworzenia oryginalnego zapytania. Program nadal trwa długo, ale teraz możesz zobaczyć, dlaczego. Jeśli zabraknie cierpliwości podczas mieszania z włączonym rejestrowaniem, wróć do przetasowania. Nadal zobaczysz leniwe efekty oceny. W jednym uruchomieniu jest wykonywanych 2592 zapytań, w tym wszystkich wartości i garnituru generowania.

Wydajność kodu można poprawić tutaj, aby zmniejszyć liczbę wykonań. Prostą poprawką, którą można zrobić, jest buforowanie wyników oryginalnego zapytania LINQ, które konstruuje talii kart. Obecnie wykonujesz zapytania ponownie i ponownie za każdym razem, gdy pętla do-while przechodzi przez iterację, ponownie skonstruując talii kart i przetasując ją za każdym razem. Aby buforować talii kart, możesz użyć metod ToArray LINQ i ToList; po dołączeniu ich do zapytań będą wykonywać te same akcje, do których im kazano, ale teraz będą przechowywać wyniki w tablicy lub na liście, w zależności od metody, którą chcesz wywołać. Dołącz metodę ToArray LINQ zarówno do zapytań, jak i ponownie uruchom program:

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

Teraz mieszania na out jest w dół do 30 zapytań. Uruchom ponownie polecenie w trybie mieszania i zobaczysz podobne ulepszenia: teraz wykonuje 162 zapytań.

Należy pamiętać, że ten przykład został zaprojektowany w celu wyróżnienia przypadków użycia, w których leniwa ocena może powodować trudności z wydajnością. Chociaż ważne jest, aby zobaczyć, gdzie leniwa ocena może mieć wpływ na wydajność kodu, równie ważne jest, aby zrozumieć, że nie wszystkie zapytania powinny być uruchamiane z niecierpliwością. Trafienie wydajności jest naliczane bez użycia ToArray jest spowodowane tym, że każdy nowy układ talii kart jest zbudowany z poprzedniego układu. Użycie leniwej oceny oznacza, że każda nowa konfiguracja pokładu jest zbudowana z oryginalnego pokładu, nawet wykonując kod, który skompilował startingDeckelement . To powoduje dużą ilość dodatkowej pracy.

W praktyce niektóre algorytmy działają dobrze przy użyciu chętnej oceny, a inne działają dobrze przy użyciu leniwej oceny. W przypadku codziennego użycia ocena leniwa jest zwykle lepszym wyborem, gdy źródło danych jest oddzielnym procesem, na przykład aparatem bazy danych. W przypadku baz danych ocena leniwa umożliwia bardziej złożone zapytania, aby wykonywać tylko jedną rundę procesu bazy danych i z powrotem do pozostałej części kodu. LINQ jest elastyczny, niezależnie od tego, czy zdecydujesz się korzystać z leniwej czy chętnej oceny, więc zmierz procesy i wybierz dowolny rodzaj oceny zapewnia najlepszą wydajność.

Podsumowanie

W tym projekcie omówiono następujące zagadnienia:

  • używanie zapytań LINQ do agregowania danych w zrozumiałą sekwencję
  • pisanie metod rozszerzenia w celu dodania własnych funkcji niestandardowych do zapytań LINQ
  • lokalizowanie obszarów w kodzie, w których nasze zapytania LINQ mogą napotkać problemy z wydajnością, takie jak obniżona szybkość
  • leniwa i chętna ocena w odniesieniu do zapytań LINQ i implikacji, które mogą mieć na wydajność zapytań

Oprócz LINQ, nauczyłeś się trochę o technice magików używanych do sztuczek kart. Magiści używają shuffle Faro, ponieważ mogą kontrolować, gdzie każda karta porusza się na pokładzie. Teraz, gdy wiesz, nie zepsuj go dla wszystkich innych!

Aby uzyskać więcej informacji na temat LINQ, zobacz: