Проблемы использования IEnumerable

в 9:52, , рубрики: .net, ASP

В этой статье я хочу рассказать о проблемах использования интерфейса IEnumerable. Мы рассмотрим, какие проблемы может принести использование этого интерфейса, когда его на самом деле нужно использовать, и чем его заменить.

А начать статью я хотел с пары примеров кода, а точнее с пары багов, встречавшихся мне в реальных проектах.

Примеры проблем

Вот первый пример — код из реального проекта, изменены только имена.

private IEnumerable<Account> GetAccountsByOrder(IEnumerable<Account> accounts, IEnumerable<OrderItem> orderItems)
{
     var orderItemsWithQuotaOwners =  _restsProvider.GetQuotaOwner(orderItems);

    return accounts.Where(
                q => orderItemsWithSourceQuotaOwners.Any(s => 
                    s.QuotaOwner == q.QuotaOwner
                    && ...
                   ));
}

Этот с виду не сложный кусок кода принес нам довольно много неприятностей. Все дело в методе GetQuotaOwner. Внутри него выполняется LINQ to SQL запрос, потом строится проекция на LINQ to entities и возвращается IEnumerable. В итоге на каждую строку quotedAccounts мы получаем новое выполнение внутренностей метода GetQuotaOwner. Что интересно, решарпер в этом случае нас не предупредил об опасности.

Это второй пример. Здесь, правда, не код реального проекта, но идея кода и проблема были взяты из реального проекта.

class Foo
{
    public string Value;
}

class Bar
{
    public string Value;
    public int ACount;
}

static void Main()
{
    Foo[] foo = new[] 
       { 
           new Foo { Value = "Abba" }, 
           new Foo { Value = "Deep Purple" }, 
           new Foo { Value = "Metallica" }
       };

    var bar = foo.Select(x => new Bar 
       { 
                   Value = x.Value, 
                   ACount = x.Value.Count(c => c == 'a' || c == 'A') 
       });

    Censure(bar);

    foreach (var one in bar)
    {
        Console.WriteLine(one.Value);
    }
}

private static void Censure(IEnumerable<Bar> bar)
{
    foreach (var one in bar)
    {
        if (one.ACount > 1)
        {
            one.Value = "<censored>";
        }
    }
}

Здесь мы получаем какие-то данные, строим их проекцию и дальше подвергаем цензуре. И с большим удивлением видим, что на экран попадают данные, не подвергнутые цензуре…

Причина проблемы довольно проста — мы дважды итерируем по коллекции, а значит мы получим две независимых коллекции инстансов класса Bar.

Понятно, что исправить эти два куска кода не представляет никакой сложности, достаточно добавить ToArray. Вопрос в другом — что мы фундаментально делали не так и как верно работать с IEnumerable.

Что абстрагирует IEnumerable

Для начала рассмотрим IEnumerable как таковой. Если не вдаваться в технические подробности, этот интерфейс абстрагирует последовательность элементов. Причем об этой последовательности неизвестно решительно ничего: конечная она или бесконечная, какова стоимость операций над ней.

Вот простой пример — var lines = File.ReadLines(«data.txt»);

Что мы теперь можем делать с lines? Ну если мы не хотим убить производительность нашей программы, мы не можем дважды итерировать по этой коллекции. Значит, что невинный код

    var lines = File.ReadLines("data.txt");
    string lastLine = lines.ElementAt(lines.Count());

должен быть для нас табу.

Может быть ещё хуже:

class RandomStrings : IEnumerable<int>
{
    Random _rnd = new Random();

    public IEnumerator<int> GetEnumerator()
    {
        while (true)
            yield return _rnd.Next();
    }
}

Теперь даже один невинный одиночный Count() вешает наше приложение.

Отсюда следует один простой вывод: работать с IEnumerable не имея предположений, что там сидит внутри, очень сложно.

Безусловно, для нашего примера с чтением файла можно реализовать эффективное получение последней строки (или, скажем, какую-то потоковую обработку строк), но для этого нужно перестать думать о IEnumerable как о коллекции и начать писать более сложный код.

Но в реальных программах, чаще всего, программист думает об IEnumerable как о коллекции. К примеру, даже появился такой паттерн — защитная копия IEnumerable. Т.е. вызов ToArray() в начале метода, когда туда приходит IEnumerable.

То есть мы сразу говорим — к нам пришла конечная последовательность, которая легко влезает в память.Но зачем мы тогда используем IEnumerable, когда имеем ввиду коллекцию?

Здесь, правда, придирчивый читатель может спросить — а что значит правильно работать с коллекцией? Коллекции бывают разные — связный список тоже коллекция, и получение последней строки для связного списка в том стиле, как это делалось выше, тоже вообще-то крайне не эффективно (хотя безусловно и не так страшно, как в случае с IEnumerable, где повторная итерация по коллекции может быть связана с огромным объёмом работ).

Поэтому стоит уточнить понятия и говорить о векторе(в .NET List, далее я буду назвать эту коллекцию листом) или массиве.

Тогда мы действительно сможем программировать по контракту — если на вход метода передается IList работаем как с листом, зная, что доступ к произвольному элементу и получение числа элементов это O(1), а если уж нам пришел IEnumerable — значит придется попотеть, реализовывая корректную и эффективную работу с ним.

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

LINQ

Ситуация в .NET с засильем IEnumerable обострилась с введением LINQ. Если раньше прикладник мог видеть этот интерфейс пару раз в жизни, то теперь любой LINQ запрос порождает IEnumerable.

Возникает вопрос — что делать с такими IEnumerable? Можно впасть в одну крайность — сразу преобразовать в массив или List. Такой подход имеет право на жизнь. Он гарантирует отсутствие проблем с повторной итерацией. С другой стороны может быть порождено много лишних массивов, которые потом придется собирать сборщику мусора.

Можно придерживаться компромиссного подхода: работать с IEnumerable внутри метода, отдавая наружу только массивы или листы. Минус этого подхода в том, что придется более осторожно относится к переменным типа IEnumerable (var в реальных исходниках...), избегая повторных итераций по ним, в том случае, если это может отрицательно повлиять на производительность. Концептуально этот подход тоже допустим — внутри одного метода мы вполне можем знать природу данного конкретного инстанса IEnumerable и не стараться обрабатывать его как сферический IEnumerable в вакууме.

Выбор типа коллекции

Как уже говорилось, для передачи коллекций между методами ICollection не самый удачный тип, т.к. эффективно работать с произвольной реализацией ICollection только чуть проще, чем с IEnumerable.

Можно выбрать IList, но этот интерфейс имеет один огромный минус по сравнению с IEnumerable — он позволяет редактировать коллекции, тогда как в 95% случаев сами коллекции подразумеваются как объекты только для чтения.

Вместо IList можно воспользоваться старым добрым массивом. Он, правда, позволяет присваивать элементы. Но, на мой взгляд, наиболее частые операции над коллекциями — это удаление и добавление элементов. В то время как присваивание элемента по индексу для бизнес-приложений — это экзотика. По этому в качестве коллекции только для чтения вполне можно использовать массивы.

Ещё одна возможность — использовать ReadOnlyCollection. Сразу хочу сказать, что этот класс имеет не совсем верное имя. Его единственный конструктор имеет следующую сигнатуру public ReadOnlyCollection (IList list). То есть корректнее было бы его назвать ReadOnlyList. На первый взгляд использование этого класса повсеместно может быть не очень удобно, но если написать экстеншн

public ReadOnlyCollection<T> ToReadOnly(this IEnumerable<T> data)

, это может быть рабочим вариантом.

Ну а фреймворк 4.5 уже решил эту проблему: он вводит интерфейс IReadOnlyCollection и IReadOnlyList. Причем List реализует IReadOnlyList, т.е. можно писать

IReadOnlyList<Foo> Do(IReadOnlyList<Bar> bar)
{
    return bar.Where(x => IsGood(x)).ToList();
}

Выводы

Повсеместное использование в сигнатурах методов IEnumerable нарушает принципы программирования по контракту и ведет к ошибкам.

Для передачи между методами использовать IEnumerable можно только в том случае, когда на самом деле необходима работа с IEnumerable, и работа с обычными коллекциями будет не эффективна или невозможна.

Для передачи коллекций только для чтения между методами можно пользоваться массивами, классом ReadOnlyCollection и интерфейсом IReadOnlyList.

P.S.
На ту же тему есть ещё одна статья на хабре.

Автор: rumatavz

Источник


* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js