
Это часть серии статей:
- Перечисление в .NET
- Перечисление в .NET II -
Count() - Перечисление в .NET III -
Enumerable.Empty<T>() - Перечисление в .NET IV - Поиск элемента
- Перечисление в .NET V -
ToList()или нетToList()
Перечисление в .NET
Все разработчики .NET знают и используют IEnumerable , но, просматривая код, я обнаружил, что многие попадают в одни и те же ловушки, что приводит к приложениям, в которых легко избежать проблем с производительностью.
IEnumerable очень полезно, и его трудно избежать. В этой статье я исследую некоторые его нюансы, чтобы вы могли лучше использовать его возможности.
ПРИМЕЧАНИЕ. Эта статья содержит ссылки на SharpLab, где вы можете протестировать примеры кода. Щелкните ссылки и измените код по своему желанию, чтобы лучше понять концепции, описанные здесь.
IEnumerable и IEnumerator
Перечисление в .NET основано на двух очень простых интерфейсах: IEnumerable и IEnumerator. Для каждого есть неуниверсальная и универсальная версии, но теперь давайте сосредоточимся только на их ролях.
IEnumerable- это просто фабрикаIEnumerators- его единственный методGetEnumerator(), возвращает новый экземпляр объекта, реализующегоIEnumerator.
IEnumeratorвыполняет перечисление - МетодMoveNext()продвигает перечисление к следующей позиции, а свойствоCurrentвозвращает значение для этой позиции. МетодReset()предназначен для взаимодействия с COM и может не поддерживаться коллекцией.
Учитывая эти интерфейсы, следует выделить несколько важных моментов:
- Они не допускают мутаций коллекций. Только операции чтения.
- Они не допускают произвольный доступ. Только последовательное перечисление.
- Они не предоставляют доступа к какой-либо другой информации о сборе. Разрешить только перечисление и все.
В этой статье я подробнее рассмотрю их последствия.
ПРИМЕЧАНИЕ. Каждый
IEnumeratorиз одного и того жеIEnumerableдолжен быть независимым и иметь разные позиции перечисления. Если нет, то при более чем однократном перечислении одного и того жеIEnumerableбудут получены разные последовательности значений. Я обнаружил, что некоторые провайдеры не соблюдают это правило. Остерегайтесь этого, поскольку некоторые из упомянутых здесь правил могут работать некорректно в таких случаях.
Перечисление
Самый простой способ перечисления - использовать цикл foreach . Начнем с простого метода, который принимает IEnumerable<int> и выводит все его элементы на консоль:
Вы можете проверить в SharpLab, что на самом деле приведенный выше код расширен до while цикла. Сосредоточившись на перечислении, мы можем возобновить его так:
Обратите внимание, что сначала создается новый экземпляр IEnumerator . Затем он запускает цикл while, вызывающий MoveNext(), останавливаясь, когда возвращается false. Если установлено значение true, свойство Current возвращает новое значение.
Общая версия IEnumerable реализует IDisposable , поэтому ключевое слово using используется для решения этой проблемы .
Это очень просто, но давайте сосредоточимся на нюансах ...
Count () vs. Any ()
Хорошая практика - проверять аргументы и избегать ненужных вычислений, поэтому я очень часто обнаруживал следующее:
Он использует метод расширения Count(), чтобы проверить, является ли перечисляемое пустым и завершается ли досрочно. К сожалению, здесь выполняется больше вычислений, чем многие ожидают ...
Из выделенных точек на интерфейсах перечисления мы уже знаем, что эти могут выполнять перечисление только последовательно и не имеют дополнительной информации о коллекции. Count() реализация выглядит примерно так:
Обратите внимание, что он должен перечислить все элементы, считая, сколько раз MoveNext() возвращает true . Как следствие, Count() имеет сложность O (n).
Это означает, что предыдущий пример кода перечисляет коллекцию три раза. Сначала проверяется, пуста ли она, вторая - вычисляет сумму, а третья - количество элементов.
ПРИМЕЧАНИЕ. LINQ использует оптимизацию, при которой, если коллекция реализует
ICollection, она просто вызывает свойствоCount. Проблема в том, что во многих коллекциях это не реализовано. Чтобы избежать неожиданностей, я всегда предполагаю, что они этого не делают.
Есть лучшее решение. Any() - это метод расширения, который возвращает true, если перечисляемый объект содержит хотя бы один элемент; в противном случае false. Any() реализация выглядит примерно так:
Обратите внимание, что он создает экземпляр IEnumerator, а затем вызывает MoveNext() только один раз. Шлейфа нет! Any() имеет сложность O (1).
Вы всегда должны использовать Any(), чтобы проверить, пусто ли перечисляемое:
Обычно вам даже не нужно проверять, пуст ли он. foreach в этом случае не входит в цикл. Вот реализация Average(), которая создает один экземпляр IEnumerator и перечисляет коллекцию только один раз:
При необходимости всегда используйте Any (), чтобы узнать, содержит ли IEnumerable какой-либо элемент.
Count () против Count
Иногда недостаточно найти, является ли перечисляемое пустым. Возможно, вам потребуется узнать фактическое количество предметов.
Count() - это метод расширения для интерфейса IEnumerable , а Count - свойство интерфейса IReadOnlyCollection. При их использовании единственная заметная разница - наличие или отсутствие скобок, но их поведение сильно отличается.
IReadOnlyCollection реализуется большинством коллекций в платформе .NET. Они сохраняют значение счетчика как частное поле или могут легко вычислить его из других частных полей, позволяя Count иметь сложность O (1).
IReadOnlyCollection происходит от IEnumerable и просто добавляет свойство Count только для чтения . IReadOnlyList и IReadOnlyDictionary оба являются производными от IReadOnlyCollection и добавляют произвольный доступ.
Если вы вернете один из них, вместо обычного IEnumerable, вызывающие абоненты смогут как перечислять, так и эффективно выполнять другие поддерживаемые операции, сохраняя неизменность. При необходимости вы можете вернуть другие интерфейсы или типы коллекций.
Вы можете добавить еще один метод расширения Average() для IReadOnlyCollection , который использует преимущество свойства Count и гарантирует, что метод не будет изменять коллекцию. Он повышает производительность, поскольку не создает экземпляр IEnumerator и не вызывает MoveNext() , когда коллекция пуста:
ПРИМЕЧАНИЕ. Хотя
ICollectionдолжен быть производным отIReadOnlyCollection, по историческим причинам и для обеспечения обратной совместимости, это не так. Таким образом, вы не можете передатьICollectionэтому методу. Чтобы полностью поддерживать все сценарии, у вас должен быть еще один метод расширения дляICollection. С другой стороны, это вызывает проблемы неоднозначности для коллекций, реализующих оба интерфейса, вынуждая вас выполнять приведение к одному из них.
Всегда выставляйте интерфейс наивысшего допустимого уровня возвращаемой коллекции и используйте интерфейс самого низкого допустимого уровня. IEnumerable, IReadOnlyCollection, IReadOnlyList и IReadOnlyDictionary сохраняют неизменность.
Урожай
Реализация MyRange() ,, найденная выше, заполняет список всеми значениями диапазона и затем возвращает его. Это неэффективно и может вызвать переполнение памяти. Решение - использовать ключевое слово yield.
В этом случае компилятор автоматически создает реализацию IEnumerable , которая перечисляет значения без выделения коллекции в памяти. Вы можете проверить в SharpLab сгенерированный код, поддерживающий такое поведение.
В приведенном выше примере кода метод проверяет аргумент count и выдает исключение, если он меньше нуля. Вы ожидаете, что он будет брошен сразу после вызова метода. Это не так. Он выдается только после первого вызова MoveNext(). Зайдите в SharpLab и поиграйте со значением count, чтобы визуализировать это поведение.
Чтобы добиться желаемого поведения, нам нужно разделить метод на две части:
- немедленно для проверки,
- ленив для перечисления.
Это можно сделать с помощью локальной функции:
Проверьте в SharpLab, что теперь исключение генерируется немедленно.
Ленивое вычисление - это способность выполнять код только при необходимости и столько раз, сколько требуется, независимо от того, где находится код в конвейере выполнения ». Вы должны воспользоваться этим, чтобы минимизировать использование ресурсов при создании перечислимых элементов.
Используйте yield при создании перечислимых объектов.
Состав
Я слишком часто обнаруживал использование ToList() или ToArray(), когда разработчики хотят повторно использовать результат выражения LINQ:
Это приводит к ненужному выделению памяти и копированию в нее всех значений перечисления. Это приводит к значительному снижению производительности. Эти методы следует использовать только тогда, когда строго необходимо кэшировать значения и гарантировано, что они всегда умещаются в памяти.
Выражения LINQ можно использовать повторно, сохраняя ленивое вычисление:
Вы можете увидеть, как это работает в SharpLab.
Используйте композицию для повторного использования выражений, сохраняя ленивую оценку.
null против Enumerable.Empty ‹T› ()
Я видел много случаев, когда разработчики возвращали null , когда на самом деле имели в виду, что он пуст. Важно понимать, что они имеют два очень разных значения:

- null - недопустимое состояние, при котором
IEnumerableэкземпляр не был создан, - пусто - допустимое состояние, в котором перечисление не содержит значений.
Возврат null нарушает использование foreach и композиции методов. Вы можете увидеть в SharpLab, как плохо он себя ведет.
Вы должны вернуть пустое перечисление. Пространство имен System.Linq содержит Enumerable.Empty<T>() статический метод, который возвращает именно это; реализация IEnumerable , которая генерирует IEnumerator реализации, где метод MoveNext() всегда возвращает ложь.
Вот пример того, как его использовать:
Вы можете увидеть, как это работает в SharpLab.
При использовании yield вы можете выйти из перечисления с помощью yield break, и, если до этого момента не было возвращено никакого значения, результатом будет пустое перечисление:
Вы можете увидеть, как это работает в SharpLab.
Никогда не возвращайте нулевое перечислимое значение. Вместо этого используйте Enumerable.Empty ‹T› () или yield break.
IQueryable
Пространство имен System.Linq, также известное как LINQ to Objects, содержит множество методов расширения для IEnumerable. Отсутствие поддержки перечисления с произвольным доступом означает, что для поиска элементов требуется полное сканирование и что для таких операций, как упорядочение или группировка, требуется выделение памяти с копиями значений.
Базы данных имеют механизмы, оптимизированные для этих операций, и поддерживают гораздо большие объемы данных. IQueryable - это интерфейс перечисления , который преобразует дерево выражений LINQ в нечто эквивалентное, которое может обрабатывать база данных. Выполнение запроса делегируется в базу данных. IQueryable происходит от IEnumerable , поэтому результаты можно перечислить так же, как мы это делали до сих пор.
Драйверы .NET для баз данных обычно поддерживают IQueryable , и я собираюсь показать пару примеров, но есть и другие поставщики, такие как LinqToExcel, LinqToTwitter. , LinqToCsv , LINQ-to-BigQuery , LINQ-to-GameObject-for-Unity , ElasticLINQ, GpuLinq и многие другие.
Entity Framework Core
Entity Framework (EF) - это объектно-реляционное сопоставление (ORM), которое включает «LINQ-to-SQL», то есть поддерживает запросы LINQ в ядре базы данных SQL.
Ниже приведен пример, основанный на модели базы данных, определенной в статье Краткий обзор Entity Framework Core. Он применяет запрос LINQ к свойству Blogs , имеющему тип DbSet<Blog>.
DbSet<T> реализует IQueryable интерфейс, который в данном случае преобразует дерево выражений LINQ в SQL. Метод ToSql(), используемый в строке 9, возвращает сгенерированную строку, которая будет отправлена в базу данных. В приведенном выше примере выводится следующее:
SELECT TOP(5) [blog].[Url] FROM [Blogs] AS [blog] WHERE [blog].[Rating] > 3 ORDER BY [blog].[Rating] DESC
Обратите внимание, что он эквивалентен запросу LINQ, определенному в коде. Это означает, что он будет полностью выполняться ядром базы данных. foreach просто нужно перечислить возвращаемый результат. Операторы LINQ не выполняют фильтрацию, проекцию, упорядочивание или подсчет элементов.
ПРИМЕЧАНИЕ. Вы можете получить метод расширения
ToSql()из сущности Риона Уильямса (обновленные версии находятся в комментариях).
Теперь представьте, что вы хотите применить операторы LINQ к результату запроса. Не используйте ToList() или ToArray(). Вместо этого используйте AsEnumerable(). Это позволит сохранить ленивую оценку без использования памяти.
В следующем примере выполняется проекция результатов с форматированием строки, которая будет записана в консоль:
Это особенно полезно для баз данных NOSQL, поскольку они обычно поддерживают ограниченный набор операций, определенных в LINQ. Вы все равно должны спроектировать модель данных так, чтобы механизм базы данных выполнял запрос в максимально возможной степени.
Драйвер DataStax C # для Apache Cassandra
Этот драйвер поддерживает IQueryable через класс CqlQuery<T> . В этом случае он преобразует дерево выражений LINQ в CQL, язык запросов, используемый Cassandra.
Следующий код основан на модели данных, аналогичной модели из предыдущего примера, и применяет запрос к таблице blogs_by_rating. Сопоставление определяется атрибутами класса BlogByRating , расположенного внизу.
Метод CqlQuery<T>.ToString() возвращает CQL-запрос, отправленный в базу данных. Строка 18 приведенного выше кода выводит следующее:
SELECT "url" FROM "blogs_by_rating" WHERE "rating" = ?
Знак вопроса указывает на то, что он использует подготовленное заявление. Это означает, что он будет повторно использован, а значения будут привязаны при вызове.
Методы CqlQueryBase.Execute() и CqlQueryBase.ExecuteAsync() возвращают IEnumerable , поэтому вы можете использовать операторы LINQ напрямую без необходимости AsEnumerable().
ПРИМЕЧАНИЕ. Этот драйвер - один из тех случаев, упомянутых выше, когда перечисление более одного раза приводит к различным последовательностям значений. Это связано с тем, как реализовано
GetEnumerator(), где результирующиеIEnumeratorэкземпляры разделяют состояние. В этом случае вам, возможно, придется использоватьToList(), чтобы сохранить последовательность в памяти , или вызыватьExecute()перед каждым перечислением, чтобы получить новыйIEnumerableэкземпляр.
Выполняйте как можно больше запросов в IQueryable. При необходимости используйте AsEnumerable () для выполнения ленивой оценки.
Заключение
Вкратце, вот выводы по каждому из пунктов, затронутых выше:
- При необходимости всегда используйте
Any(), чтобы определить, содержит лиIEnumerableкакой-либо элемент.
- Всегда предоставлять интерфейс наивысшего допустимого уровня возвращаемой коллекции и использовать интерфейс самого низкого допустимого уровня.
IEnumerable,IReadOnlyCollection,IReadOnlyListиIReadOnlyDictionaryсохраняют неизменность.
- Используйте
yieldпри создании перечислимых элементов.
- Используйте композицию для повторного использования выражений, сохраняя ленивую оценку.
- Никогда не возвращайте нулевой перечислимый. Вместо этого используйте
Enumerable.Empty<T>()илиyield break.
- Выполняйте как можно больше запросов в
IQueryable. При необходимости используйтеAsEnumerable()для выполнения ленивой оценки.
Надеюсь, вам понравилась эта статья и вы нашли ее полезной. Поделитесь этим со своей командой.
ПРИМЕЧАНИЕ. Эта статья была посвящена клиентской стороне перечисления. Если вы хотите реализовать
IEnumerable, вы можете найти несколько советов в другой моей статье Как использоватьSpan<T>иMemory<T>». Если вы хотите реализоватьIQueryable, проверьте проект с открытым исходным кодом Relinq.

📝 Прочтите этот рассказ позже в Журнале l.
🗞 Просыпайтесь каждое воскресное утро и слышите самые интересные истории, мнения и новости недели, ожидающие в вашем почтовом ящике: Получите примечательный информационный бюллетень›