Jaką przewagę uzyskano dzięki wdrożeniu LINQ w sposób, który nie buforuje wyników?

20

Jest to znana pułapka dla osób, które moczyły stopy za pomocą LINQ:

public class Program
{
    public static void Main()
    {
        IEnumerable<Record> originalCollection = GenerateRecords(new[] {"Jesse"});
        var newCollection = new List<Record>(originalCollection);

        Console.WriteLine(ContainTheSameSingleObject(originalCollection, newCollection));
    }

    private static IEnumerable<Record> GenerateRecords(string[] listOfNames)
    {
        return listOfNames.Select(x => new Record(Guid.NewGuid(), x));
    }

    private static bool ContainTheSameSingleObject(IEnumerable<Record>
            originalCollection, List<Record> newCollection)
    {
        return originalCollection.Count() == 1 && newCollection.Count() == 1 &&
                originalCollection.Single().Id == newCollection.Single().Id;
    }

    private class Record
    {
        public Guid Id { get; }
        public string SomeValue { get; }

        public Record(Guid id, string someValue)
        {
            Id = id;
            SomeValue = someValue;
        }
    }
}

Spowoduje to wydrukowanie „Fałsz”, ponieważ dla każdej nazwy podanej w celu utworzenia oryginalnej kolekcji funkcja select jest ciągle ponownie oceniana, a wynikowy Recordobiekt jest tworzony od nowa. Aby to naprawić, ToListna końcu można dodać proste wezwanie do GenerateRecords.

Jaką korzyść Microsoft miał nadzieję uzyskać, wdrażając go w ten sposób?

Dlaczego implementacja po prostu nie buforuje wyników w wewnętrznej tablicy? Jedną konkretną częścią tego, co się dzieje, może być odroczenie wykonania, ale można to nadal wdrożyć bez tego zachowania.

Po dokonaniu oceny danego członka kolekcji zwróconej przez LINQ, jaką korzyść zapewnia nie zachowanie wewnętrznego odwołania / kopii, ale ponowne obliczenie tego samego wyniku, jako zachowanie domyślne?

W sytuacjach, w których istnieje szczególna potrzeba logiki dla tego samego elementu kolekcji, który jest wielokrotnie przeliczany, wydaje się, że można to określić za pomocą opcjonalnego parametru, a domyślne zachowanie może zrobić inaczej. Ponadto przewaga prędkości uzyskana dzięki odroczonemu wykonaniu jest ostatecznie zmniejszana o czas potrzebny do ciągłego przeliczania tych samych wyników. Wreszcie jest to mylący blok dla tych, którzy są nowi w LINQ, i może prowadzić do subtelnych błędów w ostatecznie czyimś programie.

Jaka jest z tego korzyść i dlaczego Microsoft podjął tę pozornie bardzo świadomą decyzję?

Panzercrisis
źródło
1
Wystarczy wywołać ToList () w metodzie GenerateRecords (). return listOfNames.Select(x => new Record(Guid.NewGuid(), x)).ToList(); To daje „kopię w pamięci podręcznej”. Problem rozwiązany.
Robert Harvey
1
Wiem, ale zastanawiałem się, dlaczego w ogóle musieliby to zrobić.
Panzercrisis
11
Ponieważ leniwa ocena ma znaczące zalety, z których nie mniej ważne jest „och, przy okazji, ten rekord zmienił się od czasu ostatniego żądania; oto nowa wersja”, co dokładnie ilustruje twój przykład kodu.
Robert Harvey
Mógłbym przysiąc, że przeczytałem tu prawie identycznie sformułowane pytanie w ciągu ostatnich 6 miesięcy, ale nie znajduję go teraz. Najbliższe, jakie mogę znaleźć, to od 2016 roku na stackoverflow: stackoverflow.com/q/37437893/391656
Mr.Mindor
29
Mamy nazwę pamięci podręcznej bez zasady ważności: „wyciek pamięci”. Mamy nazwę pamięci podręcznej bez zasad unieważniania: „farma błędów”. Jeśli nie zamierzasz proponować zawsze poprawnych zasad dotyczących wygaśnięcia i unieważnienia, które działają dla każdego możliwego zapytania LINQ, twoje pytanie w pewnym stopniu odpowiada samo.
Eric Lippert

Odpowiedzi:

51

Jaką przewagę uzyskano dzięki wdrożeniu LINQ w sposób, który nie buforuje wyników?

Zapisywanie wyników w pamięci podręcznej po prostu nie zadziałałoby dla wszystkich. Tak długo, jak masz małe ilości danych, świetnie. Dobrze dla ciebie. Ale co, jeśli twoje dane są większe niż pamięć RAM?

Nie ma to nic wspólnego z LINQ, ale IEnumerable<T>ogólnie z interfejsem.

Jest to różnica między File.ReadAllLines i File.ReadLines . Jeden wczyta cały plik do pamięci RAM, a drugi da ci go wiersz po wierszu, dzięki czemu możesz pracować z dużymi plikami (o ile mają one podział wiersza).

Możesz łatwo buforować wszystko, co chcesz buforować, zmaterializując sekwencję wywołując jedną z nich .ToList()lub jedną .ToArray()z nich. Ale ci z nas, którzy nie chcą tego buforować, mamy szansę tego nie zrobić.

I na powiązaną notatkę: jak buforujesz następujące elementy?

IEnumerable<int> AllTheZeroes()
{
    while(true) yield return 0;
}

Nie możesz. Właśnie dlatego IEnumerable<T>istnieje.

nvoigt
źródło
2
Twój ostatni przykład byłby bardziej przekonujący, gdyby była to prawdziwa nieskończona seria (taka jak Fibonnaci), a nie tylko niekończący się ciąg zer, co nie jest szczególnie interesujące.
Robert Harvey
23
@RobertHarvey To prawda, pomyślałem, że łatwiej jest zauważyć, że jest to niekończący się strumień zer, gdy nie ma logiki do zrozumienia.
nvoigt
2
int i=1; while(true) { i++; yield fib(i); }
Robert Harvey
2
Przykład, o którym myślałem, to Enumerable.Range(1,int.MaxValue)- bardzo łatwo jest ustalić dolną granicę, ile pamięci będzie zużywać.
Chris
4
Inną rzeczą, którą zobaczyłem, while (true) return ...było while (true) return _random.Next();wygenerowanie nieskończonego strumienia liczb losowych.
Chris
24

Jaką korzyść Microsoft miał nadzieję uzyskać, wdrażając go w ten sposób?

Poprawność Mam na myśli, że wyliczenie rdzenia może zmieniać się między połączeniami. Buforowanie spowoduje niepoprawne wyniki i otworzy całą „kiedy / jak unieważnić tę pamięć podręczną?” Puszka robaków.

A jeśli weźmiesz pod uwagę, że LINQ został pierwotnie zaprojektowany jako sposób wykonywania LINQ na źródłach danych (takich jak framework encji lub SQL bezpośrednio), to wyliczanie miało się zmienić, ponieważ to właśnie robią bazy danych .

Ponadto istnieją obawy dotyczące zasady pojedynczej odpowiedzialności. Znacznie łatwiej jest utworzyć kod zapytania, który działa i zbudować buforowanie na nim, niż zbudować kod, który wysyła zapytania i buforuje, ale następnie usuwa buforowanie.

Telastyn
źródło
3
Warto wspomnieć, że ICollectionistnieje i prawdopodobnie zachowuje się tak, jak spodziewa IEnumerablesię OP
Caleth
Jeśli używasz IEnumerable <T> do odczytu otwartego kursora bazy danych, wyniki nie powinny ulec zmianie, jeśli używasz bazy danych z transakcjami ACID.
Doug
4

Ponieważ LINQ jest i od początku miał być ogólną implementacją wzorca Monady popularnego w funkcjonalnych językach programowania , a Monada nie jest ograniczona do tego, aby zawsze uzyskiwać te same wartości, biorąc pod uwagę tę samą sekwencję wywołań (w rzeczywistości jej użycie w programowaniu funkcjonalnym jest popularny właśnie ze względu na tę właściwość, która pozwala na uniknięcie deterministycznego zachowania funkcji czystych).

Jules
źródło
4

Innym powodem, o którym nie wspomniano, jest możliwość łączenia różnych filtrów i transformacji bez tworzenia śmieciowych wyników pośrednich.

Weźmy na przykład:

cars.Where(c => c.Year > 2010)
.Select(c => new { c.Model, c.Year, c.Color })
.GroupBy(c => c.Year);

Jeśli metody LINQ obliczyłyby wyniki natychmiast, mielibyśmy 3 kolekcje:

  • Gdzie wynik
  • Wybierz wynik
  • Wynik GroupBy

Z których zależy nam tylko na ostatnim. Zapisywanie środkowych wyników nie ma sensu, ponieważ nie mamy do nich dostępu, a chcemy tylko wiedzieć o samochodach, które zostały już odfiltrowane i pogrupowane według roku.

Jeśli zachodzi potrzeba zapisania któregokolwiek z tych wyników, rozwiązanie jest proste: rozdzielić połączenia .ToList()na części i wywołać je i zapisać w zmiennej.


Na marginesie, w JavaScript, metody Array faktycznie zwracają wyniki natychmiast, co może prowadzić do większego zużycia pamięci, jeśli nie jest się ostrożnym.

Arturo Torres Sánchez
źródło
3

Zasadniczo ten kod - umieszczanie w Guid.NewGuid ()środku Selectinstrukcji - jest wysoce podejrzany. To z pewnością jakiś zapach kodu!

Teoretycznie niekoniecznie spodziewalibyśmy się, że Selectinstrukcja utworzy nowe dane, ale odzyska istniejące dane. Chociaż Select może łączyć dane z wielu źródeł w celu uzyskania połączonej zawartości o innym kształcie, a nawet obliczać dodatkowe kolumny, nadal możemy oczekiwać, że będzie funkcjonalny i czysty. Umieszczenie NewGuid ()wnętrza sprawia, że ​​jest on niefunkcjonalny i nieczysty.

Tworzenie danych może być dokuczane poza selekcją i wprowadzane do pewnego rodzaju operacji tworzenia, dzięki czemu selekcja może pozostać czysta i nadawać się do ponownego użycia, w przeciwnym razie selekcja powinna zostać wykonana tylko raz i opakowana / zabezpieczona - to jest .ToList ()sugestią.

Jednak, dla jasności, wydaje mi się, że problemem jest mieszanie twórczości wewnątrz selekcji, a nie brak buforowania. Umieszczenie w NewGuid()środku wybranego wydaje mi się niewłaściwym mieszaniem modeli programowania.

Erik Eidt
źródło
0

Odroczone wykonywanie pozwala osobom zapisującym kod LINQ (a ściślej mówiąc, używającym IEnumerable<T>) jawnie wybrać, czy wynik zostanie natychmiast obliczony i zapisany w pamięci, czy nie. Innymi słowy, pozwala programistom wybrać czas obliczania w zależności od kompromisu przestrzeni dyskowej, który jest najbardziej odpowiedni dla ich zastosowania.

Można argumentować, że większość aplikacji chce wyników natychmiast, więc powinno to być domyślne zachowanie LINQ. Ale istnieje wiele innych interfejsów API (np. List<T>.ConvertAll), Które oferują takie zachowanie i zrobiły to od czasu stworzenia Frameworka, podczas gdy do wprowadzenia LINQ nie było możliwości odroczenia wykonania. Co, jak pokazały inne odpowiedzi, jest warunkiem wstępnym włączenia niektórych rodzajów obliczeń, które w innym przypadku byłyby niemożliwe (poprzez wyczerpanie całej dostępnej pamięci) przy natychmiastowym wykonaniu.

Ian Kemp
źródło