Усиливаем контроль типов: где в типичном C#-проекте присутствует непрошеный элемент слабой типизации?

от автора

Проблема

Мы привыкли говорить о языках вроде C# как строго и статически типизированных. Это, конечно, правда, и во многих случаях тип, указываемый нами для некоторой языковой сущности хорошо выражает наше представление о ее типе. Но есть широко распространенные примеры, когда мы по привычке («и все так делают») миримся с не совсем верным выражением «желаемого типа» в «объявленном типе». Самый яркий — ссылочные типы, безальтернативно оснащенные значением «null».

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

Рассмотрим фрагмент кода:

public interface IUserRepo  { 	User Get(int id); 	User Find(int id); } 

Этот интерфейс требует дополнительного комментария: «Get возвращает всегда не null, но кидает Exception в случае ненахождения объекта; а Find, не найдя, возвращает null». «Желаемые», подразумеваемые автором типы возврата у этих методов разные: «Обязательно User» и «Может быть, User». А «объявленный» тип — один и тот же. Если язык не заставляет нас явно выражать эту разницу, то это не означает, что мы не можем и не должны делать это по собственной инициативе.

Решение

В функциональных языках, например, в F#, существует стандартный тип FSharpOption<T>, который как раз и представляет для любого типа контейнер, в котором может либо быть одно значение T, либо отсутствовать. Рассмотрим, какие возможности хотелось бы иметь от такого типа, чтобы им было удобно пользоваться, в том числе приверженцами разных стилей кодирования с разной степенью знакомства с функциональными языками.
С учетом этого гипотетического типа можно переписать наш репозиторий в таком виде:

public interface IUserRepo  { 	User Get(int id); 	Maybe<User> Find(int id); } 

Сразу оговоримся, что первый метод все еще может вернуть null. Простого способа запретить это на уровне языка — нет. Однако, можно это сделать хотя бы на уровне соглашения в команде разработки. Успех такого начинания зависит от людей; в моем проекте такое соглашение принято и успешно соблюдается.
Конечно, можно пойти дальше и встроить в процесс сборки проверки на наличие ключевого слова null в исходном коде (с оговоренными исключениями из этого правила). Но в этом пока не было потребности, хватает просто внутренней дисциплины.
А вообще можно пойти и еще дальше, например, принудительно внедрить во все подходящие методы Contract.Ensure(Contract.Result<T>() != null) через какое-нибудь AOP-решение, например, PostSharp, в таком случае даже члены команды с низкой дисциплиной не смогут вернуть злосчастный null.

В новой версии интерфейса явно декларируется, что Find может и не найти объект, и в этом случае вернет значение Maybe<User>.Nothing. В этом случае никто не сможет по забывчивости не проверить результат на null. Пофантазируем далее об использовании такого репозитория:

// забывчивый разработчик забыл проверить на null var user = repo.Find(userId); // возвращает теперь не User, а Maybe<User> var userName = user.Name; // не компилируется, у Maybe нет Name  var maybeUser = repo.Find(userId); // зато код ниже компилируется, string userName; if (maybeUser.HasValue) // таким образом нас заставили НЕ забыть проверить на наличие объекта { 	var user = maybeUser.Value; 	userName = user.Name; } else  	userName = "unknown"; 

Этот код аналогичен тому, что мы бы написали с проверкой null, просто условие в if выглядит несколько иначе. Однако, постоянное повторение подобных проверок, во-первых, захламляет код, делая суть его операций менее явно заметной, во-вторых, утомляет разработчика. Поэтому было бы крайне удобно иметь для большинства стандартных операций готовые методы. Вот предыдущий код в fluent-стиле:

string userName = repo.Find(userId).Select(u => u.Name).OrElse("unknown"); 

Для тех же, кому близки функциональные языки и do-нотация, может быть поддержан совсем «функциональный» стиль:

string userName = (from user in repo.Find(userId) select user.Name).OrElse("unknown"); 

Или, пример посложнее:

(  from roleAProfile in provider.FindProfile(userId, type: "A")  from roleBProfile in provider.FindProfile(userId, type: "B")  from roleCProfile in provider.FindProfile(userId, type: "C")  where roleAProfile.IsActive() && roleCProfile.IsPremium()  let user = repo.GetUser(userId)  select user ).Do(HonorAsActiveUser); 

с его императивным эквивалентом:

var maybeProfileA = provider.FindProfile(userId, type: "A"); if (maybeProfileA.HasValue) { 	var profileA = maybeProfileA.Value; 	var maybeProfileB = provider.FindProfile(userId, type: "B"); 	if (maybeProfileB.HasValue) 	{ 		var profileB = maybeProfileB.Value; 		var maybeProfileC = provider.FindProfile(userId, type: "C"); 		if (maybeProfileC.HasValue) 		{ 			var profileC = maybeProfileC.Value; 			if (profileA.IsActive() && profileC.IsPremium()) 			{ 				var user = repo.GetUser(userId); 				HonorAsActiveUser(user); 			} 		} 	} } 

Также требуется интеграция Maybe<T> с его достаточно близким родственником — IEnumerable<T>, как минимум в таком виде:

var admin = users.MaybeFirst(u => u.IsAdmin); // вместо FirstOrDefault(u => u.IsAdmin); Console.WriteLine("Admin is {0}", admin.Select(a => a.Name).OrElse("not found")); 

Из приведенных выше «мечтаний» ясно, что хочется иметь в типе Maybe

  • доступ к информации о наличии значения
  • и к самому значению, если оно доступно
  • набор удобных методов (или методов-расширений) для потокового стиля вызовов
  • поддержка синтаксиса LINQ-выражений
  • интеграция с IEnumerable<T> и другими компонентами, при работе с которыми часто возникают ситуации отсутствия значения

Рассмотрим, какие решения может предложить нам Nuget для быстрого включения в проект и сравним их по приведенным выше критериям:

Название пакета Nuget и тип типа HasValue Value FluentAPI Поддержка LINQ Интеграция с IEnumerable Примечания и исходный код
Option, class есть нет, только pattern-matching минимальное нет нет github.com/tejacques/Option/
Strilanc.Value.May, struct есть нет, только pattern-matching богатое есть есть Принимает null как допустимое значение в May
github.com/Strilanc/May
Options, struct есть есть среднее есть есть Также предлагается тип Either
github.com/davidsidlinger/options
NeverNull, class есть есть среднее нет нет github.com/Bomret/NeverNull
Functional.Maybe, struct есть есть богатое есть есть github.com/AndreyTsvetkov/Functional.Maybe
Maybe, нет типа минимальное нет методы расширения работают с обычным null
github.com/hazzik/Maybe
WeeGems.Options, struct есть есть минимальное нет нет Также есть другие функциональные полезности: мемоизация, частичное применение функций
bitbucket.org/MattDavey/weegems

Так сложилось, что у меня в проекте вырос свой пакет, он есть среди вышеперечисленных.

Из этой таблицы видно, что самое «легкое», минимально инвазивное решение — это Maybe от hazzik, которое не требует никак менять API, а просто добавляет пару методов-расширений, позволяющих избавиться от одинаковых if-ов. Но, увы, никак не защищает забывчивого программиста от получения NullReferenceException.

Самые богатые пакеты — Strilanc.Value.Maybe (тут автор объясняет, в частности, почему он решил что (null).ToMaybe() не то же самое, что Maybe.Nothing), Functional.Maybe, Options.

Выбирайте на вкус. А вообще, хочется, конечно, стандартного решения от Microsoft, а еще функциональных типов в C#, кортежей и т.п 🙂. Поживем — увидим.

ссылка на оригинал статьи http://habrahabr.ru/post/200100/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *