Чего опасаться с итераторами и коллекциями в C#

Чего опасаться с итераторами и коллекциями в C#

1 марта 2023 г.

Эта статья не предназначена для того, чтобы убедить вас, читатель, в том, что использование итератора или материализованной коллекции универсально решит ваши проблемы. Использование итераторов и материализованных коллекций может быть использовано для решения сценариев, которые мы будем рассматривать, но оба будут иметь разный набор плюсов и минусов, которые мы можем исследовать дальше. Цель этой статьи — осветить сценарии, основанные на реальном опыте, когда либо итератор, либо материализованная коллекция неправильно понимались, использовались неправильно и в конечном итоге приводили к куче головной боли.

Если во время чтения этой статьи вы обнаружите, что говорите «Ну, конечно, но они должны были…», вы, вероятно, правы. Проблема принципиально не в использовании итератора или материализованной коллекции, а в непонимании того, как их эффективно использовать. Поэтому я надеюсь, что когда вы работаете с новыми разработчиками программного обеспечения или, возможно, с людьми, менее знакомыми с некоторыми из этих концепций, вы можете напомнить, что нужно делиться своей мудростью.

Дополнительное видео!

https://www.youtube.com/watch?v=0s_VMhZSOwQ&embedable=true

Общий итератор & Настройка сценария коллекции

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

Вы создаете некий API, который может использовать остальная часть вашего приложения, и вы будете использовать результаты этого API в таких ситуациях, как:

* Запуск методов LINQ (Any(), Count() или даже фильтрация с использованием Where()) * Отображение результирующих наборов данных в пользовательском интерфейсе * Использование полученных данных для сортировки, фильтрации или иного запуска алгоритмов с этими данными в качестве источника

Еще один ключевой момент, о котором следует упомянуть, заключается в том, что, поскольку это привязано к реальному миру… кодовые базы меняются и развиваются с течением времени. Люди придумывают новые варианты использования уровня доступа к данным. В хранилище данных добавлено больше данных, расширяющих границы, которые люди никогда бы не приняли. К вам присоединяются новые или несколько младших разработчиков.

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

Материализация больших наборов данных

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

В этом примере нет ничего явно неправильного, и на самом деле, оставив фактический запрос на ваше воображение, я упустил, откуда может возникнуть много проблем. Давайте воспользуемся примером из моего репозитория GitHub. /strong> чтобы смоделировать, как это может выглядеть, чтобы у нас была точка отсчета:

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

И теперь, когда у нас есть фрагмент кода, который имитирует извлечение из базы данных путем создания полной коллекции, давайте посмотрим на некоторый вызывающий код, который может это реализовать (также на GitHub):

long memoryBefore = GC.GetTotalMemory(true);
Console.WriteLine($"{DateTime.Now} - Getting data from the database using List...");
List<string> databaseResultsList = PretendThisGoesToADatabaseAsList();
Console.WriteLine($"{DateTime.Now} - Got data from the database using List.");

Console.WriteLine($"{DateTime.Now} - Has Data: {databaseResultsList.Any()}");
Console.WriteLine($"{DateTime.Now} - Count of Data: {databaseResultsList.Count}");

long memoryAfter = GC.GetTotalMemory(true);
Console.WriteLine($"{DateTime.Now} - Memory Increase (bytes): {memoryAfter - memoryBefore}");

Вызывающий код сделает снимок памяти, прежде чем мы вызовем наш метод и выполним операции с результатом. Две вещи, которые мы будем делать с результатом, это вызов метода Any() LINQ, а затем вызов Count непосредственно в списке. В качестве примечания: метод Count() LINQ на самом деле не требует полного перечисления, поскольку он имеет оптимизацию для проверки наличия известной длины.

Изучение результатов материализованной коллекции

В примере с материализованной коллекцией мы можем вызвать метод и сохранить набор результатов в памяти. Учитывая две операции, которые мы пытаемся использовать с коллекцией, Any() и Count, эта информация быстро доступна для нас, потому что мы один раз заплатили за снижение производительности, чтобы материализовать результаты в список.

По сравнению с итератором этот подход не рискует позволить вызывающим объектам случайно полностью перенумеровать результаты. Это связано с тем, что результирующий набор материализуется один раз. Однако здесь подразумевается, что в зависимости от размера результатов и того, насколько дорого может обойтись полная материализация этого полного набора результатов, вы можете заплатить несоразмерную цену за такие вещи, как Any(), которым нужно знать только о существовании одного элемент, прежде чем они вернут true.

И если вы помните, что я сказал в начале этой статьи, если ваш разум автоматически перескакивает на «Ну, кто-то должен создать специальный запрос для этого», тогда… да, это абсолютно решение. Но я слышал, что вы должны сказать, что очень часто что-то подобное ускользает от проверки кода из-за синтаксиса LINQ, который у нас есть. Особенно, если кто-то прав что-то вроде:

CallTheMethodThatActuallyMaterializesToAList().Any()

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

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

Если вызывающим абонентам редко приходится иметь дело с полным набором данных, и им нужно выполнять такие операции, как Any(), First() или другие более легкие операции, которым не обязательно нужен весь результирующий набор… У них нет выбора. с этим API. Они будут платить полную цену за материализацию всего набора результатов, хотя на самом деле, возможно, им просто нужно было пройтись по нескольким элементам.

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

Давайте посмотрим на итераторы…

Давайте продолжим и сравним предыдущий пример с использованием итератора. Мы начнем с кода, который вы можете найти на GitHub:

IEnumerable<string> PretendThisGoesToADatabaseAsIterator()
{
    // let's simulate some exaggerated latency to the DB
    Thread.Sleep(5000);
    Console.WriteLine($"{DateTime.Now} - <DB now sending back results>");

    // now let's assume we run some query that pulls back 100,000 strings from
    // the database
    for (int i = 0; i < 100_000; i++)
    {
        // simulate a tiny bit of latency on the "reader" that would be
        // reading data back from the database... every so often we'll
        // sleep a little bit just to slow it down
        if ((i % 100) == 0)
        {
            Thread.Sleep(1);
        }

        yield return Guid.NewGuid().ToString();
    }
}

Как вы можете видеть в приведенном выше коде, у нас есть итератор, структура которого почти идентична, за исключением:

* Это итератор * Здесь требуется ключевое слово yield return. * Тип возврата: IEnumerable вместо списка

В качестве быстрого резюме, итератор не сможет предоставить вызывающему абоненту счет, как мы могли бы сделать с другими типами сбора, и все, что он может сделать, это позволить вызывающему абоненту пройти через элемент по элементу.

Мы можем использовать аналогичный фрагмент кода вызова для нашего итератора, но давайте добавим пару дополнительных строк для записи консоли (здесь, на GitHub):

long memoryBefore = GC.GetTotalMemory(true);
Console.WriteLine($"{DateTime.Now} - Getting data from the database using iterator...");
IEnumerable<string> databaseResultsIterator = PretendThisGoesToADatabaseAsIterator();
Console.WriteLine($"{DateTime.Now} - "Got data" (not actually... it's lazy evaluated) from the database using iterator.");

Console.WriteLine($"{DateTime.Now} - Has Data: {databaseResultsIterator.Any()}");
Console.WriteLine($"{DateTime.Now} - Finished checking if database has data using iterator.");
Console.WriteLine($"{DateTime.Now} - Count of Data: {databaseResultsIterator.Count()}");
Console.WriteLine($"{DateTime.Now} - Finished counting data from database using iterator.");

long memoryAfter = GC.GetTotalMemory(true);
Console.WriteLine($"{DateTime.Now} - Memory Increase (bytes): {memoryAfter - memoryBefore}");

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

Итераторы делают все проблемы уйти?

Короткий ответ: нет. Развернутый ответ: Итераторы могут устранить некоторые из проблем, которые мы видели ранее с материализованными коллекциями, но у них есть свои проблемы для людей, которые не знакомы с работой с ними.

Когда мы рассматриваем объем памяти в этом примере, это почти ничто по сравнению с предыдущим примером. Дело в том, что в этом примере кода вызова нам никогда не требовалось материализовать весь набор результатов, чтобы мы могли ответить на интересующие нас вопросы.

Всегда ли так будет? Точно нет. Однако одним из преимуществ итераторов здесь является то, что у вызывающей стороны теперь есть выбор. Эти варианты включают, хотят ли они просто выполнить частичное перечисление, полное перечисление или полное перечисление для материализации набора результатов. Ключевым моментом здесь является гибкость использования API.

Но, конечно, за гибкость приходится платить… И это то, что я гораздо чаще вижу у новых программистов на C#, потому что они на самом деле не знакомы с итераторами. Пример выше? Конечно, он вообще не использует много памяти… Но он дважды запустит PretendThisGoesToADatabaseAsIterator.

И да, для вас, читатель с острыми глазами, вы, вероятно, уже заметили это, но с небольшой регулировкой на соглашение и призыв к вызовам:

var results = GetEntriesFromDatabase();
var any = results.Any();
var count = results.Count();

И вдруг вы не можете сказать, имеете ли вы дело с итератором или с материализованной коллекцией. И прежде чем вы закричите: «Вот почему мы никогда не используем var!», позвольте мне еще раз изменить его:

IEnumerable<string> results = GetEntriesFromDatabase();
var any = results.Any();
var count = results.Count();

И правда в том, что var здесь не имеет значения, потому что вы просто не знаете, является ли GetEntriesFromDatabase() итератором или материализованной коллекцией.

Таким образом, не вдаваясь в сорняки миллиона различных способов, которыми мы могли бы попытаться улучшить это, я хотел бы подчеркнуть для вас, что люди МОГУТ и ДЕЛАЮТ это испортить в базах производственного кода. Все время.

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

Заключительные мысли: итераторы или материализованные коллекции?

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

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

Если вы более старший инженер-программист и читаете эту статью, раздосадованную тем, что у вас есть способы решить мои примеры… Отлично :)

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

Мои личные предпочтения? Мне нравится использовать API-интерфейсы на основе итераторов, потому что мне нравится иметь гибкость для потоковой передачи результатов. Тем не менее, после многих лет занятий этим, я копаюсь в некоторых характеристиках производительности. Особенно, когда у нас есть доступ к таким вещам, как интервалы, я могу вернуться, чтобы провести еще немного исследований!

Также опубликовано здесь


Оригинал