Przejdź do treści

Blog “Zakasane rękawy”

Podcast

Audycje » Programowanie na śniadanie » Odcinek #4 » Skrypt

PNŚ #4. Kilka rzeczy o LINQ, których możesz nie wiedzieć

Otrzymaj powiadomienie o nowych odcinkach

To jest podcast "Programowanie na śniadanie". Słuchasz odcinka numer 4, który powędrował dziś w kierunku .NET-a i będzie o LINQ, czyli Language Integrated Query. To czy tematycznie podcast tam zostanie czy wróci na ogólniejsze terytorium może zależeć od Ciebie, więc pisz, dzwoń i dziel się refleksjami. A teraz zapraszam do odcinka!

intro

Wstęp

Cześć! Z tej strony Paweł, witam Cię w czwartym odcinku podcastu "Programowanie na śniadanie". Ten odcinek jest trochę inny, bo w poprzednich starałem się wybierać tematy możliwie niezależne od stosu technologicznego, ten natomiast dotyczy wyłącznie platformy .NET.

Pozwalam sobie na taką odskocznię, żeby wyjść naprzeciw tym z Was, którzy programują w językach .NET-owych no i ponieważ sam również programuję w C#. Przyznam, że ciągle mam też różne dylematy na temat tego jak może i jak powinien wyglądać podcast i ile trwać. Chwilowo przede wszystkim eksperymentuję, próbując znaleźć jakiś obszar w którym dzielenie się wiedzą w formie audio będzie dla Ciebie jako słuchacza czy słuchaczki użyteczne. Dlatego też tak ciągle zachęcam do napisania wiadomości czy maila i szczerej opinii, bo to pozwala mi robić mniej niewypałów i daje szansę działać z czymś innym, bardziej wartościowym. Jeśli blogujesz albo coś nagrywasz to sam lub sama wiesz że nie wszystko wychodzi i że takie uwagi są cenne.

Dzisiejszy odcinek spróbuję zrealizować w formie pytań i odpowiedzi, przy czym te pytania to są obszary w którym sam czułem ostatnio potrzebę uporządkowania sobie wiedzy i rozjaśnienia. LINQ jest fajną abstrakcją i można na co dzień z nim pracować nie rozumiejąc co się dzieje pod spodem, ale po jakimś czasie warto nadrobić podstawy i używać API bardziej świadomie. Jak kiedyś napisał Joel Spolsky na blogu w artykule "The Law of Leaky abstractions", "abstrakcje oszczędzają nam czas poświęcany na pracę, ale nie oszczędzają nam czasu poświęcanego na naukę". Dlatego mając okazję nauczyć się paru rzeczy o LINQ warto może skorzystać i zerknąć co dzieje się tam pod spodem.

Czego nie wiedziałem o LINQ - lista pytań

Zacznę więc od listy siedmiu pytań, które nasunęły mi się kiedy zastanawiałem się, jak działa LINQ.

Myślę, że każdy z nas, kto programuje w C# dłużej niż tydzień spotkał się w kodzie z LINQ i pewnie sam użył przynajmniej raz metody .Where(), żeby przefiltrować jakąś listę albo inne źródło danych. Ale dla porządku zacznę od krótkiej definicji.

LINQ (z "Q" na końcu), czyli Language Integrated Query to komponent platformy .NET, który pozwala w językach takich jak np. C# pisać zapytania przypominające zapytania SQL w relacyjnych bazach danych.

W użyciu LINQ jest prostą i przyjemną abstrakcją, która pozwala przekształcać zbiory danych np. poprzez ich filtrowanie, sortowanie, grupowanie. Można też wykonywać operacje agregujące takie jak wyciąganie ze zbioru minimum, maksimum albo średniej. Można też podobnie jak w SQLu używać złączeń, czyli joinów, pozwalających pracować w zapytaniu na więcej niż jednym zbiorze danych.

Zakładam tu, że składnia LINQ jest dla Ciebie znajoma i masz przynajmniej podstawowe doświadczenie z użyciem metod, więc przejdę do pytań które sobie spisałem i tak pyt. 1:

  1. Czym jest LINQ to Objects i jak się ma do Linq to Entities?
  2. Czym jest LINQ-to-XML? Czy też pracuje na IEnumerablach, czy to już całkiem inne API, tylko z podobną nazwą?
  3. Czy obie z dwóch dostępnych składni zapytań LINQ są równoważne, czy któraś ma większe możliwości? Czy wybór to jedynie kwestia preferencji, czy któraś jest wyraźnie lepsza i zalecana?
  4. LINQ działa na typach IEnumerable, które mogą być nieskończone. Co się stanie, jeśli spróbujemy użyć np. operacji Reverse() albo OrderBy() na nieskończonym zbiorze?
  5. Jeśli LINQ pracuje na IEnumerable<T>, to jakim cudem może zaimplementować sortowanie? Algorytmy sortowania nie pracują przecież na strumieniu, tylko raczej skaczą po całej tablicy danych, przesuwają elementy, więc IEnumerable wydaje się tu mocno ograniczające.
  6. Czy pod spodem LINQ faktycznie pracuje na IEnumerable, czy potrafi iść na skróty jeśli rozpozna że obiekt jest np. typu List<T>?
  7. Czym jest Parallel Linq, częścią LINQ czy niezależną biblioteką i do czego służy?

Pytań jest sporo, więc bez zwlekania przejdźmy do pierwszego i oczywiście do odpowiedzi na nie.

1. Czym jest LINQ to Objects i jak się ma do LINQ to Entities?

Pytanie 1, czyli Czym jest LINQ to Objects i jak się ma do LINQ to Entities?

Linq to Objects, to jest ten podstawowy LINQ, którego używamy na co dzień pracując z .NETowymi typami implementującymi IEnumerable<T>, czyli np. popularnymi List<T>, Dictionary<T>, HashSet<T> i masą innych typów. Od strony implementacji LINQ to Objects jest zestawem zwykłych extension methods napisanych w C# i umieszczonych w namespace System.Linq. Metody dostarczane przez LINQ są określone na interfejsie IEnumerable<T> i możemy ich używać na każdym obiekcie implementującym ten interfejs.

Dla przypomnienia, IEnumerable<T> to bardzo prosty interfejs i dostarcza tylko jedną metodę. Ta metoda pozwala pobrać Enumerator, czyli obiekt który zawiera wskaźnik na jakiś pojedynczy element tej kolekcji i operacje pozwalające na przesunięcie wskaźnika wprzód oraz resetu wskaźnika na pozycję początkową.

Czyli łącząc to w całość, LINQ to Objects do działania wymaga jedynie możliwości poruszania się po kolekcji naprzód i pobrania elementu kolekcji na którym w danej chwili się zatrzymaliśmy.

Linq to Entities to również zestaw extension methods, ale już nie na IEnumerable, ale na IQueryable. Służy do tego, żeby budować zapytania ale już nie tyle do kolekcji w pamięci procesu jak LINQ-to-Objects, ale do bazy danych poprzez model Entity Framework. Jest więc ściśle związane z tym ORM-em, no i pewnie dla tych z Was którzy pracują na co dzień z Entity Framework to rozróżnienie jest naturalne, bardziej niż dla mnie jako że miałem dotąd przyjemność głównie z NHibernatem w roli ORM-a.

Implementacja metod takich jak Where() dla Linq to Entities to już całkiem inny kod niż w LINQ-to-Objects. O ile w LINQ-to-Objects kod zawiera napisaną normalnie w C# logikę która wykonuje te operacja na kolekcjach, to w przypadku LINQ-to-Entities dopisując wywołania metod LINQ, jedynie rozbudowujemy zapytanie przechowywane w obiektach IQueryable<T>. Ostatecznie odpowiedzialnością kodu jest wygenerowanie zapytania SQL, które będzie wykonane po stronie bazy danych.

Podsumowując, LINQ-to-Objects pracuje na IEnumerable<T> i jest bardzo ogólnym mechanizmem, a LINQ-to-Entities pracuje na IQueryable<T> i jest ściśle związane z Entity Framework.

Mam nadzieję, że niczego przesadnie nie przekręciłem i za bardzo nie uprościłem i że możemy przejść do drugiego pytania.

2. Czym jest LINQ-to-XML?

Czym jest LINQ-to-XML? A konkretnie, czy też pracuje na IEnumerable<T>, czy to już całkiem inne API, tylko z podobną nazwą?

Nie używałem LINQ-to-XML, ale zastanawiało mnie jak w LINQ można pisać zapytania do XML-a, skoro XML może opisywać złożone obiekty, z rozbudowaną i nieregularną strukturą. Struktura listy jest tylko małym podzbiorem tego, co można wyrzeźbić w XML-u. Czym jest więc LINQ-to-XML, bo nie za bardzo widzę jak z pliku XML można zrobić IEnumerable<T> na którym mogłoby działać takie klasyczne LINQ-to-Objects?

LINQ-to-XML to API pozwalające na pracę z plikami XML, nie tylko na ich czytanie, ale też modyfikację. LINQ-to-XML siedzi w namespace System.Xml.Linq, więc to znów inny kod niż LINQ-to-Objects i dostarcza model pliku XML w postaci kilku klas reprezentujących np. dokument, tag xml-owy, atrybut czy komentarz.

W szczególności obiekty te dostarczają nam różnego rodzaju kolekcje: kolekcję atrybutów tagu, albo listę dzieci, albo listę przodków w strukturze XML-a. Te wszystkie zbiory danych są zwracane przez obiekty jako obiekty typu IEnumerable i można na nich wykonywać operacje z użyciem LINQ. W tym sensie zestaw klas LINQ-to-XML pozwala faktycznie pisać zapytania LINQ operujące na XML-ach.

Nie wiem natomiast, czy nazewnictwo jest tutaj przesadnie fortunne, bo w zasadzie poza tym faktem LINQ-to-XML nie ma szczególnie wiele wspólnego z abstrakcją LINQ jaką znamy. W przeciwieństwie do LINQ-to-Entities, nie jest to żadna alternatywna implementacja metod takich jak Where() czy OrderBy(), a jedynie zestaw klas pozwalających pracować z XML-em.

3. Którą składnię wybrać, query syntax czy method syntax?

Trzecie pytanie wzięło się z moich wątpliwości odnośnie do tego, którą składnię LINQ wybrać przy pisaniu kodu.

LINQ pozwala na wybór jednego z dwóch podejść. W pierwszym podejściu wywołujemy po prostu metody na kolekcjach tak jak normalnie wywołujemy metody na obiektach, czyli piszemy mojaLista.OrderBy() i w parametrze określamy przez lambda expression po czym sortować.

Drugie podejście to użycie składni podobnej do SQL-owej, gdzie zaczynamy konstrukcję od słowa kluczowego from i jedziemy dalej z zapytaniem, które nie za bardzo wygląda jak kod w C#, ale ma wsparcie IDE, kompiluje się i działa.

Która z tych składni jest lepsza? W tutorialach powtarza się zwykle, że to kwestia preferencji i tego co nam wygodniej czytać, ale chwilę poczytałem i nie do końca bym się z tym zgodził. Query syntax, czyli składnia rozpoczynająca zapytanie od from, jest częścią języka C# i zgodnie z jego specyfikacją przekształca potem taki kod na podstawie sztywnych reguł na drugą z form, czyli tą z wywołaniem metod i lambdami. Konkretnie w przekształconym kodzie używanych jest 11 metod, takich jak Where, Select, SelectMany, Join itd. więc sygnatury tych metod są niejako zahardkodowane w specyfikacji C#.

Są metody które są dostępne TYLKO w method syntax, np. używane na co dzień Count() albo Take() albo Skip(). Nie uciekniemy więc przed składnią z lambdami, co najwyżej będziemy mieszać obie.

Prawdę mówiąc ja bardzo rzadko trafiam w kodzie na składnię SQLo-podobną, i w związku z tym że konkurencyjna wobec niej składnia z lambdami daje większe możliwości, przyjąłbym właśnie ją za standard.

4. Jak zachowa się wywołanie OrderBy() lub Reverse() na nieskończonym zbiorze?

Kolejna wątpliwość, która przyszła mi do głowy związana jest z tym że IEnumerable<T> może nam dostarczyć coś, co nie jest kolekcją, a raczej jakimś szeregiem. Kolekcja w C# rozumiana jako coś co implementuje ICollection<T> ma property Count, a więc ma jakąś określoną liczbę elementów. Natomiast przez IEnumerable<T> możemy łatwo dostarczyć Enumerator który wygeneruje nam nieskończony ciąg np. liczb, np. dostarczając w kółko na zmianę liczby 5 i 10. Co w takim razie stanie się jeśli na takim nieskończonym szeregu wywołamy operację sortowania LINQ, czyli OrderBy? Albo np. Reverse(), która odwraca kolejność elementów otrzymanych na wejściu

Łatwo sprawdzić odpowiedź, operacja taka jak sortowanie nieskończonego szeregu liczb nigdy się nie zakończy, program w końcu wyczerpie dostępną pamięć i zakończy się wyjątkiem. To w sumie intuicyjne, bo nie mogliśmy się spodziewać, że uda się posortować szereg nie mający końca.

Żeby jednak trochę szerzej spojrzeć na zagadnienie, warto zauważyć że niektóre metody LINQ bez problemu mogą działać na nieskończonych szeregach. Jeśli np. złożymy dwie operacje takie jak filtrowanie przez Where(), a po nim nastąpi Take(), które pobiera z szeregu zadaną ilość elementów i ją zwraca, to nie wpadniemy w żadną nieskończoną pętlę tak jak to było w przypadku OrderBy().

To co różni operację Where() od operacji OrderBy() to to, że Where() do działania nie potrzebuje tak naprawdę całego zbioru danych, a jedynie pojedynczego elementu dla którego Where() podejmuje decyzję, czy ten element spełnia warunki filtrowania czy nie. Ujmując to inaczej, Where() może pracować na strumieniu danych, nawet nieskończonym i na raty zwracać wynik swojego działania. W kontraście do tego OrderBy() potrzebuje od razu całego zbioru danych żeby zwrócić sensowny wynik.

I wszystkie operacje w LINQ można sklasyfikować na podstawie sposobu ich wykonania do jednej z trzech kategorii: operacji wykonywanych natychmiast, operacji wykonywanych leniwie strumieniowych i operacji wykonywanych leniwie niestrumieniowych.

OrderBy() jest przykładem operacji wykonywanej leniwie, ale która nie jest strumieniowa. Tego typu operacja wymaga kompletu danych wejściowych żeby zwrócić cokolwiek na wyjściu. Innym przykładem może być GroupBy().

Where() jest operacją wykonywaną również leniwie, ale na strumieniu danych, a więc nie potrzebuje całego zbioru danych żeby przetwarzać sobie kolejne, pojedyncze elementy. Podobnymi operacjami będą Cast<T>() pozwalająca rzutować typ na inny, Select(), albo Take().

Trzecim rodzajem operacji są operacje wykonywane natychmiast. Taką operacją jest np. Average(), Any(), Single() i wiele innych.

5. Jak jest zaimplementowane sortowanie na IEnumerable<T>?

Piąte pytanie jakie sobie zadałem, to "jak można efektywnie zaimplementować sortowanie na czymś takim IEnumerable<T>?

Zadałem sobie to pytanie, bo IEnumerable<T> dostarcza tylko strumień danych wejściowych, a najszybsze algorytmy sortowania nie pracują na strumieniu przychodzących danych tylko lubią skakać po całej tablicy, przestawiać rzeczy itd.

Odpowiedź jest dość prosta, zerkając na implementację OrderBy() w Linq to Objects można zauważyć, że najpierw strumień danych wejściowych jest czytany i zachowywany w buforze typu Array<T>, a dopiero potem tablica jest sortowana Quicksortem.

Głębiej w temat sortowania na razie nie planuję wchodzić, ale dodam może tylko, że jeśli po OrderBy() użyjemy metody ThenBy(), żeby dodać więcej kluczy sortowania, to nie zwiększy to złożoności obliczeniowej bo sortowanie zostanie wykonane leniwie dopiero kiedy poprosimy o wynik całej operacji.

6. Czy pod spodem LINQ faktycznie pracuje na IEnumerable, czy potrafi iść na skróty?

Szóste pytanie z listy poddaje w wątpliwość to, czy LINQ uczciwie pracuje zawsze na IEnumerable tak jak wskazuje kontrakt metod LINQ, czy czasami idzie na skróty i próbuje wykryć że kolekcja którą dostał to np. powszechnie używany typ taki jak List<T>.

I okazuje się, że faktycznie tak jest. Jeśli weźmiemy pod lupę np. metodę Last(), która zwraca ostatni element przekazanej kolekcji, to LINQ to Objects w swojej implementacji najpierw sprawdzi czy może przekazana referencja do IEnumerable<T> nie jest obiektem który implementuje też IList<T>. A jeśli implementuje, to nie ma sensu przedzierać się po całej kolekcji aż dojdziemy do ostatniego elementu, tylko można skorzystać z property Count, wybadać jaki jest indeks ostatniego elementu i poprosić o niego.

Pewnie takie rozpoznawanie typu obiektu łamie jakąś zasade programowania, ale możemy być wdzięczni, że LINQ dbając o wydajność implementacji ucieka się czasami do takich sztuczek i wykonuje operacje szybciej niż gdyby to robił pracując faktycznie zawsze na IEnumerable<T>.

7. Czym jest Parallel Linq, częścią LINQ czy niezależną biblioteką i do czego służy?

Ostatnie, ósme pytanie które mam na liście jest o technologię Parallel Linq, czyli "równoległy LINQ". A więc: czym właściwie jest, do czego służy, kto to zrobił i czy powinniśmy tego używać na co dzień.

Parallel Linq to implementacja Linq to Objects umożliwiająca zrównoleglanie niektórych operacji wykonywanych przez LINQ. Została wprowadzona w .NET 4.0, tak jak i Task Parallel Library rewolucjonizujący programowanie równoległe i asynchroniczne w .NET-cie.

Klasyczny LINQ-to-Objects pracuje na typie IEnumerable<T>. Parallel Linq pracuje na typie ParallelEnumerable. Przekształcenie IEnumerable w ParallelEnumerable jest proste, bo na IEnumerable wystarczy wywołać metodą AsParallel() i już mamy dostęp do wszystkich metod równoległego LINQ na tych samych obiektach, na których do tej pory wywoływaliśmy klasyczne metody LINQ.

Widać więc, że implementacja Parallel Linq nie zastąpiła LINQ-to-Objects, a zamiast tego mamy model opt-in, a więc żeby wykonać zapytanie LINQ w sposób równoległy musimy celowo zrobić ten dodatkowy krok i użyć metody AsParallel().

Wykonanie zapytań równolegle może poprawić wydajność zapytania, może też pogorszyć wydajność zapytania, i może zmienić jego wynik w pewnych scenariuszach, więc dobrze jest to potraktować jako metodę optymalizacji, a z optymalizacją wiadomo - lepiej się na nią nie rzucać dopóki nie jest potrzebna.

Podsumowanie

To koniec przeglądu na dziś, pytań dotyczących LINQ mam na liście mam jeszcze trochę, ale też biorąc pod uwagę feedback wiem, że powinienem robić raczej krótsze odcinki i w tym kierunku chcę zmierzać. Gratuluję Ci wytrwałości :) Jeśli masz pomysł co mógłbym zrobić, żeby ten podcast bardziej odpowiadał Twoim konkretnie potrzebom i był dla Ciebie ciekawszy, to daj znać. Na moim blogu zakasanerekawy.taurit.pl znajdziesz różne formy kontaktu, czytam maile, odpisuję i zawsze jestem wdzięczny za feedback.

Raz jeszcze dzięki, życzę Ci jak zwykle udanego dnia i do usłyszenia!