Hryniewski.NET | ToList(), or not ToList()...

ToList(), or not ToList()...

...oto jest pytanie! Po długiej przerwie w pisaniu postaram się Wam na nie odpowiedzieć. Co dzieje się w momencie użycia tej metody? Kiedy powinniśmy z niej korzystać, a kiedy możemy to sobie odpuścić? Oczywiście nie będę w stanie przeanalizować każdej możliwej sytuacji, ale mam nadzieję, że po dzisiejszej lekturze będziecie korzystać z tej metody w pełni świadomie.

Na początku zaznaczę, że przed lekturą przydatna będzie znajomość moich poprzednich postów o LINQ lub wiedza jak właściwie działa LINQ (zwłaszcza deferred execution). Wyposażony w niezbędną wiedzę, bystry umysł i jakąś kawę możesz usiąść i pogrążyć się w lekturze. Tak, można też na stojąco lub przyjmując pozycję horyzontalną.

Zdarza się, że metody, w których korzystamy z LINQ wyglądają mniej więcej w ten sposób:

public static List<SomeObject> VeryUsefullHelper(List<string> input)
{
    return input
        .Where(x => !x.Equals(something) && x.IsImportant)
        .Select(x => ParseToSomeObject(x))
        .Where(x => x.Validate());
        .ToList()
}

I... nie ma w tym nic złego, bo przecież działają dokładnie tak jak powinny. Sęk w tym, że wyplutą z powyższej metody listę przekazujemy dwie linijki niżej do metody, która wykona operacje na kilu jej właściwościach, przepuści przez Selecta i zakończy się kolejnym ToListem(), tym razem wypluwając nam jakieś ViewModele lub inne DTO. Te z kolei powędrują na przykład do widoku, gdzie machniemy foriczem, a potem z zadowoleniem będziemy się wpatrywać w wygenerowaną tabelkę z jakimiś bardzo ważnymi informacjami.

I fakt, dane będą się zgadzać, każdy obiekt przejdzie jakąś walidację i nigdzie nie zobaczymy żadnego wyjątku. Załóżmy jednak, że nasz kod będzie składał się wyłącznie z metod przyjmujących listy i ostatnią linijką każdej metody będzie ToList(). Załóżmy też, że zaczynamy od kolekcji w pamięci, a nie od bazy danych, bo to zupełnie inny przypadek. Niech droga naszej kolekcji wygląda mniej więcej w ten sposób.

//...
var collection = PopulateMyCollection();
//...
var validatedCollection = ValidateCollection(collection);
//...
var dtoCollection = ProjectToDTO(validatedCollection);
//I think I need to group this in Dictionary
var dtoDictionary = MagicDictionaryMaker(dtoCollection);
//...
//And let's iterate in view:
foreach (var item in dtoDictionary)
{
    //Render some fancy table in my beautiful view
}

Czy wszystko będzie działać jak należy? Zakładając, że z kodem wszystko jest w porządku, to pewnie tak. Przecież tylko wyciągamy jakąś listę, przeciągamy ją przez kilka metod i iterujemy kolekcję w widoku, by otrzymać upragniony wynik.

Ale czy na pewno? Kluczowym słowem jest tutaj iteracja. Bo biorąc pod uwagę założenia jakie podałem przed tym blokiem "kodu", to ile razy właściwie iterowaliśmy po obiektach w naszej kolekcji?

Jeśli uważasz, że był to jeden raz w foreachu, to znak, że ... ten post jest dla Ciebie. W rzeczywistości każde użycie metody takiej jak ToList(), ToDictionary() lub ToArray(). Wykonuje iterację. Posłużę się teraz wycinkiem z kodu LINQ i pokażę Wam, co właściwie dzieje się po użyciu ToList().

public static List<TSource> ToList<TSource>(this IEnumerable<TSource> source)
{
    if (source == null) throw Error.ArgumentNull("source");
    return new List<TSource>(source);
}

Jak widać tworzona jest po prostu nowa lista, a oto jak wygląda jej konstruktor, który przyjmuje w parametrze obiekt IEnumerable<T>.:

public List(IEnumerable<T> collection)
{
    if (collection == null)
        ThrowHelper.ThrowArgumentNullException(ExceptionArgument.collection);
    Contract.EndContractBlock();

    ICollection<T> c = collection as ICollection<T>;
    if (c != null)
    {
        int count = c.Count;
        if (count == 0)
        {
            _items = _emptyArray;
        }
        else
        {
            _items = new T[count];
            c.CopyTo(_items, 0);
            _size = count;
        }
    }
    else
    {
        _size = 0;
        _items = _emptyArray;
        // This enumerable could be empty.  Let Add allocate a new array, if needed.
        // Note it will also go to _defaultCapacity first, not 1, then 2, etc.

        using (IEnumerator<T> en = collection.GetEnumerator())
        {
            while (en.MoveNext())
            {
                Add(en.Current);
            }
        }
    }
}

Sporo kodu jak na jedną, malutką metodkę prawda? Jak to może wpłynąć na wydajność mogliście przeczytać w poprzednim poście dotyczącym optymalizacji LINQ jako bonus pod sam koniec. Natomiast jak można podejść do sprawy inaczej i ograniczyć ilość iteracji można dowiedzieć się rozumiejąc jak działa defered execution w LINQ, któremu poświęciłem oddzielny post jakiś czas temu i po którego lekturze nie powinniście mieć żadnych wątpliwości.

Czy zatem Twoim celem życiowym powinno być teraz usunięcie każdego wystąpienia metody ToList jakie zobaczysz na monitorze? Tak, ale tylko i wyłącznie jeśli jej obecność jest tam zbędna. Często jest tak, że metoda ta jest wykorzystywana tylko po to, żeby wypluć listę, której tak naprawdę nie potrzebujemy i mogłaby to być jakakolwiek inna kolekcja, bo służy nam wyłącznie jako pudełko, do którego wrzucamy x obiektów, by kiedyś tam po nich przeiterować. Sam ToList i podobne mu metody nie powinien być używany wszędzie, ale tak jak są miejsca gdzie jego obecność nie jest mile widziana, tak też są miejsca gdzie jego nieobecność może doprowadzić do mniejszych i większych skutków ubocznych.

Kiedy zatem nie powinniśmy zapomnieć o tej metodzie? Pierwszym przykładem jaki przychodzi na myśl jest operowanie na danych pobieranych z bazy, na przykład przy pomocy Entity Framework i typu IQueryable, którego przerzucanie między metodami i wykonywanie na nim różnych operacji, może doprowadzić do tego, że wygenerowany SQL będzie po pierwsze niewydajny, a po drugie dziwaczny. Dlatego po odfiltrowaniu wyników z bazki kulturalnym zachowaniem jest zmaterializowanie zapytania (używając na przykład ToList) i wykonywanie dalszych operacji na kolekcji znajdującej się w pamięci. Powinno to wyglądać mniej więcej w ten sposób:

return dbContext.Objects
    .Where(x => x.IsSomething && x.Value > 0)
    .Skip(n)
    .Take(y)
    .ToList(); //Hooray, we're in memory now!

Powinniśmy korzystać z metody ToList również wtedy, kiedy istnieje możliwość, że dane źródłowe ulegną zmianie podczas wykonywania operacji. Bardzo łatwo to zaobserwować na prostym przykładzie: 

var random = new Random();
var source = Enumerable.Range(1, 10)
    .Select(x => x * random.Next(10));


Console.WriteLine(source.Sum());
Console.WriteLine(source.Sum());

Wyniki zwrócone w obu liniach będą się od siebie różnić. Przyczyną jest to, że przy każdym wywołaniu metody Sum wykonywana jest oddzielna iteracja, podczas której ewaluowana jest zawartość Selecta, a więc generowane są nowe liczby losowe. Gdybyśmy więc korzystając z jednej zmiennej zawierającej IEnumerable chcieli najpierw przeliczyć jej elementy, a następnie na bazie otrzymanej liczby wykonać jakieś operacje, a w międzyczasie liczba elementów uległaby zmianie, to wyniki byłyby ze sobą niespójne. Rozwiązaniem byłoby umieszczenie ToList zaraz po Select.

Na dodatek jeśli próbowalibyśmy w ten sposób dostać się do danych źródłowych, które musielibyśmy w jakiś sposób pobrać, to robilibyśmy to za każdym razem. W przypadku pobierania ich z sieci lub metody o długim czasie wykonywania co prawda zaoszczędzilibyśmy trochę czasu, ale stracilibyśmy o wiele więcej na ciągłych próbach dostępu do danych. Warto to sprawdzić na prostym kawałku kodu:

static int accessCount = 0;
static List<int> Source
{
    get
    {
        accessCount++;
        return new List<int> { 1, 2, 3, 4, 5 };
    }
}

static void TestMethod()
{
    var x = Source.Where(n => true).Sum();
    var y = Source.Where(n => true).Count();
    var z = Source.Where(n => true).FirstOrDefault();
    Console.WriteLine(accessCount);
}

W tym prostym przykładzie w konsoli zostanie wypisana liczba 3, bo tyle razy sięgaliśmy do źródła. Gdyby źródłem był jakiś ogromny plik .csv, bylibyśmy zmuszeni odczytywać go za każdym razem. W powyższym przykładzie sprawę załatwiłby ToList lub nawet ToArray. Wystarczyłoby wyciągnąć nasze źródło do oddzielnej zmiennej i wykorzystać ją do dalszych operacji.

var source = Source.ToArray();
var x = source.Where(n => true).Sum();
var y = source.Where(n => true).Count();
var z = source.Where(n => true).FirstOrDefault();
Console.WriteLine(accessCount);

Nie ma jednoznacznej odpowiedzi na tytułowe pytanie, ale mam nadzieję, że po dzisiejszej lekturze, będziesz w stanie sobie na nie odpowiedzieć samodzielnie za każdym razem, gdy zajdzie taka potrzeba.

EDIT: Coś popsułem przy publikacji i tekst był zduplikowany w kilku miejscach, poprawiłem i można go czytać w spokoju (ale i tak nie umiem w kontrol-fał ;)).