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