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

в 21:34, , рубрики: .net, maybe, метки: ,

Проблема

Мы привыкли говорить о языках вроде 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#, кортежей и т.п :). Поживем — увидим.

Автор: AndreyTS

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js