Наверное, почти каждый .NET-разработчик сталкивался со случаями, когда для удобства кодирования рутинных действий и сокращения boilerplate-кода при работе со стандартными типами данных не хватает возможностей стандартной же библиотеки.
И практически в каждом проекте появляются сборки и пространства имен вида Common, ProjectName.Common и т.д., содержащие дополнения для работы со стандартными типами данных: перечислениями Enums, Nullable-структурами, строками и коллекциями — перечислениями IEnumerable<T>, массивами, списками и собственно коллекциями.
Как правило, эти дополнения реализуются с помощью механизма extension methods (методов расширения). Часто можно наблюдать наличие реализаций монад, также построенных на механизме методов расширения.
(Забегая вперед — рассмотрим и вопросы, неожиданно возникающие, и которые можно не заметить, когда созданы свои расширения для IEnumerable<T>, а работа ведется с IQueryable<T>).
Написание этой статьи инспирировано прочтением давней статьи-перевода Проверки на пустые перечисления и развернувшейся дискуссии к ней.
Статья давняя, но тема по-прежнему актуальная, тем более, что код, похожий на пример из статьи, приходилось встречать в реальной работе от проекта к проекту.
В исходной статье поднят вопрос, по своей сути касающийся в целом Common-библиотек, добавляемых в рабочие проекты.
Проблема в том, что подобные расширения в продуктовых проектах добавляются наспех, т.к. разработчики занимаются созданиям новых фич, а на создание, продумывание и отладку базовой инфраструктуры времени и ресурсов не выделяется.
Кроме того, как правило, разработчик, добавляя нужное ему Common-дополнение, создает его так, чтобы это дополнение заточено под кейсы из его фичи, и не задумывается, что раз это дополнение общего характера, то оно должно быть максимально абстрагировано от предметной логики и иметь универсальный характер — как это сделано в стандартных библиотеках платформ.
В результате в многочисленных Common-подпапках проектов получаются залежи кода, приведенного в исходной статье:
public void Foo<T>(IEnumerable<T> items)
{
if(items == null || items.Count() == 0)
{
// Оповестить о пустом перечислении
}
}
Автор указал на проблему с методом Count() и предложил создать такой метод расширения:
public static bool IsNullOrEmpty<T>(this IEnumerable<T> items)
{
return items == null || !items.Any();
}
Но и наличие такого метода не решает все проблемы:
- В комментариях развернулась дискуссия на тему, что метод Any() делает одну итерацию, что может привести к проблеме, когда последующая итерация по коллекции (которая и предполагается после проверки IsNullOrEmpty) будет произведена не с первого, а со второго элемента, и предметная логика об этом не узнает.
- На что было получено возражение, что метод Any() для проверки создает отдельный итератор (заметим, это определенные накладные расходы).
А теперь обратим внимание, что все стандартные коллекции .NET, кроме, собственно "бесконечной" последовательности IEnumerable<T> — массивы, списки и непосредственно коллекции — реализуют стандартный интерфейс IReadOnlyCollection<T>, предоставляющий свойство Count — и не нужно никаких итераторов с накладными расходами.
Таким образом, целесообразно создать два метода расширения:
public static bool IsNullOrEmpty<T>(this IReadOnlyCollection<T> items)
{
return items == null || items.Count == 0;
}
public static bool IsNullOrEmpty<T>(this IEnumerable<T> items)
{
return items == null || !items.Any();
}
В таком, случае, при вызове IsNullOrEmpty<T> подходящий метод будет выбран компилятором, в зависимости от типа объекта, для которого происходит вызов расширения. Сам вызов в обоих случаях будет выглядеть одинаково.
Однако, далее в дискуссии один из комментаторов указал, что, вероятно, для IQueryable<T> (интерфейс "бесконечной" последовательности для работы с запросами к БД, наследующий от IEnumerable<T>) наиболее оптимальным будет как раз вызов метода Count().
Эта версия требует проверки, включая проверки работы с разными ORM — EF, EFCore, Linq2Sql, и, если это так, то появляется потребность в создании третьего метода.
На самом деле, для IQueryable<T> есть свои extension-реализации Any(), Count() и других методов работы с коллекциями (класс System.Linq.Queryable), которые и предназначены для работы с ORM, в отличие от аналогичных реализаций для IEnumerable<T> (класс System.Linq.Enumerable).
При этом, вероятно, Queryable-версия Any() работает даже оптимальнее, чем Queryable-проверка Count() == 0.
Для вызова нужных Queryable-версий Any() или Count(), если мы хотим вызвать именно нашу проверку IsNullOrEmpty, потребуется новый метод с IQueryable<T>-входным параметром.
Таким образом, нужно создать третий метод:
public static bool IsNullOrEmpty<T>(this IQueryable<T> items)
{
return items == null || items.Count() == 0;
}
или
public static bool IsNullOrEmpty<T>(this IQueryable<T> items)
{
return items == null || !items.Any();
}
В итоге, для реализации корректной для всех случаев (для всех ли?) простой null-безопасной проверки коллекций на "пустоту", нам пришлось провести небольшое исследование и реализовать три метода расширения.
А если на начальном этапе создать только часть методов, например, только первые два (не нужны эти методы; нужно делать продуктовые фичи), то может получиться вот что:
- Как только эти методы появились, их начинают использовать в продуктовом коде.
- В какой то момент вызовы Enumerable-версий IsNullOrEmpty проникнут в код работы с ORM, и эти вызовы точно будут работать неоптимально.
- Что делать дальше? Добавлять Queryable-версии методов и пересобирать проект? (Добавляем только новые методы расширения, продуктовый код не трогаем — после пересборки переключение на нужные методы произойдет автоматически.) Это приведет к необходимости регрессионного тестирования всего продукта.
По этой же причине, все эти методы желательно реализовать в одной сборке и одном пространстве имен (можно в разных классах, например, EnumerableExtensions и QueryableExtensions), чтобы при случайном отключении пространства имен или сборки мы не возвратились к ситуации, когда с IQueryable<T>-коллекциями происходит работа с помощью обычных Enumerable-расширений.
На мой взгляд, обилие подобных расширений практически в каждом проекте говорит о недостаточной проработанности стандартной библиотеки и в целом модели платформы.
Часть проблем автоматически снялась бы при наличии поддержки Not Nullability в платформе, другая часть — наличием в стандартной библиотеке большего количества учитывающих более широкий спектр кейсов расширений для работы со стандартными типами данных.
Причем, реализованные на современный лад — именно в виде расширений с использованием обобщений (Generics).
Дополнительно поговорим об этой в следующей статье.
P.S. Что интересно, если посмотреть на Kotlin и его стандартную библиотеку, при разработке которого явно был внимательно изучен опыт других языков, в первую очередь, на мой взгляд — Java, C# и Ruby, то можно легко обнаружить как раз эти вещи — Not Nullability и обилие extensions, при наличии которых не возникает необходимости добавлять свои "велосипедные" реализации микробиблиотек для работы со стандартными типами.