Pull to refresh

Методы расширения для типов стандартной библиотеки .NET

Reading time 5 min
Views 12K

Наверное, почти каждый .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, при наличии которых не возникает необходимости добавлять свои "велосипедные" реализации микробиблиотек для работы со стандартными типами.

Tags:
Hubs:
+11
Comments 58
Comments Comments 58

Articles