Udostępnij za pomocą


Praca z zapytaniem Language-Integrated (LINQ)

Wprowadzenie

W tym samouczku przedstawiono funkcje na platformie .NET i w języku C#. Uczysz się, jak:

  • Generowanie sekwencji za pomocą LINQ.
  • Pisanie metod, których 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 iluzjonisty: tasowania faro. Tasowanie faro to technika, w której dzielisz talię kart dokładnie na pół, a następnie przeplatasz każdą kartę z każdej połowy, 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.

W tym samouczku przedstawiono lekkie spojrzenie na manipulowanie sekwencjami danych. Aplikacja tworzy talię kart, wykonuje sekwencję tasowań i za każdym razem zapisuje wynik. Porównuje również zaktualizowaną kolejność z kolejnością pierwotną.

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

Utwórz nową aplikację. Otwórz wiersz polecenia i utwórz nowy katalog dla aplikacji. Ustaw to jako bieżący katalog. Wpisz polecenie dotnet new console -o LinqFaroShuffle w wierszu polecenia. To polecenie tworzy pliki początkowe 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

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.

Zastanów się, co stanowi talię kart. Talia kart do gry ma cztery kolory, a każdy kolor ma 13 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 bardziej zwięźle stworzyć talię kart niż w tradycyjny sposób. Zamiast tworzyć klasę Card , utwórz dwie sekwencje reprezentujące garnitury i rangi. Utwórz parę metod iteratora, które generują rangi i kolory jako ciągi znaków:

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ść te metody w instrukcji Console.WriteLine w Program.cs pliku . Obie metody używają składni yield return do utworzenia sekwencji podczas działania. 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ść zapytanie LINQ w górnej Program.cs części pliku. Oto jak wygląda:

var startingDeck = from s in Suits()
                   from r in Ranks()
                   select (Suit: s, Rank: r);

// Display each card that's generated and placed in startingDeck
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 w tym przykładzie. Pierwszy element w pierwszej sekwencji źródłowej (Kolory) jest połączony z każdym elementem w drugiej sekwencji (Rangi). Ten proces tworzy wszystkie 13 kart pierwszego garnituru. 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 tworzysz linQ w składni zapytania używanej w poprzednim przykładzie, czy używasz 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 => (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 składnię, która najlepiej sprawdza się w Twojej sytuacji. Jeśli na przykład 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 przykład utworzony w tym momencie. Wyświetla wszystkie 52 karty w talii. Pomocne może być uruchomienie tego przykładu w debugerze, aby zobaczyć, jak działają metody Suits() i Ranks(). Można wyraźnie zobaczyć, że każdy ciąg w każdej sekwencji jest generowany tylko zgodnie z potrzebami.

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

Manipulowanie kolejnością

Następnie skoncentruj się na tym, jak tasujesz 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 za foreach pętlą

var top = startingDeck.Take(26);
var bottom = startingDeck.Skip(26);

Jednak nie ma metody shuffle, aby z niej skorzystać w standardowej bibliotece, więc musisz napisać własną. Stworzona przez ciebie metoda mieszania ilustruje kilka technik używanych w programach opartych na LINQ, dlatego każda część tego procesu jest objaśniona krok po kroku.

Aby dodać funkcje umożliwiające interakcję z wynikami IEnumerable<T> zapytań LINQ, należy napisać kilka specjalnych rodzajów metod nazywanych metodami rozszerzeń. Metoda rozszerzenia to metoda statyczna specjalnego przeznaczenia, 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:

public static class CardExtensions
{
    extension<T>(IEnumerable<T> sequence)
    {
        public IEnumerable<T> InterleaveSequenceWith(IEnumerable<T> second)
        {
            // Your implementation goes here
            return default;
        }
    }
}

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

Kontener extension określa typ, który jest rozszerzony. Węzeł extension deklaruje typ i nazwę parametru odbiornika dla wszystkich elementów członkowskich wewnątrz kontenera extension . W tym przykładzie rozszerzasz parametr IEnumerable<T>, a parametr ma nazwę sequence.

Deklaracje elementów rozszerzenia są wyświetlane tak, jakby były członkami typu odbiornika:

public IEnumerable<T> InterleaveSequenceWith(IEnumerable<T> second)

Metodę wywołuje się tak, jak gdyby była metodą składową rozszerzanego typu. 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ń.

Ponieważ podzieliłeś talię na połówki, musisz połączyć te połówki razem. W kodzie oznacza to, że enumerujesz obie sekwencje pozyskane za pośrednictwem Take i Skip jednocześnie, przeplatając elementy i tworząc jedną sekwencję: teraz potasowana talia 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 ma metodę do przejścia do następnego elementu i właściwość, która zwraca bieżący element w sekwencji. Te dwa składniki służą do enumeracji kolekcji i zwracania jej elementów. Ta metoda Interleave jest metodą iteratora, więc zamiast tworzenia i zwracania kolekcji należy użyć składni yield return pokazanej w poprzednim kodzie.

Oto implementacja tej metody:

public IEnumerable<T> InterleaveSequenceWith(IEnumerable<T> second)
{
    var firstIter = sequence.GetEnumerator();
    var secondIter = second.GetEnumerator();

    while (firstIter.MoveNext() && secondIter.MoveNext())
    {
        yield return firstIter.Current;
        yield return secondIter.Current;
    }
}

Teraz, gdy napisałeś tę metodę, wróć do metody Main i przetasuj talię raz.

var shuffledDeck = top.InterleaveSequenceWith(bottom);

foreach (var c in shuffledDeck)
{
    Console.WriteLine(c);
}

Porównania

Określ, ile tasowań potrzeba, aby przywrócić talię kart do oryginalnej kolejności. Aby dowiedzieć się, napisz metodę, która określa, czy dwie sekwencje są równe. Po utworzeniu tej metody umieść kod, który tasuje talię w pętli, a następnie sprawdź, 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ś, by tasować talię. Jednak tym razem, zamiast używać yield return dla każdego elementu, porównujesz pasujące elementy każdej sekwencji. Gdy cała sekwencja jest wyliczana, jeśli każdy element jest zgodny, sekwencje są takie same:

public bool SequenceEquals(IEnumerable<T> second)
{
    var firstIter = sequence.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;
}

Ta metoda przedstawia 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 dla zapytania LINQ.

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:

var startingDeck = from s in Suits()
                   from r in Ranks()
                   select (Suit: s, Rank: r);

// Display each card generated and placed in startingDeck in the console
foreach (var card in startingDeck)
{
    Console.WriteLine(card);
}

var top = startingDeck.Take(26);
var bottom = startingDeck.Skip(26);

var shuffledDeck = top.InterleaveSequenceWith(bottom);

var times = 0;
// Re-use the shuffle variable from earlier, or you can make a new one
shuffledDeck = startingDeck;
do
{
    shuffledDeck = shuffledDeck.Take(26).InterleaveSequenceWith(shuffledDeck.Skip(26));

    foreach (var card in shuffledDeck)
    {
        Console.WriteLine(card);
    }
    Console.WriteLine();
    times++;

} while (!startingDeck.SequenceEquals(shuffledDeck));

Console.WriteLine(times);

Uruchom utworzony do tej pory kod i zwróć uwagę, jak układ talii zmienia się przy każdym tasowaniu. 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

Utworzony do tej pory przykład wykonuje out shuffle, w którym karty górne i dolne pozostają takie same przy każdym rozdaniu. Wprowadźmy jedną zmianę: zamiast tego użyj mieszania in shuffle, w którym 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ą. Ta zmiana wymaga jednego wiersza kodu. Zaktualizuj bieżące zapytanie tasowania, przełączając pozycje Take i Skip. Ta zmiana zmienia kolejność górnej i dolnej połowy pokładu:

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

Uruchom program ponownie, a zobaczysz, że potrzeba 52 iteracji, aby talia zmieniła kolejność. Zauważasz również poważne pogorszenie wydajności, ponieważ program nadal działa.

Istnieje kilka powodów tego spadku wydajności. Możesz rozwiązać jedną z głównych przyczyn: nieefektywne wykorzystanie leniwej ewaluacji.

Technika leniwej oceny wskazuje, że ocena wyrażenia nie jest wykonywana, 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 programie takim jak ten, leniwa ocena powoduje wykładniczy wzrost czasu wykonywania.

Pamiętaj, że oryginalny zestaw wygenerowałeś przy użyciu zapytania LINQ. Każde mieszanie jest generowane przez wykonywanie trzech zapytań LINQ na poprzedniej talii. Wszystkie te zapytania są wykonywane leniwie. Oznacza to również, że są one wykonywane ponownie za każdym razem, gdy żądana jest sekwencja. Kiedy dotrzesz do 52 iteracji, wielokrotnie regenerujesz oryginalną talię. Napisz dziennik, aby zademonstrować to zachowanie. Po zebraniu danych można zwiększyć wydajność.

Extensions.cs W pliku wpisz lub skopiuj metodę w poniższym przykładzie kodu. Ta metoda rozszerzenia tworzy nowy plik o nazwie debug.log w katalogu projektu i rejestruje, które zapytanie jest obecnie wykonywane w pliku dziennika. Dołącz tę metodę rozszerzenia do dowolnego zapytania, aby oznaczyć, że zapytanie zostało wykonane.

public IEnumerable<T> LogQuery(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;
}

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

var startingDeck = (from s in Suits().LogQuery("Suit Generation")
                    from r in Ranks().LogQuery("Rank Generation")
                    select (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 dostrzegasz efekty leniwego obliczania. W jednym uruchomieniu wykonuje 2592 zapytania, w tym wartość i generowanie garnituru.

Możesz zwiększyć wydajność kodu, aby zmniejszyć liczbę wykonań. Prostą poprawką jest buforowanie wyników oryginalnego zapytania LINQ, które konstruuje talię kart. Obecnie wykonujesz zapytania ponownie i ponownie za każdym razem, gdy pętla do-while przechodzi przez iterację, rekonstruując talii kart i przetasując je za każdym razem. Aby buforować talię kart, zastosuj metody LINQ ToArray i ToList. Po dołączeniu ich do zapytań wykonują te same akcje, do których im kazano, ale teraz przechowują wyniki w tablicy lub 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:

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 polecenie w trybie mieszania i zobaczysz podobne ulepszenia: teraz wykonuje 162 zapytania.

Ten przykład został zaprojektowany w celu wyróżnienia przypadków użycia, w których leniwa ocena może powodować problemy 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 chcesz używać leniwego, czy bezpośredniego przetwarzania, więc zmierz swoje procesy i wybierz tę ocenę, która daje 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 dodania niestandardowych funkcji do zapytań LINQ.
  • Lokalizowanie obszarów w kodzie, w których zapytania LINQ mogą napotkać problemy z wydajnością, takie jak obniżona szybkość.
  • Leniwa i natychmiastowa ocena w zapytaniach LINQ oraz ich wpływ na wydajność zapytań.

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

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