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

habr.png

Наверное, почти каждый .NET-разработчик сталкивался со случаями, когда для удобства кодирования рутинных действий и сокращения boilerplate-кода при работе со стандартными типами данных не хватает возможностей стандартной же библиотеки.


И практически в каждом проекте появляются сборки и пространства имен вида Common, ProjectName.Common и т.д., содержащие дополнения для работы со стандартными типами данных: перечислениями Enums, Nullable-структурами, строками и коллекциями — перечислениями IEnumerable, массивами, списками и собственно коллекциями.


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


(Забегая вперед — рассмотрим и вопросы, неожиданно возникающие, и которые можно не заметить, когда созданы свои расширения для IEnumerable, а работа ведется с IQueryable).


Написание этой статьи инспирировано прочтением давней статьи-перевода Проверки на пустые перечисления и развернувшейся дискуссии к ней.


Статья давняя, но тема по-прежнему актуальная, тем более, что код, похожий на пример из статьи, приходилось встречать в реальной работе от проекта к проекту.


В исходной статье поднят вопрос, по своей сути касающийся в целом Common-библиотек, добавляемых в рабочие проекты.


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


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


В результате в многочисленных Common-подпапках проектов получаются залежи кода, приведенного в исходной статье:


public void Foo(IEnumerable items) 
{
 if(items == null || items.Count() == 0)
 {
  // Оповестить о пустом перечислении
 }
}


Автор указал на проблему с методом Count () и предложил создать такой метод расширения:


public static bool IsNullOrEmpty(this IEnumerable items)
{
  return items == null || !items.Any();
}


Но и наличие такого метода не решает все проблемы:


  • В комментариях развернулась дискуссия на тему, что метод Any () делает одну итерацию, что может привести к проблеме, когда последующая итерация по коллекции (которая и предполагается после проверки IsNullOrEmpty) будет произведена не с первого, а со второго элемента, и предметная логика об этом не узнает.
  • На что было получено возражение, что метод Any () для проверки создает отдельный итератор (заметим, это определенные накладные расходы).


А теперь обратим внимание, что все стандартные коллекции .NET, кроме, собственно «бесконечной» последовательности IEnumerable — массивы, списки и непосредственно коллекции — реализуют стандартный интерфейс IReadOnlyCollection, предоставляющий свойство Count — и не нужно никаких итераторов с накладными расходами.


Таким образом, целесообразно создать два метода расширения:


public static bool IsNullOrEmpty(this IReadOnlyCollection items)
{
  return items == null || items.Count == 0;
}

public static bool IsNullOrEmpty(this IEnumerable items)
{
  return items == null || !items.Any();
}


В таком, случае, при вызове IsNullOrEmpty подходящий метод будет выбран компилятором, в зависимости от типа объекта, для которого происходит вызов расширения. Сам вызов в обоих случаях будет выглядеть одинаково.


Однако, далее в дискуссии один из комментаторов указал, что, вероятно, для IQueryable (интерфейс «бесконечной» последовательности для работы с запросами к БД, наследующий от IEnumerable) наиболее оптимальным будет как раз вызов метода Count ().


Эта версия требует проверки, включая проверки работы с разными ORM — EF, EFCore, Linq2Sql, и, если это так, то появляется потребность в создании третьего метода.


На самом деле, для IQueryable есть свои extension-реализации Any (), Count () и других методов работы с коллекциями (класс System.Linq.Queryable), которые и предназначены для работы с ORM, в отличие от аналогичных реализаций для IEnumerable (класс System.Linq.Enumerable).


При этом, вероятно, Queryable-версия Any () работает даже оптимальнее, чем Queryable-проверка Count () == 0.


Для вызова нужных Queryable-версий Any () или Count (), если мы хотим вызвать именно нашу проверку IsNullOrEmpty, потребуется новый метод с IQueryable-входным параметром.


Таким образом, нужно создать третий метод:


public static bool IsNullOrEmpty(this IQueryable items)
{
  return items == null || items.Count() == 0;
}


или


public static bool IsNullOrEmpty(this IQueryable items)
{
  return items == null || !items.Any();
}


В итоге, для реализации корректной для всех случаев (для всех ли?) простой null-безопасной проверки коллекций на «пустоту», нам пришлось провести небольшое исследование и реализовать три метода расширения.


А если на начальном этапе создать только часть методов, например, только первые два (не нужны эти методы; нужно делать продуктовые фичи), то может получиться вот что:


  • Как только эти методы появились, их начинают использовать в продуктовом коде.
  • В какой то момент вызовы Enumerable-версий IsNullOrEmpty проникнут в код работы с ORM, и эти вызовы точно будут работать неоптимально.
  • Что делать дальше? Добавлять Queryable-версии методов и пересобирать проект? (Добавляем только новые методы расширения, продуктовый код не трогаем — после пересборки переключение на нужные методы произойдет автоматически.) Это приведет к необходимости регрессионного тестирования всего продукта.


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


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


Часть проблем автоматически снялась бы при наличии поддержки Not Nullability в платформе, другая часть — наличием в стандартной библиотеке большего количества учитывающих более широкий спектр кейсов расширений для работы со стандартными типами данных.


Причем, реализованные на современный лад — именно в виде расширений с использованием обобщений (Generics).


Дополнительно поговорим об этой в следующей статье.


P.S. Что интересно, если посмотреть на Kotlin и его стандартную библиотеку, при разработке которого явно был внимательно изучен опыт других языков, в первую очередь, на мой взгляд — Java, C# и Ruby, то можно легко обнаружить как раз эти вещи — Not Nullability и обилие extensions, при наличии которых не возникает необходимости добавлять свои «велосипедные» реализации микробиблиотек для работы со стандартными типами.

© Habrahabr.ru