Проблема
Мы привыкли говорить о языках вроде 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