Udostępnij za pośrednictwem


Praca z zapytaniem Language-Integrated (LINQ)

Wprowadzenie

Ten samouczek zawiera informacje na temat funkcji platformy .NET Core i języka C#. Dowiesz się, jak wykonywać następujące działania:

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

Poznasz te techniki, tworząc aplikację, która demonstruje jedną z podstawowych umiejętności każdego magika: tasowania faro. Krótko mówiąc, tasowanie Faro to technika, w której dzielisz talię kart dokładnie na pół, a następnie przeplatasz karty z obu połówek, aby odbudować oryginalną talię.

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 talię kart, a następnie wykonuje sekwencję tasowań, zapisując 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

  • Najnowszy .NET SDK
  • Edytor programu Visual Studio Code
  • Zestaw deweloperski C#

Tworzenie aplikacji

Pierwszym krokiem jest utworzenie nowej aplikacji. Otwórz wiersz polecenia i utwórz nowy katalog dla aplikacji. Ustaw to jako 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 przez program dotnet new console znajdują się następujące wiersze:

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

Jeśli te trzy wiersze (using dyrektywy) nie są w górnej części pliku, program może nie zostać skompilowany.

Wskazówka

Na potrzeby tego samouczka możesz zorganizować kod w przestrzeni nazw o nazwie LinqFaroShuffle w celu dopasowania do przykładowego kodu lub użyć domyślnej globalnej przestrzeni nazw. Jeśli zdecydujesz się używać przestrzeni nazw, upewnij się, że wszystkie klasy i metody są spójnie w obrębie tej samej przestrzeni nazw lub dodaj odpowiednie using instrukcje zgodnie z potrzebami.

Teraz, gdy masz wszystkie potrzebne odwołania, zastanów się, co stanowi talię kart. Talia kart do gry ma cztery kolory, a każdy kolor ma trzynaście wartości. Zwykle można rozważyć utworzenie Card klasy od razu i wypełnienie kolekcji Card obiektów ręcznie. Dzięki LINQ można być bardziej zwięzłym niż przy użyciu tradycyjnych metod tworzenia 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 stworzyć talię kart. Umieścisz zapytanie LINQ 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);
    }
}

Wielokrotne klauzule from tworzą SelectMany, która tworzy pojedynczą sekwencję poprzez łączenie każdego elementu pierwszej sekwencji z każdym elementem drugiej sekwencji. Kolejność jest ważna dla naszych celów. Pierwszy element w pierwszej sekwencji źródłowej (Kolory) jest połączony z każdym elementem w drugiej sekwencji (Rangi). To produkuje wszystkie trzynaście kart pierwszego koloru. Ten proces jest powtarzany z każdym elementem w pierwszej sekwencji (Garnitury). Wynik końcowy to talia kart uporządkowanych według koloró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.

Uruchom teraz przykład, który zbudowałeś. Talia kart będzie wyświetlała wszystkie 52 karty. Bardzo przydatne może być uruchomienie tego przykładu w debugerze, aby obserwować wykonanie metod Suits() i Ranks(). Można wyraźnie zobaczyć, że każdy ciąg w każdej sekwencji jest generowany tylko w razie potrzeby.

Okno konsoli przedstawiające aplikację wypisującą 52 karty.

Manipulowanie kolejnością

Następnie skoncentruj się na tym, jak będziesz tasować karty w talii. Pierwszym krokiem w każdym dobrym tasowaniu jest podzielenie talii na dwie części. 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 do wykorzystania w bibliotece standardowej, więc musisz napisać własną. 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ć pewne funkcje do sposobu, w jaki wchodzisz w interakcję z wynikiem zapytań LINQ, należy napisać specjalne metody, nazywane 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
        }
    }
}

Uwaga / Notatka

Jeśli używasz edytora innego niż Visual Studio (np. Visual Studio Code), może być konieczne dodanie using LinqFaroShuffle; go do góry pliku Program.cs , aby metody rozszerzenia były dostępne. Visual Studio automatycznie dodaje tę instrukcję using, ale inne edytory mogą tego nie robić.

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 talię na połówki, musisz je połączyć. W kodzie oznacza to, że będziesz jednocześnie wyliczać obie sekwencje pozyskane za pośrednictwem Take i Skip, przetwarzać elementy i tworzyć jedną sekwencję: twoją teraz potasowaną talię 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 posiada metodę umożliwiającą przejście do następnego elementu oraz właściwość pozwalającą pobrać 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 tworzyć kolekcję i zwracać kolekcję, użyjesz składni yield return pokazanej wcześniej.

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 metody Main i przetasuj talię jednorazowo:

// 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 przetasowań potrzeba, aby ustawić talię z powrotem w jej oryginalnej kolejności? 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 talię, w pętli i sprawdzić, kiedy talia kart wraca do pierwotnego porządku.

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ś, by tasować talię. 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 działaniu, gdy używasz do określenia, kiedy talia jest z powrotem w oryginalnej kolejności. Wstaw kod mieszania wewnątrz pętli, a następnie zatrzymaj, gdy sekwencja jest z powrotem w jej 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ść przy każdym przetasowaniu. Po 8 przetasowaniach (iteracjach pętli do-while) talia powraca do pierwotnej konfiguracji, w której była, gdy została utworzona z początkowego zapytania LINQ.

Optymalizacje

Próbka, którą stworzyłeś dotychczas, wykonuje przetasowanie zewnętrzne, gdzie górne i dolne karty pozostają niezmienione przy każdym wykonaniu. Wprowadźmy jedną zmianę: zamiast tego użyjemy techniki mieszania, w której wszystkie 52 karty zmieniają pozycję. Dla przetasowania wewnętrznego przeplatasz talię tak, aby pierwsza karta z dolnej połowy 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 tasowania, przełączając pozycje Take i Skip. Spowoduje to zmianę kolejności górnej i dolnej połowy talii kart.

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 ewaluacji.

W skrócie, leniwa ewaluacja oznacza, że ewaluacja wyrażenia nie jest przeprowadzana, dopóki jego wartość nie będzie potrzebna. Zapytania LINQ to instrukcje, które są oceniane z opóźnieniem. 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 oryginalną talię przy użyciu zapytania LINQ. Każde mieszanie jest generowane przez wykonywanie trzech zapytań LINQ na poprzedniej talii. 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 dotrzesz do 52. iteracji, regenerujesz oryginalną talię 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;
}

Zobaczysz czerwoną falistą linię pod File, co oznacza, że nie istnieje. Nie zostanie skompilowany, ponieważ kompilator nie wie, co File to jest. Aby rozwiązać ten problem, dodaj następujący wiersz kodu pod pierwszym wierszem w Extensions.cs.

using System.IO;

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

Następnie dodaj komunikat dziennika do definicji każdego zapytania:

// 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 wtedy, gdy tworzysz oryginalne zapytanie. Program nadal trwa długo, ale teraz możesz zobaczyć, dlaczego. Jeśli stracisz cierpliwość podczas wykonywania operacji mieszania wejściowego z włączonym rejestrowaniem, przejdź z powrotem do operacji mieszania wyjściowego. Nadal zobaczysz efekty leniwej ewaluacji. Podczas jednego uruchomienia wykonywane są 2592 zapytania, w tym generowanie wszystkich wartości i kolorów.

Wydajność kodu można poprawić tutaj, aby zmniejszyć liczbę wykonań. Prostą poprawką, którą można zrobić, to buforowanie wyników oryginalnego zapytania LINQ, konstruującego talię kart. Obecnie wykonujesz zapytania ponownie i ponownie za każdym razem, gdy pętla do-while przechodzi przez iterację, ponownie konstruując talię kart i przetasowując ją za każdym razem. Aby buforować talię kart, możesz użyć metod ToArray i ToList LINQ; po dołączeniu ich do zapytań, będą one wykonywać te same zadania, jakie im zlecisz, ale teraz będą przechowywać wyniki w tablicy lub liście, w zależności od wybranej metody. 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 tasowanie na zewnątrz zostało zredukowane do 30 zapytań. Uruchom ponownie z włączonym trybem mieszania, a zobaczysz podobne ulepszenia: teraz wykonuje 162 zapytania.

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 rozpoznać, w jakich sytuacjach leniwa ewaluacja może mieć wpływ na wydajność kodu, równie ważne jest, aby zrozumieć, że nie wszystkie zapytania powinny być uruchamiane natychmiastowo. Spadek wydajności, który występuje przy braku użycia ToArray, jest spowodowany tym, że każdy nowy układ talii kart jest budowany na podstawie poprzedniego układu. Użycie oceny leniwej oznacza, że każda nowa konfiguracja układu jest zbudowana z oryginalnego układu, nawet wykonując kod, który konstruował startingDeck. 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 leniwa ewaluacja jest zwykle lepszym wyborem, gdy źródło danych jest oddzielnym procesem, na przykład silnikiem bazy danych. W przypadku baz danych leniwe przetwarzanie pozwala na wykonywanie bardziej złożonych zapytań przy tylko jednym przesłaniu do procesu bazy danych i z powrotem do reszty kodu. LINQ jest elastyczny, niezależnie od tego, czy zdecydujesz się korzystać z leniwej czy natychmiastowej oceny, więc zmierz procesy i wybierz taki rodzaj oceny, który 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 rozszerzeń w celu dodawania niestandardowych funkcji własnych 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ść
  • leniwe i gorliwe przetwarzanie w kontekście zapytań LINQ oraz ich wpływ na wydajność zapytań

Oprócz LINQ, nauczyłeś się trochę o technice magików używanych do sztuczek kart. Magiści używają tasowania Faro, ponieważ mogą kontrolować, gdzie każda karta porusza się w talii. Teraz, gdy wiesz, nie psuj tego innym!

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