Czy linq jest bardziej wydajny niż wydaje się na powierzchni?

13

Jeśli napiszę coś takiego:

var things = mythings
    .Where(x => x.IsSomeValue)
    .Where(y => y.IsSomeOtherValue)

Czy to to samo, co:

var results1 = new List<Thing>();
foreach(var t in mythings)
    if(t.IsSomeValue)
        results1.Add(t);

var results2 = new List<Thing>();
foreach(var t in results1)
    if(t.IsSomeOtherValue)
        results2.Add(t);

A może pod przykryciem jest jakaś magia, która działa mniej więcej tak:

var results = new List<Thing>();
foreach(var t in mythings)
    if(t.IsSomeValue && t.IsSomeOtherValue)
        results.Add(t);

A może jest to coś zupełnie innego?

ConditionRacer
źródło
4
Możesz to zobaczyć w ILSpy.
ChaosPandion
1
To bardziej jak drugi przykład niż pierwszy, ale drugi odpowiedź ChaosPandion, że ILSpy jest twoim przyjacielem.
Michael,

Odpowiedzi:

27

Zapytania LINQ są leniwe . To oznacza kod:

var things = mythings
    .Where(x => x.IsSomeValue)
    .Where(y => y.IsSomeOtherValue);

robi bardzo mało. Pierwotny enumerable ( mythings) jest wyliczany tylko wtedy, gdy wynikowy enumerable ( things) jest zużywany, np. Przez foreachpętlę .ToList()lub .ToArray().

Jeśli zadzwonisz things.ToList(), jest to mniej więcej odpowiednik twojego ostatniego kodu, być może z pewnym (zwykle nieistotnym) narzutem z liczników.

Podobnie, jeśli używasz pętli foreach:

foreach (var t in things)
    DoSomething(t);

Jego działanie jest podobne do:

foreach (var t in mythings)
    if (t.IsSomeValue && t.IsSomeOtherValue)
        DoSomething(t);

Niektóre zalety wydajności lenistwa dla wyliczeń (w przeciwieństwie do obliczania wszystkich wyników i przechowywania ich na liście) polegają na tym, że zużywa bardzo mało pamięci (ponieważ przechowywany jest tylko jeden wynik na raz) i że nie ma znaczącego wzrostu koszt początkowy.

Jeśli wyliczalny jest tylko częściowo wyliczony, jest to szczególnie ważne. Rozważ ten kod:

things.First();

Sposób, w jaki LINQ jest implementowany, mythingsbędzie wyliczany tylko do pierwszego elementu, który pasuje do twoich warunków where. Jeśli ten element znajduje się na początku listy, może to być ogromny wzrost wydajności (np. O (1) zamiast O (n)).

Cyanfish
źródło
1
Jedną różnicą w wydajności między LINQ a używanym równoważnym kodem foreachjest to, że LINQ używa wywołań delegowanych, które mają pewne obciążenie. Może to być znaczące, gdy warunki działają bardzo szybko (co często robią).
svick,
2
To właśnie miałem na myśli przez moduł liczący narzut. Może to być problem w niektórych (rzadkich) przypadkach, ale z mojego doświadczenia wynika, że ​​nie jest to często - zwykle czas potrzebny na rozpoczęcie jest bardzo krótki, lub są znacznie ważniejsze od innych wykonywanych operacji.
Cyanfish,
Paskudnym ograniczeniem leniwej oceny Linq jest to, że nie ma możliwości zrobienia „migawki” wyliczenia inaczej niż za pomocą metod takich jak ToListlub ToArray. Gdyby coś takiego zostało poprawnie wbudowane IEnumerable, byłoby możliwe poproszenie listy o „zrobienie migawki” o wszelkich aspektach, które mogą się zmienić w przyszłości bez konieczności generowania wszystkiego.
supercat,
7

Poniższy kod:

var things = mythings
    .Where(x => x.IsSomeValue)
    .Where(y => y.IsSomeOtherValue);

Jest niczym, ponieważ z powodu leniwej oceny nic się nie stanie.

var things = mythings
    .Where(x => x.IsSomeValue)
    .Where(y => y.IsSomeOtherValue)
    .ToList();

Jest inaczej, ponieważ ocena zostanie uruchomiona.

Każdy przedmiot mythingszostanie przekazany pierwszemu Where. Jeśli przejdzie, zostanie przekazany drugiemu Where. Jeśli przejdzie, będzie częścią wyjścia.

Wygląda to mniej więcej tak:

var results = new List<Thing>();
foreach(var t in mythings)
{
    if(t.IsSomeValue)
    {
        if(t.IsSomeOtherValue)
        {
            results.Add(t);
        }
    }
}
Cyril Gandon
źródło
7

Odłóż na bok wykonanie (które już wyjaśniają inne odpowiedzi, po prostu podkreślę inny szczegół), bardziej przypomina to twój drugi przykład.

Miejmy tylko wyobrazić zadzwonić ToListna things.

Realizacja Enumerable.Wherezwrotów a Enumerable.WhereListIterator. Kiedy wywołujesz Whereto WhereListIterator(inaczej połączenie łańcuchowe Where), nie dzwonisz już Enumerable.Where, ale Enumerable.WhereListIterator.Where, co faktycznie łączy predykaty (używanie Enumerable.CombinePredicates).

Więc to bardziej jak if(t.IsSomeValue && t.IsSomeOtherValue).

Leniwiec
źródło
„zwraca Enumerable.WhereListIterator” kazał mi kliknąć. Prawdopodobnie bardzo prosta koncepcja, ale tego nie zauważyłem w ILSpy. Dzięki
ConditionRacer
Zobacz, jak Jon Skeet ponownie wdrożył tę optymalizację, jeśli jesteś zainteresowany bardziej szczegółową analizą.
Servy
1

Nie, to nie to samo. W twoim przykładzie thingsjest IEnumerable, który w tym momencie jest nadal tylko iteratorem, a nie rzeczywistą tablicą lub listą. Ponadto, ponieważ thingsnie jest używany, pętla nigdy nie jest nawet oceniana. Ten typ IEnumerablepozwala na iterację elementów - yieldwedług instrukcji Linq i przetwarzanie ich dalej z większą liczbą instrukcji, co oznacza, że ​​w końcu naprawdę masz tylko jedną pętlę.

Ale jak tylko dodasz instrukcję taką jak .ToArray()lub .ToList(), zamawiasz utworzenie rzeczywistej struktury danych, tym samym ograniczając swój łańcuch.

Zobacz to powiązane pytanie SO: /programming/2789389/how-do-i-implement-ienumerable

Julien Guertault
źródło