Принятого не воротай: Enumerable vs List

    Когда-то я работал в команде, где слегка недолюбливали LINQ, за то, что такой код якобы сложно отлаживать. У нас была договоренность: после каждой цепочки LINQ, разработчик создает локальную переменную, в которую записывает результат ToArray(). Независимо от того, потребуется ли массив далее по методу, или он работает только с IEnumerable. Перед return, результат также приводился к массиву, кажется, во всей кодовой базе не было методов, возвращающих или принимающих коллекцию, отличную от массива.

    Бородатое легаси! - подумаете вы и будете правы. Однако, несмотря то, что прошло много лет, с тех пор, как LINQ стал использоваться повсеместно, а IDE позволяют смотреть данные в отладке, некоторые разработчики все еще плохо представляют себе критерии выбора принимаемого и возвращаемого типа, если речь заходит о коллекциях.

    Начнем с того, что есть коллекция? Поскольку BCL имеет одноименный тип данных, важно понимать, что коллекция - это тип данных, спроектированный для оперирования группой элементов, имеющих некую общую характеристику (тип данных). Таким образом, все, что можно перечислить является коллекцией.

    Предпочитайте абстракции

    Независимо от того, возвращаете вы или принимаете данные, удобнее работать с абстракциями. Вы контролируете доступ к объекту, предоставляя только необходимые члены. Вы также можете безболезненно поменять реализацию, не нарушая имеющийся код работы с параметром или возвращаемым значением. Возвращать конкретный тип имеет смысл, когда функционала стандартного интерфейса недостаточно, а выделять специфичный не имеет смысла.

    Lazy loading

    Вопрос - а прогрузились ли данные(например в IEnumerable) или нет, сам по себе не касается типа. Метод может возвращать как свою реализацию IList, так и стандартную, при этом с отложенной загрузкой данных. Информировать пользователя о том, используется ли lazy loading, посредством возвращаемого типа — плохая идея. Вы обременяете тип несвойственными ему обязанностями. Комментарий, либо специфичный постфикс 'Lazy' в названии метода, будут более удачным решением, если это не ясно из контекста.

    IReadOnlyCollection

    Несмотря на то, что по тем или иным причинам, у нас нет IArray, у нас есть IReadOnlyCollection, добавляющий к перечислению свойство-размер. 

    namespace System.Collections.Generic 
    { 
      public interface IReadOnlyCollection : IEnumerable, IEnumerable 
      { 
        int Count { get; } 
      }
    }

    Соответствующий класс-враппер был добавлен в версии фреймворка 4.5, для удобного создания read-only коллекций. К нему легко можно преобразовать как Array, так и List, поскольку оба они реализуют IList.

    С IEnumerable дела обстоят хуже… Здесь, чтобы получить IReadOnlyCollection, вам так или иначе придётся сначала получить List. Таким образом, де-факто стандартом здесь будет являться List.

    Возвращать Array или IReadOnlyCollection вместо List, смысл есть только когда вам важно подчеркнуть неизменяемость. Во всех остальных случаях IList предложит более широкую функциональность примерно за ту же стоимость.

    Возвращайте пустые коллекции вместо null

    Кажется, уже все про это знают, тем не менее, я то и дело, иногда, то тут, то там, получаю null. Это побуждает меня и коллег добавлять проверки на null, что сводит к минимуму комфортную работу с пустыми коллекциями. Задумайтесь - null это не 0 элементов. Возвращая 1 раз null, вы навсегда обрекаете пользователя штамповать проверки на null, там, где они избыточны. Например:

     if(myEnumerable != null) 
     { 
       foreach(var item in myEnumerable) 
       { 
       } 
     }  

    Хуже, но более лаконично:

    foreach(var item in myEnumerable ?? Enumerable.Empty<T>()) 
    {
    }

    IEnumerable/ICollection/IList

    Для начала, вкратце, что есть что:

    IEnumerable

    собственно, неизменяемая коллекция-перечисление, вам доступен только перечислитель

    IReadOnlyCollection : IEnumerable

    неизменяемая коллекция-перечисление, вам доступен перечислитель и размер

    ICollection : IEnumerable

    коллекция с возможностью добавлять и удалять элементы, также доступен размер и признак изменяемости(IsReadOnly)

    IReadOnlyList : IReadOnlyCollection

    неизменяемая коллекция с порядком следования элементов, вам доступен индексатор

    IList : ICollection

    коллекция с порядком следования элементов, с возможностью добавлять и удалять элементы по индексу

    Принимайте максимально обобщенный тип, нет смысла принимать больше данных, чем требуется методу для работы. Более того, передавать кучу неиспользуемых данных это антипаттерн. Принимая IEnumerable вы предоставляете возможность пользователю передать, в том числе и более конкретные типы - ICollection, IList… не прибегая к преобразованию.

    Возвращайте максимально конкретный тип, нет смысла прятать за обобщенный интерфейс те данные, которыми вы уже располагаете, в результате работы метода. Если вы имеете массив, то вы ничего не теряете, возвращая IReadOnlyCollection. Возвращая IEnumerable вы скрываете знание о размере и в случае, если оно понадобится пользователю, прийдется изменять сигнатуру метода, а если это невозможно — создавать дублирующий метод-перегрузку. Если результатом работы вашего метода является коллекция фиксированного размера и вы хотите избежать lazy loading, имеет смысл вернуть IList или ICollection, если вам важно указать пользователю на неизменяемость — их read-only аналоги.

    Web API и HTTP

    Если у вас одна часть приложения общается с другой частью по HTTP, например это разные слои сервисов или микросервисы, вы вероятно будете делать зарос из одного сервиса в другой через класс-клиент. И здесь, на секунду может показаться, что у вас есть выбор использовать что угодно, начиная с IEnumerable и заканчивая IList.

    На самом деле, по HTTP ваша коллекция уедет как JSON - сериализованный массив, вся целиком. И приедет, если мы говорим о популярных десериализаторах(Newtonsoft.Json, System.Text.Json), не иначе как List. В данном случае нет никакого смысла отдавать\принимать что-то другое. Указывая IEnumerable в response контроллера вы только усложняете понимание кода.


    Я не касался темы производительности, поскольку, если ставить её во главу угла, критерии выбора будут сильно зависеть от конкретной ситуации, а это уже совсем другая история.

    Буду рад поправкам и дополнениям, рекомендую ознакомиться с Framework Design Guidelines for Collections.

    Ads
    AdBlock has stolen the banner, but banners are not teeth — they will be back

    More

    Comments 23

      0

      Корректорское замечание.
      На протяжении статьи, в том числе в подзаголовках, несколько раз упоминается IRealonly. Если это не то же самое, что IReadonly, поясните, пожалуйста, что это. Если это опечатка и имелось в виду IReadonly, исправьте, пожалуйста.

        0
        Благодарю!
        +3
        IReadOnlyList: IReadOnlyCollection — неизменяемая коллекция с порядком следования элементов, вам доступен индексатор
        Строго говоря любой IEnumerable определяет порядок следования, тут просто есть доступ по индексу элемента. Причём никто не мешает сделать реализацию, где индексы будут расставлены случайно (зачем — отдельный вопрос)
          0

          Насчет производительности. Имею опыт, делал профайлинг одного старого проекта (на Яве правда) обьемом около 550т строк. Методы возвращали массивы, которые создавались из List перед return. Вот на всей кодовой базе, в реальных условиях, с синтетической нагрузкой приближенной к боевой, разница в скорости между вариантом "до рефакторинга" и "после", на 8й Яве, укладывалась с статистическую погрешность. Размеры коллекций были от 100 до 100т элементов.

            0
            А память вы не измеряли?
            Вообще хорошая идея — взять какой-нибудь небольшой проект на .Net, повставлять приведений к массиву и качественно измерить потребление ресурсов.
              0
              Для коллекции ссылочных типов перегон IEnumerable через ToList() означает копирование ссылок из итератора в лист, вряд ли вы в реальном проекте заметите какие-то существенные изменения в использовании памяти, другое дело — типы значимые. Но это предположение никак не отменяет факт, что перечисления нужно использовать правильно :)
                0
                Измерял, никакого существенного изменения за время теста, а это около 6..7 часов на 1 тест-кейс (требуемый уровень RPS например), не было.
                Общее впечатление после недели опыта в разных режимах: с погрешностью в +- 2..3% абсолютно одинаковое поведение было. Тот проект начинался еще на jdk1.3/1.4, вот там я думаю разница уже запросто могла бы быть.
                +1
                ну, List и массив хранят данные. там может быть очень много оптимизаций при преобразовании одного в другое.
                IEnumerable не обязательно хранит данные, в случае LINQ там целые цепочки их получения, которые могут упираться в коллекцию в оперативки, в генеративную функцию, в базу данных… Во всех этих случаях могут быть самые разные преобразования в тот момент, когда ты непосредственно генерируешь коллекцию. Цепочка может сложиться в оптимизированный запрос к БД, в таких случаях преждевременное получение коллекции может породить большее количество данных выгруженных из БД в оперативку.
                  0
                  В .net List является динамическим массивом и внутри у него неонка массив.
                  Судя по коду, который даёт декомпилятор для mscorlib 4.0 .net 4.6.2 преобразование к массиву (List\<T\>.ToArray()) создаёт новый массив, размером с текущую коллекцию и через Array.Copy копирует в него элементы текущего.
                  Array.Copy — довольно быстрая штука, отдельная аллокация памяти, в принципе, тоже.
                  Но делать так 10000+ в цикле точно не стоит, накладные расходы станут ооочень заметны.

                  Вообще, если речь идёт именно о максимальной производительности кода и числодробилках (где вызовы вашей целевой функции могут и миллионы раз происходить, и больше) от linq и лишних выделений памяти придётся отказаться довольно быстро.

                  p.s. как в хабровском редакторе эскейпить угловые скобки?
                  +1
                  Что касается рекомендаций к ознакомлению, то можно ознакомится со всей книгой Цвалины Framework Design Guidelines: Conventions, Idioms, and Patterns for Reusable .NET Libraries. Второе издание есть на русском, но в этом году вышло третье издание с асинхронностью и прочими плюшками. Крайне интересно читать, тем более, что прямо в книге разворачиваются холивары по некоторым вопросам.
                    –1

                    Вот я вызываю метод
                    var people = GetPeople(): IEnumerable;
                    Дальше мне надо пройтись по people несколько раз. Скажем, банально:
                    return new {
                    All: people,
                    Tall: GetTallPeople(people),
                    Count: people.Count(),
                    }

                    Вот как мне быть — сразу ее в ToArray() бахнуть, или рассчитывать на то, что там никаких тяжелых ленивых вычислений не делается, и она у меня точно два раза не будет считаться?


                    Т.к. если кидаться IEnumerable между методами постоянно, такое будет то и дело, я считаю что IEnumerable должен жить внутри методов, ни возвращать его, ни принимать его — не надо. За исключением случаев, когда ленивость — подразумевается и понятна.


                    Я вообще везде, где не нужна ленивость, кидаюсь массивами. Массивы удобнее чем всякие IReadonlyCollection — хотя бы тем что всем понятнее, и букв надо меньше писать.

                      0
                      Если цель писать меньше букв, то тогда вам в какой-нибудь нетипизированный язык)

                      Гайд фреймвока рекомендует принимать IEnumerable, даже если вам нужен Count(). Здесь подразумевается, что вызывающий скорее всего передаст объект со свойсвом размера и реализация Count() не будет итерировать.

                      Если вы в методе только перечисляете, при этом уверены что колекция уже подгружена, то можно и не делать ToArray().

                        0
                        А как я могу быть уверенным, видя что мне вернули IEnumerable, что коллекция уже подгружена, или что мне не вернули return someCollection.Select(x => ComputeAnExpensiveHashFunction(x))?

                        Если даже CLR так не делает — например все Extension методы из System.Collection.LINQ делают противоположное?

                        И если мне возвращает этот IEnumerable какой-нибудь джунами написанный метод, и они может сейчас массив возвращают, а потом передумают и начнут в БД ходить?

                        Это же не хаскель, где ленивые списки реализованы так, что если они один раз посчитались кем-то, то второй раз считаться не будут. C# предоставляет такой выбор:
                        1. IEnumerable — который может запустить ракету в космос сходить в API какой — если по ней проитерироваться, и второй раз сходить туда же — если второй раз проитерироваться.
                        2. массивы и производные — гарантированно посчитанные и лежащие в памяти

                        Я — за второе везде, потому что:
                        — мы таки взяли C#, потому что хотим нанимать много средних разрабов. Иначе мы бы взяли другой язык. Если взял C#, то бери и принцип: «чем тупее — тем ловчее».
                        — возвращая массив — мы убираем лишний повод что-то сделать не так, не приобретая никаких минусов кроме «илиты на хабрах считают иначе»
                        — нормального варианта типа «коллекция, которая если один раз проитерировалась — гарантированно не будет это делать второй раз» — нам разрабы .NET не предоставили
                        0
                        Никаких проблем. Если метод возвращает IEnumerable — значит, нет никакой уверенности, что коллекция уже прогружена или не будет изменено поведение потом. Так что однозначно ToArray.
                        +2
                        У массивов есть куча важных и неприятных недостатков, из-за которых они не всегда подходят для возвращаемого типа из метода и точно не стоит делать ToArray() на каждом шагу.

                        1. Массив это объект и подвержен сборке мусора. И если мы возвращаем его из функции, то он сразу попадет в GC Gen 1. А в плохом раскладе и в Gen 2. Массовый ToArray() по всему коду может удвоить нагрузку на GC, что не критично для небольшого проекта, но может быть важно в бекэнде. Чтобы бороться с этим в .Net Core сделан ArrayPool<>, но с ним есть нюанс №2.
                        2. Получив из ArrayPool<> массив нельзя быть уверенным, что его длина не больше, чем надо и что вы заполните все его элементы. В итоге некоторые программисты начинают возвращать из каждого метода ArraySegment, собственную обертку, отдельно значение count. Либо таки возвращают массив и каждый элемент при переборе сравнивают с null или default. Отдельно остается вопрос, что массивы надо бы и возвращать в пул, а за этим автор метода уже проследить не может.
                        3. Массив структур физически хранит в себе эти структуры и его создание через ToArray() или CopyTo приведет к массовому копированию. Ну а сам массив потенциально может попасть в LOH, для этого достаточно иметь всего 85000 байт размера. Обработка LOH объектов обычно блокирующая, т.е. останавливает ваше приложение. Есть исключения, но речь не о них.
                        4. В отличие от IReadOnlyList<>, в массиве можно заменить элемент с каким-то индексом и изначальный метод не узнает об этом.

                        Моя рекомендация: возвращайте из методов IEnumerable, IReadOnlyCollection, IReadOnlyList (если нужен доступ по индексу). Не заморачивайтесь с массивами, если это не требуется явно. И точно не делайте ToArray по поводу и без повода, от этого код не станет работать быстрее.
                          +1
                          Информировать пользователя о том, используется ли lazy loading, посредством возвращаемого типа — плохая идея. Вы обременяете тип несвойственными ему обязанностями.

                          А вот не факт что они ему несвойственны, использование типов для изоляции поведения — это очень популярный паттерн в ФП, да и в DDD занимаются чем-то похожим.


                          Другое дело, что IEnumerable<> для этих целей не подходит.


                          Возвращать Array или IReadOnlyCollection вместо List, смысл есть только когда вам важно подчеркнуть неизменяемость. Во всех остальных случаях IList предложит более широкую функциональность примерно за ту же стоимость.

                          Только помните, что подчеркивание неизменяемости является очень даже полезной привычкой.

                            +1
                            Только помните, что подчеркивание неизменяемости является очень даже полезной привычкой
                            Интерфейс IReadOnlyCollection не гарантирует неизменяемости [исходной] коллекции, которую он представляет. Об этом надо помнить, если выполняете несколько раз перечисление коллекции или получаете свойство Count.
                            На мой взгляд, применение интерфейса оправдано если имеется только один экземпляр класса (объект), владеющий исходной коллекцией, и ему необходимо по запросу предоставить ссылку на элементы коллекции, но не предоставлять методы изменения коллекции. Т.е., реализовать паттерн «Один хозяин — много клиентов».
                              0

                              По построению он этого, конечно же, не гарантирует, хотя бы потому что единственный способ гарантировать неизменяемость — это сделать защитную копию либо использовать ImmutableArray/ImmutableList.


                              Но интерфейс, который нельзя использовать — никому не нужен, а задача программиста — писать рабочую программу, а не ломать её. Поэтому должно быть соглашение о том, что можно, а чего нельзя делать с передаваемыми объектами. И лично я вижу его следующим образом:


                              1. коллекция только для чтения, переданная в метод, не должна изменяться снаружи пока этот метод не вернёт управление;
                              2. коллекция только для чтения, которую вернул некоторый метод, должна изменяться владельцем только в очевидных случаях.
                                –2
                                Попробуйте ImmutableArray
                            +2
                            Вообще-то, Enumerable это ни в коем случае не лист и не коллекция; ставить меж ними знак равенства — верная дорога к интересным багам
                            Например,
                            IEnumerable<int> getEnumerable()
                                    {
                                        for (var rand = new Random();;)
                                        {
                                            yield return rand.Next();
                                        }
                                    }
                            


                            Попробуйте вызвать getEnumerable().ToList() :))
                            • UFO just landed and posted this here
                                0

                                Но не использовать null вместо пустых коллекций ещё лучше же.

                                • UFO just landed and posted this here

                              Only users with full accounts can post comments. Log in, please.