Как стать автором
Обновить

Null, великий и ужасный

Время на прочтение5 мин
Количество просмотров127K

Ошибка дизайна


Именно так и никак иначе: null в C# — однозначно ошибочное решение, бездумно скопированное из более ранних языков.


  1. Самое страшное: в качестве значения любого ссылочного типа может использоваться универсальный предатель — null, на которого никак не среагирует компилятор. Зато во время исполнения легко получить нож в спину — NullReferenceException. Обрабатывать это исключение бесполезно: оно означает безусловную ошибку в коде.
  2. Перец на рану: сбой (NRE при попытке разыменования) может находится очень далеко от дефекта (использование null там, где ждут полноценный объект).
  3. Упитанный пушной зверек: null неизлечим — никакие будущие нововведения в платформе и языке не избавят нас от прокаженного унаследованного кода, который физически невозможно перестать использовать.

Этот ящик Пандоры был открыт еще при создании языка ALGOL W великим Хоаром, который позднее назвал собственную идею ошибкой на миллиард долларов.


Лучшая историческая альтернатива


Разумеется, она была, причем очевидная по современным меркам


  1. Унифицированный Nullable для значимых и ссылочных типов.
  2. Разыменование Nullable только через специальные операторы (тернарный — ?:, Элвиса — ?., coalesce — ??), предусматривающие обязательную обработку обоих вариантов (наличие или отсутствие объекта) без выбрасывания исключений.
  3. Примеры:
    object o = new object(); // ссылочный тип - корректная инициализация
    object o = null; // ссылочный тип - ошибка компиляции, так как null недопустим
    object? n = new object; // nullable тип - корректная инициализация
    object? n = null; // nullable тип - корректная инициализация
    object o = n; // ссылочный тип - ошибка компиляции, типы object и object? несовместимы
    object o = n ?? new object(); // разыменование с fallback значением (coalesce), дополнительное значение будет вычислено только если n != null
    Type t = n ? value.GetType() : typeof(object); // специальный тернарный оператор - value означает значение n, если оно не null
    Type? t = n ? value.GetType(); // бинарная форма оператора ? - возвращает null, если первый операнд null, иначе вычисляет второй операнд и возвращает его, завернутого в nullable
  4. В этом случае NRE отсутствует по определению: возможность присвоить или передать null определяется типом значения, конвертация с выбросом исключения отсутствует.

Самое трагичное, что все это не было откровением и даже новинкой уже к моменту проектирования первой версии языка. Увы, тогда матерых функциональщиков в команде Хейлсберга не было.


Лекарства для текущей реальности


Хотя прогноз очень серьезный, летального исхода можно избежать за счет применения различных практик и инструментов. Способы и их особенности пронумерованы для удобства ссылок.


  1. Явные проверки на null в операторе if. Очень прямолинейный способ с массой серьезных недостатков.


    1. Гигантская масса шумового кода, единственное назначение которого — выбросить исключение поближе к месту предательства.
    2. Основной сценарий, загроможденный проверками, читается плохо
    3. Требуемую проверку легко пропустить или полениться написать
    4. Проверки можно добавлять отнюдь не везде (например, это нельзя сделать для автосвойств)
    5. Проверки не бесплатны во время выполнения.

  2. Атрибут NotNull. Немного упрощает использование явных проверок


    1. Позволяет использовать статический анализ
    2. Поддерживается R#
    3. Требует добавления изрядного количества скорее вредного, чем бесполезного кода: в львиной доле вариантов использования null недопустим, а значит атрибут придется добавлять буквально везде.

  3. Паттерн проектирования Null object. Очень хороший способ, но с ограниченной сферой применения.


    1. Позволяет не использовать проверок на null там, где существует эквивалент нуля в виде объекта: пустой IEnumerable, пустой массив, пустая строка, ордер с нулевой суммой и т.п. Самое впечатляющее применение — автоматическая реализация интерфейсов в мок-библиотеках.
    2. Бесполезен в остальных ситуация: как только вам потребовалось отличать в коде нулевой объект от остальных — вы имеете эквивалент null вместо null object, что является уже двойным предательством: неполноценный объект, который даже NRE не выбрасывает.

  4. Конвенция о возврате живых объектов по умолчанию. Очень просто и эффективно.


    1. Любой метод или свойство, для которых явно не заявлена возможность возвращать null, должны всегда предоставлять полноценный объект. Для поддержания достаточно выработки хорошей привычки, например, посредством ревью кода.


    2. Разработчики сторонних библиотек ничего про ваше соглашение не знают
    3. Нарушения соглашения выявить непросто.

  5. Конвенция о стандартных способах явно указать что свойство или метод может вернуть null: например, префикс Try или суффикс OrDefault в имени метода. Органичное дополнение к возврату полноценных объектов по умолчанию. Достоинства и недостатки те же.


  6. Атрибут CanBeNull. Добрый антипод-близнец атрибута NotNull.


    1. Поддерживается R#
    2. Позволяет помечать явно опасные места, вместо массовой разметки по площадям как NotNull
    3. Неудобен в случае когда null возвращается часто.

  7. Операторы C# (тернарный, Элвиса, coalesce)


    1. Позволяют элегантно и лаконично организовать проверку и обработку null значений без потери прозрачности основного сценария обработки.
    2. Практически не упрощают выброс ArgumentException при передаче null в качестве значения NotNull параметра.
    3. Покрывают лишь некоторую часть вариантов использования.
    4. Остальные недостатки те же, что и у проверок в лоб.

  8. Тип Optional. Позволяет явно поддержать отсутствие объекта.


    1. Можно полностью исключить NRE
    2. Можно гарантировать наличие обработки обоих основных вариантов на этапе компиляции.
    3. Против легаси этот вариант немного помогает, вернее, помогает немного.
    4. Во время исполнения помимо дополнительных инструкций добавляется еще и memory traffic

  9. Монада Maybe. LINQ для удобной обработки случаев как наличия, так и отсутствия объекта.


    1. Сочетает элегантность кода с полнотой покрытия вариантов использования.
    2. В сочетании с типом Optional дает кумулятивный эффект.
    3. Отладка затруднена, так как с точки зрения отладчика вся цепочка вызовов является одной строкой.
    4. Легаси по-прежнему остается ахиллесовой пятой.

  10. Программирование по контракту.


    1. В теории почти идеал, на практике все гораздо печальнее.
    2. Библиотека Code Contracts скорее мертва, чем жива.
    3. Очень сильное замедление сборки, вплоть до невозможности использовать в цикле редактирование-компиляция-отладка.

  11. Пакет Fody/NullGuard. Автоматические проверки на null на стероидах.


    1. Проверяется все: передача параметров, запись, чтение и возврат значений, даже автосвойства.
    2. Никакого оверхеда в исходном коде
    3. Никаких случайных пропусков проверок
    4. Поддержка атрибута AllowNull — с одной стороны это очень хорошо, а с другой — аналогичный атрибут у решарпера другой.
    5. С библиотеками, агрессивно использующими null, требуется довольно много ручной работы по добавлению атрибутов AllowNull
    6. Поддержка отключения проверки для отдельных классов и целых сборок
    7. Используется вплетение кода после компиляции, но время сборки растет умеренно.
    8. Сами проверки работают только во время выполнения.
    9. Гарантируется выброс исключения максимально близко к дефекту (возврату null туда, где ожидается реальный объект).
    10. Тотальность проверок помогает даже при работе с легаси, позволяя как можно быстрее обнаружить, пометить и обезвредить даже null, полученный из чужого кода.
    11. Если отсутствие объекта допустимо — NullGuard сможет помочь только при попытках передать его куда не следует.
    12. Вычистив дефекты в тестовой версии, можно собрать промышленную из тех же исходников с отключенными проверками, получив нулевую стоимость во время выполнения при гарантии сохранения всей прочей логики.

  12. Ссылочные типы без возможности присвоения null (если добавят в одну из будущих версий C#)


    1. Проверки во время компиляции.
    2. Можно полностью ликвидировать NRE в новом коде.
    3. В реальности не реализовано, надеюсь, что только пока
    4. Единообразия со значимыми типами не будет.
    5. Легаси достанет и здесь.


Итоги


Буду краток — все выводы в таблице:


Настоятельная рекомендация Антипаттерн На ваш вкус и потребности
4, 5, 7, 11, 12 (когда и если будет реализовано) 1, 2 3, 6, 8, 9, 10

На предвосхищение ООП через 20 лет не претендую, но дополнениям и критике буду очень рад.


Обновление


добавил примеры кода к утопической альтернативе.

Теги:
Хабы:
Всего голосов 56: ↑45 и ↓11+34
Комментарии290

Публикации

Истории

Работа

Ближайшие события

2 – 18 декабря
Yandex DataLens Festival 2024
МоскваОнлайн
11 – 13 декабря
Международная конференция по AI/ML «AI Journey»
МоскваОнлайн
25 – 26 апреля
IT-конференция Merge Tatarstan 2025
Казань