Hryniewski.NET | Deferred execution w LINQ - kiedy wywoływane jest zapytanie?

Deferred execution w LINQ - kiedy wywoływane jest zapytanie?

LINQ jest funkcjonalnością, która zainteresowała mnie w C# praktycznie od początku mojej styczności z tym językiem. Jest to narzędzie naprawdę potężne i w odpowiednich rękach może bardzo usprawnić działanie aplikacji i zwiększyć czytelność kodu. Sęk w tym, że o ile łatwo pisze się wszelkiego rodzaju Selecty, Whery i ToListy, to fajnie byłoby jeszcze wiedzieć co, kiedy i dlaczego dzieje się z naszymi danymi w troskliwych, zero-jedynkowych łapkach LINQ. Na zachętę powiem, że w poście są obrazki (cóż, jeden obrazek, trochę kodu i wklejek z konsoli ;) ).

Zacznijmy od prostej aplikacji konsolowej, która utworzy kolekcję i wypisze jej zawartość do konsoli. Będę operował na bardzo prostym kodzie i w większości nie ulegnie on zmianie, więc aby nie wydłużać niepotrzebnie posta, będę umieszczał tylko zmiany w kodzie.

public class Item
    {
        public string Name { get; set; }
        public int Quantity { get; set; }
    } 

    class Program
    {
        static void Main(string[] args)
        {
            var items = new List<Item>
            {
                new Item { Name = "Bacon", Quantity = 100},
                new Item { Name = "Book", Quantity = 10 },
                new Item { Name = "Key", Quantity = 1 },
                new Item { Name = "Stone", Quantity = 0 }
            };

            var LINQitems = items.Where(i => i.Quantity > 0);


            foreach (var i in LINQitems)
            {
                Console.WriteLine("I have " + i.Quantity + " of " + i.Name);
            }

            Console.ReadKey();
        }
    }

Jak bardzo łatwo się domyślić na wyjściu otrzymamy 3 linie tekstu:

I have 100 of Bacon
I have 10 of Book
I have 1 of Key

Nie jest to nic zaskakującego, nawet dla osoby po kilku godzinach styczności z językiem. O wiele ciekawszy wynik otrzymamy gdy odrobinę zmodyfikujemy kod i już po określeniu, że chcemy tylko przedmioty, których mamy więcej niż zero, dodamy coś do wspomnianej listy. 

var LINQitems = items.Where(i => i.Quantity > 0);
items.Add(new Item { Name = "UselessThingCalledIE", Quantity = 7});

Co wypluje nam konsola? Jeśli ktoś nie zna specyfiki LINQ i IEnumerable, pewnie z góry założy, że elementy kolekcji LINQitems, do której nie dodaliśmy żadnego elementu i w związku z tym otrzymamy ten sam wynik. Okazuje się jednak, że tym razem mamy do czynienia z czterema liniami tekstu.

I have 100 of Bacon
I have 10 of Book
I have 1 of Key
I have 7 of UselessThingCalledIE

Ale przecież nie jest to nic dziwnego i wszystko można zwalić na referencję (items.Where można zresztą w dużym uproszczeniu traktować jako referencję z dodatkowym warunkiem). Bo przecież to nic dziwnego, że referencja do kolekcji zachowuje się w ten sposób.

Okazuje się jednak, że sprawa nie jest tak prosta i nie można referencją tłumaczyć wszystkiego. Przykład, który to pokaże jest również bardzo prosty i warto zastanowić się przez chwilę co właściwie otrzymamy na wyjściu. Dla osób, które nie zetknęły się wcześniej z deferred execution odpowiedź może być nieco zaskakująca. Oto zmodyfikowany kod: 

var LINQitems = items.Select(i =>
            {
                Console.WriteLine("Processing item - " + i.Name);
                return new Item { Name = i.Name, Quantity = i.Quantity / 2 };
            });

Console.WriteLine("Adding item to items collection");
items.Add(new Item { Name = "UselessThingCalledIE", Quantity = 7});
Console.WriteLine("Added item to items collection");

var n = 1;
            foreach (var i in LINQitems)
            {
                Console.WriteLine("Foreach iteration no. : " + n);
                Console.WriteLine("I have " + i.Quantity + " of " + i.Name);
                n++;
            }

Warto zwrócić uwagę na to co dzieje się wewnątrz Selecta oraz zwrócić uwagę na to, w jaki sposób dotyczy to obiektu, który dodajemy już po nim. A oto co otrzymamy w konsoli.

Adding item to items collection
Added item to items collection
Processing item - Bacon
Foreach iteration no. : 1
I have 50 of Bacon
Processing item - Book
Foreach iteration no. : 2
I have 5 of Book
Processing item - Key
Foreach iteration no. : 3
I have 0 of Key
Processing item - Stone
Foreach iteration no. : 4
I have 0 of Stone
Processing item - UselessThingCalledIE
Foreach iteration no. : 5
I have 3 of UselessThingCalledIE

Wszystko co wypisujemy w konsoli i Quantity dzielone na pół wewnątrz Selecta nie tylko dotyczy obiektu, który dodajemy po nim, ale sama ewaluacja tego co w nim wpisaliśmy następuje dopiero podczas iteracji w pętli foreach. Co ważne dzieje się to przed wykonaniem jakichkolwiek instrukcji w pętli.
No dobra, a co jeśli do naszego zapytania LINQ dołączymy jeszcze jeden element, by zobaczyć jak się zachowa? Przykładowo użyjmy metody Where.

var LINQitems = items.Select(i =>
            {
                Console.WriteLine("Processing item - " + i.Name);
                return new Item { Name = i.Name, Quantity = i.Quantity / 2 };
            })
            .Where(i => i.Quantity >= 5);

I spójrzmy na wyniki, które są bardzo interesujące.

Adding item to items collection
Added item to items collection
Processing item - Bacon
Foreach iteration no. : 1
I have 50 of Bacon
Processing item - Book
Foreach iteration no. : 2
I have 5 of Book
Processing item - Key
Processing item - Stone
Processing item - UselessThingCalledIE

Szczególną uwagę przykuwają ostatnie obiekty, dla których iteracja pętli się nie wykonała, ale konsola wypisała to co określiliśmy wewnątrz Selecta, co uwidacznia, że wciąż jest on ewaluowany nawet mimo tego, że Where nie dopuszcza go do zbioru wyników. Aby zoptymalizować nieco nasze zapytanie przesuńmy więc Where przed Select, by ten nie ewaluował czegoś, co nie jest nam do niczego potrzebne. Kolejność ma znaczenie i przy pracy nad kolekcjami warto o tym pamiętać, co doskonale okazuje powyższy przykład w połączeniu z kolejnym.

var LINQitems = items
                .Where(i => i.Quantity >= 5)
                .Select(i =>
                {
                    Console.WriteLine("Processing item - " + i.Name);
                    return new Item { Name = i.Name, Quantity = i.Quantity / 2 };
                });

Wynik jest już stosunkowo łatwy do przewidzenia:

Adding item to items collection
Added item to items collection
Processing item - Bacon
Foreach iteration no. : 1
I have 50 of Bacon
Processing item - Book
Foreach iteration no. : 2
I have 5 of Book
Processing item - UselessThingCalledIE
Foreach iteration no. : 3
I have 3 of UselessThingCalledIE

Dlaczego?

Podczas pisania zapytań LINQ musimy pamiętać o tym, że nie zwraca ono wyników od razu, a pełni rolę swego rodzaju filtra stosowanego przy ostatecznym wyciąganiu danych z kolekcji (np. ToList(), ForEach() etc., metody zwracające typ inny niż IEnumerable<T> oraz proste pętle jak w podanych przeze mnie przykładach). Jest to naprawdę bardzo potężne narzędzie i warto wiedzieć co się w nim dzieje i w którym momencie. Dzięki temu możemy uniknąć iterowania kolekcji kilkukrotnie lub robić to świadomie i w konkretnym celu, bo chociaż LINQ pozwala na dosyć dużo bez wnikania w jego wewnętrzne warstwy, to nie zwalnia nas z myślenia i kontrolowania co się dziele w naszym kodzie.