Pull to refresh

Comments 88

UFO just landed and posted this here
Простите, а как Вы делаете вывод о количестве ошибок в проекте? Человек привёл весьма эффективную технику, которая позволяет переложить ответственность за NullReferenceException на контроль типов. Как результат — ошибки пропали совсем.
Я сам использую нечто подобное, но в личных проектах. При большом количестве людей соблюдать правила не получается, в результате система начинает разваливаться. Запретить null на уровне компилятора нельзя.

А то кодили на питоне, потом перешли на c# и, удивительно, все ошибки вида "{aaa} object has no attribute {bbb}" пропали. Наверное там столько скрытых ошибок в проекте.
Всё очень просто при наличии Resharper. Говорим, что PossibleNullReferenceException — это ошибка, и начинаем закрывать все места, где разработчик забыл о возможности прихода null.
После предыдущей статьи я для интереса сделал так, и на моих проектах и нашлось всего одно такое место, и то крайне маловероятное в коде анализа данных, десериализуемых из вкомпилированного в DLL ресурсного файла.
А теперь попробуйте сделать тоже самое включив, но в CodeInspection-Settings поставить «When entity doesn't have explicit NotNull attrobute».
Количество потенциальных мест с ошибками увеличится в разы, и возможно, часть из них будет реальна.
ReSharper по по умолчанию корректно работает только в встроенными функциями, на которые у них атрибуты расставлены.
В рабочем проекте у меня настроено считать ошибкой отсутствие [NotNull] или [CanBeNull] на всех публичных методах, пропертях, конструкторах. Этого хватает, чтобы избежать большинства ошибок.
В своих проектах всё равно предпочитаю использовать проверки основанные на типах а не на решарпере.
Я хотел сказать, что давно работаю по методике, которая позволяла мне обходиться без подсказок Resharper, он лишь подтвердил правильность подхода.
Спасибо за совет насчет полного покрытия кода атрибутами — к сожалению узнал о них не так давно, и еще не до конца разобрался — пока вставлял только в критически важные места.
Так у всех есть та или иная методика, как писать код правильно. И у всех она более или мне работает. =)
Просто мне не понравилось, как dmomen необоснованно обругал один из предложенных подходов.
Тоже хотели так сделать, но наткнулись на то, что нельзя помечать таким образом элементы коллекций (а у нас во многих местах заранее известно, что они NotNull). Как вы вышли из такой ситуации?
Пришли к соглашению, что null не является валидным элементом всех коллекций, за исключением IEnumerable<Nullable<T>>. Возможно что-то подправили в решарперовском переопределении свойств. (у них же на всё xml-ки есть)
Точно не помню, что делали, поскольку это было давно и особой проблемы не создавало. Возможно, что я просто не так вас понял.
Переводить же все баги в разряд скрытых и трудноотлавливаемых — это как лгать самому себе.

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

чем жестче будет падать система — тем лучше, быстрее починят

Это именно то, что у нас и происходило. Сразу ошибка «Объект не найден», сгенерированная кодом вида

return items.FirstMaybe(cond).OrElse(()  => new ItemNotFoundException());

вместо NullReference где-то еще позже.

Так что ваш вывод о скрытых ошибках неверен, все наоборот — мы не прячем что-либо, а делаем еще одну неявную вещь — явной. Так что куча ошибок была, и была быстро исправлена, т.к. это были форсированные исключения, бросаемые очень близко к самой точке возникновения проблемы.
Полностью не согласен. Иначе можно дойти до:
Зачем нам нужна эта противная сборка мусора. Всегда были стандартные средства для ручного управления памятью. Усложнять систему созданием гарбадж коллектора совсем необязательно.

И вообще основной смысл систем со статической типизацией: отловить потенциальную ошибку до того, как код вообще станет рабочим. Вариант с Option/Maybe/Nullable крайне хорош в этом плане. Контракты несколько хуже.

Основная проблема null, в том что он не сохраняет тип. Зачем это нужно хорошо описано в Types and Programming Languages
Зачем нам нужна эта противная сборка мусора. Всегда были стандартные средства для ручного управления памятью. Усложнять систему созданием гарбадж коллектора совсем необязательно.


Сборка мусора, как раз упрощает систему, поскольку забирает ответственность за контролем памяти от клиента. Вариант с Maybe добавляет новый элемент в прогамму и способствует непреднамеренному копированию ссылок.

Основная проблема null, в том что он не сохраняет тип. Зачем это нужно хорошо описано в Types and Programming Languages


А еще Boxing/Unboxing не сохраняет тип. Надо сделать строго типизированный вариант для этого. И еще, для каждого проекта писать свою реализацию кеша. А еще что-бы не получить DivisionByZero сделать struct который не сможет принимать 0.

Невозможно покрыть все скользкие места. Иногда нужно просто писать корректный код,
Сборка мусора, как раз упрощает систему, поскольку забирает ответственность за контролем памяти от клиента. Вариант с Maybe добавляет новый элемент в прогамму и способствует непреднамеренному копированию ссылок.
Сборка мусора тоже добавляет новый элемент в язык. Принципиальных отличий нет. Разве что сборку мусора не отключить в текущей реализации языка.
А так Maybe забирает ответственность за контролем невалидных данных (если следовать контракту, что явно не указанная возможность null — ошибка) с пользователя на компилятор. Задачи одного порядка.

А про непреднамеренное копирование ссылок я вообще не понял. Если Вы про ModifyItem(ref item), то это ад и погибель. Item должен быть неизменяемым. Добавление функции с сигнатурой
Maybe<TResult> Map<T, TResult>(this Maybe<T> souce, Func<T,TResult> transformation);
вообще избавит от большинства проблем.
Сборка мусора тоже добавляет новый элемент в язык. Принципиальных отличий нет. Разве что сборку мусора не отключить в текущей реализации языка.

Я ни разу не трогал сборщик в реальных проектах. Работает он сам по себе, никого не трогает и не мешает. Его можно настроить или вызвать, но это не обязанность, а лишь возможность.

А так Maybe забирает ответственность за контролем невалидных данных (если следовать контракту, что явно не указанная возможность null — ошибка) с пользователя на компилятор. Задачи одного порядка.

Если метод возвращает Item то это будет или Item или унаследовавший тип. Как можно получить что либо еще? Null не тип и не объект, а метка в ссылке. Объекта или нет или он есть и является указанного типа.

А про непреднамеренное копирование ссылок я вообще не понял. Если Вы про ModifyItem(ref item), то это ад и погибель. Item должен быть неизменяемым. Добавление функции с сигнатурой
Maybe<TResult> Map<T, TResult>(this Maybe<T> souce, Func<T,TResult> transformation);
вообще избавит от большинства проблем.

Ref это способ передать ссылку по стеку. То, что вы можете пользоваться одной и той же ссылкой в начале и в конце метода, вас не смущает?
И я не понял, почему объект должен быть неизменяемым (как я понял, речь об immutable)? Как же мне тогда обновлять репозиторий?
И как добавление метода с данной сигнатурой спасет? Даже если это не вызовет ада со ссылками, это просто функция, язык не обязывает ею пользоваться. Напротив, ref в сигнатуре оставляет лишь один способ воспользоваться методом.
> Его можно настроить или вызвать, но это не обязанность, а лишь возможность.
Я хотел сказать, что сборщик, это фича (языка, платформы, системы), которая точно так-же позволяет переложить часть ответственности с программиста на машину. Пусть уже встроенная в язык, но фича. И если уж отказыватся от фич типизации, то почему-бы не отказаться и от этой. Видимо <irony> надо ставить…

> Если метод возвращает Item то это будет или Item или унаследовавший тип. Как можно получить что либо еще? Null не тип и не объект, а метка в ссылке. Объекта или нет или он есть и является указанного типа.
Ещё раз повторю мысль из всех комментариев. По умолчанию null это метко. В большинстве случаев мне, как программисту, не хочется проверять исключительный случай, когда объект null. Более того мне кажется неправильным разделение на `один особенный объект` и `все остальные`.
Поэтому предлагается ввести конструкцию Maybe, которая позволит явно указать места, где метка null может появляться, а в остальных местах про неё просто забыть. Таким образом во всех функциях где возможно исключительное поведение (возвращение нулл, как GetItem) ставится Maybe<T>.
В местах где такой возможности нет (метод int SaveItem(Item toSave)) Maybe<T> не используется.
Такой подход позволяет находить проблемы задолго то их появления в тестировании, а следовательно экономить время.

> И я не понял, почему объект должен быть неизменяемым (как я понял, речь об immutable)? Как же мне тогда обновлять репозиторий?
Под объектом я имел ввиду экземпляр Maybe<T>. Класс должен выглядеть как
private readonly T item;
public Maybe(T obj){
if(obj == null) throw new InvalidOperationException();
item = obj;
}
и не менять объект внутри него не при каких обстоятельствах.

Метод же, при использовании map выглядел бы как
public Maybe<Item> SomeMethod(){
return GetMbItem().Map(ModifyItem);
}
Такой вариант написания отловил бы использование ref (конструкции, которою стоит использовать очень осторожно и как можно меньше), сократил бы код, упростил бы понимание.
Я хотел сказать, что сборщик, это фича (языка, платформы, системы), которая точно так-же позволяет переложить часть ответственности с программиста на машину. Пусть уже встроенная в язык, но фича. И если уж отказыватся от фич типизации, то почему-бы не отказаться и от этой. Видимо <irony> надо ставить…

Эта фича исключила контроль памятью из приложения, сократив количество кода и уменьшив сложность. Что бы работать с ней, нужно делать меньше, чем без нее. Maybe увеличивает сложность, не решая задач. Не вижу тут никакой иронии.

Ещё раз повторю мысль из всех комментариев. По умолчанию null это метко. В большинстве случаев мне, как программисту, не хочется проверять исключительный случай, когда объект null. Более того мне кажется неправильным разделение на `один особенный объект` и `все остальные`.
Поэтому предлагается ввести конструкцию Maybe, которая позволит явно указать места, где метка null может появляться, а в остальных местах про неё просто забыть. Таким образом во всех функциях где возможно исключительное поведение (возвращение нулл, как GetItem) ставится Maybe<T>.

Null не особенный и не объект. Это свойство ссылки. Заворачивая ее в контейнер, вы не создаете особенный объект, не добавляете дефолтный функционал и не исключаете возможности ошибиться. Это лишь вредит созданием новой ссылки, повышая требования к слежке за ссылками.

Под объектом я имел ввиду экземпляр Maybe<T>. Класс должен выглядеть как
private readonly T item;
public Maybe(T obj){
if(obj == null) throw new InvalidOperationException();
item = obj;
}
и не менять объект внутри него не при каких обстоятельствах.


Если я могу увидеть объект, то я могу создать новую ссылку на него. Так и делают: var item = mbItem.Value; Новая ссылка, новая головная боль.
Maybe увеличивает сложность, не решая задач. Не вижу тут никакой иронии.
То что оно не решает задач исключительно Ваше мнение.

Null не особенный и не объект. Это свойство ссылки.
Null это страшное наследие из C, которое во многих современных языках уже прибили. И да, я не хочу думать над программой в терминах указателей, ссылок и их свойств. Оставьте это ребятам из лагеря плюсов.

Если я могу увидеть объект, то я могу создать новую ссылку на него. Так и делают: var item = mbItem.Value; Новая ссылка, новая головная боль.
Тогда исключительно Ваша проблема, что Вы решили себе создать немного новой головной боли. Я показал как в реальной задаче сделать так, чтобы боли не возникло даже случайно.
И да, я не хочу думать над программой в терминах указателей, ссылок и их свойств. Оставьте это ребятам из лагеря плюсов.

Тогда вы лишаете себя удовольствия делать много классных штук, в своей программе и если вам это так не нравится, то следует выбрать другой язык.
Пойду сделаю пару классных EXC_BAD_ACCESS да выпью чаю
Чем эта ошибка отличается от поломанной базы данных, тысяч неверно отосланных писем или какой то ошибки происходящей только у одного пользователя?
если вам это так не нравится, то следует выбрать другой язык.

Абсолютно не следует, на то C# и мультипарадигменный язык.

Я бы хотел больше ФП-шности в нем, но не перехожу на F# или Haskell почему-то. Вам, возможно, арифметики указателей не хватает, и все же вы тоже не ушли в C.

Допустимы разные стили в рамках одного языка, только и всего. Вполне может быть, что для кого-то ваши «много классных штук» будут находиться в нерекомендуемом подмножестве языка. Как те же ref и out.
Сталкиваясь с новым подходом, я всегда пытаюсь применить его на нового разработчика в команде. Как в Москве не знаю, но в Калуге, из-за Москвы находящейся в 200км, текучка весьма себе высокая.
Итак. Я новичок. Я вижу метод:
Maybe<User> Find(int id);

Ок, думаю я, значит мне надо для проверки фамилии написать вот так:
var user = FindUser(1);
if (user.Value.LastName == "Иванов")

Да, на кодеревью, это завернут и скажут написать вот так:
var user = FindUser(1);
if (user.HasValue)
{
    if (user.Value.LastName == "Иванов")
    ...
}
else
{
    // Обработка отсутствия значения
}

А теперь, объясните уже не этому новичку, а мне, чем приведенная конструкция лучше вот этой:
var user = FindUser(1);
if (user != null)
{
    if (user.LastName == "Иванов")
    ...
}
else
{
    // Обработка nul
}

Да, и еще, что делать в связи с тем, что большая часть системных методов будет вести себя по второму сценарию (например методы из EntityFramework и базирующихся на них RIA-сервисы), а часть методов по первому. Как объяснить новичку такой дуализм в коде?
Самое интересное будет если code review нет, а новичок по привычке напишет второй вариант — то-то будет веселуха.
Не, ну если нет кодеревью за новичками, то никакие методы не позволят избежать закостыливания проекта… У нас для новичков это обычная практика, плюс исправления багов идут через кодеревью.
В наших обрвзовательных реалиях я думаю вам стоит перейти на Pascal. Я не знаю других промышленых языков которые бы позволяли писать на них с незначительным числом глупых ошибок без освоения некторой теории и приобщения к какой-то культуре разработки.
Эх, Pascal, середина девяностых, школьные олимпиады по программированию… Прям ностальгия.
Нет, в наших реалиях приходится бежать чуть впереди паровоза. Переходить на C# в 2003 году, переходить на WPF еще в 2008 студии (плеваться, но переходить) и отъедать за счет скорости разработки, за счет качества, за счет появившихся возможностей свой кусок пирога у тех, кто этим не занимается. Да, и этим всем надо заниматься осознанно. Например, пока не переходить на EF 6 с EF 5 и т.д. И продолжать учиться самому и учить свою команду.
Так я и не говорю, что потерял. Наоборот выиграл. В то время заказчики от красивостей WPF тащились, а делались они достаточно легко.
А я где-то выше писал, что в рабочем проекте такой подход не использую по именно этой причине. Убедить всех людей следовать единому стилю тяжело, контролировать это ещё трудней.
Как сделать изящное решение на С#, чтобы обеспечить контроль использования обеих веток (Value и Null) я не знаю.

Теоретически должно хватить
Maybe<TResult> Map<T, TResult>(this Maybe<T> souce, Func<T,TResult> transformation);
и
T Extract<T>(this Maybe<T> souce, T fallbackValue)
без возможности извлечь значение из но Maybe, но С# не функциональный язык и использовать такое было бы неудобно.

(например методы из EntityFramework и базирующихся на них RIA-сервисы)
Про это уже писали в habrahabr.ru/post/118934/
Новичок вполне может знать LINQ:

var ivanov = from u in FindUser(1) where u.LastName == "Иванов"
1. Как новичок, увидев вместо User MaybeUser я бы либо спросил у старшего, что это и как пользоваться, либо недюжинным мозговым усилием перевел слово Maybe на русский и догадался, что стоит сперва проверить на наличие значения.

2. А вот на кодревью скажут написать не так, как автор предложил, а что-то вроде такого:

FindUser(1)
    .Where(u => u.LastName == "Иванов")
    .Match(GreetUser, orElse: LaunchMissiles)
И как после этого жить, если к методу возвращающему один элемент (мы же по первичному ключу ищем) можно применить Where…
Нет, идея красивая, но или это действительно должно быть в языке, чтобы переходя из проекта в проект, из команды в команду этот подход был как «отче наш», либо сложность перехода на него становится очень высокой.
LINQ – монада, у монады есть map/flatMap (select/selectMany) и может быть filter (where), тоже вроде бы чисто «коллекционные» операции
Решение очевидно — не должно быть свойства Value :)
Так или иначе, но в одной из библиотек из той таблицы именно так и сделано. Есть метод Match, который обязывает обработать оба варианта, и только через него можно получить внутреннее значение. Честно, по-пацански по-монадически.
Невозможно покрыть все скользкие места. Иногда нужно просто писать корректный код,

Невозможно. Поэтому можно покрыть разумное подмножество покрываемого, в остальном писать корректный код.

PS: DivisionByZero покрыто в double, где есть две бесконечности и NaN.
Boxing/Unboxing это преобразования. Сохранять или не сохранять тип они не могут. Существование implicit преобразований отдельная тема, но типов оно не меняет.
В целом поддерживаю (поставил плюс статье), однако есть несколько замечаний/предложений:

1.
Зачем засовывать один ссылочный тип в другой и добавлять в контейнер свойство HasValue, для меня решительно не понятно.
— а почему вы решили, что контейнер Maybe обязательно должен быть ссылочным типом? Что мешает его сделать значимым, наподобие контейнера Nullable? В критикуемой вами статье есть таблица с реализациями, так вторая реализация Strilanc — May использует значимый тип (public struct May<T> : IMayHaveValue, IEquatable<May<T>>).

2.
В случае если метод ModifyItem подменил объект, то метод SomeMethod вернет контейнер со старым объектом. Иди потом, ищи этот баг.
— а вот здесь поподробнее, пожалуйста. Метод SomeMethod() из вашего примера вернёт старый объект в контейнере лишь и только в том случае, если при создании экземпляра контейнера Maybe его код выполняет глубокую копию своего значения и сохраняет именно эту копию. Все же контейнеры, если они написаны адекватно, хранят лишь ссылку на значение, которая может быть NULL (в этом случае метод контейнера HasValue возвратит false).

3. В целом же я согласен со статьёй, и для себя в таких ситуациях использую шаблон Boolean TryGet(ID, out Result).
1. Разумеется можно использовать struct, но это приводит к другим сложным моментам, из за копирования struct'а. Nullable работает хорошо поскольку оперирует value types, или предназначен для этого, во всяком случае. Использование struct'а, как контейнера для класса, создаст много проблем с целостностью ссылок в разных частях стека, поэтому я сразу отбросил этот вариант, как слишком неуправляемый. Если вы считаете, что стоит это упомянуть, я обязательно сделаю.

2. Возможно я не так понял это замечание, поправте если так. Метод SomeMethod достает объект из репозитория и, в случае если он действительно есть, хочет его модифицировать. В строке var item = mbItem.Value; создается новая ссылка на тот же объект, которая будет передана в метод ModifyItem. Если внутри этого метода, эта ссылка будет стоять слева от знака равно, то ей будет присвоен новый адрес, в то время как ссылка в контейнере будет ссылаться на старый объект. Таким образом новый объект останется внутри блока if.
1. По этому поводу пересмотрел все реализации из таблицы из статьи «Усиливаем контроль типов». Итог — некоторые реализации используют структуры, некоторые — классы. Лично я предпочёл бы структуры, так как NotNull-контейнер, который сам может быть NULL, это как минимум странно. Не отрицая возможных проблем с ним, не считаю, что копирование по значению при передаче в методы — это зло. Впрочем, это уже другая тема.

2. Нет, это я изначально недопонял. Ваше описание (из вашего комментария выше) совершенно верно — ModifyItem вернёт через параметр новую ссылку на новый экземпляр объекта, которая не выйдет за пределы блока if. Впрочем, возможна и ситуация, когда тот же ModifyItem модифицирует полученное значение без создания новой ссылки — в этом случае всё будет ОК.
В целом автора поддерживаю — оборачивание объекта в контейнер только для защиты от NullReference — идея плохая, но не могу согласиться с выражением

Data access layer должен достать объект из репозитория и передать клиенту. Он не знает как обработать отсутствие объекта и вообще не в курсе плохо ли это. За это отвечает уровень логики.


DAL может иметь более одного метода получения данных, как это делает тот же Linq, и эти методы могут налагать различные ограничения на наличие искомого объекта. В зависимости от того является ли отсутствие объекта нормальной бизнес-ситуацией клиентская часть вызывает тот или иной метод и может получить исключение, обработку которого желательно предусмотреть.
Это делает код более строгим и защищенным от ошибок в бизнес-логике.
Все верно, однако в том же linq есть, например, методы Single и SingleOrDefault, и оба вызываются кем-то сверху. И тот кто наверху и решает какой из методов больше подходит. Сам же linq не знает о том какой и методов уместней, он просто делает, что ему сказали.
однако

Ну я именно это и назвал задачей клиентской части. DAL только делает то, что ему сказали и кидает человеческий Exception, если у него потребовали First, а ни одного объекта нет.
Главное не оставлять клиента с методом который бросает исключение всякий раз, как объект не найден. Его можно добавить, но это уже дело вкуса.
Очень тяжело сопоставить предыдущую статью и эту, ощущение что они обе из разных областей.
В первой озвучивается типичный для меня при следовании DDD подход к реализации Repository: методы Get и Find (в практике еще часто встречаю метод Exists для других сценариев). При чем первый не должен возвращать null ни в коем случае — либо результат, либо exception, который должен быть более конкретным чем NRE, хотя бы RecordNotFound.

Второй момент в исходной статье — Null Object pattern, который в приведеннои примере позволяет следовать функциональному («ленивому») подходу.

Контракт сам по себе — описывает как должна вести себя система. Раньше такое делали при помощи Assert'ов (по крайней мере во время c++ & mfc 4.2), а теперь с контрактами можно хоть на интерфейс проверки навесить.

В данной же статье «забыт» метод Find, остается только Get и усиленно критикуется.
Ну и про Null Object pattern:
Ссылочный тип может ссылаться на null. Зачем засовывать один ссылочный тип в другой и добавлять в контейнер свойство HasValue, для меня решительно не понятно.

Типичная реализация, вполне согласована с Nullable<> по синтаксису.
А приведенный код где используется ref вообще никак не согласуется с предыдущим «функциональным» принципом (согласно книге Framework Design Guidelines кода с ref и out следует избегать).
В первой озвучивается типичный для меня при следовании DDD подход к реализации Repository: методы Get и Find (в практике еще часто встречаю метод Exists для других сценариев). При чем первый не должен возвращать null ни в коем случае — либо результат, либо exception, который должен быть более конкретным чем NRE, хотя бы RecordNotFound.

Я не трогал метод Find, потому что с ним все хорошо. Проблема в том, что при работе с репозиторием, на DAL накладывают ответственность, которая лежит на бизнес логике. Если объекта который должен быть, не найден, то нужно отреагировать в соответствии с политикой приложения а не библиотеке по доступу к репозиторию. Бросать исключение внутри, тоже не дело, поскольку есть места, где это совершенно неважно, есть объект или нет.

Второй момент в исходной статье — Null Object pattern, который в приведеннои примере позволяет следовать функциональному («ленивому») подходу.

Null object pattern это из другой оперы. Его идея заключается в том, что если объект не найден или его функционал не требуется, то создается другой объект с тем же интерфейсом, который ничего не делает. Например, если на одном из серверов не нужно кеширование, ему дается пустышка, которая ничего не делает. Посмотрите тут пример на C#.

Про Nullable я ответил чуть выше.
Еще раз повторюсь — не вижу ничего плохого в том, что DAL может быть гибким и иметь несколько вариантов получения одних и тех же данных, в том числе и с Exception при отсутствии.
Кроме того варианты появления Null вовсе не ограничиваются одним только DAL, и перекрывать нужно все возможные места. Просто где-то это должен быть If(...==null)...else..., а где-то и try...catch — это упрощает контроль за бизнес-логикой.
Я не говорю, что плохо если такой метод есть, плохо если только такой и есть. DAL не должен знать когда ему бросать исключение на основе данных, а на основе того, что ему сказали сверху.
Я не трогал метод Find, потому что с ним все хорошо.

В исходной статье как раз с Get «все хорошо», он кидает исключение когда не находит сущность, а вот Find возвращает упакованный в «монаду» объект, если он не найден.
Null object pattern это из другой оперы.

Это как раз из той же оперы, решают одни и те же проблемы (только приводят к разным другим). Я следую принципу «Null-object pattern poor man's maybe monad» для языков без поддержки монад.
А решение через Maybe — относительно дешевое. В соответствии с практиками ООП и DDD, Null-object предпочтительнее и ближе к домену, но в то же время затратнее (количество кода => поддержка + написание) для полной («правильной» по отношению к остальной бизнес логике) реализации.
Null object не всегда можно подставить. Например, я хочу получить пост с таким то id. Если этого поста нет, то что мне подставить вместо? С точки зрения логики не существует дефолтного поста. И даже если это будет реализовано, это приведет к условным проверкам. Например, придется проверять Id на 0 или еще как нибудь извращаться.
Нет, у Null Object есть признак того, что это Null Object, например булевое свойство IsEmpty. Но лично я не понимаю в чем принципиальная разница между проверкой на Null и проверкой на IsEmpty — и ту и другую легко можно забыть, так что это просто подход к программированию, но не защита от ошибок в бизнес-логике. Как раз наоборот — человек, не знакомый с API может проверять объект на Null. Эта проверка никогда не сработает (потому что Null Object), и вся дальнейшая логика идёт лесом.
Извините, перепутал null object pattern.
А так, полностью с вами согласен.
Разница на самом деле есть. Используя данный паттерн, вы собираете в одном месте разбросанные по проекту куски кода а-ля if (document.ResponsibleUser == null) {… }. У null-пользователя будут заполненные значением по-умолчанию ФИО, логин, список ролей и т.д.
И заменяете их на if (maybe.HasValue)
Мой комментарий о том, что с паттерном Null Object их вообще не будет. Соль паттерна именно в этом.
Тогда как же вы узнаете о том, что объект не был найден?
Для начала отвечу вопросом на вопрос: а зачем? Я клоню к тому, что в каких-то случаях мне важно, вернули ли мне существующий объект, в каких-то — будет достаточно и «пустышки». Там, где существование важно, от проверки типа obj != null / obj.HasValue / obj is NullObject не уйти. Где не важно — есть способ избавиться от разбросанных по коду if (obj == null ) { /* special case */ } и собрать их в единственном месте.

Но принимать ответственность за принятие данного решения лежит исключительно на мне, как авторе клиентского кода. Вызываемого мною метода не должен касаться контекст его выполнения.
Вы говорите о том, что в части случаев, клиент может попросить вернуть дефолтный объект. Но это тоже не должно находится в DAL. Откуда ему знать, как должен выглядеть дефолтный объект? К тому же, останется много мест, где проверка на существование объекта останется нужна.
Не дефолтный! Пустой объект :)

На самом деле, мы похоже что говорим о разном. Я не предлагаю из DAL возвращать Null Object. Ну его нафиг, сами разгребайте потом такой код. И пожалуйста Maybe — туда же.

Человек выше уточнил наличие принципиальной разницы между if (obj == nul) {} vs Null Object, на что я и ответил.
Теперь понял, извините. Никогда не пробовал, звучит интересно. Как раз переписываю логику с данными, попробую.
Вот скажите при чем тут DAL? Именно эта часть обычно уже написана в другой библиотеке и просто используется (nHibernate, EF, RavenDB...). А то что имеет смысл абстрагировать, это уже бизнес сущности. В таком случае репозиторий имеет дело с бизнес объектами, которые представляют не только данные, но и поведение (где и появляется ООП). Вот на стороне бизнес модели и реализуется более специфичное поведение.

С подобным можно столкнуться когда доменная область строится через EventSource, где хранится не сам объект, а только набор событий. Тогда имеем: сохраненный объект (1+ событий), новый объект (0 событий) и несуществующий объект (0 событий). Вот тут и получаем что только отвечающий бизнес код должен знать где использовать Find, а где Get.
Просто исходная ветка комментариев, куда я встрял с ответом, чем породил данное обсуждение, была про DAL. Вот поэтому круг примеров и сузился до Get/Find :) Я тоже не сразу это понял.
ФИО по умолчанию? Простите, но мест в логике где такая пустышка применима — раз, два и обчелся.
Я не очень понял суть вашего комментария. Придумайте другой пример, если не понравилось «ФИО по-умолчанию». Сути то это не поменяет: используя означенный паттерн, мы собираем в одном месте поведение системы в случаях, когда входной объект — null. (Для того же объекта «пользователь» это ещё и сброшенный флаг активности, пустой список назначенных ролей и т.д.)
Я имел в виду, что не понимаю зачем мне дефолтный юзер если мне нужен вполне конкретный с конкретным кодом. Если мне потребуется создать дефолтного юзера, то я для этого воспользуюсь фабрикой.
Все зависит от бизнес контекста и поведения, которое требуется.
В основном получение записи по идентификатору — та операция которая должна всегда возвращать сущность и при отсутствии таковой будет исключительная ситуация.
Другой же момент когда в бизнес логике явно предусматривается возможность что таковой сущности не существует. Тогда в той же логике есть определенное поведение, которое предусмотрено. Возможно, это поведение будет проверять «подмененную» сущность на существующую, хотя это не является предпочтительным применением (при следовании DDD нет понятия проверки Id на 0, это техническое решение, а функциональное будет иметь соответствующее имя, хотя бы — IsNew).

Идея в подмене состоит в том, что мы не получаем исключительную ситуацию, а продолжаем работу так же, как когда сущность присутствует. Разница в том что методы этой сущности не делают ничего, но и не прерывают выполнение остальной логики. Например, мы выполняем ряд операций над сущностью пользователя, который может быть связан с профилем компании (который это изменение также должно затронуть). Вместо добавления множества проверок на существование профиля компании — мы продолжаем выполнение логики на подмененной сущности, эти операции ничего не делают и с точки зрения производительности нам никак не мешают (мы же не «экономим на спичках»). В результате наш код более читаем, так как содержит только операции, да и цикломатическая сложность уменьшается с уменьшением ветвлений.
Опять же, это не всегда подходит. Если я захожу на страницу профиля, а этот профиль отсутствует, я должен получить сообщение об ошибке, а не пустой профиль.
Что записит от заложенной бизнес логики и для которой присутствует метод Get, а не Find.
Да можно легко.
Null post object — это пост с сообщением, что такой пост не найден.
> Ссылочный тип может ссылаться на null. Зачем засовывать один ссылочный тип в другой и добавлять в контейнер свойство HasValue, для меня решительно не понятно. Для проверки на HasValue? Что мешает обратится к содержимому объекту без этой проверки? Можно точно так-же безалаберно не проверить на null через неравенство.

У null нет семантики. Это что-то из до ООП мира C. Он не значит «значение найдено, это пустое значение» или «значение не найдено».

Использование Maybe даёт понимание когда данные вернулись, а когда нет. И заставляет проконтролировать наличие возврата.

> В случае если метод ModifyItem подменил объект, то метод SomeMethod вернет контейнер со старым объектом. Иди потом, ищи этот баг.

Когда SomeMethod может изменять аргумент, а тем более заменять его? Аргументы должны быть immutable.
Для поддержания семантики можно использовать TryGet упомянутый Klotos. Очень гибкое и удобное решение. Maybe добавляет сложности, но не добавляет решений.

Что касается аргументов, то я считаю, что никому они ничего не должны. Передавая класс и поменяв в нем что-то, это отобразится везде, а передавая struct нет. А поменяв что-то в классе внутри struct'а, это отобразится опять-же везде. В коде нет ничего святого. Есть случаи когда лучше скопировать ссылку, когда ее лучше передать, а есть когда удобней передать указатель. А выстрелить себе в ногу, всегда способ найдется.
Посмотрите на функциональные языки, поймете откуда Maybe берет свои истоки.
В новых языках (например котлине) из коробки есть nullability check — все типы могут Nullable или нет (определяется знаком вопроса в декларации типа).
На практике null это исключение, а не наоборот. Поэтому выделять nullable значение в отдельную сущность — удобно.
Одно из решений — ввести аттрибуты: Nullable/NotNull (говорю про яву), но есть и решение средствами языка — как раз описанная возможность с помощью Maybe.
И мне кажется это хорошо повышает как безопасность кода по поводу NullRefExc, так и его читаемость. Конечно же если весь код написан в таком стиле.

>> Maybe добавляет сложности, но не добавляет решений.
Решение NullRefExc оно точно добавляет.
Как ещё один вариант: паттерн special case. Фаулер обычно дело говорит. Если репозиторий может вернуть NULL вместо пользователя, то пусть это (в некоторых случаях) будет NullUser.
Я уже писал в той статье, проблему решат нультипы. Если объект не найден то вернуть заранее созданый валидный спец объект этого же типа. После этого, можно обрабатывать логику на любом уровне. А все промежуточные слои не трогать, пусть они выполняют только свою работу. Очень полезно при изменчивой разработке, когда логика пишется параллельно с данными. Можно добавлять данные и удалять, менять проверки и все будет работать.
Можно втиснуть проверку на null внутрь метода и в случае, если объект не найден, бросить Exception. Но этим мы просто переименовываем NullReferenceException в, к примеру, ItemNotFoundException.
Выброс исключения делает не с целью замены NRE на что то другое, а для лучшей диагностики ошибок. Чтобы исключение возникло именно тогда, когда у тебя уехала логика. Если ты не кинешь исключение, то NRE у тебя возникнет через пол часа в коде абсолютно не связанным с GetItem
Согласен на 120%.
Примитивный пример:
public void Buy(int itemId, int count)
{
....
Decimal totalPrice = CalcTotalPrice(DAL.GetItem(ItemId), count);
....
}

private decimal CalcTotalPrice(Item item, int count)
{
  return item.Price * count;
}


При отсутствии товара ловим NRE в CalcTotalPrice, после чего начинаем дебаг с выяснением откуда же туда этот Null приехал.
Примитивный пример:
гораздо веселее, когда результат вызова GetItem сохраняется в поле какого нить класса, о обращение к этому полю идет в другой момент, ну там например при клике по кнопке юзером. Или когда результат вызова GetItem замыкается linq-to-objects запросом или там лямбдой куда нить в TPL уходит. Вот тут начинается тут начинаются долгие ночи в обнимку с дебагером.
На самом деле, как мне кажется, можно и исключение кидать, и null вернуть, если логика приложения подразумевает такую возможность.

Спор здесь очень странный развернулся — надо или не надо возвращать null. Вам не кажется, что это вообще-то зависит от того, может вообще «не быть» передаваемого объекта, или нет?

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

А если я возвращаю ФИО человека по его номеру телефона, то null — обычное дело и должен обрабатываться (в зависимости от ситуации) на уровне пользовательского интерфейса — я вижу 2 случая: телефон не валиден, телефон не личный (а корпоративный, например) и владельца как такового нет. Про оба случая должен узнать пользователь (или как минимум контроллер)

Во втором примере надо вернуть null контроллеру — пусть разбирается, что с ним делать. Это уже не вопрос dao.

А оборачивать null во что-то, чтобы его не проверять — это почти то же самое, что пытаться спрятаться в песок от опасности (да, я знаю, что страуссы так не делают, зато так делают некоторые люди) :)
По поводу выбрасывания исключения в dao, это не правильно, если для одной сущности это так, а для другой нет. Например если метод Get для номера телефона может вернуть null, а Get для пользователя выбросить исключение. Так делать нельзя. Можно писать 2 метода, один с NRE другой без. Однако это ничего по сути не меняет. Только способ обработки null. В одном месте проверить через не равенство, в другом блоком try. И в любом случае, это будет сделано на уровне логики.
Вопрос лишь в том, что считать dao. В любом случае, в проекте должен быть слой, отвечающий за получение данных из базы/с удаленного сервера, который контроллирует их валидность и выдаёт только разумные. А в случае бреда — не пропускает и кидает исключение. Вот этот слой я и подразумевал в данном случае под dao.

Можно писать 2 метода, один с NRE другой без

Ни в коем случае. Это лишает смысла всю проверку!

В C#, увы, такого нет, а вот в Java есть очаровательная вещь — checked exceptions. Они нужны, чтобы исключительные ситуации, которые можно обработать разумными средствами, не забывать обрабатывать. Вариант «нет билета» — как раз такая ситуация. Означает, что база отвалилась (сессия закончилась, еще какая-нибудь мелкая проблема).

Вы согласны, что одни методы должны всегда вернуть непустой результат и если они не могут этого сделать, это исключительная ситуация, а другие вполне могут вернуть пустоту как штатный результат? И если да, то как это согласуется с вашим заявлением:
это не правильно, если для одной сущности это так, а для другой нет
Data access layer занимается доступом к данным. Ему сказали принеси, он принес. Или нет. Если дверь в базы данных закрыта, вопи. Про сами данные, слой ничего не знает, посему не может знать когда плохо если их нет. Всю обработку данных я осуществляю в логике. Там можно записать лог, вытащить другие данные и восстановить часть потерянных, сказать гуи что случилось или перезапустить все. DAL не знает что за приложение запущено и в каком оно состоянии и не может делать никаких решений.

Вы согласны, что одни методы должны всегда вернуть непустой результат и если они не могут этого сделать, это исключительная ситуация, а другие вполне могут вернуть пустоту как штатный результат? И если да, то как это согласуется с вашим заявлением:

Такие методы есть, но DAL об этом ничего не знает. Поэтому исключение должен бросать тот метод, который знает.
Dictionary тоже ничего не знает о бизнес-логике данных, но он будет весьма недоволен, если попросить у него элемент по несуществующему ключу.
Поэтому в нем есть метод KeyExists. Сначала вы проверяете наличие ключа и только потом достаете объект. Вы не проверяете наличие объекта через попытку достать его. Но при доступе к удаленным данным, такой подход не очень хорош, вместо одного запроса, нужно будет сделать 2. А что делать с bulk query вообще не понятно.
Dictionary<int, User> users = ....

User user;
if(!users.TryGetValue(int key, out User user))
{
........
}
UFO just landed and posted this here
Как автор изначальной статьи, чувствую себя должным ответить, хотя по частям все эти мысли уже изложены выше другими участниками.

1. Автор приводит несколько, назовем их так, странные реализации GetItem, неявно постулируя, что они вытекают из первичной статьи. Однако же, они из нее ничуть не следуют.

Из нее может следовать, например, вот такая реализация, гарантирующая либо объект, либо исключение:

public Item GetItem(int itemId)
{
    return dataAccessor
		.getItemById(itemId)
		.ToMaybe()
		.OrElse(() => new ItemNotFound(itemId));
}


Либо такая, явно декларирующая возможность неудачного поиска, и трактующая его как нормальное, штатное поведение:

public Maybe<Item> FindItem(int itemId) // а Get выше тогда должен реализовываться через этот Find для устранения дублирования
{
    return dataAccessor.getItemById(itemId).ToMaybe(); 
}


2.
При этом в одном из примеров он использует контракты, что противоречит их принципам.
При этом «противоречие принципам» автор иллюстрирует якобы необходимостью вставить в сторожевое условие сам код метода:
Что бы достать объект из репозитория, нужно сначала вытащить объект из репозитория.


Но если мы посмотрим на GetItem из п.1 моего комментария, то видим, что туда нужно только вставить Contract.Ensures(Contract.Result() != null); и более ничего; никакого коллапса вселенной бесконечной рекурсии это не вызывает.

3.
Правда в том, что никто не может гарантировать существование объекта в репозитории.

Я уже писал, что именно репозитории тут не специфичны. Но если в каком-то конкретном случае правда именно в этом (что объекта где-то может быть), я вовсе не предлагаю пытаться гарантировать наличие объекта, а предлагаю явно выразить эту правду, вернув Maybe.

4.
Я перефразирую: nullable reference. Масло масляное

Только потому кажется «масляным», что в виду ошибки проектирования языка классы в Algol C# по умолчанию снабжены значением null.

Anthony Hoare
I call it my billion-dollar mistake. It was the invention of the null reference in 1965. At that time, I was designing the first comprehensive type system for references in an object oriented language (ALGOL W)…

посмотреть на человека-легенду

Если старичок Хоар покажется слишком далеким от нашего времени, вот цитата из Барта Де Смета, для .NET человека «изнутри», книга C# Unleashed, одна из Must-Read по классификации замечательного SergeyT:

Цитирую врезку из этой книги под характерным названием «Жизнь без null»

Давайте явно уясним одну вещь: (a) возможность/невозможность принимать значение null и (b) различие между значимыми и ссылочными типами — это ((a) vs (b)) ортогональные (независимые) свойства и это правильно. Было бы хорошо, если бы любой тип мог явно задекларировать, поддерживает ли он значение null, или нет.


И далее по тексту Барт говорит, что проектировщики C# не могли сделать эту ортогональность полной, т.е. в классах нет возможности «отключить null», и это вызвано лишь причинами совместимости. Например, в экспериментальном Spec# (где можно не думать так сильно о совместимости) такая возможность есть.

И раз современные средства позволяют в некотором локальном пространстве (проекте) ее исправить, то странно говорить о том, что это неверный подход. Что не мешает ему быть неоправданным в конкретном проекте по тысяче разных причин.

5.
Что мешает обратится к содержимому объекту без этой проверки? Можно точно так-же безалаберно не проверить на null через неравенство.


Я считаю, что вероятность первого много ниже, чем вероятность второго. Чтобы обратиться к содержимому Maybe без проверки, нужно совершить явное действие — добавить «.Value». Чтобы не проверить на null делать не нужно ничего, это уже сделано :).

Спотыкание об это «.Value» — это вроде как если бы у программиста был ангел-хранитель, который при каждом случае, когда тот написал GetSome().DoStuff(), волшебным образом смотрел бы в исходник GetSome(), и при необходимости стучал бы его по плечу и говорил: «А кстати, там может быть null».

Замечу, что как раз невозможность случайно игнорировать Maybe здорово стимулирует к тому, чтобы проектировать систему с минимальным количеством необязательных возвращаемых значений. И оказывается, что далеко не так часто нужны nullable-классы, как это кажется на первый взгляд. Они нужны приблизительно так же часто, как nullable-структуры. Иногда.

6. Для аргументирования вредности подхода автор приводит код:

public Maybe<Item> SomeMethod()
{
    var mbItem = GetMbItem();

    if(mbItem.HasValue)
    {
        var item = mbItem.Value;

        ModifyItem(ref item); 
    }

    return mbItem;
}


Во-первых, вполне явная ошибка: изменяем одну переменную, возвращаем другую, чего хорошего тут ждать, непонятно.

Однако, в духе Maybe, этот код пишется совсем иначе (учитывая, что ref и out использовать без крайней необходимости не следует, т.к. это ломает возможности функционального программирования):

public Maybe<Item> SomeMethod()
{
    return GetMbItem().Select(ModifyItem);
}


где ModifyItem имеет вид Item ModifyItem(Item item){...}

Или, если очень приспичило, даже с ref:

public Maybe<Item> SomeMethod()
{
    return GetMbItem().Select(i => { ModifyItem(ref i); return i; });
}


7. Что же касается тезиса
для того, чтобы не допускать NullReferenceException, нужно быть внимательным,
готов принять даже более широкий тезис, например, такой: «нужно быть внимательным».

И да удастся это нам всем :)
Sign up to leave a comment.

Articles