Ошибка дизайна
Именно так и никак иначе: null в C# — однозначно ошибочное решение, бездумно скопированное из более ранних языков.
- Самое страшное: в качестве значения любого ссылочного типа может использоваться универсальный предатель — null, на которого никак не среагирует компилятор. Зато во время исполнения легко получить нож в спину — NullReferenceException. Обрабатывать это исключение бесполезно: оно означает безусловную ошибку в коде.
- Перец на рану: сбой (NRE при попытке разыменования) может находится очень далеко от дефекта (использование null там, где ждут полноценный объект).
- Упитанный пушной зверек: null неизлечим — никакие будущие нововведения в платформе и языке не избавят нас от прокаженного унаследованного кода, который физически невозможно перестать использовать.
Этот ящик Пандоры был открыт еще при создании языка ALGOL W великим Хоаром, который позднее назвал собственную идею ошибкой на миллиард долларов.
Лучшая историческая альтернатива
Разумеется, она была, причем очевидная по современным меркам
- Унифицированный Nullable для значимых и ссылочных типов.
- Разыменование Nullable только через специальные операторы (тернарный — ?:, Элвиса — ?., coalesce — ??), предусматривающие обязательную обработку обоих вариантов (наличие или отсутствие объекта) без выбрасывания исключений.
Самое трагичное, что все это не было откровением и даже новинкой уже к моменту проектирования первой версии языка. Увы, тогда матерых функциональщиков в команде Хейлсберга не было.
Лекарства для текущей реальности
Хотя прогноз очень серьезный, летального исхода можно избежать за счет применения различных практик и инструментов.
Способы и их особенности пронумерованы для удобства ссылок.
- Явные проверки на null в операторе if. Очень прямолинейный способ с массой серьезных недостатков.
- Гигантская масса шумового кода, единственное назначение которого — выбросить исключение поближе к месту предательства.
- Основной сценарий, загроможденный проверками, читается плохо
- Требуемую проверку легко пропустить или полениться написать
- Проверки можно добавлять отнюдь не везде (например, это нельзя сделать для автосвойств)
- Проверки не бесплатны во время выполнения.
- Атрибут NotNull. Немного упрощает использование явных проверок
- Позволяет использовать статический анализ
- Поддерживается R#
- Требует добавления изрядного количества скорее вредного, чем бесполезного кода: в львиной доле вариантов использования null недопустим, а значит атрибут придется добавлять буквально везде.
- Паттерн проектирования Null object. Очень хороший способ, но с ограниченной сферой применения.
- Позволяет не использовать проверок на null там, где существует эквивалент нуля в виде объекта: пустой IEnumerable, пустой массив, пустая строка, ордер с нулевой суммой и т.п. Самое впечатляющее применение — автоматическая реализация интерфейсов в мок-библиотеках.
- Бесполезен в остальных ситуация: как только вам потребовалось отличать в коде нулевой объект от остальных — вы имеете эквивалент null вместо null object, что является уже двойным предательством: неполноценный объект, который даже NRE не выбрасывает.
- Конвенция о возврате живых объектов по умолчанию. Очень просто и эффективно.
- Любой метод или свойство, для которых явно не заявлена возможность возвращать null, должны всегда предоставлять полноценный объект. Для поддержания достаточно выработки хорошей привычки, например, посредством ревью кода.
- Разработчики сторонних библиотек ничего про ваше соглашение не знают
- Нарушения соглашения выявить непросто.
- Конвенция о стандартных способах явно указать что свойство или метод может вернуть null: например, префикс Try или суффикс OrDefault в имени метода. Органичное дополнение к возврату полноценных объектов по умолчанию. Достоинства и недостатки те же.
- Атрибут CanBeNull. Добрый антипод-близнец атрибута NotNull.
- Поддерживается R#
- Позволяет помечать явно опасные места, вместо массовой разметки по площадям как NotNull
- Неудобен в случае когда null возвращается часто.
- Операторы C# (тернарный, Элвиса, coalesce)
- Позволяют элегантно и лаконично организовать проверку и обработку null значений без потери прозрачности основного сценария обработки.
- Практически не упрощают выброс ArgumentException при передаче null в качестве значения NotNull параметра.
- Покрывают лишь некоторую часть вариантов использования.
- Остальные недостатки те же, что и у проверок в лоб.
- Тип Optional. Позволяет явно поддержать отсутствие объекта.
- Можно полностью исключить NRE
- Можно гарантировать наличие обработки обоих основных вариантов на этапе компиляции.
- Против легаси этот вариант немного помогает, вернее, помогает немного.
- Во время исполнения помимо дополнительных инструкций добавляется еще и memory traffic
- Монада Maybe. LINQ для удобной обработки случаев как наличия, так и отсутствия объекта.
- Сочетает элегантность кода с полнотой покрытия вариантов использования.
- В сочетании с типом Optional дает кумулятивный эффект.
- Отладка затруднена, так как с точки зрения отладчика вся цепочка вызовов является одной строкой.
- Легаси по-прежнему остается ахиллесовой пятой.
- Программирование по контракту.
- В теории почти идеал, на практике все гораздо печальнее.
- Библиотека Code Contracts скорее мертва, чем жива.
- Очень сильное замедление сборки, вплоть до невозможности использовать в цикле редактирование-компиляция-отладка.
- Пакет Fody/NullGuard. Автоматические проверки на null на стероидах.
- Проверяется все: передача параметров, запись, чтение и возврат значений, даже автосвойста.
- Никакого оверхеда в исходном коде
- Никаких случайных пропусков проверок
- Поддержка атрибута AllowNull — с одной стороны это очень хорошо, а с другой — аналогичный атрибут у решарпера другой.
- С библиотеками, агрессивно использующими null, требуется довольно много ручной работы по добавлению атрибутов AllowNull
- Поддержка отключения проверки для отдельных классов и целых сборок
- Используется вплетение кода после компиляции, но время сборки растет умеренно.
- Сами проверки работают только во время выполнения.
- Гарантируется выброс исключения максимально близко к дефекту (возврату null туда, где ожидается реальный объект).
- Тотальность проверок помогает даже при работе с легаси, позволяя как можно быстрее обнаружить, пометить и обезвредить даже null, полученный из чужого кода.
- Если отсутствие объекта допустимо — NullGuard сможет помочь только при попытках передать его куда не следует.
- Вычистив дефекты в тестовой версии, можно собрать промышленную из тех же исходников с отключенными проверками, получив нулевую стоимость во время выполнения при гарантии сохранения всей прочей логики.
- Ссылочные типы без возможности присвоения null (если добавят в одну из будущих версий C#)
- Проверки во время компиляции.
- Можно полностью ликвидировать NRE в новом коде.
- В реальности не реализовано, надеюсь, что только пока
- Единообразия со значимыми типами не будет.
- Легаси достанет и здесь.
Итоги
Буду краток — все выводы в таблице:
Настоятельная рекомендация | Антипаттерн | На ваш вкус и потребности |
---|---|---|
4, 5, 7, 11, 12 (когда и если будет реализовано) | 1, 2 | 3, 6, 8, 9, 10 |
На предвосхищение ООП через 20 лет не претендую, но дополнениям и критике буду очень рад.
Автор: Bonart